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`
|
* - `./components/SplashScreen`
|
||||||
* - `./pages/Dashboard`
|
* - `./pages/Dashboard`
|
||||||
* - `./pages/History`
|
* - `./pages/History`
|
||||||
* - `./pages/SessionDetail`
|
* - `./pages/SessionDetail` (lazy)
|
||||||
* - `./pages/SettingsHub`
|
* - `./pages/SettingsHub` (lazy)
|
||||||
* - `./pages/Workspace`
|
* - `./pages/Workspace` (lazy)
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||||
@@ -54,23 +54,57 @@
|
|||||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||||
* ============================================================================= */
|
* ============================================================================= */
|
||||||
|
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate, useParams } from "react-router-dom";
|
||||||
import { useCallback } from "react";
|
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { DocumentTitle } from "./components/DocumentTitle";
|
import { DocumentTitle } from "./components/DocumentTitle";
|
||||||
import { SplashScreen } from "./components/SplashScreen";
|
import { SplashScreen } from "./components/SplashScreen";
|
||||||
import { GlobalTooltip } from "./components/Tooltip";
|
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 { Dashboard } from "./pages/Dashboard";
|
||||||
import { History } from "./pages/History";
|
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 { NotFound } from "./pages/NotFound";
|
||||||
import { useWebSocket } from "./hooks/useWebSocket";
|
import { useWebSocket } from "./hooks/useWebSocket";
|
||||||
import { useNotifications } from "./hooks/useNotifications";
|
import { useNotifications } from "./hooks/useNotifications";
|
||||||
import { eventBus } from "./lib/eventBus";
|
import { eventBus } from "./lib/eventBus";
|
||||||
import type { WSMessage } from "./lib/types";
|
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}.
|
* Application root component mounted by {@link main.tsx}.
|
||||||
* @returns Routed dashboard UI inside `BrowserRouter`.
|
* @returns Routed dashboard UI inside `BrowserRouter`.
|
||||||
@@ -83,19 +117,39 @@ export default function App() {
|
|||||||
const { connected } = useWebSocket(onMessage);
|
const { connected } = useWebSocket(onMessage);
|
||||||
useNotifications();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SplashScreen />
|
<SplashScreen />
|
||||||
{/* One renderer gives every `title` in the app the designed bubble —
|
{/* One renderer gives every `title` in the app the designed bubble —
|
||||||
see components/Tooltip.tsx for why it is not a wrapper component. */}
|
see components/Tooltip.tsx for why it is not a wrapper component. */}
|
||||||
<GlobalTooltip />
|
<GlobalTooltip />
|
||||||
|
{/* App-wide toasts and promise-based confirms: lib/toast.ts, lib/confirm.ts. */}
|
||||||
|
<Toaster />
|
||||||
|
<ConfirmHost />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<DocumentTitle />
|
<DocumentTitle />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout wsConnected={connected} />}>
|
<Route element={<Layout wsConnected={connected} />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="sessions" element={<History />} />
|
<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="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="workflows"
|
path="workflows"
|
||||||
@@ -107,8 +161,22 @@ export default function App() {
|
|||||||
element={<Navigate to="/settings/agents/claude-code" replace />}
|
element={<Navigate to="/settings/agents/claude-code" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="run" element={<Navigate to="/workspace" replace />} />
|
<Route path="run" element={<Navigate to="/workspace" replace />} />
|
||||||
<Route path="workspace" element={<Workspace />} />
|
<Route
|
||||||
<Route path="settings/*" element={<SettingsHub />} />
|
path="workspace"
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<Workspace />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="settings/*"
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<SettingsHub />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react";
|
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 { AgentStatusBadge } from "./StatusBadge";
|
||||||
import { useContextMenu } from "./ContextMenu";
|
import { useContextMenu } from "./ContextMenu";
|
||||||
import { SessionRowMenu } from "./SessionRowMenu";
|
import { SessionRowMenu } from "./SessionRowMenu";
|
||||||
@@ -82,7 +83,14 @@ interface AgentCardProps {
|
|||||||
onClick?: () => void;
|
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 navigate = useNavigate();
|
||||||
const { t } = useTranslation("kanban");
|
const { t } = useTranslation("kanban");
|
||||||
const ctx = useContextMenu<true>();
|
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
|
// 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
|
// 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.
|
// Haiku, not Opus. Falls back to the session model only for the main agent.
|
||||||
let subagentModel: string | null = null;
|
const subagentModel = useMemo(() => {
|
||||||
if (!isMain && agent.metadata) {
|
if (isMain || !agent.metadata) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(agent.metadata) as { model?: string };
|
const parsed = JSON.parse(agent.metadata) as { model?: string };
|
||||||
subagentModel = parsed?.model ? formatModelName(parsed.model) : null;
|
return parsed?.model ? formatModelName(parsed.model) : null;
|
||||||
} catch {
|
} catch {
|
||||||
subagentModel = null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}, [isMain, agent.metadata]);
|
||||||
// The model badge (footer) must reflect THIS card's agent: the session model
|
// The model badge (footer) must reflect THIS card's agent: the session model
|
||||||
// for main, the subagent's own model for subagents.
|
// for main, the subagent's own model for subagents.
|
||||||
const displayModel = isMain ? model : subagentModel;
|
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
|
// "29 agents" looks like it should equal a 29-subagent stat when the session
|
||||||
// actually has 28 subagents + 1 main.
|
// actually has 28 subagents + 1 main.
|
||||||
const subagentCount = Math.max(0, agentCount - 1);
|
const subagentCount = Math.max(0, agentCount - 1);
|
||||||
let sessionTurns = 0;
|
const sessionTurns = useMemo(() => {
|
||||||
if (isMain && session?.metadata) {
|
if (!isMain || !session?.metadata) return 0;
|
||||||
try {
|
try {
|
||||||
const m = JSON.parse(session.metadata) as { turn_count?: number };
|
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 {
|
} catch {
|
||||||
sessionTurns = 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}, [isMain, session?.metadata]);
|
||||||
const subtitle = isMain
|
const subtitle = isMain
|
||||||
? [
|
? [
|
||||||
cwdBase,
|
cwdBase,
|
||||||
@@ -162,13 +170,17 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
|||||||
.join(" · ") || null
|
.join(" · ") || null
|
||||||
: [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null;
|
: [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null;
|
||||||
|
|
||||||
function handleClick() {
|
const sessionHref = `/sessions/${agent.session_id}`;
|
||||||
if (onClick) {
|
// The whole card is clickable with the mouse; the title is the focusable
|
||||||
onClick();
|
// control (a real link, or a button when the caller handles the click).
|
||||||
} else {
|
function handleClick(e: React.MouseEvent) {
|
||||||
navigate(`/sessions/${agent.session_id}`);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -176,9 +188,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
|||||||
onContextMenu={(e) => ctx.open(e, true)}
|
onContextMenu={(e) => ctx.open(e, true)}
|
||||||
className={`card-hover p-4 cursor-pointer overflow-hidden ${
|
className={`card-hover p-4 cursor-pointer overflow-hidden ${
|
||||||
isWaiting
|
isWaiting
|
||||||
? "border-l-2 border-l-yellow-500/60"
|
? "border-l-2 border-l-status-warning/60"
|
||||||
: isActive
|
: 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="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
||||||
isMain
|
isMain ? "bg-accent/15 text-accent" : "bg-syntax-keyword/15 text-syntax-keyword"
|
||||||
? "bg-accent/15 text-accent"
|
|
||||||
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 overflow-hidden">
|
<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
|
||||||
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
|
229d93fd" or "Main Agent - work - e3f8e613") swap the
|
||||||
229d93fd" or "Main Agent - work - e3f8e613") swap the
|
placeholder for the real session name when one exists; custom
|
||||||
placeholder for the real session name when one exists; custom
|
(sub)agent names are left untouched. */}
|
||||||
(sub)agent names are left untouched. */}
|
{onClick ? (
|
||||||
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
|
<button type="button" onClick={onClick} className={titleClass}>
|
||||||
</p>
|
{titleText}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to={sessionHref} className={titleClass}>
|
||||||
|
{titleText}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
|
{subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +267,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
|||||||
{timeAgo(agent.updated_at || agent.started_at)}
|
{timeAgo(agent.updated_at || agent.started_at)}
|
||||||
</span>
|
</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>}
|
{realSessionName && <span className="truncate max-w-[10rem]">{realSessionName} ·</span>}
|
||||||
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
|
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -266,4 +282,4 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react";
|
import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
|
|
||||||
interface DateTimePickerProps {
|
interface DateTimePickerProps {
|
||||||
@@ -52,16 +53,22 @@ interface DateTimePickerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
title?: 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({
|
export function DateTimePicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "Select date & time",
|
placeholder,
|
||||||
className = "",
|
className = "",
|
||||||
"aria-label": ariaLabel,
|
"aria-label": ariaLabel,
|
||||||
title,
|
title,
|
||||||
|
endOfDay = false,
|
||||||
}: DateTimePickerProps) {
|
}: DateTimePickerProps) {
|
||||||
|
const { t, i18n } = useTranslation("sessions");
|
||||||
|
const locale = i18n.resolvedLanguage ?? i18n.language;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [alignment, setAlignment] = useState<"left" | "right">("left");
|
const [alignment, setAlignment] = useState<"left" | "right">("left");
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -97,9 +104,18 @@ export function DateTimePicker({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
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) => {
|
const formatDisplay = (d: Date | null) => {
|
||||||
if (!d || isNaN(d.getTime())) return "";
|
if (!d || isNaN(d.getTime())) return "";
|
||||||
return d.toLocaleString(undefined, {
|
return d.toLocaleString(locale, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -112,7 +128,9 @@ export function DateTimePicker({
|
|||||||
if (dateObj && !isNaN(dateObj.getTime())) {
|
if (dateObj && !isNaN(dateObj.getTime())) {
|
||||||
newDate.setHours(dateObj.getHours(), dateObj.getMinutes());
|
newDate.setHours(dateObj.getHours(), dateObj.getMinutes());
|
||||||
} else {
|
} 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);
|
updateValue(newDate);
|
||||||
};
|
};
|
||||||
@@ -135,7 +153,9 @@ export function DateTimePicker({
|
|||||||
const day = String(d.getDate()).padStart(2, "0");
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
const h = String(d.getHours()).padStart(2, "0");
|
const h = String(d.getHours()).padStart(2, "0");
|
||||||
const mi = String(d.getMinutes()).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) => {
|
const clearValue = (e: React.MouseEvent) => {
|
||||||
@@ -168,10 +188,15 @@ export function DateTimePicker({
|
|||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDateClick(i)}
|
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
|
className={`w-6 h-6 flex items-center justify-center rounded text-[11px] transition-colors
|
||||||
${
|
${
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-accent text-white font-medium"
|
? "bg-accent-solid text-white font-medium"
|
||||||
: isToday
|
: isToday
|
||||||
? "bg-surface-3 text-accent font-medium"
|
? "bg-surface-3 text-accent font-medium"
|
||||||
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
|
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
|
||||||
@@ -193,20 +218,27 @@ export function DateTimePicker({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="dialog"
|
||||||
title={title}
|
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" />
|
<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"}`}>
|
<span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
|
||||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
{dateObj ? formatDisplay(dateObj) : (placeholder ?? t("datePicker.placeholder"))}
|
||||||
</span>
|
</span>
|
||||||
{dateObj && (
|
|
||||||
<X
|
|
||||||
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
|
|
||||||
onClick={clearValue}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
</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 && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -219,18 +251,20 @@ export function DateTimePicker({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
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"
|
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs font-medium text-fg-secondary">
|
<span className="text-xs font-medium text-fg-secondary">
|
||||||
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
|
{viewDate.toLocaleString(locale, { month: "long", year: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
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"
|
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@@ -240,8 +274,15 @@ export function DateTimePicker({
|
|||||||
{/* Calendar Grid */}
|
{/* Calendar Grid */}
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
{/* 2026-01-04 is a Sunday: seven local days give the weekday initials. */}
|
||||||
<div key={day} className="w-6 text-center text-[10px] font-medium text-fg-muted">
|
{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}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -253,10 +294,11 @@ export function DateTimePicker({
|
|||||||
<div className="pt-3 border-t border-border flex items-center justify-between">
|
<div className="pt-3 border-t border-border flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5 text-fg-secondary">
|
<div className="flex items-center gap-1.5 text-fg-secondary">
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<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>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
|
aria-label={t("datePicker.time")}
|
||||||
value={timeValue}
|
value={timeValue}
|
||||||
onChange={handleTimeChange}
|
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"
|
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;
|
refs.length > 0 ? t("eventDetail.seeDetailsBelow", { fields: refs.join(" · ") }) : null;
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
|
<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">
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
|
||||||
{t("eventDetail.summary")}
|
{t("eventDetail.summary")}
|
||||||
</span>
|
</span>
|
||||||
@@ -394,8 +394,8 @@ function CodeView({ value }: { value: unknown }) {
|
|||||||
const text = typeof value === "string" ? value : safeStringify(value);
|
const text = typeof value === "string" ? value : safeStringify(value);
|
||||||
|
|
||||||
return (
|
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="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
|
<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">
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide">
|
||||||
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
|
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { Checkbox } from "./Checkbox";
|
import { Checkbox } from "./Checkbox";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search, X, Filter } from "lucide-react";
|
import { Search, X, Filter } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
@@ -115,6 +115,45 @@ export const EMPTY_FILTERS: EventFiltersValue = {
|
|||||||
to: "",
|
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 {
|
export function isEmptyFilters(f: EventFiltersValue): boolean {
|
||||||
return (
|
return (
|
||||||
f.event_type.length === 0 &&
|
f.event_type.length === 0 &&
|
||||||
@@ -180,13 +219,18 @@ export function EventFilters({
|
|||||||
tool_names: [],
|
tool_names: [],
|
||||||
});
|
});
|
||||||
const [searchDraft, setSearchDraft] = useState(value.q);
|
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.
|
// Debounce text search - only push up after 300ms of inactivity.
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||||
searchTimer.current = setTimeout(() => {
|
searchTimer.current = setTimeout(() => {
|
||||||
if (searchDraft !== value.q) onChange({ ...value, q: searchDraft });
|
const latest = valueRef.current;
|
||||||
|
if (searchDraft !== latest.q) onChange({ ...latest, q: searchDraft });
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => {
|
return () => {
|
||||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||||
@@ -248,6 +292,7 @@ export function EventFilters({
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-fg-muted">→</span>
|
<span className="text-xs text-fg-muted">→</span>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
|
endOfDay
|
||||||
value={value.to}
|
value={value.to}
|
||||||
onChange={(val: string) => onChange({ ...value, to: val })}
|
onChange={(val: string) => onChange({ ...value, to: val })}
|
||||||
aria-label={t("eventFilters.to")}
|
aria-label={t("eventFilters.to")}
|
||||||
@@ -301,11 +346,10 @@ export function EventFilters({
|
|||||||
onToggle={(item) => toggle("agent_id", item)}
|
onToggle={(item) => toggle("agent_id", item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!hideSessionFilter && sessionOptions && sessionOptions.length > 0 && (
|
{!hideSessionFilter && (
|
||||||
<ChipGroup
|
<SessionChipGroup
|
||||||
label={t("eventFilters.sessionId")}
|
label={t("eventFilters.sessionId")}
|
||||||
options={sessionOptions.map((s) => s.id)}
|
known={sessionOptions}
|
||||||
labels={Object.fromEntries(sessionOptions.map((s) => [s.id, s.label]))}
|
|
||||||
selected={value.session_id}
|
selected={value.session_id}
|
||||||
onToggle={(item) => toggle("session_id", item)}
|
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({
|
function ChipGroup({
|
||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
selected,
|
selected,
|
||||||
onToggle,
|
onToggle,
|
||||||
labels,
|
labels,
|
||||||
|
onOpenChange,
|
||||||
|
search,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
selected: string[];
|
selected: string[];
|
||||||
onToggle: (item: string) => void;
|
onToggle: (item: string) => void;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
/** Rendered above the options (a search box). */
|
||||||
|
search?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => onOpenChange?.(open), [open, onOpenChange]);
|
||||||
|
|
||||||
// Click-outside dismiss.
|
// Click-outside dismiss.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -368,6 +488,7 @@ function ChipGroup({
|
|||||||
aria-label={label}
|
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"
|
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 ? (
|
{options.length === 0 ? (
|
||||||
<p className="text-[11px] text-fg-muted px-2 py-1 italic">
|
<p className="text-[11px] text-fg-muted px-2 py-1 italic">
|
||||||
{t("eventFilters.noOptions")}
|
{t("eventFilters.noOptions")}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function EventFiltersInfo() {
|
|||||||
<p className="text-[11px] text-fg-secondary mb-2">
|
<p className="text-[11px] text-fg-secondary mb-2">
|
||||||
{t("eventFilters.help.lifecycleDesc")}
|
{t("eventFilters.help.lifecycleDesc")}
|
||||||
</p>
|
</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")}
|
{t("eventFilters.help.lifecycleFlow")}
|
||||||
</code>
|
</code>
|
||||||
</Section>
|
</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,
|
Bot,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
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 { 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 { styleForTool } from "./conversation/toolStyle";
|
||||||
import type { Agent, Session, SessionStats } from "../lib/types";
|
import type { Agent, Session, SessionStats } from "../lib/types";
|
||||||
|
|
||||||
@@ -76,8 +78,8 @@ interface SessionOverviewProps {
|
|||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */
|
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||||
const REFRESH_DEBOUNCE_MS = 600;
|
const REFRESH_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
/** Compact tile used in the top stat row. */
|
/** Compact tile used in the top stat row. */
|
||||||
function StatTile({
|
function StatTile({
|
||||||
@@ -93,24 +95,25 @@ function StatTile({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||||
}) {
|
}) {
|
||||||
|
// Theme tokens only: status colours for states, syntax hues for categories.
|
||||||
const palette = {
|
const palette = {
|
||||||
default: "border-surface-3 bg-surface-2 text-fg-secondary",
|
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",
|
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
|
||||||
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
|
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",
|
rose: "border-status-danger/20 bg-status-danger/5 text-status-danger",
|
||||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
|
cyan: "border-syntax-property/20 bg-syntax-property/5 text-syntax-property",
|
||||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
|
blue: "border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin",
|
||||||
}[tone];
|
}[tone];
|
||||||
|
|
||||||
const iconTone = {
|
const iconTone = {
|
||||||
default: "text-fg-muted",
|
default: "text-fg-muted",
|
||||||
violet: "text-violet-700 dark:text-violet-400",
|
violet: "text-syntax-keyword",
|
||||||
emerald: "text-status-success",
|
emerald: "text-status-success",
|
||||||
amber: "text-status-warning",
|
amber: "text-status-warning",
|
||||||
rose: "text-rose-700 dark:text-rose-400",
|
rose: "text-status-danger",
|
||||||
cyan: "text-cyan-700 dark:text-cyan-400",
|
cyan: "text-syntax-property",
|
||||||
blue: "text-blue-500",
|
blue: "text-syntax-builtin",
|
||||||
}[tone];
|
}[tone];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,8 +161,8 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
const [stats, setStats] = useState<SessionStats | null>(null);
|
||||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
// 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);
|
return () => window.clearInterval(id);
|
||||||
}, [session.status]);
|
}, [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 () => {
|
const fetchStats = async () => {
|
||||||
if (fetchingRef.current) return;
|
if (fetchingRef.current) {
|
||||||
|
pendingRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchingRef.current = true;
|
fetchingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const result = await api.sessions.stats(session.id);
|
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.
|
// Non-fatal - overview just won't update this round.
|
||||||
} finally {
|
} finally {
|
||||||
fetchingRef.current = false;
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [session.id]);
|
}, [session.id]);
|
||||||
|
|
||||||
// Live refresh on websocket events (debounced).
|
// Live refresh on this session's websocket events, throttled (a steady
|
||||||
useEffect(() => {
|
// stream still refreshes) and again after a reconnect.
|
||||||
const unsubscribe = eventBus.subscribe((msg) => {
|
useLiveRefresh(() => void fetchStats(), {
|
||||||
if (isRemoteDataRefreshMessage(msg)) {
|
throttleMs: REFRESH_THROTTLE_MS,
|
||||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
match: (msg) => {
|
||||||
refreshTimerRef.current = setTimeout(() => {
|
if (isRemoteDataRefreshMessage(msg)) return true;
|
||||||
refreshTimerRef.current = null;
|
if (
|
||||||
fetchStats();
|
msg.type !== "new_event" &&
|
||||||
}, REFRESH_DEBOUNCE_MS);
|
msg.type !== "agent_created" &&
|
||||||
return;
|
msg.type !== "agent_updated" &&
|
||||||
}
|
msg.type !== "session_updated"
|
||||||
const isRelevant =
|
)
|
||||||
msg.type === "new_event" ||
|
return false;
|
||||||
msg.type === "agent_created" ||
|
|
||||||
msg.type === "agent_updated" ||
|
|
||||||
msg.type === "session_updated";
|
|
||||||
if (!isRelevant) return;
|
|
||||||
const data = msg.data as { session_id?: string; id?: string };
|
const data = msg.data as { session_id?: string; id?: string };
|
||||||
// Match either by session_id (events) or by id (session_updated)
|
// Match either by session_id (events) or by id (session_updated)
|
||||||
const matchesSession = data.session_id === session.id || data.id === session.id;
|
return 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]);
|
|
||||||
|
|
||||||
// Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name).
|
// Exact totals from the server (`tools_used` is only the top 15). An older
|
||||||
// We approximate "tool calls" as half of that (each call produces a Pre + Post event).
|
// server lacks them: fall back to the visible list's count.
|
||||||
const toolCallCount = useMemo(() => {
|
const toolCallCount = stats?.tool_call_count ?? 0;
|
||||||
if (!stats) return 0;
|
const distinctTools = stats?.distinct_tool_count ?? stats?.tools_used.length ?? 0;
|
||||||
const total = stats.tools_used.reduce((s, t) => s + t.count, 0);
|
|
||||||
return Math.round(total / 2);
|
|
||||||
}, [stats]);
|
|
||||||
|
|
||||||
const maxToolCount = useMemo(() => {
|
const maxToolCount = useMemo(() => {
|
||||||
if (!stats) return 0;
|
if (!stats) return 0;
|
||||||
@@ -249,11 +244,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
// Avg event rate (events / minute)
|
// Avg event rate (events / minute)
|
||||||
const eventRate = useMemo(() => {
|
const eventRate = useMemo(() => {
|
||||||
if (!stats || !session.started_at) return null;
|
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
|
const end = session.ended_at
|
||||||
? new Date(session.ended_at).getTime()
|
? parseDate(session.ended_at).getTime()
|
||||||
: stats.last_event_at
|
: stats.last_event_at
|
||||||
? new Date(stats.last_event_at).getTime()
|
? parseDate(stats.last_event_at).getTime()
|
||||||
: now;
|
: now;
|
||||||
const minutes = Math.max(1, (end - start) / 60_000);
|
const minutes = Math.max(1, (end - start) / 60_000);
|
||||||
return stats.total_events / minutes;
|
return stats.total_events / minutes;
|
||||||
@@ -287,11 +282,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
</span>
|
</span>
|
||||||
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
|
<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">
|
<span className="text-xs text-status-success font-medium flex-shrink-0">
|
||||||
{activeAgent.name || "Agent"}
|
{activeAgent.name || t("overview.agent")}
|
||||||
</span>
|
</span>
|
||||||
{activeAgent.current_tool && (
|
{activeAgent.current_tool && (
|
||||||
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
|
<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 className="text-status-success">{activeAgent.current_tool}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -309,45 +304,51 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
{/* Stat tiles */}
|
{/* Stat tiles */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Events"
|
label={t("overview.events")}
|
||||||
value={stats.total_events.toLocaleString()}
|
value={stats.total_events.toLocaleString()}
|
||||||
hint={
|
hint={
|
||||||
eventRate !== null && eventRate > 0
|
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
|
: undefined
|
||||||
}
|
}
|
||||||
icon={<Activity className="w-3 h-3" />}
|
icon={<Activity className="w-3 h-3" />}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Tool calls"
|
label={t("overview.toolCalls")}
|
||||||
value={toolCallCount.toLocaleString()}
|
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" />}
|
icon={<Wrench className="w-3 h-3" />}
|
||||||
tone="violet"
|
tone="violet"
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Subagents"
|
label={t("overview.subagents")}
|
||||||
value={stats.agents.subagent.toLocaleString()}
|
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" />}
|
icon={<GitBranch className="w-3 h-3" />}
|
||||||
tone="cyan"
|
tone="cyan"
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Compactions"
|
label={t("overview.compactions")}
|
||||||
value={stats.agents.compaction.toLocaleString()}
|
value={stats.agents.compaction.toLocaleString()}
|
||||||
icon={<Layers className="w-3 h-3" />}
|
icon={<Layers className="w-3 h-3" />}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Errors"
|
label={t("overview.errors")}
|
||||||
value={stats.error_count.toLocaleString()}
|
value={stats.error_count.toLocaleString()}
|
||||||
icon={<AlertTriangle className="w-3 h-3" />}
|
icon={<AlertTriangle className="w-3 h-3" />}
|
||||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Duration"
|
label={t("overview.duration")}
|
||||||
value={durationLabel}
|
value={durationLabel}
|
||||||
hint={session.status === "active" ? "running" : "completed"}
|
hint={t(`common:status.${effectiveSessionStatus(session)}`, {
|
||||||
|
defaultValue: effectiveSessionStatus(session),
|
||||||
|
})}
|
||||||
icon={<Clock className="w-3 h-3" />}
|
icon={<Clock className="w-3 h-3" />}
|
||||||
tone={session.status === "active" ? "emerald" : "default"}
|
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="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">
|
<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">
|
<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" />
|
<Wrench className="w-3.5 h-3.5 text-syntax-keyword" />
|
||||||
Top tools
|
{t("overview.topTools")}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-fg-muted font-mono">
|
<span className="text-[10px] text-fg-muted font-mono">
|
||||||
{stats.tools_used.length} total
|
{t("overview.distinctTotal", { count: distinctTools })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{stats.tools_used.length === 0 ? (
|
{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">
|
<div className="space-y-1.5">
|
||||||
{stats.tools_used.slice(0, 8).map((t) => (
|
{stats.tools_used.slice(0, 8).map((t) => (
|
||||||
@@ -406,7 +409,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
if (stats.agents.compaction > 0) {
|
if (stats.agents.compaction > 0) {
|
||||||
rows.push({
|
rows.push({
|
||||||
key: "__compaction__",
|
key: "__compaction__",
|
||||||
label: "Context Compaction",
|
label: t("overview.contextCompaction"),
|
||||||
count: stats.agents.compaction,
|
count: stats.agents.compaction,
|
||||||
isCompaction: true,
|
isCompaction: true,
|
||||||
});
|
});
|
||||||
@@ -418,20 +421,24 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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">
|
<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" />
|
<GitBranch className="w-3.5 h-3.5 text-syntax-property" />
|
||||||
Subagents
|
{t("overview.subagents")}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="text-center py-6 text-xs text-fg-muted">
|
<div className="text-center py-6 text-xs text-fg-muted">
|
||||||
No subagents in this session.
|
{t("overview.noSubagents")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{rows.slice(0, 8).map((r) => {
|
{rows.slice(0, 8).map((r) => {
|
||||||
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
|
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 (
|
return (
|
||||||
<div key={r.key} className="flex items-center gap-2">
|
<div key={r.key} className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -468,9 +475,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
<div className="flex items-center justify-between mb-2.5">
|
<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">
|
<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" />
|
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||||
Token flow
|
{t("overview.tokenFlow")}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
||||||
</div>
|
</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">
|
<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">
|
<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" />
|
<Activity className="w-3.5 h-3.5 text-fg-secondary" />
|
||||||
Event mix
|
{t("overview.eventMix")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{stats.events_by_type.slice(0, 12).map((e) => (
|
{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 }) {
|
function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const segments = [
|
const segments = [
|
||||||
{
|
{
|
||||||
key: "cache_read",
|
key: "cache_read",
|
||||||
label: "Cache read",
|
label: t("common:token.cacheRead"),
|
||||||
value: tokens.cache_read_tokens,
|
value: tokens.cache_read_tokens,
|
||||||
cls: "bg-sky-500",
|
cls: "bg-syntax-builtin",
|
||||||
text: "text-sky-700 dark:text-sky-300",
|
text: "text-syntax-builtin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "cache_write",
|
key: "cache_write",
|
||||||
label: "Cache write",
|
label: t("common:token.cacheWrite"),
|
||||||
value: tokens.cache_write_tokens,
|
value: tokens.cache_write_tokens,
|
||||||
cls: "bg-violet-500",
|
cls: "bg-syntax-keyword",
|
||||||
text: "text-violet-700 dark:text-violet-300",
|
text: "text-syntax-keyword",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "input",
|
key: "input",
|
||||||
label: "Input",
|
label: t("common:token.input"),
|
||||||
value: tokens.input_tokens,
|
value: tokens.input_tokens,
|
||||||
cls: "bg-status-success",
|
cls: "bg-status-success",
|
||||||
text: "text-status-success",
|
text: "text-status-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "output",
|
key: "output",
|
||||||
label: "Output",
|
label: t("common:token.output"),
|
||||||
value: tokens.output_tokens,
|
value: tokens.output_tokens,
|
||||||
cls: "bg-orange-500",
|
cls: "bg-syntax-number",
|
||||||
text: "text-orange-700 dark:text-orange-300",
|
text: "text-syntax-number",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,26 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "./ContextMenu";
|
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 };
|
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({
|
export function SessionRowMenu({
|
||||||
session,
|
session,
|
||||||
x,
|
x,
|
||||||
@@ -26,7 +43,7 @@ export function SessionRowMenu({
|
|||||||
onClose();
|
onClose();
|
||||||
fn();
|
fn();
|
||||||
};
|
};
|
||||||
const copy = (text: string) => pick(() => void navigator.clipboard.writeText(text));
|
const copy = (text: string) => pick(() => void copyText(text));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
|
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
|
||||||
@@ -34,11 +51,7 @@ export function SessionRowMenu({
|
|||||||
{t("menu.details")}
|
{t("menu.details")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{session.lane_id != null && (
|
{session.lane_id != null && (
|
||||||
<MenuItem
|
<MenuItem onSelect={pick(() => navigate(workspaceHref(session)!))}>
|
||||||
onSelect={pick(() =>
|
|
||||||
navigate(`/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t("menu.inWorkspace")}
|
{t("menu.inWorkspace")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -68,7 +68,10 @@ export function SplashScreen() {
|
|||||||
const { t } = useTranslation("splash");
|
const { t } = useTranslation("splash");
|
||||||
// Show at most once per tab session. Read synchronously so we never flash an
|
// 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.).
|
// 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(() => {
|
const [mounted, setMounted] = useState(() => {
|
||||||
|
if (window.location.pathname !== "/") return false;
|
||||||
try {
|
try {
|
||||||
return !sessionStorage.getItem(SESSION_KEY);
|
return !sessionStorage.getItem(SESSION_KEY);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -109,7 +112,11 @@ export function SplashScreen() {
|
|||||||
/* sessionStorage may be unavailable (private mode) - show anyway */
|
/* sessionStorage may be unavailable (private mode) - show anyway */
|
||||||
}
|
}
|
||||||
exitTimer.current = window.setTimeout(beginExit, HOLD_MS);
|
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 () => {
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
if (exitTimer.current) window.clearTimeout(exitTimer.current);
|
if (exitTimer.current) window.clearTimeout(exitTimer.current);
|
||||||
if (doneTimer.current) window.clearTimeout(doneTimer.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 type { LucideIcon } from "lucide-react";
|
||||||
import { Tip } from "./Tip";
|
import { Tip } from "./Tip";
|
||||||
import { StatValueSkeleton } from "./Skeleton";
|
import { StatValueSkeleton } from "./Skeleton";
|
||||||
@@ -74,6 +75,8 @@ interface StatCardProps {
|
|||||||
raw?: string;
|
raw?: string;
|
||||||
/** When true, render skeletons in place of value/trend. */
|
/** When true, render skeletons in place of value/trend. */
|
||||||
loading?: boolean;
|
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",
|
accentColor = "text-accent",
|
||||||
raw,
|
raw,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
to,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
return (
|
const body = (
|
||||||
<div className="card p-5">
|
<>
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
||||||
{label}
|
{label}
|
||||||
@@ -109,6 +113,16 @@ export function StatCard({
|
|||||||
<span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
|
<span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
* The "why" chip nested inside a Waiting badge: a small rounded pill with the
|
||||||
* reason's icon and short label. Urgent reasons (permission prompts,
|
* reason's icon and short label. Urgent reasons (permission prompts,
|
||||||
* interruptions) get a hotter amber fill than the calm idle-between-turns
|
* interruptions) get the amber warning fill; the calm idle-between-turns
|
||||||
* ones so a scan of a list surfaces the rows that actually block on the human.
|
* ones stay neutral, so a scan of a list surfaces the rows that actually block on the human.
|
||||||
*/
|
*/
|
||||||
function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
||||||
const { t } = useTranslation();
|
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 ${
|
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
|
||||||
cfg.urgent
|
cfg.urgent
|
||||||
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
|
? "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" />
|
<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 { container } = renderCard(<AgentCard agent={makeAgent({ status: "waiting" })} />);
|
||||||
const card = container.querySelector(".card-hover");
|
const card = container.querySelector(".card-hover");
|
||||||
expect(card?.className).toContain("border-l-2");
|
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", () => {
|
it("should not apply active border for completed agents", () => {
|
||||||
@@ -267,7 +267,7 @@ describe("AgentCard", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||||
const card = container.querySelector(".card-hover");
|
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", () => {
|
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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
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 type { EventFiltersValue } from "../EventFilters";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
@@ -82,4 +88,49 @@ describe("EventFilters", () => {
|
|||||||
fireEvent.click(clear);
|
fireEvent.click(clear);
|
||||||
expect(onChange).toHaveBeenCalledWith(EMPTY_FILTERS);
|
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();
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||||
const dot = container.querySelector(".animate-pulse-dot");
|
const dot = container.querySelector(".animate-pulse-dot");
|
||||||
expect(dot).toBeInTheDocument();
|
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();
|
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||||
const dot = container.querySelector(".animate-pulse-dot");
|
const dot = container.querySelector(".animate-pulse-dot");
|
||||||
expect(dot).toBeInTheDocument();
|
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", () => {
|
it("marks urgent reasons with the hotter amber tint", () => {
|
||||||
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
|
render(<AgentStatusBadge status="waiting" reason="notification" />);
|
||||||
expect(container.querySelector(".text-status-warning")).toBeInTheDocument();
|
expect(screen.getByText("Needs input").closest("span.rounded-full")).toHaveClass(
|
||||||
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
|
"text-status-warning"
|
||||||
expect(calm.querySelector(".text-status-warning")).not.toBeInTheDocument();
|
);
|
||||||
|
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", () => {
|
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { Check, Copy, FileCode } from "lucide-react";
|
import { Check, Copy, FileCode } from "lucide-react";
|
||||||
import { canonicalLang, highlight, tokenClass, type Token } from "../../lib/highlight";
|
import { canonicalLang, highlight, tokenClass, type Token } from "../../lib/highlight";
|
||||||
|
|
||||||
@@ -122,14 +124,13 @@ export function CodeBlock({
|
|||||||
const lineTokens = useMemo(() => splitTokensByLine(tokens), [tokens]);
|
const lineTokens = useMemo(() => splitTokensByLine(tokens), [tokens]);
|
||||||
const totalLines = lineTokens.length;
|
const totalLines = lineTokens.length;
|
||||||
const gutter = showLineNumbers ?? totalLines >= 4;
|
const gutter = showLineNumbers ?? totalLines >= 4;
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
// copyText reports a failure itself (non-secure origin, no permission).
|
||||||
await navigator.clipboard.writeText(code);
|
if (await copyText(code, true)) {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
window.setTimeout(() => setCopied(false), 1500);
|
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">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{totalLines > 1 && (
|
{totalLines > 1 && (
|
||||||
<span className="text-fg-muted font-mono">
|
<span className="text-fg-muted font-mono">
|
||||||
{totalLines} {totalLines === 1 ? "line" : "lines"}
|
{t("conversation.lines", { count: totalLines })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -190,15 +191,15 @@ export function CodeBlock({
|
|||||||
className={`inline-flex items-center gap-1 transition-colors ${
|
className={`inline-flex items-center gap-1 transition-colors ${
|
||||||
copied ? "text-status-success" : "text-fg-muted hover:text-fg-secondary"
|
copied ? "text-status-success" : "text-fg-muted hover:text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
aria-label="Copy code"
|
aria-label={t("conversation.copyCode")}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{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>
|
</button>
|
||||||
|
|||||||
@@ -54,11 +54,12 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
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 { api } from "../../lib/api";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||||
|
|
||||||
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
||||||
@@ -77,6 +78,7 @@ interface ConversationViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
|
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
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
|
// manual refresh), we queue exactly one re-fetch so events that landed
|
||||||
// during the in-flight request aren't silently dropped.
|
// during the in-flight request aren't silently dropped.
|
||||||
const pendingFetchRef = useRef(false);
|
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
|
// Refresh-button spinner state - separate from initial `loading` so the
|
||||||
// existing skeleton doesn't blink during a manual refresh.
|
// existing skeleton doesn't blink during a manual refresh.
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@@ -117,7 +122,9 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadTranscripts();
|
loadTranscripts();
|
||||||
const interval = window.setInterval(loadTranscripts, TRANSCRIPTS_REFRESH_MS);
|
const interval = window.setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") loadTranscripts();
|
||||||
|
}, TRANSCRIPTS_REFRESH_MS);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
@@ -134,6 +141,9 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
// Initial load: fetch the latest N messages
|
// Initial load: fetch the latest N messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
genRef.current++;
|
||||||
|
lastLineRef.current = 0;
|
||||||
|
firstLineRef.current = 0;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -152,7 +162,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
firstLineRef.current = result.first_line;
|
firstLineRef.current = result.first_line;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setError(err instanceof Error ? err.message : "Failed to load transcript");
|
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -184,12 +194,14 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
pendingFetchRef.current = false;
|
pendingFetchRef.current = false;
|
||||||
|
|
||||||
const wasBootstrap = lastLineRef.current === 0;
|
const wasBootstrap = lastLineRef.current === 0;
|
||||||
|
const gen = genRef.current;
|
||||||
try {
|
try {
|
||||||
const result = await api.sessions.transcript(sessionId, {
|
const result = await api.sessions.transcript(sessionId, {
|
||||||
agent_id: selectedTranscript || undefined,
|
agent_id: selectedTranscript || undefined,
|
||||||
...(wasBootstrap ? {} : { after: lastLineRef.current }),
|
...(wasBootstrap ? {} : { after: lastLineRef.current }),
|
||||||
limit: 50,
|
limit: 50,
|
||||||
});
|
});
|
||||||
|
if (gen !== genRef.current) return; // another transcript is shown now
|
||||||
if (result.messages.length === 0) return;
|
if (result.messages.length === 0) return;
|
||||||
|
|
||||||
lastLineRef.current = result.last_line;
|
lastLineRef.current = result.last_line;
|
||||||
@@ -393,31 +405,31 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
<select
|
<select
|
||||||
value={selectedTranscript || ""}
|
value={selectedTranscript || ""}
|
||||||
onChange={(e) => setSelectedTranscript(e.target.value || null)}
|
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) => (
|
{transcripts.map((tr) => (
|
||||||
<option key={t.id} value={t.id}>
|
<option key={tr.id} value={tr.id}>
|
||||||
{t.name}
|
{tr.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</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">
|
<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" />
|
<MessagesSquare className="w-3 h-3" />
|
||||||
{total} message{total !== 1 ? "s" : ""}
|
{t("conversation.messages", { count: total })}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
disabled={refreshing || loading}
|
disabled={refreshing || loading}
|
||||||
title="Refresh conversation"
|
title={t("conversation.refresh")}
|
||||||
aria-label="Refresh conversation"
|
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-violet-500/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
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" : ""}`} />
|
<RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
{t("common:refresh")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -440,30 +452,26 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
{loadingHistory && (
|
{loadingHistory && (
|
||||||
<div className="flex justify-center py-3">
|
<div className="flex justify-center py-3">
|
||||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scroll-up for history hint */}
|
{/* Scroll-up for history hint */}
|
||||||
{hasMore && !loadingHistory && !loading && (
|
{hasMore && !loadingHistory && !loading && (
|
||||||
<div className="flex justify-center py-2">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||||
Loading conversation...
|
{t("conversation.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="mx-auto max-w-md py-12 text-center">
|
<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">
|
<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.
|
{t("conversation.emptyBody")}
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -478,10 +486,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
setShowNewMsg(false);
|
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" />
|
<ArrowDown className="w-3 h-3" />
|
||||||
New messages
|
{t("conversation.newMessages")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -76,45 +78,35 @@ import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../
|
|||||||
* and accent so attribution is unambiguous. */
|
* and accent so attribution is unambiguous. */
|
||||||
const SENDER_STYLES: Record<
|
const SENDER_STYLES: Record<
|
||||||
TranscriptSender,
|
TranscriptSender,
|
||||||
{ label: string; icon: typeof User; avatarRing: string; accentBar: string; headerText: string }
|
{ icon: typeof User; avatarRing: string; accentBar: string; headerText: string }
|
||||||
> = {
|
> = {
|
||||||
user: {
|
user: {
|
||||||
label: "User",
|
|
||||||
icon: User,
|
icon: User,
|
||||||
avatarRing:
|
avatarRing: "bg-syntax-builtin/15 text-syntax-builtin ring-1 ring-syntax-builtin/30",
|
||||||
"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-syntax-builtin/40",
|
||||||
accentBar: "before:bg-blue-600/40",
|
headerText: "text-syntax-builtin",
|
||||||
headerText: "text-blue-700 dark:text-blue-300",
|
|
||||||
},
|
},
|
||||||
assistant: {
|
assistant: {
|
||||||
label: "Assistant",
|
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
avatarRing:
|
avatarRing: "bg-syntax-keyword/15 text-syntax-keyword ring-1 ring-syntax-keyword/30",
|
||||||
"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-syntax-keyword/40",
|
||||||
accentBar: "before:bg-violet-500/40",
|
headerText: "text-syntax-keyword",
|
||||||
headerText: "text-violet-800 dark:text-violet-200",
|
|
||||||
},
|
},
|
||||||
orchestrator: {
|
orchestrator: {
|
||||||
label: "Main agent",
|
|
||||||
icon: Workflow,
|
icon: Workflow,
|
||||||
avatarRing:
|
avatarRing: "bg-syntax-string/15 text-syntax-string ring-1 ring-syntax-string/30",
|
||||||
"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-syntax-string/40",
|
||||||
accentBar: "before:bg-teal-500/40",
|
headerText: "text-syntax-string",
|
||||||
headerText: "text-teal-800 dark:text-teal-200",
|
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
label: "System",
|
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
avatarRing:
|
avatarRing: "bg-surface-4 text-fg-secondary ring-1 ring-border-light/30",
|
||||||
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-fg-secondary ring-1 ring-border-light/30",
|
|
||||||
accentBar: "before:bg-surface-4/40",
|
accentBar: "before:bg-surface-4/40",
|
||||||
headerText: "text-fg-secondary",
|
headerText: "text-fg-secondary",
|
||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
label: "Tool",
|
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
avatarRing:
|
avatarRing: "bg-status-warning/15 text-status-warning ring-1 ring-status-warning/30",
|
||||||
"bg-gradient-to-br from-status-warning/30 to-orange-500/20 text-status-warning ring-1 ring-status-warning/30",
|
|
||||||
accentBar: "before:bg-status-warning/40",
|
accentBar: "before:bg-status-warning/40",
|
||||||
headerText: "text-status-warning",
|
headerText: "text-status-warning",
|
||||||
},
|
},
|
||||||
@@ -242,7 +234,7 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
|||||||
key={key}
|
key={key}
|
||||||
text={seg.text}
|
text={seg.text}
|
||||||
icon={<AlertTriangle className="w-3.5 h-3.5 text-status-warning/70 flex-shrink-0" />}
|
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"
|
borderClass="border-status-warning/20"
|
||||||
bgClass="bg-status-warning/5"
|
bgClass="bg-status-warning/5"
|
||||||
textClass="text-status-warning/80"
|
textClass="text-status-warning/80"
|
||||||
@@ -253,13 +245,11 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
|||||||
<CollapsibleBlock
|
<CollapsibleBlock
|
||||||
key={key}
|
key={key}
|
||||||
text={seg.text}
|
text={seg.text}
|
||||||
icon={
|
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-keyword/70 flex-shrink-0" />}
|
||||||
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
|
title={i18n.t("sessions:conversation.persistedOutput")}
|
||||||
}
|
borderClass="border-syntax-keyword/20"
|
||||||
title="Persisted output"
|
bgClass="bg-syntax-keyword/5"
|
||||||
borderClass="border-violet-500/20"
|
textClass="text-syntax-keyword"
|
||||||
bgClass="bg-violet-500/5"
|
|
||||||
textClass="text-violet-700/80 dark:text-violet-300/80"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "text": {
|
case "text": {
|
||||||
@@ -296,6 +286,7 @@ function CollapsibleBlock({
|
|||||||
<div className={`rounded-lg border ${borderClass} ${bgClass} overflow-hidden`}>
|
<div className={`rounded-lg border ${borderClass} ${bgClass} overflow-hidden`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
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"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:opacity-80 transition-colors"
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{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) {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||||
Loading conversation...
|
{t("conversation.loading")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return <div className="text-center py-12 text-fg-muted text-sm">{t("conversation.empty")}</div>;
|
||||||
<div className="text-center py-12 text-fg-muted text-sm">No conversation records found.</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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{messages.map((msg, idx) => {
|
{messages.map((msg, idx) => {
|
||||||
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
||||||
// not as a user/assistant row.
|
// not as a user/assistant row.
|
||||||
if (msg.type === "session_event") {
|
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
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 { useState } from "react";
|
||||||
import { ChevronRight, AlertCircle, FileText, CheckCircle2 } from "lucide-react";
|
import { ChevronRight, AlertCircle, FileText, CheckCircle2 } from "lucide-react";
|
||||||
import type { TranscriptContent } from "../../lib/types";
|
import type { TranscriptContent } from "../../lib/types";
|
||||||
@@ -107,6 +108,9 @@ function buildSummary(toolUse: TranscriptContent): string | null {
|
|||||||
return 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. */
|
/** Render the input pane with tool-aware formatting. */
|
||||||
function renderInput(toolUse: TranscriptContent) {
|
function renderInput(toolUse: TranscriptContent) {
|
||||||
const input = toolUse.input;
|
const input = toolUse.input;
|
||||||
@@ -118,7 +122,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={String((input as { _truncated: string })._truncated)}
|
code={String((input as { _truncated: string })._truncated)}
|
||||||
lang="plain"
|
lang="plain"
|
||||||
label="Input (truncated)"
|
label={tt("inputTruncated")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,7 +134,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
if (tool === "bash" && typeof obj.command === "string") {
|
if (tool === "bash" && typeof obj.command === "string") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<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" && (
|
{typeof obj.description === "string" && (
|
||||||
<p className="text-xs text-fg-muted italic px-1">{obj.description}</p>
|
<p className="text-xs text-fg-muted italic px-1">{obj.description}</p>
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +149,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
code={obj.content}
|
code={obj.content}
|
||||||
lang={langFromPath(obj.file_path)}
|
lang={langFromPath(obj.file_path)}
|
||||||
filename={obj.file_path}
|
filename={obj.file_path}
|
||||||
label="New file"
|
label={tt("newFile")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,19 +160,19 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
|
<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>
|
<span className="font-mono">{obj.file_path}</span>
|
||||||
{obj.replace_all === true && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{typeof obj.old_string === "string" && (
|
{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" && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -178,7 +182,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
if (tool === "read" && typeof obj.file_path === "string") {
|
if (tool === "read" && typeof obj.file_path === "string") {
|
||||||
return (
|
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">
|
<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>
|
<span className="font-mono break-all">{obj.file_path}</span>
|
||||||
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
||||||
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
|
<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="space-y-1.5">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||||
Pattern
|
{tt("pattern")}
|
||||||
</span>
|
</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}
|
{obj.pattern}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
{typeof obj.path === "string" && (
|
{typeof obj.path === "string" && (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||||
Path
|
{tt("path")}
|
||||||
</span>
|
</span>
|
||||||
<code className="font-mono text-fg-secondary">{obj.path}</code>
|
<code className="font-mono text-fg-secondary">{obj.path}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +217,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
{typeof obj.glob === "string" && (
|
{typeof obj.glob === "string" && (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||||
Glob
|
{tt("glob")}
|
||||||
</span>
|
</span>
|
||||||
<code className="font-mono text-fg-secondary">{obj.glob}</code>
|
<code className="font-mono text-fg-secondary">{obj.glob}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,17 +227,18 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default: pretty JSON
|
// 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. */
|
/** Render the result pane: detect diff/json/text. */
|
||||||
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||||
const text = toolResult.output ?? "";
|
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 isError = !!toolResult.is_error;
|
||||||
const tool = toolName.toLowerCase();
|
const tool = toolName.toLowerCase();
|
||||||
const label = isError ? "Error" : "Output";
|
const label = isError ? tt("error") : tt("output");
|
||||||
|
|
||||||
// Heuristics for language
|
// Heuristics for language
|
||||||
let lang = "plain";
|
let lang = "plain";
|
||||||
@@ -273,6 +278,7 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
|
|||||||
{/* Collapsed/expanded toggle */}
|
{/* Collapsed/expanded toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
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"
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -297,16 +303,16 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
|
|||||||
{isError ? (
|
{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">
|
<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" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
error
|
{tt("statusError")}
|
||||||
</span>
|
</span>
|
||||||
) : hasResult ? (
|
) : 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">
|
<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" />
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
ok
|
{tt("statusOk")}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-fg-muted text-[10px] uppercase tracking-wider font-mono">
|
<span className="text-fg-muted text-[10px] uppercase tracking-wider font-mono">
|
||||||
pending
|
{tt("statusPending")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</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 }) {
|
export function Terminal({ command, description }: { command: string; description?: string }) {
|
||||||
return (
|
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="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">terminal</span>
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide">terminal</span>
|
||||||
<CopyButton text={command} />
|
<CopyButton text={command} />
|
||||||
</div>
|
</div>
|
||||||
@@ -207,8 +207,8 @@ function OutputBlock({
|
|||||||
}) {
|
}) {
|
||||||
const color = variant === "err" ? "text-status-danger" : "text-fg-secondary";
|
const color = variant === "err" ? "text-status-danger" : "text-fg-secondary";
|
||||||
return (
|
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="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>
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
|
||||||
<CopyButton text={text} />
|
<CopyButton text={text} />
|
||||||
</div>
|
</div>
|
||||||
@@ -234,9 +234,9 @@ export function LineNumberedCode({
|
|||||||
}) {
|
}) {
|
||||||
const lines = text.split(/\r?\n/);
|
const lines = text.split(/\r?\n/);
|
||||||
return (
|
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 && (
|
{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>
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
|
||||||
<CopyButton text={text} />
|
<CopyButton text={text} />
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +246,7 @@ export function LineNumberedCode({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{lines.map((line, i) => (
|
{lines.map((line, i) => (
|
||||||
<tr key={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}
|
{i + startLine}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 text-fg-secondary whitespace-pre-wrap break-words">{line}</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 <p className="text-[11px] text-fg-muted italic">no diff</p>;
|
||||||
}
|
}
|
||||||
return (
|
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">
|
<div className="overflow-auto max-h-96">
|
||||||
{hunks.map((hunk, i) => (
|
{hunks.map((hunk, i) => (
|
||||||
<HunkView key={i} hunk={hunk} />
|
<HunkView key={i} hunk={hunk} />
|
||||||
|
|||||||
@@ -58,8 +58,11 @@
|
|||||||
|
|
||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as d3 from "d3";
|
|
||||||
import type { CompactionImpactData } from "../../lib/types";
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -122,11 +125,11 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
|||||||
.attr("y1", "0%")
|
.attr("y1", "0%")
|
||||||
.attr("x2", "0%")
|
.attr("x2", "0%")
|
||||||
.attr("y2", "100%");
|
.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
|
grad
|
||||||
.append("stop")
|
.append("stop")
|
||||||
.attr("offset", "100%")
|
.attr("offset", "100%")
|
||||||
.attr("stop-color", "#3730a3")
|
.attr("stop-color", "rgb(var(--accent-solid))")
|
||||||
.attr("stop-opacity", 0.7);
|
.attr("stop-opacity", 0.7);
|
||||||
|
|
||||||
const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
|
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 barH = innerH - by;
|
||||||
|
|
||||||
const bg = g.append("g");
|
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) {
|
if (d.sessions > 0) {
|
||||||
bar = bg
|
bar = bg
|
||||||
@@ -254,7 +257,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
|||||||
.attr("fill", "transparent")
|
.attr("fill", "transparent")
|
||||||
.style("cursor", "pointer")
|
.style("cursor", "pointer")
|
||||||
.on("mouseenter", (event: MouseEvent) => {
|
.on("mouseenter", (event: MouseEvent) => {
|
||||||
if (bar) bar.attr("fill", "#a5b4fc");
|
if (bar) bar.attr("fill", "rgb(var(--accent-hover))");
|
||||||
opts.onHover(event, d);
|
opts.onHover(event, d);
|
||||||
})
|
})
|
||||||
.on("mousemove", (event: MouseEvent) => opts.onMove(event))
|
.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 avgPerSession = affected > 0 ? data.totalCompactions / affected : 0;
|
||||||
const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 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(() => {
|
useEffect(() => {
|
||||||
if (!svgRef.current || !hasData) return;
|
if (!svgRef.current || !hasData) return;
|
||||||
const histo = toHistogram(data.perSession);
|
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)),
|
onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)),
|
||||||
onLeave: () => setTip(null),
|
onLeave: () => setTip(null),
|
||||||
});
|
});
|
||||||
}, [data, hasData, t]);
|
}, [data, hasData, t, width]);
|
||||||
|
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ErrorPropagationData } from "../../lib/types";
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -240,7 +245,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
height="14"
|
height="14"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#f59e0b"
|
stroke="rgb(var(--status-warning))"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
import type { PipelineStats } from "../../lib/types";
|
import type { PipelineStats } from "../../lib/types";
|
||||||
|
|
||||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
/** 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.
|
* Per-node pass statistics for the selected pipeline template.
|
||||||
* @returns The panel element, or an empty-state hint when nothing was recorded.
|
* @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 { t } = useTranslation("workflows");
|
||||||
const [data, setData] = useState<PipelineStats | null>(null);
|
const [data, setData] = useState<PipelineStats | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -46,18 +51,14 @@ export function LanePipelinePanel() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
if (data) onAvailability?.(hasPipelines);
|
||||||
const unsub = eventBus.subscribe(() => {
|
}, [data, hasPipelines, onAvailability]);
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => void refresh(), 3000);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unsub();
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
if (error) return <p className="text-xs text-status-danger">{error}</p>;
|
if (error) return <p className="text-xs text-status-danger">{error}</p>;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
@@ -71,7 +72,7 @@ export function LanePipelinePanel() {
|
|||||||
<select
|
<select
|
||||||
data-testid="pipeline-stats-lane-picker"
|
data-testid="pipeline-stats-lane-picker"
|
||||||
aria-label={t("lanes.laneLabel")}
|
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 ?? ""}
|
value={laneId ?? ""}
|
||||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||||
>
|
>
|
||||||
@@ -113,7 +114,7 @@ export function LanePipelinePanel() {
|
|||||||
<select
|
<select
|
||||||
data-testid="pipeline-stats-picker"
|
data-testid="pipeline-stats-picker"
|
||||||
aria-label={t("lanes.pipelineLabel")}
|
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}
|
value={selected.id}
|
||||||
onChange={(e) => setSelectedId(e.target.value)}
|
onChange={(e) => setSelectedId(e.target.value)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -47,8 +47,11 @@
|
|||||||
|
|
||||||
import { useRef, useEffect, useMemo, useState, useCallback } from "react";
|
import { useRef, useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as d3 from "d3";
|
|
||||||
import type { OrchestrationData } from "../../lib/types";
|
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 ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,52 +107,45 @@ const BADGE_H = 14;
|
|||||||
|
|
||||||
// Layer labels are now computed via i18n inside the component
|
// Layer labels are now computed via i18n inside the component
|
||||||
|
|
||||||
const OUTCOME_COLORS: Record<string, { fill: string; stroke: string; text: string }> = {
|
// Every colour is a theme token (or a mix of one over the card surface), so
|
||||||
completed: { fill: "#052e16", stroke: "#16a34a", text: "#4ade80" },
|
// the DAG reads in light themes too — it used to be dark-only hex.
|
||||||
error: { fill: "#1f0808", stroke: "#dc2626", text: "#f87171" },
|
const tok = (v: string) => `rgb(var(${v}))`;
|
||||||
abandoned: { fill: "#1c1a04", stroke: "#ca8a04", text: "#facc15" },
|
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<
|
const OUTCOME_COLORS: Record<string, { fill: string; stroke: string; text: string }> =
|
||||||
DAGNode["kind"],
|
Object.fromEntries(
|
||||||
{ id: string; stops: Array<{ offset: string; color: string }> }
|
Object.entries(OUTCOME_VAR).map(([k, v]) => [
|
||||||
> = {
|
k,
|
||||||
session: {
|
{ fill: mix(v, 15), stroke: tok(v), text: tok(v) },
|
||||||
id: "grad-session",
|
])
|
||||||
stops: [
|
);
|
||||||
{ offset: "0%", color: "#312e81" },
|
|
||||||
{ offset: "100%", color: "#4338ca" },
|
const KIND_GRADIENTS = Object.fromEntries(
|
||||||
],
|
(Object.keys(KIND_VAR) as DAGNode["kind"][]).map((kind) => [
|
||||||
},
|
kind,
|
||||||
main: {
|
{
|
||||||
id: "grad-main",
|
id: `grad-${kind}`,
|
||||||
stops: [
|
stops: [
|
||||||
{ offset: "0%", color: "#1e3a5f" },
|
{ offset: "0%", color: mix(KIND_VAR[kind], 10) },
|
||||||
{ offset: "100%", color: "#1d4ed8" },
|
{ offset: "100%", color: mix(KIND_VAR[kind], 25) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
subagent: {
|
])
|
||||||
id: "grad-subagent",
|
) as Record<DAGNode["kind"], { id: string; stops: Array<{ offset: string; color: string }> }>;
|
||||||
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" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -168,7 +164,13 @@ function successRate(completed: number, total: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function outcomeColorSet(status: 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 ────────────────────────────────────────────────────────────
|
// ── Layout builder ────────────────────────────────────────────────────────────
|
||||||
@@ -641,7 +643,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
.append("path")
|
.append("path")
|
||||||
.attr("d", path)
|
.attr("d", path)
|
||||||
.attr("fill", "none")
|
.attr("fill", "none")
|
||||||
.attr("stroke", "#6366f1")
|
.attr("stroke", tok("--accent"))
|
||||||
.attr("stroke-width", stroke + 2)
|
.attr("stroke-width", stroke + 2)
|
||||||
.attr("stroke-opacity", isZero ? 0 : 0.08)
|
.attr("stroke-opacity", isZero ? 0 : 0.08)
|
||||||
.attr("filter", "url(#edge-glow)");
|
.attr("filter", "url(#edge-glow)");
|
||||||
@@ -651,7 +653,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
.append("path")
|
.append("path")
|
||||||
.attr("d", path)
|
.attr("d", path)
|
||||||
.attr("fill", "none")
|
.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-width", isZero ? 1 : stroke)
|
||||||
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
|
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
|
||||||
.attr("stroke-linecap", "round");
|
.attr("stroke-linecap", "round");
|
||||||
@@ -698,7 +700,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
.attr("height", (d) => d.height + 6)
|
.attr("height", (d) => d.height + 6)
|
||||||
.attr("rx", NODE_RX + 3)
|
.attr("rx", NODE_RX + 3)
|
||||||
.attr("fill", "none")
|
.attr("fill", "none")
|
||||||
.attr("stroke", "#6366f1")
|
.attr("stroke", tok("--accent"))
|
||||||
.attr("stroke-width", 2)
|
.attr("stroke-width", 2)
|
||||||
.attr("filter", "url(#glow)")
|
.attr("filter", "url(#glow)")
|
||||||
.attr("opacity", 0.8);
|
.attr("opacity", 0.8);
|
||||||
@@ -717,7 +719,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
return `url(#${KIND_GRADIENTS[d.kind].id})`;
|
return `url(#${KIND_GRADIENTS[d.kind].id})`;
|
||||||
})
|
})
|
||||||
.attr("stroke", (d) => {
|
.attr("stroke", (d) => {
|
||||||
if (d.id === selectedNode) return "#6366f1";
|
if (d.id === selectedNode) return tok("--accent");
|
||||||
if (d.kind === "outcome" && d.meta?.status) {
|
if (d.kind === "outcome" && d.meta?.status) {
|
||||||
return outcomeColorSet(d.meta.status).stroke;
|
return outcomeColorSet(d.meta.status).stroke;
|
||||||
}
|
}
|
||||||
@@ -831,18 +833,22 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
{t("orchestration.legend")}
|
{t("orchestration.legend")}
|
||||||
</span>
|
</span>
|
||||||
{LEGEND_ITEMS.map((item) => (
|
{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
|
<span
|
||||||
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
|
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
|
||||||
style={{ background: item.color, border: `1px solid ${item.border}` }}
|
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>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
<span
|
<span
|
||||||
className="inline-block h-[2px] w-8 rounded flex-shrink-0"
|
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>
|
<span className="text-[11px] text-fg-muted">{t("orchestration.edgeWeight")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1014,51 +1020,16 @@ function describeNode(node: DAGNode, t: TFn): { layer: string; description: stri
|
|||||||
// ── Utility functions ─────────────────────────────────────────────────────────
|
// ── Utility functions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function borderColorForKind(kind: DAGNode["kind"]): string {
|
function borderColorForKind(kind: DAGNode["kind"]): string {
|
||||||
switch (kind) {
|
return tok(KIND_VAR[kind]);
|
||||||
case "session":
|
|
||||||
return "#6366f1";
|
|
||||||
case "main":
|
|
||||||
return "#3b82f6";
|
|
||||||
case "subagent":
|
|
||||||
return "#22c55e";
|
|
||||||
case "nested":
|
|
||||||
return "#14b8a6";
|
|
||||||
case "outcome":
|
|
||||||
return "#6366f1";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function textColorForKind(kind: DAGNode["kind"]): string {
|
function textColorForKind(kind: DAGNode["kind"]): string {
|
||||||
switch (kind) {
|
return tok(KIND_VAR[kind]);
|
||||||
case "session":
|
|
||||||
return "#a5b4fc";
|
|
||||||
case "main":
|
|
||||||
return "#93c5fd";
|
|
||||||
case "subagent":
|
|
||||||
return "#86efac";
|
|
||||||
case "nested":
|
|
||||||
return "#5eead4";
|
|
||||||
case "outcome":
|
|
||||||
return "#c4b5fd";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeBgForKind(kind: DAGNode["kind"], status?: string): string {
|
function badgeBgForKind(kind: DAGNode["kind"], status?: string): string {
|
||||||
if (kind === "outcome" && status) {
|
if (kind === "outcome" && status) return mix(OUTCOME_VAR[status] ?? "--border", 25);
|
||||||
return outcomeColorSet(status).stroke + "33";
|
return mix(KIND_VAR[kind], 25);
|
||||||
}
|
|
||||||
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)";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtCount(n: number): string {
|
function fmtCount(n: number): string {
|
||||||
@@ -1070,11 +1041,11 @@ function fmtCount(n: number): string {
|
|||||||
// ── Legend data ───────────────────────────────────────────────────────────────
|
// ── Legend data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LEGEND_ITEMS = [
|
const LEGEND_ITEMS = [
|
||||||
{ label: "Sessions", color: "#312e81", border: "#6366f1" },
|
{ key: "sessions", v: "--accent" },
|
||||||
{ label: "Main Agent", color: "#1e3a5f", border: "#3b82f6" },
|
{ key: "mainAgent", v: "--syn-builtin" },
|
||||||
{ label: "Subagent Types", color: "#052e16", border: "#22c55e" },
|
{ key: "subagentTypes", v: "--status-success" },
|
||||||
{ label: "Compactions", color: "#134e4a", border: "#14b8a6" },
|
{ key: "compactions", v: "--syn-property" },
|
||||||
{ label: "Completed", color: "#052e16", border: "#16a34a" },
|
{ key: "completed", v: "--status-success" },
|
||||||
{ label: "Error", color: "#1f0808", border: "#dc2626" },
|
{ key: "error", v: "--status-danger" },
|
||||||
{ label: "Abandoned", color: "#1c1a04", border: "#ca8a04" },
|
{ 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 { useRef, useEffect, useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as d3 from "d3";
|
|
||||||
import type { SessionComplexityItem } from "../../lib/types";
|
import type { SessionComplexityItem } from "../../lib/types";
|
||||||
import { formatModelName } from "../../lib/format";
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,14 +68,14 @@ const MIN_BUBBLE_R = 4;
|
|||||||
const MAX_BUBBLE_R = 32;
|
const MAX_BUBBLE_R = 32;
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
completed: "#22c55e",
|
completed: "rgb(var(--status-success))",
|
||||||
error: "#ef4444",
|
error: "rgb(var(--status-danger))",
|
||||||
active: "#6366f1",
|
active: "rgb(var(--accent))",
|
||||||
abandoned: "#eab308",
|
abandoned: "rgb(var(--status-warning))",
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
function statusColor(status: string): string {
|
||||||
return STATUS_COLOR[status] ?? "#6b7280";
|
return STATUS_COLOR[status] ?? "rgb(var(--fg-muted))";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Duration formatting ───────────────────────────────────────────────────────
|
// ── Duration formatting ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -56,8 +56,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
|
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { workspaceHref } from "../SessionRowMenu";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { formatDateTime, formatMs, formatModelName } from "../../lib/format";
|
import { formatDateTime, formatDuration, formatModelName } from "../../lib/format";
|
||||||
import type {
|
import type {
|
||||||
SessionDrillIn as SessionDrillInData,
|
SessionDrillIn as SessionDrillInData,
|
||||||
DashboardEvent,
|
DashboardEvent,
|
||||||
@@ -157,14 +159,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
|||||||
const { t } = useTranslation(["workflows", "common"]);
|
const { t } = useTranslation(["workflows", "common"]);
|
||||||
const indentPx = depth * 20;
|
const indentPx = depth * 20;
|
||||||
const isMain = node.type === "main";
|
const isMain = node.type === "main";
|
||||||
const dur = node.ended_at
|
const dur = node.ended_at ? formatDuration(node.started_at, node.ended_at) : t("common:running");
|
||||||
? formatMs(
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: t("common:running");
|
|
||||||
const sc = statusColor(node.status);
|
const sc = statusColor(node.status);
|
||||||
const statusLabel = t(`common:status.${node.status}`, { defaultValue: 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)}`}
|
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
type="button"
|
<Link
|
||||||
onClick={onClose}
|
to={`/sessions/${encodeURIComponent(session.id)}`}
|
||||||
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"
|
className="btn-ghost text-xs px-2 py-1"
|
||||||
aria-label={t("drillIn.closePanel")}
|
>
|
||||||
>
|
{t("drillIn.openSession")}
|
||||||
<X className="w-4 h-4" />
|
</Link>
|
||||||
</button>
|
{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>
|
</div>
|
||||||
<TabBar active={activeTab} onChange={onTabChange} />
|
<TabBar active={activeTab} onChange={onTabChange} />
|
||||||
</div>
|
</div>
|
||||||
@@ -478,51 +486,40 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [allSessions, setAllSessions] = useState<Session[]>([]);
|
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [allLoaded, setAllLoaded] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(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);
|
setLoading(true);
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ limit: PAGE_SIZE, offset: pageOffset })
|
.list({ q: q || undefined, limit: PAGE_SIZE, offset: pageOffset })
|
||||||
.then(({ sessions: page }) => {
|
.then(({ sessions: page }) => {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
setSessions((prev) => (replace ? page : [...prev, ...page]));
|
setSessions((prev) => (replace ? page : [...prev, ...page]));
|
||||||
setHasMore(page.length === PAGE_SIZE);
|
setHasMore(page.length === PAGE_SIZE);
|
||||||
setOffset(pageOffset + page.length);
|
setOffset(pageOffset + page.length);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => {
|
||||||
|
if (seq === seqRef.current) setLoading(false);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load ALL sessions for search (once, lazily)
|
// (Re)load the first page while open, debounced on the search text.
|
||||||
const loadAllSessions = useCallback(() => {
|
const query = search.trim();
|
||||||
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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && sessions.length === 0) {
|
if (!open) return;
|
||||||
fetchPage(0, true);
|
const timer = setTimeout(() => fetchPage(query, 0, true), query ? 250 : 0);
|
||||||
}
|
return () => clearTimeout(timer);
|
||||||
}, [open, sessions.length, fetchPage]);
|
}, [open, query, fetchPage]);
|
||||||
|
|
||||||
// When user starts typing, load all sessions for search
|
|
||||||
useEffect(() => {
|
|
||||||
if (search.trim().length > 0) {
|
|
||||||
loadAllSessions();
|
|
||||||
}
|
|
||||||
}, [search, loadAllSessions]);
|
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -536,13 +533,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// When searching, filter across ALL sessions; otherwise show paginated
|
const filtered = sessions;
|
||||||
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;
|
|
||||||
|
|
||||||
function handleSelect(id: string) {
|
function handleSelect(id: string) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -551,7 +542,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadMore() {
|
function handleLoadMore() {
|
||||||
fetchPage(offset, false);
|
fetchPage(query, offset, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -560,7 +551,8 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"flex items-center gap-2 px-3 py-2 rounded-lg border bg-surface-3 transition-colors cursor-text",
|
"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(" ")}
|
].join(" ")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@@ -573,6 +565,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
placeholder={t("drillIn.searchPlaceholder")}
|
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"
|
className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -648,8 +641,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load more - only show when not filtering client-side */}
|
{hasMore && (
|
||||||
{!search.trim() && hasMore && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLoadMore}
|
onClick={handleLoadMore}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
import type { SkillUsage } from "../../lib/types";
|
import type { SkillUsage } from "../../lib/types";
|
||||||
|
|
||||||
export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
||||||
@@ -33,17 +33,10 @@ export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Throttled (not debounced): a steady event stream still refreshes every 3 s.
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
useLiveRefresh(() => void refresh(), {
|
||||||
const unsub = eventBus.subscribe(() => {
|
match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
|
||||||
clearTimeout(timer);
|
});
|
||||||
timer = setTimeout(() => void refresh(), 3000);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unsub();
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
if (error) return <p className="text-xs text-status-danger">{error}</p>;
|
if (error) return <p className="text-xs text-status-danger">{error}</p>;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ export function WorkflowRunsPanel({
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
||||||
{t("runs.promptLabel")}
|
{t("runs.promptLabel")}
|
||||||
</div>
|
</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}
|
{fullPrompt}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -598,7 +598,7 @@ export function WorkflowRunsPanel({
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
||||||
{t("runs.resultLabel")}
|
{t("runs.resultLabel")}
|
||||||
</div>
|
</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}
|
{fullResult}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</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",
|
"title": "Activity Feed",
|
||||||
"subtitle": "Real-time stream of all agent events",
|
"subtitle": "Real-time stream of all agent events",
|
||||||
"paused": "(paused - {{count}} buffered)",
|
"paused": "(paused — {{count}} new)",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"noActivity": "No activity yet",
|
"noActivity": "No activity yet",
|
||||||
"noActivityDesc": "Events will stream here in real-time as Claude Code agents work.",
|
"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.",
|
"noSessionTrendData": "No session trend data yet.",
|
||||||
"last7Days": "Last 7 days",
|
"last7Days": "Last 7 days",
|
||||||
"less": "Less",
|
"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",
|
"failedConnect": "Failed to connect to server",
|
||||||
"failedLoad": "Failed to load data",
|
"failedLoad": "Failed to load data",
|
||||||
"activeTrend": " active",
|
"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.",
|
"noEvents": "No events recorded.",
|
||||||
"conversation": "Conversation",
|
"conversation": "Conversation",
|
||||||
"timeline": "Timeline ({{shown}}/{{total}})",
|
"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",
|
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
||||||
"toolBadgeTitle": "Recorded by {{tool}}",
|
"toolBadgeTitle": "Recorded by {{tool}}",
|
||||||
@@ -46,5 +53,108 @@
|
|||||||
"inWorkspace": "Open in its workspace",
|
"inWorkspace": "Open in its workspace",
|
||||||
"copyId": "Copy session ID",
|
"copyId": "Copy session ID",
|
||||||
"copyFolder": "Copy folder path"
|
"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.",
|
"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."
|
"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": {
|
"drillIn": {
|
||||||
@@ -230,7 +239,9 @@
|
|||||||
"noEvents": "No events recorded for this session.",
|
"noEvents": "No events recorded for this session.",
|
||||||
"showingOf": "Showing 100 of {{total}} events",
|
"showingOf": "Showing 100 of {{total}} events",
|
||||||
"sessionInfo": " session(s) - active {{from}}%-{{to}}% of session",
|
"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": {
|
"lanes": {
|
||||||
"title": "Workspace Pipeline Pass Rates",
|
"title": "Workspace Pipeline Pass Rates",
|
||||||
@@ -316,5 +327,7 @@
|
|||||||
"sessions_one": "{{count}} session",
|
"sessions_one": "{{count}} session",
|
||||||
"sessions_other": "{{count}} sessions",
|
"sessions_other": "{{count}} sessions",
|
||||||
"pluginBreakdown": "{{skills}} skill(s) used · {{skillCalls}} skill call(s) · {{mcpCalls}} MCP tool call(s)"
|
"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",
|
"title": "Luồng hoạt động",
|
||||||
"subtitle": "Luồng thời gian thực của mọi sự kiện Agent",
|
"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",
|
"resume": "Tiếp tục",
|
||||||
"pause": "Tạm dừng",
|
"pause": "Tạm dừng",
|
||||||
"noActivity": "Chưa có hoạt độ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.",
|
"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.",
|
"noSessionTrendData": "Chưa có dữ liệu xu hướng phiên.",
|
||||||
"last7Days": "7 ngày gần nhất",
|
"last7Days": "7 ngày gần nhất",
|
||||||
"less": "Í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ủ",
|
"failedConnect": "Không thể kết nối máy chủ",
|
||||||
"failedLoad": "Không thể tải dữ liệu",
|
"failedLoad": "Không thể tải dữ liệu",
|
||||||
"activeTrend": " đang hoạt động",
|
"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.",
|
"noEvents": "Chưa có sự kiện được ghi nhận.",
|
||||||
"conversation": "Hội thoại",
|
"conversation": "Hội thoại",
|
||||||
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
"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}}",
|
"toolBadgeTitle": "Ghi lại bởi {{tool}}",
|
||||||
"menu": {
|
"menu": {
|
||||||
@@ -45,5 +52,109 @@
|
|||||||
"inWorkspace": "Mở trong workspace của nó",
|
"inWorkspace": "Mở trong workspace của nó",
|
||||||
"copyId": "Sao chép ID session",
|
"copyId": "Sao chép ID session",
|
||||||
"copyFolder": "Sao chép đường dẫn thư mục"
|
"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.",
|
"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."
|
"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": {
|
"drillIn": {
|
||||||
@@ -230,7 +239,9 @@
|
|||||||
"noEvents": "Không có sự kiện cho phiên này.",
|
"noEvents": "Không có sự kiện cho phiên này.",
|
||||||
"showingOf": "Đang hiển thị 100 / {{total}} sự kiện",
|
"showingOf": "Đang hiển thị 100 / {{total}} sự kiện",
|
||||||
"sessionInfo": " phiên - hoạt động {{from}}%-{{to}} thời lượng phiê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": {
|
"lanes": {
|
||||||
"title": "Tỉ lệ pass các node pipeline",
|
"title": "Tỉ lệ pass các node pipeline",
|
||||||
@@ -316,5 +327,7 @@
|
|||||||
"sessions_one": "{{count}} phiên",
|
"sessions_one": "{{count}} phiên",
|
||||||
"sessions_other": "{{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"
|
"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,
|
formatTime,
|
||||||
getCurrentLocale,
|
getCurrentLocale,
|
||||||
formatModelName,
|
formatModelName,
|
||||||
|
isoOrUndefined,
|
||||||
} from "../format";
|
} from "../format";
|
||||||
|
|
||||||
describe("formatMs", () => {
|
describe("formatMs", () => {
|
||||||
@@ -256,3 +257,23 @@ describe("formatModelName", () => {
|
|||||||
expect(formatModelName("o1-preview")).toBe("O1 Preview");
|
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:13Z") // parsed as-is
|
||||||
* parseDate("2026-04-18T08:49:13-04:00") // parsed as-is (explicit offset)
|
* 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
|
// Already has timezone info (Z or +/- offset) - parse directly
|
||||||
// (`/[+-]\d{2}:\d{2}$/` catches trailing `+04:00`-style offsets).
|
// (`/[+-]\d{2}:\d{2}$/` catches trailing `+04:00`-style offsets).
|
||||||
if (/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso)) {
|
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);
|
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:
|
* Formats a millisecond duration as the coarsest two-unit representation:
|
||||||
* "Nh Nm" once >= 1 hour, "Nm Ns" once >= 1 minute, else "Ns".
|
* "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"
|
* formatMs(-10) // "0s"
|
||||||
*/
|
*/
|
||||||
export function formatMs(ms: number): string {
|
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
|
if (ms < 0) return "0s"; // clamp negative spans (clock skew) to zero
|
||||||
const totalSec = Math.floor(ms / 1000);
|
const totalSec = Math.floor(ms / 1000);
|
||||||
const hours = Math.floor(totalSec / 3600); // whole hours
|
const hours = Math.floor(totalSec / 3600); // whole hours
|
||||||
@@ -334,6 +345,7 @@ export function formatMs(ms: number): string {
|
|||||||
*/
|
*/
|
||||||
export function timeAgo(iso: string): string {
|
export function timeAgo(iso: string): string {
|
||||||
const ms = Date.now() - parseDate(iso).getTime();
|
const ms = Date.now() - parseDate(iso).getTime();
|
||||||
|
if (Number.isNaN(ms)) return "-";
|
||||||
const seconds = Math.floor(ms / 1000);
|
const seconds = Math.floor(ms / 1000);
|
||||||
if (seconds < 60) return i18n.t("common:time.justNow");
|
if (seconds < 60) return i18n.t("common:time.justNow");
|
||||||
const minutes = Math.floor(seconds / 60);
|
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).
|
/** ISO timestamp the event was recorded (ingest time, not hook-reported time).
|
||||||
* Maps to `events.created_at`. Drives all time-bucketed charts. */
|
* Maps to `events.created_at`. Drives all time-bucketed charts. */
|
||||||
created_at: string;
|
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 ─────
|
// ───── Overview & analytics ─────
|
||||||
@@ -1860,6 +1866,10 @@ export interface SessionStats {
|
|||||||
events_by_type: Array<{ event_type: string; count: number }>;
|
events_by_type: Array<{ event_type: string; count: number }>;
|
||||||
/** Tool invocation counts within this session, most-used first. */
|
/** Tool invocation counts within this session, most-used first. */
|
||||||
tools_used: Array<{ tool_name: string; count: number }>;
|
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
|
/** Count of events representing an error (APIError, error-summary Stop) in
|
||||||
* this session. Drives the "N errors" badge on the detail page. */
|
* this session. Drives the "N errors" badge on the detail page. */
|
||||||
error_count: number;
|
error_count: number;
|
||||||
@@ -2899,31 +2909,31 @@ export const STATUS_CONFIG: Record<
|
|||||||
// Green: the agent is actively running a tool / doing work.
|
// Green: the agent is actively running a tool / doing work.
|
||||||
working: {
|
working: {
|
||||||
labelKey: "common:status.working",
|
labelKey: "common:status.working",
|
||||||
color: "text-emerald-700 dark:text-emerald-400",
|
color: "text-status-success",
|
||||||
bg: "bg-emerald-500/10 border-emerald-500/20",
|
bg: "bg-status-success/10 border-status-success/25",
|
||||||
dot: "bg-emerald-400",
|
dot: "bg-status-success",
|
||||||
},
|
},
|
||||||
// Yellow: idle-between-turns, or (via the AWAITING_STATUS overlay) blocked
|
// Yellow: idle-between-turns, or (via the AWAITING_STATUS overlay) blocked
|
||||||
// on user input - attention may be required.
|
// on user input - attention may be required.
|
||||||
waiting: {
|
waiting: {
|
||||||
labelKey: "common:status.waiting",
|
labelKey: "common:status.waiting",
|
||||||
color: "text-yellow-700 dark:text-yellow-400",
|
color: "text-status-warning",
|
||||||
bg: "bg-yellow-500/10 border-yellow-500/20",
|
bg: "bg-status-warning/10 border-status-warning/25",
|
||||||
dot: "bg-yellow-400",
|
dot: "bg-status-warning",
|
||||||
},
|
},
|
||||||
// Violet: the agent finished cleanly.
|
// Accent: the agent finished cleanly.
|
||||||
completed: {
|
completed: {
|
||||||
labelKey: "common:status.completed",
|
labelKey: "common:status.completed",
|
||||||
color: "text-violet-700 dark:text-violet-400",
|
color: "text-accent",
|
||||||
bg: "bg-violet-500/10 border-violet-500/20",
|
bg: "bg-accent/10 border-accent/25",
|
||||||
dot: "bg-violet-400",
|
dot: "bg-accent",
|
||||||
},
|
},
|
||||||
// Red: the agent ended in an error state.
|
// Red: the agent ended in an error state.
|
||||||
error: {
|
error: {
|
||||||
labelKey: "common:status.error",
|
labelKey: "common:status.error",
|
||||||
color: "text-red-700 dark:text-red-400",
|
color: "text-status-danger",
|
||||||
bg: "bg-red-500/10 border-red-500/20",
|
bg: "bg-status-danger/10 border-status-danger/25",
|
||||||
dot: "bg-red-400",
|
dot: "bg-status-danger",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3107,30 +3117,30 @@ export const SESSION_STATUS_CONFIG: Record<
|
|||||||
// Green: the session is live and running.
|
// Green: the session is live and running.
|
||||||
active: {
|
active: {
|
||||||
labelKey: "common:status.active",
|
labelKey: "common:status.active",
|
||||||
color: "text-emerald-700 dark:text-emerald-400",
|
color: "text-status-success",
|
||||||
bg: "bg-emerald-500/10 border-emerald-500/20",
|
bg: "bg-status-success/10 border-status-success/25",
|
||||||
dot: "bg-emerald-400",
|
dot: "bg-status-success",
|
||||||
},
|
},
|
||||||
// Yellow: blocked on user input via the AWAITING_STATUS overlay.
|
// Yellow: blocked on user input via the AWAITING_STATUS overlay.
|
||||||
waiting: {
|
waiting: {
|
||||||
labelKey: "common:status.waiting",
|
labelKey: "common:status.waiting",
|
||||||
color: "text-yellow-700 dark:text-yellow-400",
|
color: "text-status-warning",
|
||||||
bg: "bg-yellow-500/10 border-yellow-500/20",
|
bg: "bg-status-warning/10 border-status-warning/25",
|
||||||
dot: "bg-yellow-400",
|
dot: "bg-status-warning",
|
||||||
},
|
},
|
||||||
// Violet: the session ended cleanly (SessionEnd).
|
// Accent: the session ended cleanly (SessionEnd).
|
||||||
completed: {
|
completed: {
|
||||||
labelKey: "common:status.completed",
|
labelKey: "common:status.completed",
|
||||||
color: "text-violet-700 dark:text-violet-400",
|
color: "text-accent",
|
||||||
bg: "bg-violet-500/10 border-violet-500/20",
|
bg: "bg-accent/10 border-accent/25",
|
||||||
dot: "bg-violet-400",
|
dot: "bg-accent",
|
||||||
},
|
},
|
||||||
// Red: the session ended in an error state.
|
// Red: the session ended in an error state.
|
||||||
error: {
|
error: {
|
||||||
labelKey: "common:status.error",
|
labelKey: "common:status.error",
|
||||||
color: "text-red-700 dark:text-red-400",
|
color: "text-status-danger",
|
||||||
bg: "bg-red-500/10 border-red-500/20",
|
bg: "bg-status-danger/10 border-status-danger/25",
|
||||||
dot: "bg-red-400",
|
dot: "bg-status-danger",
|
||||||
},
|
},
|
||||||
// Slate: stale session swept as "abandoned" (never cleanly ended); has no
|
// Slate: stale session swept as "abandoned" (never cleanly ended); has no
|
||||||
// equivalent in STATUS_CONFIG since agents don't get abandoned.
|
// 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
|
// Muted slate distinguishes "given up / faded out" from yellow Waiting
|
||||||
// (attention required).
|
// (attention required).
|
||||||
labelKey: "common:status.abandoned",
|
labelKey: "common:status.abandoned",
|
||||||
color: "text-slate-600 dark:text-slate-400",
|
color: "text-fg-secondary",
|
||||||
bg: "bg-slate-500/10 border-slate-500/20",
|
bg: "bg-surface-3 border-border",
|
||||||
dot: "bg-slate-400",
|
dot: "bg-fg-muted",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+236
-190
@@ -62,12 +62,23 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo, useSyncExternalStore } from "react";
|
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 { 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 { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
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 { useDataScope } from "../lib/dataScope";
|
||||||
import { AgentStatusBadge } from "../components/StatusBadge";
|
import { AgentStatusBadge } from "../components/StatusBadge";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
@@ -76,9 +87,10 @@ import { useContextMenu } from "../components/ContextMenu";
|
|||||||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||||
import {
|
import {
|
||||||
EventFilters,
|
EventFilters,
|
||||||
EMPTY_FILTERS,
|
|
||||||
isEmptyFilters,
|
isEmptyFilters,
|
||||||
expandStatusToEventTypes,
|
expandStatusToEventTypes,
|
||||||
|
filtersFromParams,
|
||||||
|
filtersToParams,
|
||||||
} from "../components/EventFilters";
|
} from "../components/EventFilters";
|
||||||
import type { EventFiltersValue } from "../components/EventFilters";
|
import type { EventFiltersValue } from "../components/EventFilters";
|
||||||
import { EventFiltersInfo } from "../components/EventFiltersInfo";
|
import { EventFiltersInfo } from "../components/EventFiltersInfo";
|
||||||
@@ -92,52 +104,65 @@ import {
|
|||||||
statusFromEventType,
|
statusFromEventType,
|
||||||
} from "../lib/event-grouping";
|
} from "../lib/event-grouping";
|
||||||
import type { AgentInfo } 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";
|
import type { DashboardEvent } from "../lib/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
// Max rows a single /api/events request can return (server cap). Refreshes
|
// Throttle live-event refreshes so a burst of hook events (e.g. a stream of
|
||||||
// triggered by live events are bounded by this.
|
// PostToolUse results) triggers one refetch instead of dozens — and a steady
|
||||||
const MAX_REFRESH = 500;
|
// stream still refreshes, which a trailing debounce never did.
|
||||||
// Debounce live-event refreshes so a burst of hook events (e.g. a stream of
|
const REFRESH_THROTTLE_MS = 1000;
|
||||||
// PostToolUse results) triggers one refetch instead of dozens.
|
|
||||||
const REFRESH_DEBOUNCE_MS = 500;
|
|
||||||
|
|
||||||
export function ActivityFeed() {
|
export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) {
|
||||||
const eventCtx = useContextMenu<string>();
|
const eventCtx = useContextMenu<DashboardEvent>();
|
||||||
const { t } = useTranslation("activity");
|
const { t } = useTranslation("activity");
|
||||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(0);
|
// Filters and page live in the URL (replace), next to History's `view`.
|
||||||
const [filters, setFilters] = useState<EventFiltersValue>(EMPTY_FILTERS);
|
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 [paused, setPaused] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
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());
|
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
|
||||||
// session_id → session name. Populated from /api/sessions on mount so rows
|
// agent_id → subagent-facing info for the agents of the sessions on screen,
|
||||||
// can render a friendly session pill instead of a bare UUID.
|
// fetched in one call per new set of sessions (never every agent).
|
||||||
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.
|
|
||||||
const [agentInfoById, setAgentInfoById] = useState<Map<string, AgentInfo>>(() => new Map());
|
const [agentInfoById, setAgentInfoById] = useState<Map<string, AgentInfo>>(() => new Map());
|
||||||
|
const knownAgentSessions = useRef(new Set<string>());
|
||||||
|
|
||||||
const bufferRef = useRef<DashboardEvent[]>([]);
|
|
||||||
const pausedRef = useRef(paused);
|
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;
|
pausedRef.current = paused;
|
||||||
pageRef.current = page;
|
const live = page === 0 && !paused;
|
||||||
|
|
||||||
function toggleEvent(id: number) {
|
function toggleEvent(id: number) {
|
||||||
setExpandedEvents((prev) => {
|
setExpandedEvents((prev) => {
|
||||||
@@ -159,172 +184,155 @@ export function ActivityFeed() {
|
|||||||
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
|
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
|
||||||
session_id: filters.session_id.length > 0 ? filters.session_id : undefined,
|
session_id: filters.session_id.length > 0 ? filters.session_id : undefined,
|
||||||
q: filters.q || undefined,
|
q: filters.q || undefined,
|
||||||
from: filters.from ? new Date(filters.from).toISOString() : undefined,
|
from: isoOrUndefined(filters.from),
|
||||||
to: filters.to ? new Date(filters.to).toISOString() : undefined,
|
to: isoOrUndefined(filters.to),
|
||||||
};
|
};
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
apiParamsRef.current = apiParams;
|
|
||||||
|
|
||||||
// Global data scope; a change re-runs `load` (api injects the `sources` param).
|
// Global data scope; a change re-runs `load` (api injects the `sources` param).
|
||||||
const [scope] = useDataScope();
|
const [scope] = useDataScope();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
// Newest request wins: a filter typed during a slow load never gets
|
||||||
setLoading(true);
|
// overwritten by the older response.
|
||||||
try {
|
const loadSeq = useRef(0);
|
||||||
const { events: data, total: totalCount } = await api.events.list({
|
const load = useCallback(
|
||||||
...apiParams,
|
async (quiet = false) => {
|
||||||
limit: PAGE_SIZE,
|
const seq = ++loadSeq.current;
|
||||||
offset: page * PAGE_SIZE,
|
if (!quiet) setLoading(true);
|
||||||
});
|
try {
|
||||||
setEvents(data);
|
const { events: data, total: totalCount } = await api.events.list({
|
||||||
setTotal(totalCount);
|
...apiParams,
|
||||||
} catch (err) {
|
limit: PAGE_SIZE,
|
||||||
// Non-fatal: keep the previous list; log so dev tools surface the
|
offset: page * PAGE_SIZE,
|
||||||
// failure instead of raising an unhandled promise rejection.
|
});
|
||||||
console.error("Failed to load events:", err);
|
if (seq !== loadSeq.current) return;
|
||||||
} finally {
|
setEvents(data);
|
||||||
setLoading(false);
|
setTotal(totalCount);
|
||||||
}
|
setError(null);
|
||||||
}, [apiParams, page, scope]);
|
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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Reset to page 0 whenever filters change so the user lands on the first
|
// Agent labels for the sessions on screen that we have not seen yet.
|
||||||
// page of the new filtered result set.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(0);
|
const missing = [...new Set(events.map((e) => e.session_id))].filter(
|
||||||
}, [apiParams]);
|
(sid) => !knownAgentSessions.current.has(sid)
|
||||||
|
);
|
||||||
useEffect(() => {
|
if (missing.length === 0) return;
|
||||||
let cancelled = false;
|
for (const sid of missing) knownAgentSessions.current.add(sid);
|
||||||
// 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.
|
|
||||||
});
|
|
||||||
api.agents
|
api.agents
|
||||||
.list({ limit: 10000 })
|
.list({ session_ids: missing, limit: 5000 })
|
||||||
.then(({ agents }) => {
|
.then(({ agents }) =>
|
||||||
if (cancelled) return;
|
setAgentInfoById((prev) => {
|
||||||
const map = new Map<string, AgentInfo>();
|
const map = new Map(prev);
|
||||||
for (const a of agents) {
|
for (const a of agents)
|
||||||
map.set(a.id, {
|
map.set(a.id, {
|
||||||
type: a.type,
|
type: a.type,
|
||||||
subagent_type: a.subagent_type,
|
subagent_type: a.subagent_type,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
parent_agent_id: a.parent_agent_id,
|
parent_agent_id: a.parent_agent_id,
|
||||||
});
|
});
|
||||||
}
|
return map;
|
||||||
setAgentInfoById(map);
|
})
|
||||||
})
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Non-fatal: subagent pills fall back to the short-id label.
|
// Non-fatal: subagent pills fall back to the short-id label.
|
||||||
|
for (const sid of missing) knownAgentSessions.current.delete(sid);
|
||||||
});
|
});
|
||||||
return () => {
|
}, [events]);
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Refetches the current page using the latest filter state. Capped at
|
// Session labels come joined on each row (`session_name`, `session_cwd`).
|
||||||
// MAX_REFRESH (server limit); at the default PAGE_SIZE this is a no-op cap.
|
const sessionNameById = useMemo(() => {
|
||||||
const refreshWithPagination = useCallback(async () => {
|
const map = new Map<string, string>();
|
||||||
const size = Math.min(PAGE_SIZE, MAX_REFRESH);
|
for (const e of events)
|
||||||
try {
|
map.set(e.session_id, e.session_name?.trim() || `Session ${e.session_id.slice(0, 8)}`);
|
||||||
const { events: data, total: totalCount } = await api.events.list({
|
return map;
|
||||||
...apiParamsRef.current,
|
}, [events]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.subscribe((msg) => {
|
if (live) return;
|
||||||
// A remote source finished syncing → new remote events may exist; do a
|
return eventBus.subscribe((msg) => {
|
||||||
// filter-aware refresh (respects pause via refreshWithPagination's guard).
|
if (msg.type === "new_event") setNewCount((n) => n + 1);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
return () => {
|
}, [live]);
|
||||||
unsubscribe();
|
|
||||||
if (refreshTimerRef.current) {
|
|
||||||
clearTimeout(refreshTimerRef.current);
|
|
||||||
refreshTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [refreshWithPagination]);
|
|
||||||
|
|
||||||
function resume() {
|
function resume() {
|
||||||
pausedRef.current = false;
|
|
||||||
bufferRef.current = [];
|
|
||||||
setBufferCount(0);
|
|
||||||
setPaused(false);
|
setPaused(false);
|
||||||
|
setNewCount(0);
|
||||||
|
if (page !== 0) setPage(0);
|
||||||
// Catch-up via filtered refresh so buffered non-matching events don't leak in.
|
// 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));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
// Precompute the project name per event so row rendering doesn't re-parse
|
// 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 projectByEventId = useMemo(() => {
|
||||||
const map = new Map<number, string | null>();
|
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;
|
return map;
|
||||||
}, [events]);
|
}, [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);
|
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
{eventCtx.menu && (
|
{eventCtx.menu && (
|
||||||
<SessionRowMenu
|
<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}
|
x={eventCtx.menu.x}
|
||||||
y={eventCtx.menu.y}
|
y={eventCtx.menu.y}
|
||||||
onClose={eventCtx.close}
|
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 flex-wrap items-center justify-between gap-3 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
{!embedded && (
|
||||||
<Activity className="w-4.5 h-4.5 text-accent" />
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||||
</div>
|
<Activity className="w-4.5 h-4.5 text-accent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
{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="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" />
|
<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">
|
<p className="text-xs text-fg-muted">
|
||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
{paused && (
|
{paused && (
|
||||||
<span className="ml-2 text-yellow-700 dark:text-yellow-400">
|
<span className="ml-2 text-status-warning">{t("paused", { count: newCount })}</span>
|
||||||
{t("paused", { count: bufferCount })}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,7 +383,18 @@ export function ActivityFeed() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,6 +415,29 @@ export function ActivityFeed() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{!loading && events.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
@@ -422,25 +467,29 @@ export function ActivityFeed() {
|
|||||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||||
return (
|
return (
|
||||||
<div key={event.id ?? i} className="animate-slide-up">
|
<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
|
<div
|
||||||
role="button"
|
onClick={(e) => {
|
||||||
tabIndex={0}
|
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||||
onClick={() => {
|
|
||||||
if (event.id != null) toggleEvent(event.id);
|
if (event.id != null) toggleEvent(event.id);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onContextMenu={(e) => eventCtx.open(e, event)}
|
||||||
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)}
|
|
||||||
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
|
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<button
|
||||||
className={`w-3.5 h-3.5 text-fg-muted transition-transform flex-shrink-0 -mr-1.5 ${isOpen ? "rotate-90" : ""}`}
|
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="w-16 flex-shrink-0 text-right font-mono leading-tight">
|
||||||
<div className="text-[11px] text-fg-muted">
|
<div className="text-[11px] text-fg-muted">
|
||||||
@@ -455,10 +504,7 @@ export function ActivityFeed() {
|
|||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const sname = sessionNameById.get(event.session_id);
|
const sname = sessionNameById.get(event.session_id);
|
||||||
const project =
|
const project = projectByEventId.get(event.id) ?? null;
|
||||||
projectByEventId.get(event.id) ??
|
|
||||||
sessionProjectById.get(event.session_id) ??
|
|
||||||
null;
|
|
||||||
const origin = buildOriginLabel(
|
const origin = buildOriginLabel(
|
||||||
project,
|
project,
|
||||||
sname ?? null,
|
sname ?? null,
|
||||||
@@ -521,7 +567,7 @@ export function ActivityFeed() {
|
|||||||
onClick={() => setPage(0)}
|
onClick={() => setPage(0)}
|
||||||
disabled={page === 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"
|
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>
|
</button>
|
||||||
@@ -584,7 +630,7 @@ export function ActivityFeed() {
|
|||||||
onClick={() => setPage(totalPages - 1)}
|
onClick={() => setPage(totalPages - 1)}
|
||||||
disabled={page >= 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"
|
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>
|
</button>
|
||||||
|
|||||||
+665
-465
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ interface HistoryView {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
Icon: ComponentType<{ className?: string }>;
|
Icon: ComponentType<{ className?: string }>;
|
||||||
Panel: ComponentType;
|
Panel: ComponentType<{ embedded?: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,8 +42,10 @@ export function History() {
|
|||||||
activeId={active.id}
|
activeId={active.id}
|
||||||
onSelect={(id) => setParams(id === defaultView.id ? {} : { view: id }, { replace: true })}
|
onSelect={(id) => setParams(id === defaultView.id ? {} : { view: id }, { replace: true })}
|
||||||
tabs={views.map(({ id, label, Icon }) => ({ id, label, Icon }))}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+181
-124
@@ -57,7 +57,7 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo, useRef, type ReactNode } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -77,9 +77,14 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Workflow,
|
Workflow,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api, type RunHandle } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
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 { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||||
import { AgentCard } from "../components/AgentCard";
|
import { AgentCard } from "../components/AgentCard";
|
||||||
import { SessionOverview } from "../components/SessionOverview";
|
import { SessionOverview } from "../components/SessionOverview";
|
||||||
@@ -97,9 +102,10 @@ import {
|
|||||||
import { EventDetail } from "../components/EventDetail";
|
import { EventDetail } from "../components/EventDetail";
|
||||||
import {
|
import {
|
||||||
EventFilters,
|
EventFilters,
|
||||||
EMPTY_FILTERS,
|
|
||||||
isEmptyFilters,
|
isEmptyFilters,
|
||||||
expandStatusToEventTypes,
|
expandStatusToEventTypes,
|
||||||
|
filtersFromParams,
|
||||||
|
filtersToParams,
|
||||||
} from "../components/EventFilters";
|
} from "../components/EventFilters";
|
||||||
import type { EventFiltersValue } from "../components/EventFilters";
|
import type { EventFiltersValue } from "../components/EventFilters";
|
||||||
import { EventFiltersInfo } from "../components/EventFiltersInfo";
|
import { EventFiltersInfo } from "../components/EventFiltersInfo";
|
||||||
@@ -119,6 +125,8 @@ import {
|
|||||||
fmtCostFull,
|
fmtCostFull,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
formatModelName,
|
formatModelName,
|
||||||
|
parseDate,
|
||||||
|
isoOrUndefined,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import type {
|
import type {
|
||||||
Session,
|
Session,
|
||||||
@@ -127,6 +135,7 @@ import type {
|
|||||||
CostResult,
|
CostResult,
|
||||||
TranscriptInfo,
|
TranscriptInfo,
|
||||||
WorkflowRun,
|
WorkflowRun,
|
||||||
|
WSMessage,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
|
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
|
||||||
|
|
||||||
@@ -136,7 +145,8 @@ const EVENTS_INITIAL_BATCH = 50;
|
|||||||
const EVENTS_MORE_BATCH = 500;
|
const EVENTS_MORE_BATCH = 500;
|
||||||
// Live-refresh bounds - see ActivityFeed for rationale.
|
// Live-refresh bounds - see ActivityFeed for rationale.
|
||||||
const EVENTS_MAX_REFRESH = 500;
|
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() {
|
export function SessionDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -149,17 +159,41 @@ export function SessionDetail() {
|
|||||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||||
const [eventsTotal, setEventsTotal] = useState(0);
|
const [eventsTotal, setEventsTotal] = useState(0);
|
||||||
const [eventsLoadingMore, setEventsLoadingMore] = useState(false);
|
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 [cost, setCost] = useState<CostResult | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// True when this session is currently being driven by an in-flight Run
|
// Live dashboard runs: one holding THIS session drives the "open its pane" banner.
|
||||||
// handle on /run. Drives the "Open in Run page" banner up top.
|
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||||
const [isDashboardRun, setIsDashboardRun] = useState(false);
|
const isDashboardRun = runs.some((h) => h.sessionId === id && h.status === "running");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(() => {
|
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(() => {
|
||||||
return new 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/
|
// Keep tabs mounted once visited so switching between them doesn't unmount/
|
||||||
// remount their subtrees (which causes a perceptible flash on click).
|
// remount their subtrees (which causes a perceptible flash on click).
|
||||||
const [visitedTabs, setVisitedTabs] = useState<Set<DetailTab>>(() => new Set(["agents"]));
|
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.
|
// Refs let the websocket handler access latest state without re-subscribing.
|
||||||
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
|
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
|
||||||
const eventsLoadedCountRef = useRef(0);
|
const eventsLoadedCountRef = useRef(0);
|
||||||
const eventsRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
eventsLoadedCountRef.current = events.length;
|
eventsLoadedCountRef.current = events.length;
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
const historyState =
|
const historyState =
|
||||||
@@ -208,34 +240,20 @@ export function SessionDetail() {
|
|||||||
}, [transcriptNotFound]);
|
}, [transcriptNotFound]);
|
||||||
|
|
||||||
// Probe /api/run to see whether THIS session is currently being driven by
|
// 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
|
// a dashboard pane. If so, a banner links straight to that pane.
|
||||||
// back to /run instead of viewing this as a passive transcript.
|
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(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
probeRuns();
|
||||||
let cancelled = false;
|
}, [probeRuns]);
|
||||||
const probe = () => {
|
// A pane starting/stopping re-broadcasts its lane (`lane_update`).
|
||||||
// Defensive: tests mock the api module without a `run` namespace.
|
useLiveRefresh(probeRuns, { intervalMs: 10000, match: (msg) => msg.type === "lane_update" });
|
||||||
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]);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -350,7 +368,7 @@ export function SessionDetail() {
|
|||||||
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
|
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
|
||||||
compactions.forEach((a, i) => {
|
compactions.forEach((a, i) => {
|
||||||
const time = a.started_at
|
const time = a.started_at
|
||||||
? new Date(a.started_at).toLocaleTimeString(undefined, {
|
? parseDate(a.started_at).toLocaleTimeString(undefined, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})
|
})
|
||||||
@@ -374,8 +392,8 @@ export function SessionDetail() {
|
|||||||
tool_name: filters.tool_name.length > 0 ? filters.tool_name : undefined,
|
tool_name: filters.tool_name.length > 0 ? filters.tool_name : undefined,
|
||||||
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
|
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
|
||||||
q: filters.q || undefined,
|
q: filters.q || undefined,
|
||||||
from: filters.from ? new Date(filters.from).toISOString() : undefined,
|
from: isoOrUndefined(filters.from),
|
||||||
to: filters.to ? new Date(filters.to).toISOString() : undefined,
|
to: isoOrUndefined(filters.to),
|
||||||
};
|
};
|
||||||
}, [id, filters]);
|
}, [id, filters]);
|
||||||
|
|
||||||
@@ -497,52 +515,58 @@ export function SessionDetail() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Only this session's messages: every hook of every session broadcasts, and
|
||||||
const unsubscribe = eventBus.subscribe((msg) => {
|
// a reload per message re-read this page's whole session each time. Both
|
||||||
if (isRemoteDataRefreshMessage(msg)) {
|
// refreshes are throttled (a steady stream still updates, never freezes) and
|
||||||
load();
|
// re-run after a WebSocket reconnect.
|
||||||
if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current);
|
const isMine = (msg: WSMessage) => {
|
||||||
eventsRefreshTimerRef.current = setTimeout(() => {
|
const d = msg.data as { id?: string; session_id?: string } | undefined;
|
||||||
eventsRefreshTimerRef.current = null;
|
return d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
|
||||||
refreshEventsWithPagination();
|
};
|
||||||
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
useLiveRefresh(load, {
|
||||||
return;
|
match: (msg) =>
|
||||||
}
|
isRemoteDataRefreshMessage(msg) ||
|
||||||
// Only this session's messages: every hook of every session broadcasts,
|
((msg.type === "agent_created" ||
|
||||||
// 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" ||
|
|
||||||
msg.type === "agent_updated" ||
|
msg.type === "agent_updated" ||
|
||||||
msg.type === "session_updated"
|
msg.type === "session_updated") &&
|
||||||
) {
|
isMine(msg)),
|
||||||
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
|
});
|
||||||
loadTimerRef.current = setTimeout(() => {
|
// A filter-aware refetch that preserves the current "Load more" size.
|
||||||
loadTimerRef.current = null;
|
useLiveRefresh(refreshEventsWithPagination, {
|
||||||
load();
|
throttleMs: EVENTS_REFRESH_THROTTLE_MS,
|
||||||
}, EVENTS_REFRESH_DEBOUNCE_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") {
|
const name = exportName(`session-${id?.slice(0, 8)}-events`, kind);
|
||||||
// Debounce bursts into one filter-aware refetch that preserves the
|
if (kind === "json") downloadText(name, JSON.stringify(all, null, 2), "application/json");
|
||||||
// current "Load more" pagination size.
|
else
|
||||||
if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current);
|
downloadText(
|
||||||
eventsRefreshTimerRef.current = setTimeout(() => {
|
name,
|
||||||
eventsRefreshTimerRef.current = null;
|
toCsv(all, [
|
||||||
refreshEventsWithPagination();
|
"id",
|
||||||
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
"created_at",
|
||||||
}
|
"event_type",
|
||||||
});
|
"tool_name",
|
||||||
return () => {
|
"agent_id",
|
||||||
unsubscribe();
|
"agent_name",
|
||||||
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
|
"summary",
|
||||||
if (eventsRefreshTimerRef.current) {
|
]),
|
||||||
clearTimeout(eventsRefreshTimerRef.current);
|
"text/csv"
|
||||||
eventsRefreshTimerRef.current = null;
|
);
|
||||||
}
|
} catch (err) {
|
||||||
};
|
toast.error(err);
|
||||||
}, [id, load, refreshEventsWithPagination]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -596,14 +620,14 @@ export function SessionDetail() {
|
|||||||
<div className="animate-fade-in space-y-8">
|
<div className="animate-fade-in space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-4">
|
<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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<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)}`}
|
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||||
</h2>
|
</h1>
|
||||||
<SessionToolBadge tool={session.tool} />
|
<SessionToolBadge tool={session.tool} />
|
||||||
<SessionStatusBadge
|
<SessionStatusBadge
|
||||||
status={effectiveSessionStatus(session)}
|
status={effectiveSessionStatus(session)}
|
||||||
@@ -647,8 +671,21 @@ export function SessionDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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} />
|
<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" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -670,40 +707,34 @@ export function SessionDetail() {
|
|||||||
className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${
|
className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${
|
||||||
urgent
|
urgent
|
||||||
? "border-status-warning/40 bg-status-warning/[0.08]"
|
? "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
|
<span
|
||||||
className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${
|
className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${
|
||||||
urgent
|
urgent
|
||||||
? "bg-status-warning/15 border-status-warning/30"
|
? "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
|
<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>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<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")}
|
{t("detail.waitingBanner.title")}
|
||||||
{cfg && (
|
{cfg && (
|
||||||
<span
|
<span className={urgent ? "text-status-warning/90" : "text-status-warning/80"}>
|
||||||
className={
|
|
||||||
urgent
|
|
||||||
? "text-status-warning/90"
|
|
||||||
: "text-yellow-700/80 dark:text-yellow-300/80"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{" · "}
|
{" · "}
|
||||||
{t(cfg.labelKey)}
|
{t(cfg.labelKey)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<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")}
|
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
|
||||||
</div>
|
</div>
|
||||||
@@ -711,12 +742,12 @@ export function SessionDetail() {
|
|||||||
{session.awaiting_input_since && (
|
{session.awaiting_input_since && (
|
||||||
<span
|
<span
|
||||||
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
|
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
|
<span
|
||||||
className={`w-1.5 h-1.5 rounded-full animate-pulse-dot ${
|
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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -727,9 +758,9 @@ export function SessionDetail() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{isDashboardRun && (
|
{isDashboardRun && workspaceHref(session, runs) && (
|
||||||
<Link
|
<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"
|
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">
|
<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>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-status-success">
|
<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>
|
||||||
<div className="text-[11px] text-status-success/70">
|
<div className="text-[11px] text-status-success/70">
|
||||||
{t(
|
{t("detail.dashboardRun.body")}
|
||||||
"detail.dashboardRun.body",
|
|
||||||
"Send follow-ups, watch streaming output, or stop the run from there."
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink className="w-4 h-4 text-status-success/70 group-hover:text-status-success flex-shrink-0" />
|
<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 ${
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
activeTab === "agents"
|
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"
|
: "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 ${
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
activeTab === "conversation"
|
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"
|
: "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 ${
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
activeTab === "timeline"
|
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"
|
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -804,8 +832,9 @@ export function SessionDetail() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setTranscriptNotFound(false)}
|
onClick={() => setTranscriptNotFound(false)}
|
||||||
className="ml-auto text-status-warning/60 hover:text-status-warning transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -817,7 +846,7 @@ export function SessionDetail() {
|
|||||||
{workflows.length > 0 && (
|
{workflows.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
<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")}
|
{wfT("runs.sessionTitle")}
|
||||||
<span className="text-fg-muted font-mono">· {workflows.length}</span>
|
<span className="text-fg-muted font-mono">· {workflows.length}</span>
|
||||||
</h3>
|
</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">
|
<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")}
|
{t("detail.agents")}
|
||||||
<span className="text-fg-muted font-mono">· {agents.length}</span>
|
<span className="text-fg-muted font-mono">· {agents.length}</span>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -896,7 +925,11 @@ export function SessionDetail() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleExpanded}
|
onClick={toggleExpanded}
|
||||||
className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
|
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}
|
aria-expanded={isExpanded}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
@@ -908,7 +941,7 @@ export function SessionDetail() {
|
|||||||
)}
|
)}
|
||||||
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
|
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
|
||||||
{isSubagent && (
|
{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">
|
<div className="flex-1 min-w-0">
|
||||||
<AgentCard
|
<AgentCard
|
||||||
@@ -938,7 +971,7 @@ export function SessionDetail() {
|
|||||||
|
|
||||||
{/* Recursive children (collapsible) */}
|
{/* Recursive children (collapsible) */}
|
||||||
{hasChildren && isExpanded && (
|
{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) =>
|
{children.map((child) =>
|
||||||
renderAgentNode(child, depth + 1, childAncestors)
|
renderAgentNode(child, depth + 1, childAncestors)
|
||||||
)}
|
)}
|
||||||
@@ -951,7 +984,7 @@ export function SessionDetail() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedAgents((prev) => new Set([...prev, agent.id]))
|
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 })}
|
{t("common:subagent_label", { count: totalDesc })}
|
||||||
</button>
|
</button>
|
||||||
@@ -1074,8 +1107,24 @@ export function SessionDetail() {
|
|||||||
|
|
||||||
{visitedTabs.has("timeline") && (
|
{visitedTabs.has("timeline") && (
|
||||||
<div hidden={activeTab !== "timeline"}>
|
<div hidden={activeTab !== "timeline"}>
|
||||||
<div className="mb-3">
|
<div className="mb-3 flex flex-wrap items-start gap-2">
|
||||||
<EventFiltersInfo />
|
<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>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<EventFilters
|
<EventFilters
|
||||||
@@ -1096,7 +1145,15 @@ export function SessionDetail() {
|
|||||||
const key = event.id ?? i;
|
const key = event.id ?? i;
|
||||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => event.id != null && toggleEvent(event.id)}
|
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||||
|
|||||||
+334
-151
@@ -52,8 +52,8 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback, useSyncExternalStore } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -62,12 +62,18 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
SortDesc,
|
SortDesc,
|
||||||
SortAsc,
|
SortAsc,
|
||||||
ChevronDown,
|
|
||||||
Play,
|
Play,
|
||||||
Server,
|
Server,
|
||||||
|
Download,
|
||||||
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
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 { eventBus } from "../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||||
import { useDataScope } from "../lib/dataScope";
|
import { useDataScope } from "../lib/dataScope";
|
||||||
@@ -76,35 +82,60 @@ import { SessionToolBadge } from "../components/SessionToolBadge";
|
|||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { TableRowSkeleton } from "../components/Skeleton";
|
import { TableRowSkeleton } from "../components/Skeleton";
|
||||||
import { useContextMenu } from "../components/ContextMenu";
|
import { useContextMenu } from "../components/ContextMenu";
|
||||||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
|
||||||
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
|
||||||
import {
|
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
|
||||||
effectiveSessionStatus,
|
|
||||||
isSessionAwaitingInput,
|
|
||||||
sessionAwaitingReason,
|
|
||||||
} from "../lib/types";
|
|
||||||
import type { Session, DashboardEvent } from "../lib/types";
|
import type { Session, DashboardEvent } from "../lib/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
export function Sessions() {
|
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const rowCtx = useContextMenu<Session>();
|
const rowCtx = useContextMenu<Session>();
|
||||||
const { t } = useTranslation("sessions");
|
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 [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [filter, setFilter] = useState("");
|
// `searchInput` is what the user types; `q` in the URL is the debounced value
|
||||||
// `searchInput` is what the user types; `search` is the debounced value
|
// actually sent to the server.
|
||||||
// actually sent to the server. Without debouncing, every keystroke would
|
const [searchInput, setSearchInput] = useState(search);
|
||||||
// hit /api/sessions.
|
|
||||||
const [searchInput, setSearchInput] = useState("");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(0);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [cwd, setCwd] = useState("");
|
|
||||||
const [sortBy, setSortBy] = useState("time");
|
|
||||||
const [sortDesc, setSortDesc] = useState(true);
|
|
||||||
const [directories, setDirectories] = useState<string[]>([]);
|
const [directories, setDirectories] = useState<string[]>([]);
|
||||||
// Global data scope (which source machines to show). Included in `load`'s deps
|
// 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
|
// so switching scope re-fetches; the actual `sources` param is injected by the
|
||||||
@@ -112,9 +143,8 @@ export function Sessions() {
|
|||||||
const [scope] = useDataScope();
|
const [scope] = useDataScope();
|
||||||
// source id → label, so remote-origin rows show a friendly badge.
|
// source id → label, so remote-origin rows show a friendly badge.
|
||||||
const [sourceLabels, setSourceLabels] = useState<Map<string, string>>(() => new Map());
|
const [sourceLabels, setSourceLabels] = useState<Map<string, string>>(() => new Map());
|
||||||
// Set of session IDs that are currently being driven by an in-flight Run
|
// Live dashboard runs, so a row a pane holds links straight to that pane.
|
||||||
// handle on /run. Lets us badge those rows with a "Run" link.
|
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||||
const [dashboardRunIds, setDashboardRunIds] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
const FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||||
{ label: t("filterAll"), value: "" },
|
{ label: t("filterAll"), value: "" },
|
||||||
@@ -128,9 +158,17 @@ export function Sessions() {
|
|||||||
// Debounce the search input → 300 ms after the user stops typing, the
|
// Debounce the search input → 300 ms after the user stops typing, the
|
||||||
// committed value flips and triggers a fresh fetch.
|
// committed value flips and triggers a fresh fetch.
|
||||||
useEffect(() => {
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
api.sessions
|
api.sessions
|
||||||
@@ -156,115 +194,131 @@ export function Sessions() {
|
|||||||
loadSourceLabels();
|
loadSourceLabels();
|
||||||
}, [loadSourceLabels]);
|
}, [loadSourceLabels]);
|
||||||
|
|
||||||
// Server-side pagination: only the visible page is fetched. Cost
|
/** The list query for the current filters (paging added by the caller). */
|
||||||
// computation on the server scales with PAGE_SIZE, not with the total
|
const query = useMemo(
|
||||||
// session count, so this stays cheap regardless of how many sessions
|
() => ({
|
||||||
// exist in the database.
|
// "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 load = useCallback(async () => {
|
||||||
|
const seq = ++loadSeq.current;
|
||||||
try {
|
try {
|
||||||
// The "waiting" filter is a UI-only overlay derived from the
|
const res = await api.sessions.list({
|
||||||
// awaiting_input_since column - the underlying SessionStatus is
|
...query,
|
||||||
// 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;
|
|
||||||
} = {
|
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: page * PAGE_SIZE,
|
offset: page * PAGE_SIZE,
|
||||||
sort_by: sortBy,
|
});
|
||||||
sort_desc: sortDesc,
|
if (seq !== loadSeq.current) return;
|
||||||
};
|
|
||||||
if (filter) params.status = filter;
|
|
||||||
if (search) params.q = search;
|
|
||||||
if (cwd) params.cwd = cwd;
|
|
||||||
const res = await api.sessions.list(params);
|
|
||||||
setSessions(res.sessions);
|
setSessions(res.sessions);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (seq === loadSeq.current) setError(err instanceof Error ? err.message : t("failedList"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
|
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
|
||||||
// the matching `sources` param.
|
// the matching `sources` param.
|
||||||
}, [filter, search, cwd, sortBy, sortDesc, page, scope]);
|
}, [query, page, scope, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Reset to page 0 whenever filters or sort changes.
|
// Pull live Run handles so a row a dashboard pane holds can open that pane.
|
||||||
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.
|
|
||||||
const loadDashboardRuns = useCallback(() => {
|
const loadDashboardRuns = useCallback(() => {
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
.then((r) => {
|
.then((r) => setRuns(r.items))
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const h of r.items) {
|
|
||||||
if (h.sessionId) ids.add(h.sessionId);
|
|
||||||
}
|
|
||||||
setDashboardRunIds(ids);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboardRuns();
|
loadDashboardRuns();
|
||||||
const t = setInterval(loadDashboardRuns, 15000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, [loadDashboardRuns]);
|
}, [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));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
// The server already paginates, so the rendered page IS the loaded list.
|
// The server already paginates, so the rendered page IS the loaded list.
|
||||||
const paged = sessions;
|
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 flex-wrap items-center justify-between gap-3 mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
{!embedded && (
|
||||||
<FolderOpen className="w-4.5 h-4.5 text-accent" />
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||||
</div>
|
<FolderOpen className="w-4.5 h-4.5 text-accent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
{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="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" />
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
||||||
@@ -304,13 +363,31 @@ export function Sessions() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="text-xs text-fg-muted">
|
||||||
{t("sessionCount", { count: total })}
|
{t("sessionCount", { count: total })}
|
||||||
{filter ? ` ${filter}` : ""}
|
{filter
|
||||||
|
? ` · ${FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? filter}`
|
||||||
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
<button
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -321,6 +398,7 @@ export function Sessions() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("searchPlaceholder")}
|
placeholder={t("searchPlaceholder")}
|
||||||
|
aria-label={t("searchPlaceholder")}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
className="input w-full pl-10"
|
className="input w-full pl-10"
|
||||||
@@ -331,17 +409,17 @@ export function Sessions() {
|
|||||||
<div className="relative shrink-0 w-[180px]">
|
<div className="relative shrink-0 w-[180px]">
|
||||||
<select
|
<select
|
||||||
value={cwd}
|
value={cwd}
|
||||||
onChange={(e) => setCwd(e.target.value)}
|
onChange={(e) => update({ cwd: e.target.value || null })}
|
||||||
className="input w-full text-ellipsis bg-surface-1 pr-9 appearance-none cursor-pointer"
|
aria-label={t("filterDirectory")}
|
||||||
|
className="select w-full text-ellipsis"
|
||||||
>
|
>
|
||||||
<option value="">All Directories</option>
|
<option value="">{t("allDirectories")}</option>
|
||||||
{directories.map((d) => (
|
{directories.map((d) => (
|
||||||
<option key={d} value={d} title={d}>
|
<option key={d} value={d} title={d}>
|
||||||
{truncate(d, 30)}
|
{truncate(d, 30)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sort Controls */}
|
{/* Sort Controls */}
|
||||||
@@ -349,22 +427,23 @@ export function Sessions() {
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value)}
|
onChange={(e) => update({ sort: e.target.value === "time" ? null : 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"
|
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">
|
<option value="duration">
|
||||||
Sort by Duration ({sortDesc ? "Longest" : "Shortest"})
|
{sortDesc ? t("sortDurationDesc") : t("sortDurationAsc")}
|
||||||
</option>
|
</option>
|
||||||
<option value="price">Sort by Price ({sortDesc ? "Highest" : "Lowest"})</option>
|
<option value="price">{sortDesc ? t("sortPriceDesc") : t("sortPriceAsc")}</option>
|
||||||
</select>
|
</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>
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
<button
|
<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"
|
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" />}
|
{sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -375,7 +454,8 @@ export function Sessions() {
|
|||||||
{FILTER_OPTIONS.map((opt) => (
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
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 ${
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${
|
||||||
filter === opt.value
|
filter === opt.value
|
||||||
? "bg-surface-4 text-fg-secondary"
|
? "bg-surface-4 text-fg-secondary"
|
||||||
@@ -388,15 +468,106 @@ export function Sessions() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<EmptyState
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
title={t("noSessions")}
|
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]">
|
<table className="w-full min-w-[800px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left">
|
<tr className="border-b border-border text-left">
|
||||||
@@ -437,37 +608,49 @@ export function Sessions() {
|
|||||||
{paged.map((session) => (
|
{paged.map((session) => (
|
||||||
<tr
|
<tr
|
||||||
key={session.id}
|
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)}
|
onContextMenu={(e) => rowCtx.open(e, session)}
|
||||||
className="hover:bg-surface-4 transition-colors cursor-pointer group"
|
className="hover:bg-surface-4 transition-colors cursor-pointer group"
|
||||||
>
|
>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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)}`}
|
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||||
</p>
|
</Link>
|
||||||
<SessionToolBadge tool={session.tool} />
|
<SessionToolBadge tool={session.tool} />
|
||||||
{session.source && session.source !== "local" && (
|
{session.source && session.source !== "local" && (
|
||||||
<span
|
<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")}
|
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
|
||||||
>
|
>
|
||||||
<Server className="w-2.5 h-2.5" />
|
<Server className="w-2.5 h-2.5" />
|
||||||
{sourceLabels.get(session.source) || session.source}
|
{sourceLabels.get(session.source) || session.source}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{dashboardRunIds.has(session.id) && (
|
{(() => {
|
||||||
<Link
|
// A live dashboard pane holds it: open that pane.
|
||||||
to={`/run?session=${encodeURIComponent(session.id)}`}
|
const run = runs.find((r) => r.sessionId === session.id);
|
||||||
onClick={(e) => e.stopPropagation()}
|
const href = run ? workspaceHref(session, runs) : null;
|
||||||
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"
|
return href ? (
|
||||||
title={t("dashboardRunBadge", "Driven by Run page · click to open")}
|
<Link
|
||||||
>
|
to={href}
|
||||||
<Play className="w-2.5 h-2.5" />
|
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"
|
||||||
{t("common:dashboardRun", "Run")}
|
title={t("openInWorkspaceTitle")}
|
||||||
</Link>
|
>
|
||||||
)}
|
<Play className="w-2.5 h-2.5" />
|
||||||
|
{t("openInWorkspace")}
|
||||||
|
</Link>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-fg-muted font-mono">
|
<p className="text-[11px] text-fg-muted font-mono">
|
||||||
{session.id.slice(0, 12)}
|
{session.id.slice(0, 12)}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
|
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import type { WorkflowData, WSMessage } from "../lib/types";
|
import type { WorkflowData, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
import { WorkflowStats } from "../components/workflows/WorkflowStats";
|
import { WorkflowStats } from "../components/workflows/WorkflowStats";
|
||||||
@@ -90,7 +91,7 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
|
|||||||
|
|
||||||
type StatusFilter = "all" | "active" | "completed";
|
type StatusFilter = "all" | "active" | "completed";
|
||||||
|
|
||||||
export function Workflows() {
|
export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
|
||||||
const { t } = useTranslation("workflows");
|
const { t } = useTranslation("workflows");
|
||||||
const [data, setData] = useState<WorkflowData | null>(null);
|
const [data, setData] = useState<WorkflowData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -100,36 +101,49 @@ export function Workflows() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
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 fetchData = useCallback(async () => {
|
||||||
|
const seq = ++fetchSeq.current;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
|
||||||
const result = await api.workflows.get(statusFilter);
|
const result = await api.workflows.get(statusFilter);
|
||||||
|
if (seq !== fetchSeq.current) return;
|
||||||
|
setError(null);
|
||||||
setData(result);
|
setData(result);
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("failedLoad"));
|
if (seq === fetchSeq.current) setError(err instanceof Error ? err.message : t("failedLoad"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === fetchSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [statusFilter]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// Auto-refresh on WebSocket events
|
// Auto-refresh on the WebSocket messages that change workflow data, throttled
|
||||||
useEffect(() => {
|
// so a steady stream refreshes every few seconds instead of never.
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
useLiveRefresh(fetchData, {
|
||||||
const handler = (_msg: WSMessage) => {
|
throttleMs: 5000,
|
||||||
clearTimeout(debounceTimer);
|
match: (msg: WSMessage) =>
|
||||||
debounceTimer = setTimeout(fetchData, 3000);
|
msg.type === "session_created" ||
|
||||||
};
|
msg.type === "session_updated" ||
|
||||||
const unsub = eventBus.subscribe(handler);
|
msg.type === "agent_created" ||
|
||||||
return () => {
|
msg.type === "agent_updated" ||
|
||||||
unsub();
|
msg.type === "workflow_upserted",
|
||||||
clearTimeout(debounceTimer);
|
});
|
||||||
};
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -151,6 +165,7 @@ export function Workflows() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
embedded={embedded}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={setStatusFilter}
|
onStatusFilterChange={setStatusFilter}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
@@ -173,6 +188,7 @@ export function Workflows() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
embedded={embedded}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={setStatusFilter}
|
onStatusFilterChange={setStatusFilter}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
@@ -196,6 +212,7 @@ export function Workflows() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
embedded={embedded}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={setStatusFilter}
|
onStatusFilterChange={setStatusFilter}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
@@ -207,9 +224,13 @@ export function Workflows() {
|
|||||||
<WorkflowStats stats={data.stats} />
|
<WorkflowStats stats={data.stats} />
|
||||||
|
|
||||||
{/* Section 1: this dashboard's own pipelines */}
|
{/* Section 1: this dashboard's own pipelines */}
|
||||||
<Section number={1} title={t("lanes.title")} subtitle={t("lanes.subtitle")} infoKey="lanes">
|
{/* Pipelines are dormant by default: the section stays mounted (so it can
|
||||||
<LanePipelinePanel />
|
report a template appearing) but hidden while none is loaded. */}
|
||||||
</Section>
|
<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 2: what drives those pipelines */}
|
||||||
<Section
|
<Section
|
||||||
@@ -247,7 +268,7 @@ export function Workflows() {
|
|||||||
/>
|
/>
|
||||||
{selectedNode && (
|
{selectedNode && (
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<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">
|
<span className="badge bg-accent/15 text-accent border border-accent/20 text-xs">
|
||||||
{selectedNode}
|
{selectedNode}
|
||||||
</span>
|
</span>
|
||||||
@@ -255,7 +276,7 @@ export function Workflows() {
|
|||||||
onClick={() => setSelectedNode(null)}
|
onClick={() => setSelectedNode(null)}
|
||||||
className="text-xs text-fg-muted hover:text-fg-secondary underline"
|
className="text-xs text-fg-muted hover:text-fg-secondary underline"
|
||||||
>
|
>
|
||||||
{t("clearFilter")}
|
{t("clearSelection")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -279,7 +300,7 @@ export function Workflows() {
|
|||||||
subtitle={t("complexity.subtitle")}
|
subtitle={t("complexity.subtitle")}
|
||||||
infoKey="complexity"
|
infoKey="complexity"
|
||||||
>
|
>
|
||||||
<SessionComplexityScatter data={data.complexity} onSessionClick={setSelectedSessionId} />
|
<SessionComplexityScatter data={data.complexity} onSessionClick={selectSession} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
@@ -293,18 +314,20 @@ export function Workflows() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 7: Session Drill-In */}
|
{/* Section 7: Session Drill-In */}
|
||||||
<Section
|
<div ref={drillInRef} className="scroll-mt-4">
|
||||||
number={7}
|
<Section
|
||||||
title={t("drillIn.title")}
|
number={7}
|
||||||
subtitle={t("drillIn.subtitle")}
|
title={t("drillIn.title")}
|
||||||
infoKey="drillIn"
|
subtitle={t("drillIn.subtitle")}
|
||||||
>
|
infoKey="drillIn"
|
||||||
<SessionDrillIn
|
>
|
||||||
sessionId={selectedSessionId}
|
<SessionDrillIn
|
||||||
onClose={() => setSelectedSessionId(null)}
|
sessionId={selectedSessionId}
|
||||||
onSelectSession={(id) => setSelectedSessionId(id)}
|
onClose={() => setSelectedSessionId(null)}
|
||||||
/>
|
onSelectSession={(id) => setSelectedSessionId(id)}
|
||||||
</Section>
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -467,12 +490,15 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
|
|||||||
|
|
||||||
// ── Page Header ──
|
// ── Page Header ──
|
||||||
function PageHeader({
|
function PageHeader({
|
||||||
|
embedded,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onExport,
|
onExport,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
}: {
|
}: {
|
||||||
|
/** Inside Dashboard → Analytics → Workflow: the tab bar already names it. */
|
||||||
|
embedded?: boolean;
|
||||||
statusFilter: StatusFilter;
|
statusFilter: StatusFilter;
|
||||||
onStatusFilterChange: (f: StatusFilter) => void;
|
onStatusFilterChange: (f: StatusFilter) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -488,8 +514,10 @@ function PageHeader({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div
|
||||||
<div className="flex items-center gap-3">
|
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">
|
<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" />
|
<Workflow className="w-4.5 h-4.5 text-accent" />
|
||||||
</div>
|
</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
|
GROUP BY event_type
|
||||||
ORDER BY count DESC
|
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(`
|
sessionToolUsageCounts: db.prepare(`
|
||||||
SELECT tool_name, COUNT(*) as count
|
SELECT tool_name, COUNT(*) as count
|
||||||
FROM events
|
FROM events
|
||||||
|
|||||||
+19
-1
@@ -19,8 +19,26 @@ router.get("/", (req, res) => {
|
|||||||
const session_id = req.query.session_id;
|
const session_id = req.query.session_id;
|
||||||
const sources = parseSources(req);
|
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;
|
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.
|
// A session belongs to exactly one source, so no extra source filter needed.
|
||||||
rows = stmts.listAgentsBySession.all(session_id);
|
rows = stmts.listAgentsBySession.all(session_id);
|
||||||
} else if (sources) {
|
} else if (sources) {
|
||||||
|
|||||||
@@ -102,7 +102,15 @@ router.get("/", (req, res) => {
|
|||||||
|
|
||||||
const { sql: whereSql, params: whereParams } = buildWhere(filters);
|
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 countSql = `SELECT COUNT(*) as count FROM events ${whereSql}`;
|
||||||
|
|
||||||
const events = db.prepare(listSql).all(...whereParams, limit, offset);
|
const events = db.prepare(listSql).all(...whereParams, limit, offset);
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ router.get("/", (req, res) => {
|
|||||||
);
|
);
|
||||||
params.push(Number(lane), Number(lane));
|
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.
|
// `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 === "0") where.push("s.archived_at IS NULL");
|
||||||
if (req.query.archived === "1") where.push("s.archived_at IS NOT 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 totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||||
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
||||||
const tools = stmts.sessionToolUsageCounts.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 errors = stmts.sessionErrorCount.get(sessionId)?.count ?? 0;
|
||||||
const timeRange = stmts.sessionEventTimeRange.get(sessionId) || {};
|
const timeRange = stmts.sessionEventTimeRange.get(sessionId) || {};
|
||||||
const subagentTypes = stmts.sessionAgentTypeCounts.all(sessionId);
|
const subagentTypes = stmts.sessionAgentTypeCounts.all(sessionId);
|
||||||
@@ -370,6 +390,8 @@ router.get("/:id/stats", (req, res) => {
|
|||||||
total_events: totalEvents,
|
total_events: totalEvents,
|
||||||
events_by_type: eventsByType,
|
events_by_type: eventsByType,
|
||||||
tools_used: tools,
|
tools_used: tools,
|
||||||
|
tool_call_count: toolTotals.pre > 0 ? toolTotals.pre : (toolTotals.post ?? 0),
|
||||||
|
distinct_tool_count: toolTotals.distinct_tools ?? 0,
|
||||||
error_count: errors,
|
error_count: errors,
|
||||||
first_event_at: timeRange.first_at ?? null,
|
first_event_at: timeRange.first_at ?? null,
|
||||||
last_event_at: timeRange.last_at ?? null,
|
last_event_at: timeRange.last_at ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user