feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
+1154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>Agent Dashboard - Claude Code Monitor</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Claude Code, agent dashboard, AI monitoring, real-time dashboard, developer tools, session tracking, kanban, activity feed, analytics, cost tracking, browser notifications, token usage"
|
||||
/>
|
||||
<meta name="author" content="SmartGift" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Agent Dashboard" />
|
||||
<meta property="og:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
/>
|
||||
<meta property="og:image" content="/og-image.svg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/svg+xml" />
|
||||
<meta property="og:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.svg" />
|
||||
<meta name="twitter:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:creator" content="@smartgift" />
|
||||
|
||||
<!-- JSON-LD structured data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Agent Dashboard",
|
||||
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows, macOS, Linux",
|
||||
"softwareVersion": "1.0.0",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "SmartGift",
|
||||
"url": "https://smartgift.vn"
|
||||
},
|
||||
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates, CLI statusline"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-surface-0 text-gray-200 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5067
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "agent-dashboard-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-router-dom": "^6.28.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/d3-sankey": "^0.12.5",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @file postcss.config.js
|
||||
* @description PostCSS pipeline for the client build — Tailwind CSS and Autoprefixer.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#818cf8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#a5b4fc" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#c7d2fe" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Hexagon background -->
|
||||
<polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="url(#bg)" rx="2"/>
|
||||
<!-- Center node -->
|
||||
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95"/>
|
||||
<!-- Three connector lines radiating outward -->
|
||||
<line x1="16" y1="13" x2="16" y2="7" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="18.6" y1="17.5" x2="24" y2="20.5" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="13.4" y1="17.5" x2="8" y2="20.5" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<!-- Outer nodes -->
|
||||
<circle cx="16" cy="6" r="1.8" fill="url(#glow)"/>
|
||||
<circle cx="24.5" cy="21" r="1.8" fill="url(#glow)"/>
|
||||
<circle cx="7.5" cy="21" r="1.8" fill="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "claude-code-agent-dashboard",
|
||||
"name": "Agent Dashboard - Claude Code Monitor",
|
||||
"short_name": "Agent Dashboard",
|
||||
"description": "Real-time monitoring platform for Claude Code agent activity.",
|
||||
"author": "Nguyễn Ngọc Trí Vĩ",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#6366f1",
|
||||
"background_color": "#0f1117",
|
||||
"orientation": "any",
|
||||
"categories": ["developer-tools", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0d0d16"/>
|
||||
<stop offset="100%" stop-color="#12121e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hexGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#818cf8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="nodeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#a5b4fc" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#c7d2fe" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="titleGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#e2e2f0"/>
|
||||
<stop offset="55%" stop-color="#a5b4fc"/>
|
||||
<stop offset="100%" stop-color="#818cf8"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow1" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#10b981" stop-opacity="0.10"/>
|
||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="blur1">
|
||||
<feGaussianBlur stdDeviation="40"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="630" fill="url(#bgGrad)"/>
|
||||
<ellipse cx="200" cy="200" rx="340" ry="280" fill="url(#glow1)" filter="url(#blur1)"/>
|
||||
<ellipse cx="1050" cy="480" rx="280" ry="220" fill="url(#glow2)" filter="url(#blur1)"/>
|
||||
|
||||
<g opacity="0.07" fill="#6366f1">
|
||||
<circle cx="80" cy="80" r="1.5"/><circle cx="160" cy="80" r="1.5"/><circle cx="240" cy="80" r="1.5"/><circle cx="320" cy="80" r="1.5"/>
|
||||
<circle cx="80" cy="160" r="1.5"/><circle cx="160" cy="160" r="1.5"/><circle cx="240" cy="160" r="1.5"/><circle cx="320" cy="160" r="1.5"/>
|
||||
<circle cx="80" cy="240" r="1.5"/><circle cx="160" cy="240" r="1.5"/><circle cx="240" cy="240" r="1.5"/><circle cx="320" cy="240" r="1.5"/>
|
||||
<circle cx="900" cy="400" r="1.5"/><circle cx="980" cy="400" r="1.5"/><circle cx="1060" cy="400" r="1.5"/><circle cx="1140" cy="400" r="1.5"/>
|
||||
<circle cx="900" cy="480" r="1.5"/><circle cx="980" cy="480" r="1.5"/><circle cx="1060" cy="480" r="1.5"/><circle cx="1140" cy="480" r="1.5"/>
|
||||
<circle cx="900" cy="560" r="1.5"/><circle cx="980" cy="560" r="1.5"/><circle cx="1060" cy="560" r="1.5"/><circle cx="1140" cy="560" r="1.5"/>
|
||||
</g>
|
||||
|
||||
<polygon points="980,120 1120,200 1120,360 980,440 840,360 840,200"
|
||||
fill="none" stroke="#6366f1" stroke-width="1" opacity="0.12"/>
|
||||
<polygon points="980,150 1095,215 1095,345 980,410 865,345 865,215"
|
||||
fill="none" stroke="#6366f1" stroke-width="0.5" opacity="0.08"/>
|
||||
|
||||
<line x1="60" y1="560" x2="200" y2="460" stroke="#6366f1" stroke-width="1" opacity="0.15"/>
|
||||
<line x1="200" y1="460" x2="380" y2="540" stroke="#6366f1" stroke-width="1" opacity="0.15"/>
|
||||
<circle cx="60" cy="560" r="3" fill="#6366f1" opacity="0.3"/>
|
||||
<circle cx="200" cy="460" r="3" fill="#6366f1" opacity="0.3"/>
|
||||
<circle cx="380" cy="540" r="3" fill="#6366f1" opacity="0.3"/>
|
||||
|
||||
<g transform="translate(128, 265)">
|
||||
<polygon points="64,0 112,28 112,92 64,120 16,92 16,28" fill="url(#hexGrad)"/>
|
||||
<circle cx="64" cy="60" r="11" fill="white" opacity="0.95"/>
|
||||
<line x1="64" y1="49" x2="64" y2="18" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="73.5" y1="65.5" x2="97" y2="79" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="54.5" y1="65.5" x2="31" y2="79" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
|
||||
<circle cx="64" cy="14" r="7" fill="url(#nodeGrad)"/>
|
||||
<circle cx="100" cy="80" r="7" fill="url(#nodeGrad)"/>
|
||||
<circle cx="28" cy="80" r="7" fill="url(#nodeGrad)"/>
|
||||
</g>
|
||||
|
||||
<text x="290" y="298"
|
||||
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="72" font-weight="800" letter-spacing="-2"
|
||||
fill="url(#titleGrad)">Agent Dashboard</text>
|
||||
|
||||
<text x="292" y="355"
|
||||
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="26" font-weight="400"
|
||||
fill="#8888aa">Real-time monitoring platform for Claude Code agent activity</text>
|
||||
|
||||
<line x1="290" y1="385" x2="840" y2="385" stroke="#252538" stroke-width="1"/>
|
||||
|
||||
<g transform="translate(290, 408)" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="15" font-weight="600">
|
||||
<rect x="0" y="0" width="88" height="28" rx="6" fill="#33993320" stroke="#33993340"/>
|
||||
<text x="44" y="19" text-anchor="middle" fill="#6dbf6d">Node.js 18+</text>
|
||||
<rect x="100" y="0" width="82" height="28" rx="6" fill="#61dafb18" stroke="#61dafb30"/>
|
||||
<text x="141" y="19" text-anchor="middle" fill="#61dafb">React 18</text>
|
||||
<rect x="194" y="0" width="100" height="28" rx="6" fill="#3178c618" stroke="#3178c630"/>
|
||||
<text x="244" y="19" text-anchor="middle" fill="#5a9fd4">TypeScript</text>
|
||||
<rect x="306" y="0" width="72" height="28" rx="6" fill="#003b5718" stroke="#005f8a30"/>
|
||||
<text x="342" y="19" text-anchor="middle" fill="#5bb8e0">SQLite</text>
|
||||
<rect x="390" y="0" width="100" height="28" rx="6" fill="#10b98118" stroke="#10b98130"/>
|
||||
<text x="440" y="19" text-anchor="middle" fill="#34d399">WebSocket</text>
|
||||
<rect x="502" y="0" width="62" height="28" rx="6" fill="#f59e0b18" stroke="#f59e0b30"/>
|
||||
<text x="533" y="19" text-anchor="middle" fill="#fbbf24">MIT</text>
|
||||
</g>
|
||||
|
||||
<text x="290" y="580"
|
||||
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="18" font-weight="500"
|
||||
fill="#55556a">github.com/SmartGift · smartgift.vn</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @description Service Worker for caching static assets and handling push notifications.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// Bump this any time the SW logic changes - old clients will install the new
|
||||
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
|
||||
// freshly-built bundle starts being served on the very next request.
|
||||
const CACHE_NAME = "dashboard-v2";
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
// No pre-cache: network-first below means the cache fills lazily, and
|
||||
// there's nothing to "warm" - the v1 SW was pre-caching `/`, which is
|
||||
// exactly the file most likely to go stale after a rebuild.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== "GET") return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
// Skip API, WebSocket, and Vite HMR endpoints
|
||||
if (
|
||||
url.pathname.startsWith("/api/") ||
|
||||
url.pathname.startsWith("/ws") ||
|
||||
url.pathname.includes("__vite")
|
||||
)
|
||||
return;
|
||||
|
||||
// Hashed bundles under /assets/ are immutable for a given URL - cache-first
|
||||
// is safe and fast. A new build emits new filenames, so stale entries simply
|
||||
// don't get re-requested.
|
||||
if (url.pathname.startsWith("/assets/")) {
|
||||
event.respondWith(
|
||||
caches.match(request).then(
|
||||
(cached) =>
|
||||
cached ||
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok && response.type === "basic") {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
|
||||
// with cache fallback. The user always gets the freshest UI while online and
|
||||
// a sensible fallback when offline.
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok && response.type === "basic") {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request).then((c) => c || caches.match("/")))
|
||||
);
|
||||
});
|
||||
|
||||
// --- Push notifications (existing) ---
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
const data = event.data
|
||||
? event.data.json()
|
||||
: { title: "Agent Monitor", body: "New notification" };
|
||||
const { title, ...options } = data;
|
||||
event.waitUntil(self.registration.showNotification(title, { silent: false, ...options }));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window" }).then((windowClients) => {
|
||||
for (const client of windowClients) {
|
||||
if (client.focus) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file App.tsx
|
||||
* @description Top-level React tree for the Claude Code Agent Monitor dashboard.
|
||||
* Wires together routing, real-time WebSocket ingestion, browser notifications,
|
||||
* and the splash screen shown on cold load.
|
||||
*
|
||||
* ## Data flow
|
||||
* 1. {@link useWebSocket} connects to the server's `/ws` endpoint.
|
||||
* 2. Each inbound {@link WSMessage} is published on the in-memory
|
||||
* {@link eventBus} so any page can subscribe without prop drilling.
|
||||
* 3. {@link useNotifications} listens for alert-worthy events and surfaces OS
|
||||
* notifications when permitted.
|
||||
*
|
||||
* ## Routing
|
||||
* All feature pages nest under {@link Layout}, which owns the sidebar and
|
||||
* passes `wsConnected` for the connection badge. Unknown paths fall through to
|
||||
* {@link NotFound}.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./components/Layout`
|
||||
* - `./components/SplashScreen`
|
||||
* - `./pages/Dashboard`
|
||||
* - `./pages/KanbanBoard`
|
||||
* - `./pages/Sessions`
|
||||
* - `./pages/SessionDetail`
|
||||
* - `./pages/ActivityFeed`
|
||||
* - `./pages/Analytics`
|
||||
* - `./pages/Workflows`
|
||||
* - `./pages/Settings`
|
||||
* - `./pages/CcConfig`
|
||||
* - `./pages/Workspace`
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useCallback } from "react";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { DocumentTitle } from "./components/DocumentTitle";
|
||||
import { SplashScreen } from "./components/SplashScreen";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { KanbanBoard } from "./pages/KanbanBoard";
|
||||
import { Sessions } from "./pages/Sessions";
|
||||
import { SessionDetail } from "./pages/SessionDetail";
|
||||
import { ActivityFeed } from "./pages/ActivityFeed";
|
||||
import { Analytics } from "./pages/Analytics";
|
||||
import { Workflows } from "./pages/Workflows";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { CcConfig } from "./pages/CcConfig";
|
||||
import { Workspace } from "./pages/Workspace";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
import { eventBus } from "./lib/eventBus";
|
||||
import type { WSMessage } from "./lib/types";
|
||||
|
||||
/**
|
||||
* Application root component mounted by {@link main.tsx}.
|
||||
* @returns Routed dashboard UI inside `BrowserRouter`.
|
||||
*/
|
||||
export default function App() {
|
||||
const onMessage = useCallback((msg: WSMessage) => {
|
||||
eventBus.publish(msg);
|
||||
}, []);
|
||||
|
||||
const { connected } = useWebSocket(onMessage);
|
||||
useNotifications();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplashScreen />
|
||||
<BrowserRouter>
|
||||
<DocumentTitle />
|
||||
<Routes>
|
||||
<Route element={<Layout wsConnected={connected} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="kanban" element={<KanbanBoard />} />
|
||||
<Route path="sessions" element={<Sessions />} />
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
<Route path="activity" element={<ActivityFeed />} />
|
||||
<Route path="analytics" element={<Analytics />} />
|
||||
<Route path="workflows" element={<Workflows />} />
|
||||
<Route path="lanes" element={<Navigate to="/run" replace />} />
|
||||
<Route path="cc-config" element={<CcConfig />} />
|
||||
<Route path="run" element={<Workspace />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @file AgentCard.tsx
|
||||
* @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked. It also visually distinguishes active agents with a border highlight.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./StatusBadge`
|
||||
* - `../lib/types`
|
||||
* - `../lib/format`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AgentCard` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **AgentCard**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types";
|
||||
import type { Agent, Session } from "../lib/types";
|
||||
import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format";
|
||||
|
||||
/**
|
||||
* Display name for a main agent, swapping its auto-generated placeholder for the
|
||||
* real session title when one exists. Main agents are created as
|
||||
* `<prefix> - <placeholder>`, where the placeholder is either `Session <id8>`
|
||||
* (live hooks) or `<cwd-folder> - <id8>` (import / background sync). Replacing
|
||||
* everything after the first ` - ` covers BOTH formats — the older
|
||||
* `replace(/Session [0-9a-f]{8}/)` only matched the hook form, so imported
|
||||
* sessions kept showing `<folder> - <id8>` even after their title was known.
|
||||
*/
|
||||
function mainAgentDisplayName(agentName: string, realSessionName: string): string {
|
||||
if (!realSessionName) return agentName;
|
||||
const sep = agentName.indexOf(" - ");
|
||||
return sep >= 0 ? `${agentName.slice(0, sep)} - ${realSessionName}` : agentName;
|
||||
}
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent;
|
||||
/** Optional session data for richer main-agent rendering (model, cwd,
|
||||
* cost). Subagent display ignores this. When omitted, the card falls
|
||||
* back to the original minimal layout. */
|
||||
session?: Session;
|
||||
label?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("kanban");
|
||||
const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent);
|
||||
const status = effectiveAgentStatus(agent);
|
||||
const isActive = agent.status === "working";
|
||||
const isMain = agent.type === "main";
|
||||
|
||||
// Session-level metadata applies to every card in the session - main and
|
||||
// subagents alike. Subtitle differs by type: main uses model+cwd (its
|
||||
// auto-generated name carries no info), subagents stick with their
|
||||
// subagent_type label (more useful than repeating the session model).
|
||||
const model = formatModelName(session?.model);
|
||||
const cwdBase = pathBasename(session?.cwd);
|
||||
// Cost shown on the card is scoped to what the card represents: a main agent's
|
||||
// card stands in for the whole session, so it shows the session total; a
|
||||
// subagent's card shows that subagent's OWN cost (server-computed from its
|
||||
// token buckets). Showing the session total on a subagent card is misleading —
|
||||
// it reads as if that one subagent cost the whole session's spend. A subagent
|
||||
// with no recorded usage shows no cost (the cost > 0 guard below hides it),
|
||||
// which is truthful rather than misleading.
|
||||
const cost = isMain
|
||||
? typeof session?.cost === "number"
|
||||
? session.cost
|
||||
: 0
|
||||
: typeof agent.cost === "number"
|
||||
? agent.cost
|
||||
: 0;
|
||||
// Real (user-given) session name - the auto-generated "Session <id8>"
|
||||
// fallback carries no extra info next to the ID, so it is suppressed.
|
||||
const sessionName = session?.name?.trim() || "";
|
||||
const realSessionName = /^Session [0-9a-f]{8}$/i.test(sessionName) ? "" : sessionName;
|
||||
// A subagent's own model lives in its metadata (resolved from its transcript,
|
||||
// not the parent session's — see issue #185). Use it everywhere this card
|
||||
// shows a model so a Haiku QA agent under an Opus orchestrator reads as
|
||||
// Haiku, not Opus. Falls back to the session model only for the main agent.
|
||||
let subagentModel: string | null = null;
|
||||
if (!isMain && agent.metadata) {
|
||||
try {
|
||||
const parsed = JSON.parse(agent.metadata) as { model?: string };
|
||||
subagentModel = parsed?.model ? formatModelName(parsed.model) : null;
|
||||
} catch {
|
||||
subagentModel = null;
|
||||
}
|
||||
}
|
||||
// The model badge (footer) must reflect THIS card's agent: the session model
|
||||
// for main, the subagent's own model for subagents.
|
||||
const displayModel = isMain ? model : subagentModel;
|
||||
// Model now lives in the footer badge, so the subtitle carries project
|
||||
// context instead: main shows cwd + how many agents the session spawned +
|
||||
// how many turns it has run; subagents show their type + the project they ran
|
||||
// in. (No model here — that would duplicate the footer badge, which is what
|
||||
// main cards used to do.)
|
||||
const agentCount = typeof session?.agent_count === "number" ? session.agent_count : 0;
|
||||
// agent_count includes the main agent itself. Show how many SUBAGENTS the
|
||||
// session spawned instead, so this reconciles with the "Active Subagents"
|
||||
// dashboard stat (which excludes main agents) — otherwise a card reading
|
||||
// "29 agents" looks like it should equal a 29-subagent stat when the session
|
||||
// actually has 28 subagents + 1 main.
|
||||
const subagentCount = Math.max(0, agentCount - 1);
|
||||
let sessionTurns = 0;
|
||||
if (isMain && session?.metadata) {
|
||||
try {
|
||||
const m = JSON.parse(session.metadata) as { turn_count?: number };
|
||||
if (typeof m?.turn_count === "number") sessionTurns = m.turn_count;
|
||||
} catch {
|
||||
sessionTurns = 0;
|
||||
}
|
||||
}
|
||||
const subtitle = isMain
|
||||
? [
|
||||
cwdBase,
|
||||
subagentCount > 0 ? t("kanban:session.subagentSummary", { count: subagentCount }) : null,
|
||||
sessionTurns > 0 ? t("kanban:session.turnSummary", { count: sessionTurns }) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || null
|
||||
: [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null;
|
||||
|
||||
function handleClick() {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
navigate(`/sessions/${agent.session_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`card-hover p-4 cursor-pointer overflow-hidden ${
|
||||
isWaiting
|
||||
? "border-l-2 border-l-yellow-500/60"
|
||||
: isActive
|
||||
? "border-l-2 border-l-emerald-500/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3 min-w-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
||||
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
|
||||
}`}
|
||||
>
|
||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<p className="text-sm font-medium text-gray-200 truncate">
|
||||
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
|
||||
229d93fd" or "Main Agent - work - e3f8e613") swap the
|
||||
placeholder for the real session name when one exists; custom
|
||||
(sub)agent names are left untouched. */}
|
||||
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
|
||||
</p>
|
||||
{subtitle && <p className="text-[11px] text-gray-500 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{/* compact: cards are narrow — inline reason chip would squeeze the
|
||||
title, so the reason stays hover-tooltip-only here. */}
|
||||
<AgentStatusBadge status={status} reason={agentAwaitingReason(agent)} compact />
|
||||
</div>
|
||||
|
||||
{agent.task && (
|
||||
<p className="text-xs text-gray-400 mb-3 line-clamp-2 leading-relaxed">{agent.task}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-[11px] text-gray-500 min-w-0 overflow-hidden flex-wrap">
|
||||
{agent.current_tool && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Wrench className="w-3 h-3" />
|
||||
{agent.current_tool}
|
||||
</span>
|
||||
)}
|
||||
{/* Model badge - shown on every card when no tool is currently
|
||||
running (avoids clutter on actively-running agents that already
|
||||
display the running tool name). Uses the agent's OWN model:
|
||||
session model for main, the subagent's resolved model otherwise. */}
|
||||
{displayModel && !agent.current_tool && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Cpu className="w-3 h-3" />
|
||||
{displayModel}
|
||||
</span>
|
||||
)}
|
||||
{cost > 0 && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Coins className="w-3 h-3" />
|
||||
{fmtCost(cost)}
|
||||
</span>
|
||||
)}
|
||||
{agent.ended_at ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t("ran")}
|
||||
{formatDuration(agent.started_at, agent.ended_at)}
|
||||
</span>
|
||||
<span className="text-gray-600 flex-shrink-0">{timeAgo(agent.ended_at)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Clock className="w-3 h-3" />
|
||||
{timeAgo(agent.updated_at || agent.started_at)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1 min-w-0 opacity-50">
|
||||
{realSessionName && <span className="truncate max-w-[10rem]">{realSessionName} ·</span>}
|
||||
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
/**
|
||||
* @file AlertsNotifications.tsx
|
||||
* @description Unified "Alerts" control center embedded in the Settings page
|
||||
* (replaces the standalone /alerts route). A segmented tab UI
|
||||
* combines three concerns that used to be split across a page and a panel:
|
||||
* • Rules - define what conditions trigger an alert
|
||||
* • Channels - webhook targets that receive fired alerts (Slack/Discord/…)
|
||||
* • Activity - the live fired-alert feed with acknowledge controls
|
||||
* Tab badges reflect live state (rule count, unacked alert count), and the feed
|
||||
* + counts refetch on alert_triggered / alert_updated WebSocket messages.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/api`
|
||||
* - `../lib/eventBus`
|
||||
* - `./EmptyState`
|
||||
* - `./Skeleton`
|
||||
* - `./WebhookSettings`
|
||||
* - `./ConfirmModal`
|
||||
* - `./Checkbox`
|
||||
* - `./FieldHelp`
|
||||
* - `../lib/format`
|
||||
* - `../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AlertsNotifications` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **AlertsNotifications**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BellRing,
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ListChecks,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Webhook,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
import { WebhookSettings } from "./WebhookSettings";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { FieldHelp } from "./FieldHelp";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
// Example values surfaced in the field-help tooltips so users know what to type.
|
||||
// These are the Claude Code hook event types and common built-in tool names.
|
||||
const EVENT_TYPE_EXAMPLES = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
"Notification",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
];
|
||||
const TOOL_NAME_EXAMPLES = [
|
||||
"Bash",
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Grep",
|
||||
"Glob",
|
||||
"Task",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"TodoWrite",
|
||||
];
|
||||
const SUMMARY_EXAMPLES = ["error", "permission", "timeout", "rate limit", "denied"];
|
||||
|
||||
const RULE_TYPES: AlertRuleType[] = [
|
||||
"event_pattern",
|
||||
"inactivity",
|
||||
"status_duration",
|
||||
"token_threshold",
|
||||
];
|
||||
|
||||
type TabKey = "rules" | "channels" | "activity";
|
||||
|
||||
interface RuleFormState {
|
||||
name: string;
|
||||
rule_type: AlertRuleType;
|
||||
event_type: string;
|
||||
tool_name: string;
|
||||
summary_contains: string;
|
||||
count: string;
|
||||
window_minutes: string;
|
||||
minutes: string;
|
||||
status: "working" | "waiting";
|
||||
total_tokens: string;
|
||||
cooldown_seconds: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: RuleFormState = {
|
||||
name: "",
|
||||
rule_type: "event_pattern",
|
||||
event_type: "",
|
||||
tool_name: "",
|
||||
summary_contains: "",
|
||||
count: "1",
|
||||
window_minutes: "5",
|
||||
minutes: "10",
|
||||
status: "working",
|
||||
total_tokens: "1000000",
|
||||
cooldown_seconds: "300",
|
||||
};
|
||||
|
||||
function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
switch (form.rule_type) {
|
||||
case "event_pattern": {
|
||||
const config: AlertRule["config"] = {};
|
||||
if (form.event_type.trim()) config.event_type = form.event_type.trim();
|
||||
if (form.tool_name.trim()) config.tool_name = form.tool_name.trim();
|
||||
if (form.summary_contains.trim()) config.summary_contains = form.summary_contains.trim();
|
||||
const count = parseInt(form.count, 10);
|
||||
config.count = Number.isFinite(count) && count > 0 ? count : 1;
|
||||
if (config.count > 1) {
|
||||
const window = parseFloat(form.window_minutes);
|
||||
config.window_minutes = Number.isFinite(window) && window > 0 ? window : 5;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
case "inactivity":
|
||||
return { minutes: parseFloat(form.minutes) };
|
||||
case "status_duration":
|
||||
return { status: form.status, minutes: parseFloat(form.minutes) };
|
||||
case "token_threshold":
|
||||
return { total_tokens: parseInt(form.total_tokens, 10) };
|
||||
}
|
||||
}
|
||||
|
||||
function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, unknown>) => string) {
|
||||
const c = rule.config;
|
||||
switch (rule.rule_type) {
|
||||
case "event_pattern": {
|
||||
const parts = [
|
||||
c.event_type && `event=${c.event_type}`,
|
||||
c.tool_name && `tool=${c.tool_name}`,
|
||||
c.summary_contains && `summary~"${c.summary_contains}"`,
|
||||
].filter(Boolean);
|
||||
const base = parts.join(" · ");
|
||||
return (c.count ?? 1) > 1
|
||||
? t("ruleDesc.eventPatternCount", {
|
||||
pattern: base,
|
||||
count: c.count,
|
||||
window: c.window_minutes,
|
||||
})
|
||||
: t("ruleDesc.eventPattern", { pattern: base });
|
||||
}
|
||||
case "inactivity":
|
||||
return t("ruleDesc.inactivity", { minutes: c.minutes });
|
||||
case "status_duration":
|
||||
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
|
||||
case "token_threshold":
|
||||
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
|
||||
}
|
||||
}
|
||||
|
||||
export function AlertsNotifications() {
|
||||
const { t } = useTranslation("alerts");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
|
||||
const [tab, setTab] = useState<TabKey>("rules");
|
||||
|
||||
// Rules
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
||||
|
||||
// Feed
|
||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [unacked, setUnacked] = useState(0);
|
||||
const [unackedOnly, setUnackedOnly] = useState(false);
|
||||
const [loadingAlerts, setLoadingAlerts] = useState(true);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
const res = await api.alerts.rules.list();
|
||||
setRules(res.rules);
|
||||
} catch (err) {
|
||||
console.error("Failed to load alert rules:", err);
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoadingAlerts(true);
|
||||
try {
|
||||
const res = await api.alerts.list({
|
||||
unacked: unackedOnly || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0,
|
||||
});
|
||||
setAlerts(res.alerts);
|
||||
setTotal(res.total);
|
||||
setUnacked(res.unacked);
|
||||
} catch (err) {
|
||||
console.error("Failed to load alerts:", err);
|
||||
} finally {
|
||||
setLoadingAlerts(false);
|
||||
}
|
||||
}, [unackedOnly]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.alerts.list({
|
||||
unacked: unackedOnly || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: alerts.length,
|
||||
});
|
||||
setAlerts((prev) => [...prev, ...res.alerts]);
|
||||
setTotal(res.total);
|
||||
setUnacked(res.unacked);
|
||||
} catch (err) {
|
||||
console.error("Failed to load more alerts:", err);
|
||||
}
|
||||
}, [unackedOnly, alerts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, [loadRules]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
}, [loadAlerts]);
|
||||
|
||||
// Live updates: any fired/acked alert refreshes the feed + counts regardless
|
||||
// of which tab is open, so the Activity badge stays accurate.
|
||||
useEffect(() => {
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type === "alert_triggered" || msg.type === "alert_updated") {
|
||||
loadAlerts();
|
||||
}
|
||||
});
|
||||
}, [loadAlerts]);
|
||||
|
||||
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const onCreateRule = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const cooldown = parseInt(form.cooldown_seconds, 10);
|
||||
await api.alerts.rules.create({
|
||||
name: form.name.trim(),
|
||||
rule_type: form.rule_type,
|
||||
config: buildConfig(form),
|
||||
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
|
||||
});
|
||||
setForm(EMPTY_FORM);
|
||||
setFormOpen(false);
|
||||
loadRules();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleRule = async (rule: AlertRule) => {
|
||||
try {
|
||||
await api.alerts.rules.update(rule.id, { enabled: !rule.enabled });
|
||||
loadRules();
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle alert rule:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteRule = async (rule: AlertRule) => {
|
||||
try {
|
||||
await api.alerts.rules.remove(rule.id);
|
||||
setConfirmRule(null);
|
||||
loadRules();
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete alert rule:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const onAck = async (id: number) => {
|
||||
try {
|
||||
await api.alerts.ack(id);
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to acknowledge alert:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const onAckAll = async () => {
|
||||
try {
|
||||
await api.alerts.ackAll();
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to acknowledge alerts:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Mirror the server-side validation so obviously invalid rules never make it
|
||||
// to a request.
|
||||
const minutesVal = parseFloat(form.minutes);
|
||||
const tokensVal = parseInt(form.total_tokens, 10);
|
||||
const countVal = parseInt(form.count, 10);
|
||||
const windowVal = parseFloat(form.window_minutes);
|
||||
const canSubmit =
|
||||
form.name.trim().length > 0 &&
|
||||
(form.rule_type !== "event_pattern" ||
|
||||
(Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) &&
|
||||
Number.isFinite(countVal) &&
|
||||
countVal > 0 &&
|
||||
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
|
||||
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
|
||||
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
|
||||
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0));
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
{
|
||||
key: "rules",
|
||||
label: ts("alertsHub.tabRules"),
|
||||
icon: ListChecks,
|
||||
badge: rules.length || undefined,
|
||||
},
|
||||
{ key: "channels", label: ts("alertsHub.tabChannels"), icon: Webhook },
|
||||
{
|
||||
key: "activity",
|
||||
label: ts("alertsHub.tabActivity"),
|
||||
icon: BellRing,
|
||||
badge: unacked || undefined,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Segmented tab control */}
|
||||
<div className="inline-flex flex-wrap rounded-xl border border-border bg-surface-2 p-1 gap-1">
|
||||
{TABS.map((tb) => {
|
||||
const active = tab === tb.key;
|
||||
const Icon = tb.icon;
|
||||
return (
|
||||
<button
|
||||
key={tb.key}
|
||||
onClick={() => setTab(tb.key)}
|
||||
className={`inline-flex items-center gap-2 text-xs font-medium px-3.5 py-2 rounded-lg transition-colors ${
|
||||
active
|
||||
? "bg-surface-4 text-gray-100 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-300 hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{tb.label}
|
||||
{tb.badge != null && (
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-1.5 min-w-[18px] text-center ${
|
||||
tb.key === "activity"
|
||||
? "text-amber-300 bg-amber-500/15"
|
||||
: active
|
||||
? "text-accent bg-accent/15"
|
||||
: "text-gray-400 bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{tb.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── RULES ── */}
|
||||
{tab === "rules" && (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-200">{t("rules.title")}</h4>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{ts("alertsHub.rulesHint")}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFormOpen((open) => !open);
|
||||
setFormError(null);
|
||||
}}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0"
|
||||
>
|
||||
{formOpen ? <X className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{formOpen ? t("rules.cancel") : t("rules.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formOpen && (
|
||||
<div className="rounded-lg border border-border bg-surface-2 p-3 mb-3 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.name")}
|
||||
<FieldHelp description={t("rules.help.name")} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
placeholder={t("rules.form.namePlaceholder")}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.type")}
|
||||
<FieldHelp title={t("rules.form.type")} description={t("rules.help.type")} />
|
||||
</span>
|
||||
<div className="relative mt-1">
|
||||
<select
|
||||
value={form.rule_type}
|
||||
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
>
|
||||
{RULE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`ruleTypes.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">{t(`ruleTypeHints.${form.rule_type}`)}</p>
|
||||
|
||||
{form.rule_type === "event_pattern" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.eventType")}
|
||||
<FieldHelp
|
||||
title={t("rules.form.eventType")}
|
||||
description={t("rules.help.eventType")}
|
||||
examples={EVENT_TYPE_EXAMPLES}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.event_type}
|
||||
onChange={(e) => set({ event_type: e.target.value })}
|
||||
placeholder="PostToolUse"
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.toolName")}
|
||||
<FieldHelp
|
||||
title={t("rules.form.toolName")}
|
||||
description={t("rules.help.toolName")}
|
||||
examples={TOOL_NAME_EXAMPLES}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.tool_name}
|
||||
onChange={(e) => set({ tool_name: e.target.value })}
|
||||
placeholder="Bash"
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.summaryContains")}
|
||||
<FieldHelp
|
||||
title={t("rules.form.summaryContains")}
|
||||
description={t("rules.help.summaryContains")}
|
||||
examples={SUMMARY_EXAMPLES}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.summary_contains}
|
||||
onChange={(e) => set({ summary_contains: e.target.value })}
|
||||
placeholder="error"
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.count")}
|
||||
<FieldHelp description={t("rules.help.count")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.count}
|
||||
onChange={(e) => set({ count: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
{parseInt(form.count, 10) > 1 && (
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.windowMinutes")}
|
||||
<FieldHelp description={t("rules.help.window")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.window_minutes}
|
||||
onChange={(e) => set({ window_minutes: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(form.rule_type === "inactivity" || form.rule_type === "status_duration") && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{form.rule_type === "status_duration" && (
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.agentStatus")}
|
||||
<FieldHelp description={t("rules.help.status")} />
|
||||
</span>
|
||||
<div className="relative mt-1">
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
>
|
||||
<option value="working">working</option>
|
||||
<option value="waiting">waiting</option>
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.minutes")}
|
||||
<FieldHelp
|
||||
description={t(
|
||||
form.rule_type === "inactivity"
|
||||
? "rules.help.minutesInactivity"
|
||||
: "rules.help.minutesStatus"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.minutes}
|
||||
onChange={(e) => set({ minutes: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.rule_type === "token_threshold" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.totalTokens")}
|
||||
<FieldHelp description={t("rules.help.totalTokens")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.total_tokens}
|
||||
onChange={(e) => set({ total_tokens: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<label className="block text-xs text-gray-400">
|
||||
<span className="mb-1.5 flex items-center gap-1">
|
||||
{t("rules.form.cooldown")}
|
||||
<FieldHelp description={t("rules.help.cooldown")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.cooldown_seconds}
|
||||
onChange={(e) => set({ cooldown_seconds: e.target.value })}
|
||||
className="input w-40"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={onCreateRule}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{saving ? t("rules.saving") : t("rules.create")}
|
||||
</button>
|
||||
</div>
|
||||
{formError && <p className="text-xs text-red-400">{formError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingRules ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListChecks}
|
||||
title={t("rules.empty")}
|
||||
description={ts("alertsHub.rulesEmptyHint")}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${rule.enabled ? "text-gray-200" : "text-gray-500 line-through"}`}
|
||||
>
|
||||
{rule.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-accent bg-accent/10 border border-accent/20 rounded px-1.5 py-0.5 flex-shrink-0">
|
||||
{t(`ruleTypes.${rule.rule_type}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{describeRule(rule, t)} ·{" "}
|
||||
{t("rules.cooldown", { seconds: rule.cooldown_seconds })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onToggleRule(rule)}
|
||||
className={`text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
|
||||
rule.enabled
|
||||
? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
|
||||
: "border-border text-gray-500 hover:text-gray-300 hover:bg-surface-3"
|
||||
}`}
|
||||
title={rule.enabled ? t("rules.disable") : t("rules.enable")}
|
||||
>
|
||||
{rule.enabled ? t("rules.enabled") : t("rules.disabled")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmRule(rule)}
|
||||
className="p-1.5 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
title={t("rules.delete")}
|
||||
aria-label={t("rules.delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CHANNELS (webhooks) ── */}
|
||||
{tab === "channels" && <WebhookSettings />}
|
||||
|
||||
{/* ── ACTIVITY (fired-alert feed) ── */}
|
||||
{tab === "activity" && (
|
||||
<div className="card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-200">
|
||||
{t("feed.title")}
|
||||
{unacked > 0 && (
|
||||
<span className="ml-2 text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded-full px-2 py-0.5">
|
||||
{t("feed.unackedCount", { count: unacked })}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={unackedOnly}
|
||||
onChange={setUnackedOnly}
|
||||
label={t("feed.unackedOnly")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => loadAlerts()}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{unacked > 0 && (
|
||||
<button
|
||||
onClick={onAckAll}
|
||||
className="btn-primary inline-flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
{t("ackAll", { count: unacked })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingAlerts && alerts.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={unackedOnly ? BellOff : BellRing}
|
||||
title={t("feed.emptyTitle")}
|
||||
description={unackedOnly ? t("feed.emptyUnacked") : t("feed.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-2">
|
||||
{alerts.map((alert) => (
|
||||
<li
|
||||
key={alert.id}
|
||||
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2.5 ${
|
||||
alert.acknowledged_at
|
||||
? "border-border bg-surface-2 opacity-70"
|
||||
: "border-amber-500/30 bg-amber-500/5"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing
|
||||
className={`w-3.5 h-3.5 flex-shrink-0 ${alert.acknowledged_at ? "text-gray-500" : "text-amber-400"}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-200 truncate">{alert.message}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5 font-mono">
|
||||
{timeAgo(alert.triggered_at)} · {alert.rule_name}
|
||||
{alert.session_id && (
|
||||
<>
|
||||
{" · "}
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(alert.session_id)}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("feed.viewSession")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!alert.acknowledged_at && (
|
||||
<button
|
||||
onClick={() => onAck(alert.id)}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-gray-300 hover:text-gray-100 hover:bg-surface-3 transition-colors flex-shrink-0"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t("feed.ack")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{alerts.length < total && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<button onClick={loadMore} className="btn-ghost border border-border text-sm">
|
||||
{t("feed.loadMore", { shown: alerts.length, total })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmRule}
|
||||
title={t("rules.deleteTitle", "Delete alert rule?")}
|
||||
message={confirmRule ? t("rules.confirmDelete", { name: confirmRule.name }) : ""}
|
||||
confirmLabel={t("rules.delete")}
|
||||
cancelLabel={t("rules.cancel")}
|
||||
onCancel={() => setConfirmRule(null)}
|
||||
onConfirm={() => confirmRule && onDeleteRule(confirmRule)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file Checkbox.tsx
|
||||
* @description Accessible custom checkbox built on a `<button role="checkbox">`
|
||||
* instead of a native `<input type="checkbox">` so the control matches the
|
||||
* dashboard's dark theme (accent fill, rounded square, Lucide check mark).
|
||||
*
|
||||
* ## Keyboard & ARIA
|
||||
* Space and Enter toggle via the native button behavior. `aria-checked` mirrors
|
||||
* the `checked` prop for screen readers.
|
||||
*
|
||||
* ## Usage
|
||||
* Pass `label` for inline text, or omit it and wrap with an external `<label>`
|
||||
* when the clickable area should include more than the box itself.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `CheckboxProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `Checkbox` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **CheckboxProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **Checkbox**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
/** Props for {@link Checkbox}. */
|
||||
export interface CheckboxProps {
|
||||
/** Controlled checked state. */
|
||||
checked: boolean;
|
||||
/** Called with the toggled value when the user activates the control. */
|
||||
onChange: (v: boolean) => void;
|
||||
/** Optional label rendered to the right of the box. */
|
||||
label?: ReactNode;
|
||||
/** Extra classes on the outer `<button>`. */
|
||||
className?: string;
|
||||
/** Classes applied to the label `<span>` when `label` is set. */
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themed checkbox control.
|
||||
* @param props See {@link CheckboxProps}.
|
||||
*/
|
||||
export function Checkbox({ checked, onChange, label, className, labelClassName }: CheckboxProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`group inline-flex items-center gap-2 text-left ${className ?? ""}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
|
||||
checked
|
||||
? "bg-accent border-accent"
|
||||
: "bg-surface-2 border-border group-hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
</span>
|
||||
{label != null && (
|
||||
<span className={labelClassName ?? "text-xs text-gray-400 group-hover:text-gray-300"}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @file ConfirmModal.tsx
|
||||
* @description Centered confirmation dialog for destructive or irreversible
|
||||
* actions (delete webhook, remove alert rule, etc.). Replaces `window.confirm`
|
||||
* with themed UI that matches the dashboard and distinguishes a loading (`busy`)
|
||||
* confirm button from one refused outright (`disabled`).
|
||||
*
|
||||
* ## Dismissal
|
||||
* Clicking the backdrop, pressing Escape, or clicking the X cancels. The confirm
|
||||
* button can be styled non-destructive for neutral confirmations.
|
||||
*
|
||||
* ## Accessibility
|
||||
* Focus moves to Cancel on open (safer default), Tab cycles within the dialog,
|
||||
* Escape cancels, and focus restores to the previously focused element on close.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
/** Props for {@link ConfirmModal}. */
|
||||
export interface ConfirmModalProps {
|
||||
/** When false, nothing is rendered. */
|
||||
open: boolean;
|
||||
/** Dialog heading. */
|
||||
title: string;
|
||||
/** Optional supporting message below the title. */
|
||||
message?: string;
|
||||
/** Primary action label (e.g. "Delete"). */
|
||||
confirmLabel: string;
|
||||
/** Secondary cancel label. */
|
||||
cancelLabel: string;
|
||||
/** When true (default), confirm button uses red destructive styling. */
|
||||
destructive?: boolean;
|
||||
/** Disables confirm while an async delete is in flight. */
|
||||
busy?: boolean;
|
||||
/**
|
||||
* Disables confirm because the action is not permitted right now (a blocker, an
|
||||
* unmet checkbox, facts that failed to load). Distinct from `busy`: nothing is
|
||||
* in flight, so callers must not conflate the two — passing a refusal as `busy`
|
||||
* makes a blocked action read as perpetually loading.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/** Optional action-specific facts shown before the confirmation controls. */
|
||||
children?: ReactNode;
|
||||
/** Called when the user confirms. */
|
||||
onConfirm: () => void;
|
||||
/** Called on cancel, backdrop click, Escape, or X. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal confirmation overlay.
|
||||
* @param props See {@link ConfirmModalProps}.
|
||||
*/
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
destructive = true,
|
||||
busy = false,
|
||||
disabled = false,
|
||||
children,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const titleId = useId();
|
||||
const messageId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
previouslyFocused.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
// Prefer Cancel so Enter/activation doesn't immediately destroy data.
|
||||
const focusTimer = window.setTimeout(() => cancelRef.current?.focus(), 0);
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panelRef.current) return;
|
||||
|
||||
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable.item(0);
|
||||
const last = focusable.item(focusable.length - 1);
|
||||
if (!first || !last) return;
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
previouslyFocused.current?.focus?.();
|
||||
previouslyFocused.current = null;
|
||||
};
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-5">
|
||||
{destructive && (
|
||||
<div className="w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 id={titleId} className="text-sm font-semibold text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
{message && (
|
||||
<p id={messageId} className="text-xs text-gray-400 mt-1 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-gray-500 hover:text-gray-300 p-1 -mt-1 -mr-1"
|
||||
aria-label={cancelLabel}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-5 pb-5">
|
||||
<button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={busy || disabled}
|
||||
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${
|
||||
destructive
|
||||
? "text-red-200 bg-red-500/15 border border-red-500/30 hover:bg-red-500/25"
|
||||
: "btn-primary"
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @file DateTimePicker.tsx
|
||||
* @description A React component that provides a user-friendly interface for selecting both date and time. The component displays a button that shows the currently selected date and time in a human-readable format. When the button is clicked, a dropdown appears containing a calendar for date selection and an input for time selection. The component handles edge cases such as invalid dates and ensures that the dropdown is positioned correctly within the viewport. It also allows users to clear their selection easily. This component is designed to be reusable across the application wherever date and time input is required.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `DateTimePicker` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **DateTimePicker**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value: string; // Expected format: YYYY-MM-DDTHH:mm
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select date & time",
|
||||
className = "",
|
||||
"aria-label": ariaLabel,
|
||||
title,
|
||||
}: DateTimePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [alignment, setAlignment] = useState<"left" | "right">("left");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Parse value
|
||||
const dateObj = value ? new Date(value) : null;
|
||||
const [viewDate, setViewDate] = useState(dateObj || new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.left + 230 > window.innerWidth) {
|
||||
setAlignment("right");
|
||||
} else {
|
||||
setAlignment("left");
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dateObj && !isNaN(dateObj.getTime())) {
|
||||
setViewDate(dateObj);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const formatDisplay = (d: Date | null) => {
|
||||
if (!d || isNaN(d.getTime())) return "";
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
const newDate = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
|
||||
if (dateObj && !isNaN(dateObj.getTime())) {
|
||||
newDate.setHours(dateObj.getHours(), dateObj.getMinutes());
|
||||
} else {
|
||||
newDate.setHours(0, 0); // Default midnight
|
||||
}
|
||||
updateValue(newDate);
|
||||
};
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const timeStr = e.target.value;
|
||||
if (!timeStr) return;
|
||||
const parts = timeStr.split(":");
|
||||
if (parts.length !== 2) return;
|
||||
const h = parts[0] || "0";
|
||||
const m = parts[1] || "0";
|
||||
const newDate = dateObj && !isNaN(dateObj.getTime()) ? new Date(dateObj) : new Date();
|
||||
newDate.setHours(parseInt(h, 10), parseInt(m, 10));
|
||||
updateValue(newDate);
|
||||
};
|
||||
|
||||
const updateValue = (d: Date) => {
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
onChange(`${y}-${mo}-${day}T${h}:${mi}`);
|
||||
};
|
||||
|
||||
const clearValue = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange("");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const daysInMonth = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1).getDay();
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="w-6 h-6" />);
|
||||
}
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const isSelected = !!(
|
||||
dateObj &&
|
||||
dateObj.getDate() === i &&
|
||||
dateObj.getMonth() === viewDate.getMonth() &&
|
||||
dateObj.getFullYear() === viewDate.getFullYear()
|
||||
);
|
||||
const isToday =
|
||||
new Date().getDate() === i &&
|
||||
new Date().getMonth() === viewDate.getMonth() &&
|
||||
new Date().getFullYear() === viewDate.getFullYear();
|
||||
|
||||
days.push(
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleDateClick(i)}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded text-[11px] transition-colors
|
||||
${
|
||||
isSelected
|
||||
? "bg-accent text-white font-medium"
|
||||
: isToday
|
||||
? "bg-surface-3 text-accent font-medium"
|
||||
: "hover:bg-surface-2 text-gray-300 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const timeValue =
|
||||
dateObj && !isNaN(dateObj.getTime())
|
||||
? `${String(dateObj.getHours()).padStart(2, "0")}:${String(dateObj.getMinutes()).padStart(2, "0")}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={ariaLabel}
|
||||
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`}
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className={`flex-1 truncate ${!dateObj ? "text-gray-500" : "text-gray-200"}`}>
|
||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||
</span>
|
||||
{dateObj && (
|
||||
<X className="w-3 h-3 text-gray-500 hover:text-white shrink-0" onClick={clearValue} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute top-full mt-1 ${alignment === "right" ? "right-0" : "left-0"} z-50 bg-surface-1 border border-border rounded-lg shadow-xl p-3 w-[220px] flex flex-col gap-3`}
|
||||
>
|
||||
{/* Calendar Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-gray-400 hover:text-white"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-gray-200">
|
||||
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-gray-400 hover:text-white"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div>
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
||||
<div key={day} className="w-6 text-center text-[10px] font-medium text-gray-500">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">{days}</div>
|
||||
</div>
|
||||
|
||||
{/* Time Picker */}
|
||||
<div className="pt-3 border-t border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-gray-400">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-medium">Time</span>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-gray-200 focus:outline-none focus:border-accent w-[85px] time-input-custom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file DocumentTitle.tsx
|
||||
* @description Route-aware document title setter nested under BrowserRouter so
|
||||
* `useLocation` works. Keeps multi-tab window switchers readable.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||
|
||||
/**
|
||||
* Maps the current pathname to a localized browser tab title.
|
||||
*/
|
||||
export function DocumentTitle() {
|
||||
const { t } = useTranslation("nav");
|
||||
const location = useLocation();
|
||||
|
||||
let title = t("dashboard");
|
||||
const path = location.pathname;
|
||||
const sessionId = path.match(/^\/sessions\/([^/]+)/)?.[1];
|
||||
|
||||
if (sessionId) {
|
||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||
} else if (path.startsWith("/sessions")) {
|
||||
title = t("sessions");
|
||||
} else if (path.startsWith("/kanban")) {
|
||||
title = t("agentBoard");
|
||||
} else if (path.startsWith("/activity")) {
|
||||
title = t("activityFeed");
|
||||
} else if (path.startsWith("/analytics")) {
|
||||
title = t("analytics");
|
||||
} else if (path.startsWith("/workflows")) {
|
||||
title = t("workflows");
|
||||
} else if (path.startsWith("/cc-config")) {
|
||||
title = t("ccConfig");
|
||||
} else if (path.startsWith("/run")) {
|
||||
title = t("run");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
title = t("settings");
|
||||
} else if (path !== "/") {
|
||||
title = t("notFound");
|
||||
}
|
||||
|
||||
useDocumentTitle(title);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file EmptyState.tsx
|
||||
* @description Centered empty-state panel used whenever a page or section has
|
||||
* nothing to render yet — no sessions, no events, no search hits, or a feature
|
||||
* that has not been configured. Keeps the UI from looking broken by giving the
|
||||
* user a clear icon, title, explanation, and an optional call-to-action slot.
|
||||
*
|
||||
* ## When to use
|
||||
* Prefer this over ad-hoc "No data" paragraphs so every list/table page shares
|
||||
* the same vertical rhythm, typography, and card chrome. The optional `action`
|
||||
* slot accepts any React node (usually a `<Link>` or `<button>`) without this
|
||||
* component needing to know about routing.
|
||||
*
|
||||
* ## Accessibility
|
||||
* The icon is decorative (no separate `aria-label`); meaning comes from the
|
||||
* visible `title` (`<h3>`) and `description` (`<p>`). Callers should pass
|
||||
* translated strings via `useTranslation`.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `EmptyStateProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `EmptyState` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **EmptyStateProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **EmptyState**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
/** Props for {@link EmptyState}. */
|
||||
export interface EmptyStateProps {
|
||||
/** Lucide icon shown inside the rounded square above the title. */
|
||||
icon: LucideIcon;
|
||||
/** Primary heading — keep short (one line). */
|
||||
title: string;
|
||||
/** Supporting copy; wrapped at `max-w-md` for comfortable line length. */
|
||||
description: string;
|
||||
/** Optional CTA rendered below the description (button, link, or form). */
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a vertically centered empty state inside the main content column.
|
||||
* @param props See {@link EmptyStateProps}.
|
||||
*/
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5">
|
||||
<Icon className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-gray-300 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-500 max-w-md mb-6">{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* @file EventDetail.tsx
|
||||
* @description Inline detail view rendered below an event row when expanded.
|
||||
* Shows a human-readable summary at the top, then every top-level JSON key
|
||||
* from the hook payload as a single row. For `tool_input` and `tool_response`
|
||||
* on recognised tools, rows use tool-aware renderers (terminal blocks, diffs,
|
||||
* line-numbered code, match lists) instead of the generic JSON code view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/types`
|
||||
* - `../lib/event-grouping`
|
||||
* - `../lib/event-summary`
|
||||
* - `../lib/format`
|
||||
* - `./event-views/primitives`
|
||||
* - `./event-views/tool-views`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `EventDetail` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **EventDetail**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DashboardEvent } from "../lib/types";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { buildEventSummary } from "../lib/event-summary";
|
||||
import { formatModelName, formatDateTimeFull } from "../lib/format";
|
||||
import { CopyButton } from "./event-views/primitives";
|
||||
import { ToolInputView, ToolResponseView } from "./event-views/tool-views";
|
||||
|
||||
type EventDetailProps = {
|
||||
event: DashboardEvent;
|
||||
/** Optional lookup so the panel can surface a human-friendly agent name
|
||||
* (e.g. "technical-researcher · Subagent 14") next to the raw agent_id.
|
||||
* Callers that already have the session's agent map (SessionDetail,
|
||||
* ActivityFeed) should pass it; older callers can omit it and the panel
|
||||
* falls back to id-only display. */
|
||||
agentInfoById?: Map<string, AgentInfo>;
|
||||
/** Optional lookup so the panel can surface a human-friendly session
|
||||
* label (e.g. "AI-Assistant-Chatbot (enumerated-wandering-jellyfish)")
|
||||
* next to the raw session_id. Same fallback rules as agentInfoById. */
|
||||
sessionNameById?: Map<string, string>;
|
||||
};
|
||||
|
||||
/** Human-friendly label for an agent - `subagent_type · name` when both add
|
||||
* signal, else whichever single field is present. Returns null for main
|
||||
* agents whose name is just the session label (the agent_id row already
|
||||
* carries the structural marker `<session>-main`, no need to repeat it). */
|
||||
function agentDisplayLabel(info: AgentInfo): string | null {
|
||||
if (info.type === "main") {
|
||||
return info.name && info.name.trim().length > 0 ? info.name : null;
|
||||
}
|
||||
const type = info.subagent_type?.trim();
|
||||
const name = info.name?.trim();
|
||||
if (type && name && name !== type) return `${type} · ${name}`;
|
||||
if (type) return type;
|
||||
if (name) return name;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keys from the payload that are already rendered from event-level fields -
|
||||
// skip them to avoid showing the same value twice. Includes `id` and
|
||||
// `event_id` defensively in case a future hook payload surfaces them.
|
||||
const DUPLICATE_KEYS = new Set(["id", "event_id", "session_id", "agent_id"]);
|
||||
|
||||
/** Map raw payload keys to localized i18n labels under common:eventDetail.
|
||||
* Anything not in this map falls back to humanizeKey() so users still see
|
||||
* a tidy "Tool Name" rather than "tool_name". */
|
||||
const PAYLOAD_LABEL_KEYS: Record<string, string> = {
|
||||
tool_name: "eventDetail.toolName",
|
||||
tool_use_id: "eventDetail.toolUseId",
|
||||
tool_input: "eventDetail.toolInput",
|
||||
tool_response: "eventDetail.toolResponse",
|
||||
is_error: "eventDetail.isError",
|
||||
cwd: "eventDetail.cwd",
|
||||
permission_mode: "eventDetail.permissionMode",
|
||||
transcript_path: "eventDetail.transcriptPath",
|
||||
agent_transcript_path: "eventDetail.agentTranscriptPath",
|
||||
hook_event_name: "eventDetail.hookEventName",
|
||||
stop_reason: "eventDetail.stopReason",
|
||||
stop_hook_active: "eventDetail.stopHookActive",
|
||||
subagent_type: "eventDetail.subagentType",
|
||||
agent_type: "eventDetail.agentType",
|
||||
notification_type: "eventDetail.notificationType",
|
||||
message: "eventDetail.message",
|
||||
prompt: "eventDetail.prompt",
|
||||
model: "eventDetail.model",
|
||||
reason: "eventDetail.reason",
|
||||
source: "eventDetail.source",
|
||||
imported: "eventDetail.imported",
|
||||
type: "eventDetail.type",
|
||||
timestamp: "eventDetail.timestamp",
|
||||
uuid: "eventDetail.uuid",
|
||||
duration_ms: "eventDetail.durationMs",
|
||||
durationMs: "eventDetail.durationMs",
|
||||
compaction_number: "eventDetail.compactionNumber",
|
||||
total_compactions: "eventDetail.totalCompactions",
|
||||
last_assistant_message: "eventDetail.lastAssistantMessage",
|
||||
};
|
||||
|
||||
/** Convert `snake_case` / `camelCase` to a human-readable Title Case label
|
||||
* for any payload key not in PAYLOAD_LABEL_KEYS. Defensive fallback so
|
||||
* every row reads naturally even when a new hook field appears. */
|
||||
function humanizeKey(key: string): string {
|
||||
return key
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
type Row = { key: string; label: string; value: unknown };
|
||||
|
||||
export function EventDetail({ event, agentInfoById, sessionNameById }: EventDetailProps) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const parsed = useMemo<Record<string, unknown> | null>(() => {
|
||||
if (!event.data) return null;
|
||||
try {
|
||||
const v = JSON.parse(event.data);
|
||||
return v && typeof v === "object" && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [event.data]);
|
||||
|
||||
const summary = useMemo(() => buildEventSummary(event), [event]);
|
||||
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
const result: Row[] = [{ key: "event_id", label: t("eventDetail.eventId"), value: event.id }];
|
||||
// Full date + time + timezone - list rows only show a short time, so the
|
||||
// detail panel spells out exactly when the event was recorded.
|
||||
if (event.created_at) {
|
||||
result.push({
|
||||
key: "recorded_at",
|
||||
label: t("eventDetail.recordedAt", "Recorded at"),
|
||||
value: formatDateTimeFull(event.created_at),
|
||||
});
|
||||
}
|
||||
// Surface the session name above the raw id when we can resolve it -
|
||||
// makes "f2f3c568-..." recognisable as e.g. "AI-Assistant-Chatbot
|
||||
// (enumerated-wandering-jellyfish)" without losing the id below.
|
||||
const sessionName = sessionNameById?.get(event.session_id);
|
||||
if (sessionName && sessionName.trim().length > 0) {
|
||||
result.push({ key: "session_name", label: t("eventDetail.session"), value: sessionName });
|
||||
}
|
||||
result.push({
|
||||
key: "session_id",
|
||||
label: t("eventDetail.sessionId"),
|
||||
value: event.session_id,
|
||||
});
|
||||
if (event.agent_id) {
|
||||
// Surface the agent name above the raw id when we can resolve it -
|
||||
// makes "f2f3c568-...-subagent-14" recognisable as e.g.
|
||||
// "technical-researcher · Subagent 14" without losing the id below.
|
||||
const info = agentInfoById?.get(event.agent_id);
|
||||
const displayName = info ? agentDisplayLabel(info) : null;
|
||||
if (displayName) {
|
||||
result.push({ key: "agent_name", label: t("eventDetail.agent"), value: displayName });
|
||||
}
|
||||
result.push({ key: "agent_id", label: t("eventDetail.agentId"), value: event.agent_id });
|
||||
}
|
||||
|
||||
const payloadEntries: Array<[string, unknown]> = parsed
|
||||
? Object.entries(parsed).filter(([k]) => !DUPLICATE_KEYS.has(k))
|
||||
: [];
|
||||
for (const [k, v] of payloadEntries) {
|
||||
// Known keys → localized label; unknown keys → humanized fallback
|
||||
// (snake_case → "Snake Case") so the panel never surfaces raw JSON
|
||||
// identifiers in the row labels.
|
||||
const i18nKey = PAYLOAD_LABEL_KEYS[k];
|
||||
const label = i18nKey ? t(i18nKey) : humanizeKey(k);
|
||||
result.push({
|
||||
key: k,
|
||||
label,
|
||||
value: k === "model" && typeof v === "string" ? (formatModelName(v) ?? v) : v,
|
||||
});
|
||||
}
|
||||
|
||||
// If JSON parse failed, show the raw data as a single row using the
|
||||
// localized raw-payload label rather than a hardcoded "data" string.
|
||||
if (!parsed && event.data) {
|
||||
result.push({ key: "data", label: t("eventDetail.rawPayload"), value: event.data });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [
|
||||
event.id,
|
||||
event.session_id,
|
||||
event.agent_id,
|
||||
event.data,
|
||||
parsed,
|
||||
agentInfoById,
|
||||
sessionNameById,
|
||||
t,
|
||||
]);
|
||||
|
||||
const hasToolInput = parsed != null && "tool_input" in parsed;
|
||||
const hasToolResponse = parsed != null && "tool_response" in parsed;
|
||||
|
||||
return (
|
||||
<div className="bg-surface-2/60 border-t border-border px-5 py-4 animate-slide-up space-y-3">
|
||||
{summary && (
|
||||
<SummaryBlock
|
||||
summary={summary}
|
||||
hasToolInput={hasToolInput}
|
||||
hasToolResponse={hasToolResponse}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<FieldRow
|
||||
key={row.key}
|
||||
rowKey={row.key}
|
||||
label={row.label}
|
||||
value={row.value}
|
||||
toolName={event.tool_name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Summary block ─────────────────────────
|
||||
|
||||
function SummaryBlock({
|
||||
summary,
|
||||
hasToolInput,
|
||||
hasToolResponse,
|
||||
}: {
|
||||
summary: { icon: string; headline: string; bullets: string[] };
|
||||
hasToolInput: boolean;
|
||||
hasToolResponse: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const refs: string[] = [];
|
||||
if (hasToolInput) refs.push("tool_input");
|
||||
if (hasToolResponse) refs.push("tool_response");
|
||||
const hint =
|
||||
refs.length > 0 ? t("eventDetail.seeDetailsBelow", { fields: refs.join(" · ") }) : null;
|
||||
return (
|
||||
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
|
||||
<div className="px-3 py-1 border-b border-border bg-black/20">
|
||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide font-semibold">
|
||||
{t("eventDetail.summary")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-base leading-none" aria-hidden="true">
|
||||
{summary.icon}
|
||||
</span>
|
||||
<span className="text-[12px] text-gray-100 font-medium break-words">
|
||||
{summary.headline}
|
||||
</span>
|
||||
</div>
|
||||
{summary.bullets.length > 0 && (
|
||||
<ul className="list-disc pl-6 space-y-0.5 text-[11px] text-gray-400">
|
||||
{summary.bullets.map((b, i) => (
|
||||
<li key={i}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hint && (
|
||||
<div className="text-[11px] text-gray-500 italic pt-1 border-t border-border/40">
|
||||
↓ {hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Field row ─────────────────────────
|
||||
|
||||
function FieldRow({
|
||||
rowKey,
|
||||
label,
|
||||
value,
|
||||
toolName,
|
||||
}: {
|
||||
/** Raw payload key - used for tool-aware routing decisions so the renderer
|
||||
* doesn't break when the user-visible label is translated. */
|
||||
rowKey: string;
|
||||
label: string;
|
||||
value: unknown;
|
||||
toolName: string | null;
|
||||
}) {
|
||||
// Route tool_input / tool_response through tool-aware renderers when the
|
||||
// tool is known. Unknown tools (or unknown shape for known tools) fall back
|
||||
// to the generic CodeView below.
|
||||
if (rowKey === "tool_input") {
|
||||
const view = ToolInputView({ toolName, input: value });
|
||||
if (view) {
|
||||
return (
|
||||
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
||||
<div className="text-gray-500 font-mono pt-2">{label}</div>
|
||||
<div>{view}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rowKey === "tool_response") {
|
||||
const view = ToolResponseView({ toolName, response: value });
|
||||
if (view) {
|
||||
return (
|
||||
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
||||
<div className="text-gray-500 font-mono pt-2">{label}</div>
|
||||
<div>{view}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isInlineScalar(value)) {
|
||||
return (
|
||||
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
||||
<div className="text-gray-500 font-mono pt-0.5">{label}</div>
|
||||
<div className="text-gray-300 font-mono break-all">
|
||||
<ScalarValue value={value} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
||||
<div className="text-gray-500 font-mono pt-2">{label}</div>
|
||||
<CodeView value={value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isInlineScalar(value: unknown): boolean {
|
||||
if (value == null) return true;
|
||||
if (typeof value === "boolean" || typeof value === "number") return true;
|
||||
if (typeof value === "string") return !value.includes("\n") && value.length <= 120;
|
||||
return false;
|
||||
}
|
||||
|
||||
function ScalarValue({ value }: { value: unknown }) {
|
||||
if (value == null) return <span className="text-gray-500 italic">null</span>;
|
||||
if (typeof value === "boolean") {
|
||||
const color = value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-gray-400 border-gray-500/30 bg-gray-500/10";
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
|
||||
);
|
||||
}
|
||||
return <>{String(value)}</>;
|
||||
}
|
||||
|
||||
// ───────────────────────── Terminal-styled JSON code view (fallback) ─────────────────────────
|
||||
|
||||
function CodeView({ value }: { value: unknown }) {
|
||||
const text = typeof value === "string" ? value : safeStringify(value);
|
||||
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
|
||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">
|
||||
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
|
||||
</span>
|
||||
<CopyButton text={text} />
|
||||
</div>
|
||||
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words max-h-96 overflow-auto">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* @file EventFilters.tsx
|
||||
* @description Filter toolbar for the event list. Surfaces every filter the
|
||||
* backend supports on /api/events - event_type, tool_name, agent_id,
|
||||
* session_id, free-text search, and an ISO date range - as a single
|
||||
* controlled component. Parent owns the filter state; this component only
|
||||
* renders the inputs and emits change events.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/api`
|
||||
* - `./DateTimePicker`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `EventFiltersValue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `EMPTY_FILTERS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `isEmptyFilters` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `STATUS_TO_EVENT_TYPES` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `STATUS_OPTIONS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `expandStatusToEventTypes` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `EventFilters` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **EventFiltersValue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **EMPTY_FILTERS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **isEmptyFilters**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **STATUS_TO_EVENT_TYPES**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **STATUS_OPTIONS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **expandStatusToEventTypes**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **EventFilters**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X, Filter } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { DateTimePicker } from "./DateTimePicker";
|
||||
|
||||
export type EventFiltersValue = {
|
||||
event_type: string[];
|
||||
tool_name: string[];
|
||||
agent_id: string[];
|
||||
session_id: string[];
|
||||
status: string[];
|
||||
q: string;
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export const EMPTY_FILTERS: EventFiltersValue = {
|
||||
event_type: [],
|
||||
tool_name: [],
|
||||
agent_id: [],
|
||||
session_id: [],
|
||||
status: [],
|
||||
q: "",
|
||||
from: "",
|
||||
to: "",
|
||||
};
|
||||
|
||||
export function isEmptyFilters(f: EventFiltersValue): boolean {
|
||||
return (
|
||||
f.event_type.length === 0 &&
|
||||
f.tool_name.length === 0 &&
|
||||
f.agent_id.length === 0 &&
|
||||
f.session_id.length === 0 &&
|
||||
f.status.length === 0 &&
|
||||
f.q === "" &&
|
||||
f.from === "" &&
|
||||
f.to === ""
|
||||
);
|
||||
}
|
||||
|
||||
// Status preset → event_type values. Mirrors the status badges shown on event
|
||||
// rows (see ActivityFeed/SessionDetail statusFromEventType). "Idle" is handled
|
||||
// as everything not covered by the other presets, which translates to an empty
|
||||
// preset that doesn't restrict the query (same as no selection).
|
||||
export const STATUS_TO_EVENT_TYPES: Record<string, string[]> = {
|
||||
working: ["PreToolUse"],
|
||||
waiting: ["PostToolUse", "Stop"],
|
||||
completed: ["Stop", "SubagentStop", "Compaction"],
|
||||
error: ["error", "APIError"],
|
||||
};
|
||||
|
||||
export const STATUS_OPTIONS = ["working", "waiting", "completed", "error"] as const;
|
||||
|
||||
// Expand the selected status presets into a union of event_type values. The
|
||||
// consumer merges this with any explicit event_type selection so both layers
|
||||
// can be combined (selecting "Working" AND a specific event_type still works
|
||||
// as an OR inside the single `event_type` API param).
|
||||
export function expandStatusToEventTypes(statuses: string[]): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const s of statuses) {
|
||||
const list = STATUS_TO_EVENT_TYPES[s];
|
||||
if (list) for (const et of list) out.add(et);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
type EventFiltersProps = {
|
||||
value: EventFiltersValue;
|
||||
onChange: (next: EventFiltersValue) => void;
|
||||
// If set, hides the session filter - useful inside SessionDetail where the
|
||||
// session is already implicit.
|
||||
hideSessionFilter?: boolean;
|
||||
// Optional pre-known agent ids (SessionDetail passes the agents from its
|
||||
// parent query instead of fetching /agents again).
|
||||
agentOptions?: Array<{ id: string; label: string }>;
|
||||
// Optional pre-known session ids (ActivityFeed passes session options).
|
||||
sessionOptions?: Array<{ id: string; label: string }>;
|
||||
};
|
||||
|
||||
export function EventFilters({
|
||||
value,
|
||||
onChange,
|
||||
hideSessionFilter,
|
||||
agentOptions,
|
||||
sessionOptions,
|
||||
}: EventFiltersProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [facets, setFacets] = useState<{ event_types: string[]; tool_names: string[] }>({
|
||||
event_types: [],
|
||||
tool_names: [],
|
||||
});
|
||||
const [searchDraft, setSearchDraft] = useState(value.q);
|
||||
|
||||
// Debounce text search - only push up after 300ms of inactivity.
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
if (searchDraft !== value.q) onChange({ ...value, q: searchDraft });
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchDraft]);
|
||||
|
||||
// Keep draft in sync when parent clears filters.
|
||||
useEffect(() => {
|
||||
if (value.q !== searchDraft) setSearchDraft(value.q);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value.q]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.events
|
||||
.facets()
|
||||
.then((data) => {
|
||||
if (!cancelled) setFacets(data);
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-fatal: filters still work via text input.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = (field: keyof EventFiltersValue, item: string) => {
|
||||
const current = value[field];
|
||||
if (!Array.isArray(current)) return;
|
||||
const next = current.includes(item) ? current.filter((x) => x !== item) : [...current, item];
|
||||
onChange({ ...value, [field]: next });
|
||||
};
|
||||
|
||||
const empty = isEmptyFilters(value);
|
||||
|
||||
return (
|
||||
<div className="card p-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Filter className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
<div className="relative flex-1 min-w-[180px]">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
placeholder={t("eventFilters.searchPlaceholder")}
|
||||
aria-label={t("eventFilters.searchPlaceholder")}
|
||||
className="w-full bg-surface-2 border border-border rounded pl-7 pr-2 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<DateTimePicker
|
||||
value={value.from}
|
||||
onChange={(val: string) => onChange({ ...value, from: val })}
|
||||
aria-label={t("eventFilters.from")}
|
||||
title={t("eventFilters.from")}
|
||||
placeholder={t("eventFilters.from")}
|
||||
/>
|
||||
<span className="text-xs text-gray-600">→</span>
|
||||
<DateTimePicker
|
||||
value={value.to}
|
||||
onChange={(val: string) => onChange({ ...value, to: val })}
|
||||
aria-label={t("eventFilters.to")}
|
||||
title={t("eventFilters.to")}
|
||||
placeholder={t("eventFilters.to")}
|
||||
/>
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(EMPTY_FILTERS)}
|
||||
className="flex items-center gap-1 text-[11px] px-2 py-1 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer"
|
||||
aria-label={t("eventFilters.clearAll")}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{t("eventFilters.clearAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ChipGroup
|
||||
label={t("eventFilters.status")}
|
||||
options={[...STATUS_OPTIONS]}
|
||||
labels={{
|
||||
working: t("status.working"),
|
||||
waiting: t("status.waiting"),
|
||||
completed: t("status.completed"),
|
||||
error: t("status.error"),
|
||||
}}
|
||||
selected={value.status}
|
||||
onToggle={(item) => toggle("status", item)}
|
||||
/>
|
||||
<ChipGroup
|
||||
label={t("eventFilters.eventType")}
|
||||
options={facets.event_types}
|
||||
selected={value.event_type}
|
||||
onToggle={(item) => toggle("event_type", item)}
|
||||
/>
|
||||
<ChipGroup
|
||||
label={t("eventFilters.toolName")}
|
||||
options={facets.tool_names}
|
||||
selected={value.tool_name}
|
||||
onToggle={(item) => toggle("tool_name", item)}
|
||||
/>
|
||||
{agentOptions && agentOptions.length > 0 && (
|
||||
<ChipGroup
|
||||
label={t("eventFilters.agentId")}
|
||||
options={agentOptions.map((a) => a.id)}
|
||||
labels={Object.fromEntries(agentOptions.map((a) => [a.id, a.label]))}
|
||||
selected={value.agent_id}
|
||||
onToggle={(item) => toggle("agent_id", item)}
|
||||
/>
|
||||
)}
|
||||
{!hideSessionFilter && sessionOptions && sessionOptions.length > 0 && (
|
||||
<ChipGroup
|
||||
label={t("eventFilters.sessionId")}
|
||||
options={sessionOptions.map((s) => s.id)}
|
||||
labels={Object.fromEntries(sessionOptions.map((s) => [s.id, s.label]))}
|
||||
selected={value.session_id}
|
||||
onToggle={(item) => toggle("session_id", item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipGroup({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
labels,
|
||||
}: {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string[];
|
||||
onToggle: (item: string) => void;
|
||||
labels?: Record<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click-outside dismiss.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const selectedCount = selected.length;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className={`text-[11px] px-2 py-1 rounded border cursor-pointer flex items-center gap-1.5 ${
|
||||
selectedCount > 0
|
||||
? "border-accent/40 bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{selectedCount > 0 && (
|
||||
<span className="bg-accent/25 text-accent rounded px-1.5 font-mono">{selectedCount}</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
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"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<p className="text-[11px] text-gray-500 px-2 py-1 italic">
|
||||
{t("eventFilters.noOptions")}
|
||||
</p>
|
||||
) : (
|
||||
options.map((opt) => {
|
||||
const checked = selected.includes(opt);
|
||||
const display = labels?.[opt] ?? opt;
|
||||
return (
|
||||
<label
|
||||
key={opt}
|
||||
className="flex items-center gap-2 px-2 py-1 text-[11px] text-gray-300 rounded hover:bg-surface-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(opt)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="font-mono break-all">{display}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file EventFiltersInfo.tsx
|
||||
* @description Collapsible help panel for the Event Timeline filter toolbar.
|
||||
* Explains agent status badges, Pre/Post hook lifecycle, how filters compose,
|
||||
* and what each dropdown accepts. Mounted above {@link EventFilters} on both
|
||||
* Activity Feed and Session Detail so users can self-serve without leaving the
|
||||
* page.
|
||||
*
|
||||
* Built with native `<details>` / `<summary>` for keyboard accessibility without
|
||||
* a custom popover primitive.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./StatusBadge`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `EventFiltersInfo` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **EventFiltersInfo**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
|
||||
/**
|
||||
* Top-level accordion rendered once per timeline view.
|
||||
* @returns Expandable help card with nested sections.
|
||||
*/
|
||||
export function EventFiltersInfo() {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<details className="card bg-surface-2/40 border border-border rounded overflow-hidden">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 flex items-center text-[11px] text-gray-400 hover:text-gray-200 hover:bg-surface-2/80">
|
||||
<Info className="w-3.5 h-3.5 mr-2" />
|
||||
<span className="font-semibold uppercase tracking-wide mr-1.5">
|
||||
{t("eventFilters.help.title")}
|
||||
</span>
|
||||
<span className="text-gray-500 font-normal">- {t("eventFilters.help.subtitle")}</span>
|
||||
</summary>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
<Section title={t("eventFilters.help.statusesTitle")}>
|
||||
<p className="text-[11px] text-gray-500 mb-2">{t("eventFilters.help.statusesIntro")}</p>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[11px]">
|
||||
<dt>
|
||||
<AgentStatusBadge status="working" />
|
||||
</dt>
|
||||
<dd className="self-center">{t("eventFilters.help.statusWorkingDesc")}</dd>
|
||||
<dt>
|
||||
<AgentStatusBadge status="waiting" />
|
||||
</dt>
|
||||
<dd className="self-center">{t("eventFilters.help.statusWaitingDesc")}</dd>
|
||||
<dt>
|
||||
<AgentStatusBadge status="completed" />
|
||||
</dt>
|
||||
<dd className="self-center">{t("eventFilters.help.statusCompletedDesc")}</dd>
|
||||
<dt>
|
||||
<AgentStatusBadge status="error" />
|
||||
</dt>
|
||||
<dd className="self-center">{t("eventFilters.help.statusErrorDesc")}</dd>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<Section title={t("eventFilters.help.lifecycleTitle")}>
|
||||
<p className="text-[11px] text-gray-400 mb-2">{t("eventFilters.help.lifecycleDesc")}</p>
|
||||
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-gray-300 whitespace-pre-wrap">
|
||||
{t("eventFilters.help.lifecycleFlow")}
|
||||
</code>
|
||||
</Section>
|
||||
|
||||
<Section title={t("eventFilters.help.filtersTitle")}>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[11px] text-gray-400">
|
||||
<li>{t("eventFilters.help.filterTip1")}</li>
|
||||
<li>{t("eventFilters.help.filterTip2")}</li>
|
||||
<li className="text-amber-300/90">{t("eventFilters.help.filterTipGrouping")}</li>
|
||||
<li>{t("eventFilters.help.filterTip3")}</li>
|
||||
<li>{t("eventFilters.help.filterTip4")}</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title={t("eventFilters.help.valuesTitle")}>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[11px]">
|
||||
<Field label={t("eventFilters.status")} desc={t("eventFilters.help.valueStatusDesc")} />
|
||||
<Field
|
||||
label={t("eventFilters.eventType")}
|
||||
desc={t("eventFilters.help.valueEventTypeDesc")}
|
||||
/>
|
||||
<Field
|
||||
label={t("eventFilters.toolName")}
|
||||
desc={t("eventFilters.help.valueToolNameDesc")}
|
||||
/>
|
||||
<Field
|
||||
label={t("eventFilters.agentId")}
|
||||
desc={t("eventFilters.help.valueAgentIdDesc")}
|
||||
/>
|
||||
<Field
|
||||
label={t("eventFilters.sessionId")}
|
||||
desc={t("eventFilters.help.valueSessionIdDesc")}
|
||||
/>
|
||||
<Field
|
||||
label={t("eventFilters.searchPlaceholder")}
|
||||
desc={t("eventFilters.help.valueSearchDesc")}
|
||||
/>
|
||||
<Field
|
||||
label={`${t("eventFilters.from")} / ${t("eventFilters.to")}`}
|
||||
desc={t("eventFilters.help.valueDateRangeDesc")}
|
||||
/>
|
||||
</dl>
|
||||
</Section>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nested collapsible section inside the help panel. */
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<details className="group" open>
|
||||
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-gray-300 hover:bg-surface-2/60 flex items-center gap-2">
|
||||
<span className="text-gray-500 transition-transform group-open:rotate-90">▶</span>
|
||||
<span className="font-semibold uppercase tracking-wide text-gray-400">{title}</span>
|
||||
</summary>
|
||||
<div className="px-3 pb-3 pt-1">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** Label + description row in the filter-values glossary. */
|
||||
function Field({ label, desc }: { label: string; desc: string }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="font-semibold text-gray-300 whitespace-nowrap">{label}</dt>
|
||||
<dd className="text-gray-400">{desc}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file FieldHelp.tsx
|
||||
* @description Contextual "(?)" help trigger for dense settings forms. Opens a
|
||||
* rich popover with title, description, optional example chips, and an optional
|
||||
* footnote — all portaled to `<body>` and repositioned on scroll/resize so the
|
||||
* panel never clips inside scrolling cards.
|
||||
*
|
||||
* ## Interaction model
|
||||
* Opens on hover, focus, or click (toggle). Escape dismisses. The popover uses
|
||||
* `pointer-events-none` so moving the pointer toward it does not accidentally
|
||||
* close the trigger's hover state mid-read.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `FieldHelpProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `FieldHelp` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **FieldHelpProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **FieldHelp**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
|
||||
/** Props for {@link FieldHelp}. */
|
||||
export interface FieldHelpProps {
|
||||
/** Optional bold heading inside the popover. */
|
||||
title?: string;
|
||||
/** Main explanatory copy. */
|
||||
description: string;
|
||||
/** Short example values rendered as monospace chips. */
|
||||
examples?: string[];
|
||||
/** Secondary note shown below examples. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline help control for form fields.
|
||||
* @param props See {@link FieldHelpProps}.
|
||||
*/
|
||||
export function FieldHelp({ title, description, examples, note }: FieldHelpProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||
|
||||
const place = useCallback(() => {
|
||||
const btn = btnRef.current;
|
||||
const pop = popRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const w = pop?.offsetWidth ?? 300;
|
||||
const h = pop?.offsetHeight ?? 120;
|
||||
const pad = 10;
|
||||
let left = r.left + r.width / 2 - w / 2;
|
||||
if (left < pad) left = pad;
|
||||
if (left + w > window.innerWidth - pad) left = window.innerWidth - w - pad;
|
||||
let top = r.bottom + 8;
|
||||
if (top + h > window.innerHeight - pad) top = r.top - h - 8; // flip up
|
||||
setPos({ left, top });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
place();
|
||||
const onScroll = () => place();
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onScroll);
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
aria-label={title || t("help")}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
zIndex: 99999,
|
||||
maxWidth: 320,
|
||||
}}
|
||||
className="rounded-lg border border-border bg-surface-1 shadow-xl shadow-black/40 p-3 w-[300px] pointer-events-none"
|
||||
>
|
||||
{title && <p className="text-xs font-semibold text-gray-200 mb-1">{title}</p>}
|
||||
<p className="text-[11px] leading-relaxed text-gray-400">{description}</p>
|
||||
{examples && examples.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-600 mb-1">
|
||||
{t("examples")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{examples.map((ex) => (
|
||||
<code
|
||||
key={ex}
|
||||
className="text-[10px] font-mono text-accent bg-accent/10 border border-accent/20 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{ex}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{note && <p className="text-[10px] text-gray-500 mt-2 leading-relaxed">{note}</p>}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
/**
|
||||
* @file Import History panel - step-by-step instructions and three import modes
|
||||
* (rescan default folder, scan any path, upload files/archives). Renders inside
|
||||
* the Settings page and keeps all I/O isolated behind the api.import.* client.
|
||||
*
|
||||
* Robustness notes:
|
||||
* • Every mode funnels through the same server-side parser used for live
|
||||
* ingestion, so token counts and per-model cost are computed identically.
|
||||
* • Re-imports are idempotent: sessions are deduplicated by session ID and
|
||||
* compaction baselines prevent token double-counting.
|
||||
* • Archive extraction is guarded against path traversal on the server.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/api`
|
||||
* - `../lib/eventBus`
|
||||
* - `../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ImportHistory` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ImportHistory**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FolderOpen,
|
||||
RefreshCw,
|
||||
UploadCloud,
|
||||
FileArchive,
|
||||
FolderInput,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
HardDrive,
|
||||
ListChecks,
|
||||
Info,
|
||||
Copy,
|
||||
Check,
|
||||
XCircle,
|
||||
History,
|
||||
Terminal,
|
||||
DatabaseBackup,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
||||
|
||||
type Mode = "rescan" | "path" | "upload" | "backup";
|
||||
|
||||
type GuideResponse = Awaited<ReturnType<typeof api.import.guide>>;
|
||||
type Progress = ImportProgressMessage;
|
||||
|
||||
export function ImportHistory() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [mode, setMode] = useState<Mode>("rescan");
|
||||
const [guide, setGuide] = useState<GuideResponse | null>(null);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// "Restore backup" mode: import a full dashboard export (.json) produced by
|
||||
// the Export data button — the round-trip for consolidating machines.
|
||||
const [backupFile, setBackupFile] = useState<File | null>(null);
|
||||
const [backupResult, setBackupResult] = useState<ImportBackupResult | null>(null);
|
||||
const backupInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Load the guide once. If the API isn't reachable, fall back to sensible
|
||||
// defaults so the UI still explains what to do.
|
||||
useEffect(() => {
|
||||
api.import
|
||||
.guide()
|
||||
.then(setGuide)
|
||||
.catch(() => {
|
||||
setGuide({
|
||||
platform: "unknown",
|
||||
default_projects_dir: "~/.claude/projects",
|
||||
default_projects_dir_display: "~/.claude/projects",
|
||||
default_projects_dir_exists: false,
|
||||
default_projects_dir_stats: { projects: 0, jsonl_files: 0 },
|
||||
archive_command: "tar -czf claude-history.tar.gz -C ~/.claude projects",
|
||||
supported_extensions: [".jsonl", ".meta.json", ".zip", ".tar.gz", ".tgz", ".gz"],
|
||||
max_upload_bytes: 1024 * 1024 * 1024,
|
||||
max_upload_files: 2000,
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Stream import progress from the websocket so long-running imports stay
|
||||
// responsive. We only render the latest snapshot.
|
||||
useEffect(() => {
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type !== "import.progress") return;
|
||||
setProgress(msg.data as Progress);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setErrorMsg(null);
|
||||
setResult(null);
|
||||
setBackupResult(null);
|
||||
setProgress(null);
|
||||
}, []);
|
||||
|
||||
const handleRescan = async () => {
|
||||
reset();
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await api.import.rescan();
|
||||
setResult(res);
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanPath = async () => {
|
||||
reset();
|
||||
const trimmed = folderPath.trim();
|
||||
if (!trimmed) {
|
||||
setErrorMsg(t("import.errors.pathRequired"));
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await api.import.scanPath(trimmed);
|
||||
setResult(res);
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
reset();
|
||||
if (files.length === 0) {
|
||||
setErrorMsg(t("import.errors.noFiles"));
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await api.import.upload(files);
|
||||
setResult(res);
|
||||
setFiles([]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
reset();
|
||||
if (!backupFile) {
|
||||
setErrorMsg(t("import.errors.noFiles"));
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await api.settings.importData(backupFile);
|
||||
setBackupResult(res);
|
||||
setBackupFile(null);
|
||||
if (backupInputRef.current) backupInputRef.current.value = "";
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectFiles = (list: FileList | null) => {
|
||||
if (!list) return;
|
||||
const arr = Array.from(list).filter((f) => {
|
||||
const lower = f.name.toLowerCase();
|
||||
return (
|
||||
lower.endsWith(".jsonl") ||
|
||||
lower.endsWith(".meta.json") ||
|
||||
lower.endsWith(".zip") ||
|
||||
lower.endsWith(".tar") ||
|
||||
lower.endsWith(".tar.gz") ||
|
||||
lower.endsWith(".tgz") ||
|
||||
lower.endsWith(".gz")
|
||||
);
|
||||
});
|
||||
setFiles((prev) => {
|
||||
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
const next = [...prev];
|
||||
for (const f of arr) {
|
||||
const key = `${f.name}:${f.size}`;
|
||||
if (!seen.has(key)) next.push(f);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const copyArchiveCmd = async () => {
|
||||
if (!guide) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(guide.archive_command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
};
|
||||
|
||||
const progressText = (() => {
|
||||
if (!progress) return null;
|
||||
if (progress.phase === "scan") return t("import.progress.scan");
|
||||
if (progress.phase === "extract") {
|
||||
return t("import.progress.extract", {
|
||||
processed: progress.processed ?? 0,
|
||||
total: progress.total ?? 0,
|
||||
});
|
||||
}
|
||||
if (progress.phase === "parse") {
|
||||
return t("import.progress.parse", {
|
||||
processed: progress.processed ?? 0,
|
||||
total: progress.total ?? 0,
|
||||
});
|
||||
}
|
||||
if (progress.phase === "complete") return t("import.progress.complete");
|
||||
if (progress.phase === "error") return t("import.progress.error");
|
||||
return null;
|
||||
})();
|
||||
|
||||
const totalSize = files.reduce((s, f) => s + f.size, 0);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
|
||||
<History className="w-4 h-4 text-gray-500" />
|
||||
{t("import.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-1">{t("import.description")}</p>
|
||||
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
|
||||
|
||||
<div className="card p-5 space-y-5">
|
||||
{/* Step-by-step instructions */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setInstructionsOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-semibold text-gray-300 uppercase tracking-wider">
|
||||
<ListChecks className="w-3.5 h-3.5 text-blue-400" />
|
||||
{t("import.instructions")}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-500">{instructionsOpen ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{instructionsOpen && (
|
||||
<div className="px-4 py-4 space-y-4 text-sm text-gray-300 bg-surface-1">
|
||||
{/* Default location card */}
|
||||
{guide && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs bg-surface-2 border border-border rounded-md px-3 py-2">
|
||||
<HardDrive className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
<span className="text-gray-400">{t("import.defaultLocation")}:</span>
|
||||
<code className="font-mono text-gray-200 truncate">
|
||||
{guide.default_projects_dir_display}
|
||||
</code>
|
||||
{guide.default_projects_dir_exists ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t("import.locationFound")}
|
||||
<span className="text-gray-500 ml-1">
|
||||
· {guide.default_projects_dir_stats.projects} {t("import.projectsLabel")},{" "}
|
||||
{guide.default_projects_dir_stats.jsonl_files} {t("import.jsonlLabel")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{t("import.locationMissing")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps */}
|
||||
<div className="space-y-3">
|
||||
<Step title={t("import.stepLocate")} body={t("import.stepLocateBody")} />
|
||||
<Step title={t("import.stepArchive")} body={t("import.stepArchiveBody")}>
|
||||
{guide && (
|
||||
<div className="mt-2 flex items-center gap-2 bg-surface-2 border border-border rounded-md px-3 py-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
<code className="flex-1 text-xs font-mono text-gray-200 truncate">
|
||||
{guide.archive_command}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyArchiveCmd}
|
||||
className="text-xs text-gray-400 hover:text-gray-200 flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3" /> {t("import.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" /> {t("import.copy")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Step>
|
||||
<Step title={t("import.stepChoose")} body={t("import.stepChooseBody")} />
|
||||
<Step title={t("import.stepVerify")} body={t("import.stepVerifyBody")} />
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-gray-500 flex items-start gap-2 pt-2 border-t border-border">
|
||||
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{t("import.accuracyNote")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode switcher */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
<ModeButton
|
||||
active={mode === "rescan"}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
title={t("import.modeRescan")}
|
||||
desc={t("import.modeRescanDesc")}
|
||||
onClick={() => setMode("rescan")}
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === "path"}
|
||||
icon={<FolderInput className="w-3.5 h-3.5" />}
|
||||
title={t("import.modeFolder")}
|
||||
desc={t("import.modeFolderDesc")}
|
||||
onClick={() => setMode("path")}
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === "upload"}
|
||||
icon={<UploadCloud className="w-3.5 h-3.5" />}
|
||||
title={t("import.modeUpload")}
|
||||
desc={t("import.modeUploadDesc")}
|
||||
onClick={() => setMode("upload")}
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === "backup"}
|
||||
icon={<DatabaseBackup className="w-3.5 h-3.5" />}
|
||||
title={t("import.modeBackup")}
|
||||
desc={t("import.modeBackupDesc")}
|
||||
onClick={() => setMode("backup")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode panel */}
|
||||
<div className="bg-surface-2 border border-border rounded-lg p-4">
|
||||
{mode === "rescan" && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FolderOpen className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
<code className="font-mono text-xs text-gray-300 truncate">
|
||||
{guide?.default_projects_dir_display || "~/.claude/projects"}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRescan}
|
||||
disabled={running}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("import.runRescan")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "path" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder={t("import.folderPlaceholder")}
|
||||
className="input w-full text-sm font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500 mt-1.5">{t("import.folderHelper")}</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleScanPath}
|
||||
disabled={running}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<FolderInput className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("import.runScan")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "upload" && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
onSelectFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
||||
dragging
|
||||
? "border-blue-400 bg-blue-500/5"
|
||||
: "border-border hover:border-gray-500 bg-surface-1"
|
||||
}`}
|
||||
>
|
||||
<UploadCloud className="w-6 h-6 text-gray-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-300">{t("import.dropzoneHint")}</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">{t("import.dropzoneSub")}</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream"
|
||||
onChange={(e) => onSelectFiles(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
||||
<span className="text-gray-400">
|
||||
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-gray-500" />
|
||||
{t("import.filesSelected", { count: files.length })}
|
||||
<span className="text-gray-600 ml-2">({formatBytes(totalSize)})</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-300 text-[11px]"
|
||||
>
|
||||
{t("import.clearSelection")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={running || files.length === 0}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<UploadCloud className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("import.runUpload")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "backup" && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onClick={() => backupInputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) setBackupFile(f);
|
||||
}}
|
||||
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
||||
dragging
|
||||
? "border-blue-400 bg-blue-500/5"
|
||||
: "border-border hover:border-gray-500 bg-surface-1"
|
||||
}`}
|
||||
>
|
||||
<DatabaseBackup className="w-6 h-6 text-gray-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-300">{t("import.backupHint")}</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">{t("import.backupSub")}</p>
|
||||
<input
|
||||
ref={backupInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={(e) => setBackupFile(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
{backupFile && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
||||
<span className="text-gray-400 min-w-0">
|
||||
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-gray-500" />
|
||||
<span className="font-mono truncate">{backupFile.name}</span>
|
||||
<span className="text-gray-600 ml-2">({formatBytes(backupFile.size)})</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setBackupFile(null);
|
||||
if (backupInputRef.current) backupInputRef.current.value = "";
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-300 text-[11px]"
|
||||
>
|
||||
{t("import.clearSelection")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleRestore}
|
||||
disabled={running || !backupFile}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("import.runRestore")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* In-flight progress */}
|
||||
{running && progressText && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 bg-surface-2 border border-border rounded-md px-3 py-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-400 flex-shrink-0" />
|
||||
<span className="truncate">{progressText}</span>
|
||||
{progress?.current && (
|
||||
<code className="font-mono text-[11px] text-gray-600 truncate">
|
||||
· {progress.current.split("/").slice(-2).join("/")}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{errorMsg && (
|
||||
<div className="flex items-start gap-2 text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
|
||||
<XCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result summary */}
|
||||
{result && !running && (
|
||||
<div className="border border-emerald-500/20 bg-emerald-500/5 rounded-lg px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{t("import.result.title")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<ResultStat
|
||||
label={t("import.result.imported", { count: result.imported })}
|
||||
value={result.imported}
|
||||
color="text-emerald-300"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
||||
value={result.backfilled ?? 0}
|
||||
color="text-blue-300"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.skipped", { count: result.skipped })}
|
||||
value={result.skipped}
|
||||
color="text-gray-400"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.errors", { count: result.errors })}
|
||||
value={result.errors}
|
||||
color={result.errors > 0 ? "text-red-300" : "text-gray-500"}
|
||||
/>
|
||||
</div>
|
||||
{typeof result.files_scanned === "number" && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{t("import.result.filesScanned", { count: result.files_scanned })}
|
||||
{result.path ? ` · ${result.path}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore-from-backup result summary */}
|
||||
{backupResult && !running && (
|
||||
<div className="border border-emerald-500/20 bg-emerald-500/5 rounded-lg px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{t("import.backupResult.title")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<ResultStat
|
||||
label={t("import.backupResult.sessionsImported", {
|
||||
count: backupResult.sessions_imported,
|
||||
})}
|
||||
value={backupResult.sessions_imported}
|
||||
color="text-emerald-300"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.backupResult.sessionsSkipped", {
|
||||
count: backupResult.sessions_skipped,
|
||||
})}
|
||||
value={backupResult.sessions_skipped}
|
||||
color="text-gray-400"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.backupResult.events", { count: backupResult.events })}
|
||||
value={backupResult.events}
|
||||
color="text-violet-300"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
|
||||
value={backupResult.model_pricing}
|
||||
color="text-cyan-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{t("import.backupResult.detail", {
|
||||
agents: backupResult.agents,
|
||||
workflows: backupResult.workflows,
|
||||
runs: backupResult.dashboard_runs,
|
||||
rules: backupResult.alert_rules,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-200">{title}</p>
|
||||
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">{body}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
icon,
|
||||
title,
|
||||
desc,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
desc: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||
active
|
||||
? "border-blue-500/40 bg-blue-500/10"
|
||||
: "border-border bg-surface-2 hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
||||
active ? "text-blue-300" : "text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-snug">{desc}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultStat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-surface-2 rounded-md px-2.5 py-2">
|
||||
<p className={`text-sm font-semibold ${color}`}>{value.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider mt-0.5">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file Layout.tsx
|
||||
* @description Application shell that frames every authenticated route: persistent
|
||||
* sidebar, main content column, update notifier, and the Tabby assistant overlay.
|
||||
* The layout is the single parent route in {@link App} — child pages render inside
|
||||
* React Router's `<Outlet />` so navigation never remounts chrome.
|
||||
*
|
||||
* ## Sidebar persistence
|
||||
* Collapsed state is read once from `localStorage` via {@link loadCollapsed} and
|
||||
* written back on every toggle. Failures to access storage are swallowed so a
|
||||
* private-browsing quota error never breaks the UI.
|
||||
*
|
||||
* ## Sticky descendants
|
||||
* The inner content wrapper uses `overflow-x-clip` (not `hidden`) so horizontal
|
||||
* overflow is clipped without creating a scroll container. That keeps `position:
|
||||
* sticky` elements — e.g. the Settings page table-of-contents — pinned to the
|
||||
* viewport rather than a nested scroll box.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./Tabby/Tabby`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Layout**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { Tabby } from "./Tabby/Tabby";
|
||||
|
||||
/** Props for {@link Layout}. */
|
||||
interface LayoutProps {
|
||||
/** Live WebSocket status forwarded to the sidebar connection indicator. */
|
||||
wsConnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root layout wrapping all dashboard routes.
|
||||
* @param props See {@link LayoutProps}.
|
||||
*/
|
||||
export function Layout({ wsConnected }: LayoutProps) {
|
||||
const { t } = useTranslation("nav");
|
||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-0">
|
||||
<a href="#main-content" className="skip-to-content">
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<Tabby />
|
||||
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="min-h-screen min-w-0 transition-[margin-left,width] duration-200 outline-none"
|
||||
style={{
|
||||
marginLeft: collapsed ? "4.25rem" : "15rem",
|
||||
width: collapsed ? "calc(100% - 4.25rem)" : "calc(100% - 15rem)",
|
||||
}}
|
||||
>
|
||||
{/* overflow-x-clip (not -hidden) clips horizontal overflow without
|
||||
creating a scroll container, so descendant `position: sticky`
|
||||
elements (e.g. the Settings page TOC) still pin to the window. */}
|
||||
<div className="p-5 lg:p-6 max-w-full overflow-x-clip">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
/**
|
||||
* @file RemoteSources.tsx
|
||||
* @description Settings UI for the Remote Data Sources feature: manage the SSH
|
||||
* machines this dashboard pulls Claude Code history from, and choose the global
|
||||
* "data scope" (which machines' data the whole app shows).
|
||||
*
|
||||
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
|
||||
* global scope store ({@link useDataScope}). No secrets are entered or stored
|
||||
* here — authentication defers to the host's SSH stack (~/.ssh/config, agent,
|
||||
* keys, known_hosts); a source is just a label + ssh destination (+ optional
|
||||
* port / identity file / remote home). Live status/sync updates arrive over the
|
||||
* `remote_source.status` WebSocket message.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Supports federated dashboards: register SSH-backed or file-synced remote machines, health-check tunnels, and scope the entire UI to local vs all vs selected sources.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/api`
|
||||
* - `../lib/eventBus`
|
||||
* - `../lib/dataScope`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `RemoteSources` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **RemoteSources**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Cloud,
|
||||
Plus,
|
||||
Server,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Globe,
|
||||
Monitor,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { RemoteSource, RemoteSourceInput } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import type { ScopeMode } from "../lib/dataScope";
|
||||
|
||||
const EMPTY_FORM: RemoteSourceInput = {
|
||||
label: "",
|
||||
host: "",
|
||||
ssh_port: null,
|
||||
identity_file: "",
|
||||
remote_home: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
/** Compact status pill for a source's last-known sync state. */
|
||||
function StatusPill({ status }: { status: RemoteSource["status"] }) {
|
||||
const map: Record<RemoteSource["status"], { cls: string; label: string; pulse?: boolean }> = {
|
||||
idle: { cls: "text-gray-400 bg-gray-500/10 border-gray-500/20", label: "Idle" },
|
||||
syncing: {
|
||||
cls: "text-amber-300 bg-amber-500/10 border-amber-500/25",
|
||||
label: "Syncing",
|
||||
pulse: true,
|
||||
},
|
||||
ok: { cls: "text-emerald-300 bg-emerald-500/10 border-emerald-500/25", label: "OK" },
|
||||
error: { cls: "text-red-300 bg-red-500/10 border-red-500/25", label: "Error" },
|
||||
};
|
||||
const s = map[status] || map.idle;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-0.5 rounded-full border ${s.cls}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full bg-current ${s.pulse ? "animate-pulse-dot" : ""}`}
|
||||
/>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RemoteSources() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [sources, setSources] = useState<RemoteSource[]>([]);
|
||||
const [facetSources, setFacetSources] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scope, setScope] = useDataScope();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<RemoteSourceInput>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [syncingAll, setSyncingAll] = useState(false);
|
||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>(
|
||||
{}
|
||||
);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ id: string; purge: boolean } | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.all([api.remoteSources.list(), api.sessions.facets()])
|
||||
.then(([srcRes, facetRes]) => {
|
||||
setSources(srcRes.sources);
|
||||
setFacetSources(facetRes.sources || []);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Refresh when a sync finishes or remote-imported rows change (session counts).
|
||||
useEffect(() => {
|
||||
return eventBus.subscribe((msg) => {
|
||||
if (msg.type === "remote_source.status" || isRemoteDataRefreshMessage(msg)) load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
// ── Scope selector ──────────────────────────────────────────────────────────
|
||||
|
||||
// Union of origins that have data (facets) + all configured source ids, so a
|
||||
// freshly-added source is selectable before its first sync lands any rows.
|
||||
const configuredIds = sources.map((s) => s.id);
|
||||
const scopeOptionIds = ["local", ...new Set([...configuredIds, ...facetSources])].filter(
|
||||
(id, i, arr) => id === "local" || (arr.indexOf(id) === i && id !== "local")
|
||||
);
|
||||
const labelFor = (id: string) =>
|
||||
id === "local"
|
||||
? t("remoteSources.thisMachine", "This machine")
|
||||
: sources.find((s) => s.id === id)?.label || id;
|
||||
|
||||
function setMode(mode: ScopeMode) {
|
||||
if (mode === "selected") {
|
||||
const selected = scope.selected.length > 0 ? scope.selected : scopeOptionIds;
|
||||
setScope({ mode, selected });
|
||||
} else {
|
||||
setScope({ mode, selected: scope.selected });
|
||||
}
|
||||
}
|
||||
function toggleSelected(id: string) {
|
||||
const set = new Set(scope.selected);
|
||||
if (set.has(id)) set.delete(id);
|
||||
else set.add(id);
|
||||
setScope({ mode: "selected", selected: [...set] });
|
||||
}
|
||||
|
||||
// ── Form ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function openAdd() {
|
||||
setForm(EMPTY_FORM);
|
||||
setEditingId(null);
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
function openEdit(s: RemoteSource) {
|
||||
setForm({
|
||||
label: s.label,
|
||||
host: s.host,
|
||||
ssh_port: s.ssh_port,
|
||||
identity_file: s.identity_file || "",
|
||||
remote_home: s.remote_home || "",
|
||||
enabled: s.enabled,
|
||||
});
|
||||
setEditingId(s.id);
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
function closeForm() {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
// Normalize optional empties to null so the server stores nothing rather
|
||||
// than empty strings (its validators treat absent as "use default").
|
||||
const payload: RemoteSourceInput = {
|
||||
label: form.label.trim(),
|
||||
host: form.host.trim(),
|
||||
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
|
||||
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
|
||||
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
try {
|
||||
if (editingId) await api.remoteSources.update(editingId, payload);
|
||||
else await api.remoteSources.create(payload);
|
||||
closeForm();
|
||||
load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-source actions ────────────────────────────────────────────────────────
|
||||
|
||||
async function toggleEnabled(s: RemoteSource) {
|
||||
setBusyId(s.id);
|
||||
try {
|
||||
await api.remoteSources.update(s.id, { enabled: !s.enabled });
|
||||
load();
|
||||
} catch {
|
||||
/* surfaced via reload */
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSource(s: RemoteSource) {
|
||||
setBusyId(s.id);
|
||||
setTestResults((r) => ({ ...r, [s.id]: { ok: false, message: "" } }));
|
||||
try {
|
||||
const res = await api.remoteSources.test(s.id);
|
||||
setTestResults((r) => ({ ...r, [s.id]: { ok: res.ok, message: res.message } }));
|
||||
} catch (err) {
|
||||
setTestResults((r) => ({
|
||||
...r,
|
||||
[s.id]: { ok: false, message: err instanceof Error ? err.message : String(err) },
|
||||
}));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncNow(s: RemoteSource) {
|
||||
setBusyId(s.id);
|
||||
try {
|
||||
await api.remoteSources.sync(s.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setTestResults((r) => ({
|
||||
...r,
|
||||
[s.id]: { ok: false, message: err instanceof Error ? err.message : String(err) },
|
||||
}));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAll() {
|
||||
setSyncingAll(true);
|
||||
try {
|
||||
await api.remoteSources.syncAll();
|
||||
load();
|
||||
} catch {
|
||||
/* per-source errors surface via each source's status on reload */
|
||||
} finally {
|
||||
setSyncingAll(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const { id, purge } = confirmDelete;
|
||||
setBusyId(id);
|
||||
try {
|
||||
await api.remoteSources.remove(id, purge);
|
||||
setConfirmDelete(null);
|
||||
load();
|
||||
} catch {
|
||||
/* surfaced via reload */
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
|
||||
<Cloud className="w-4 h-4 text-gray-500" />
|
||||
{t("remoteSources.title", "Remote Data Sources")}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
{t(
|
||||
"remoteSources.description",
|
||||
"Collect Claude Code usage from other machines over SSH — e.g. a dev box or cloud VM you drive over SSH while running this dashboard locally. Authentication uses your own SSH setup (~/.ssh/config, keys, agent); no passwords are stored here."
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">
|
||||
{t(
|
||||
"cursorPathsNote",
|
||||
"Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)."
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Data scope selector */}
|
||||
<div className="card p-5 mb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Wifi className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-medium text-gray-200">
|
||||
{t("remoteSources.scopeTitle", "Data scope")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{t(
|
||||
"remoteSources.scopeDesc",
|
||||
"Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost."
|
||||
)}
|
||||
</p>
|
||||
{/* Card selector — one card per scope mode, each with a short explanation
|
||||
so the choice is self-describing rather than a bare radio label. */}
|
||||
<div className="grid gap-2.5 sm:grid-cols-3" role="radiogroup" aria-label="Data scope">
|
||||
{(
|
||||
[
|
||||
{
|
||||
mode: "all",
|
||||
Icon: Globe,
|
||||
title: t("remoteSources.scopeAll", "All sources"),
|
||||
desc: t(
|
||||
"remoteSources.scopeAllDesc",
|
||||
"This machine plus every configured remote source, combined."
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: "local",
|
||||
Icon: Monitor,
|
||||
title: t("remoteSources.scopeLocal", "This machine only"),
|
||||
desc: t(
|
||||
"remoteSources.scopeLocalDesc",
|
||||
"Only sessions collected locally — hides all remote-source data."
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: "selected",
|
||||
Icon: ListChecks,
|
||||
title: t("remoteSources.scopeSelected", "Selected sources"),
|
||||
desc: t(
|
||||
"remoteSources.scopeSelectedDesc",
|
||||
"Pick exactly which machines to include, below."
|
||||
),
|
||||
},
|
||||
] as { mode: ScopeMode; Icon: typeof Globe; title: string; desc: string }[]
|
||||
).map(({ mode, Icon, title, desc }) => {
|
||||
const active = scope.mode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setMode(mode)}
|
||||
className={`relative text-left rounded-xl border p-3 transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent/10 ring-1 ring-accent/40"
|
||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-2/70"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<CheckCircle className="w-4 h-4 text-accent absolute top-2.5 right-2.5" />
|
||||
)}
|
||||
<Icon className={`w-5 h-5 mb-2 ${active ? "text-accent" : "text-gray-400"}`} />
|
||||
<div
|
||||
className={`text-sm font-medium ${active ? "text-gray-100" : "text-gray-300"}`}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500 mt-0.5 leading-snug">{desc}</div>
|
||||
{mode === "selected" && scope.mode === "selected" && (
|
||||
<div className="text-[11px] text-accent mt-1">
|
||||
{t("remoteSources.scopeSelectedCount", "{{n}} of {{total}} selected", {
|
||||
n: scope.selected.filter((id) => scopeOptionIds.includes(id)).length,
|
||||
total: scopeOptionIds.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{scope.mode === "selected" && (
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
{t("remoteSources.scopePickMachines", "Machines to include")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scopeOptionIds.map((id) => {
|
||||
const on = scope.selected.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => toggleSelected(id)}
|
||||
aria-pressed={on}
|
||||
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-colors ${
|
||||
on
|
||||
? "bg-accent/15 border-accent/40 text-accent"
|
||||
: "bg-surface-2 border-border text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{on ? <Check className="w-3 h-3" /> : <Server className="w-3 h-3" />}
|
||||
{labelFor(id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sources list header + add button */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{t("remoteSources.listTitle", "Configured sources")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{sources.some((s) => s.enabled) && (
|
||||
<button
|
||||
onClick={syncAll}
|
||||
disabled={syncingAll}
|
||||
className="btn-ghost text-xs disabled:opacity-40"
|
||||
title={t("remoteSources.syncAll", "Sync all enabled sources")}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncingAll ? "animate-spin" : ""}`} />
|
||||
{t("remoteSources.syncAll", "Sync all")}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={openAdd} className="btn-primary text-xs">
|
||||
<Plus className="w-3.5 h-3.5" /> {t("remoteSources.add", "Add source")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit form */}
|
||||
{showForm && (
|
||||
<div className="card p-5 mb-4 space-y-3 border-accent/30">
|
||||
<div className="text-sm font-medium text-gray-200">
|
||||
{editingId
|
||||
? t("remoteSources.editTitle", "Edit source")
|
||||
: t("remoteSources.addTitle", "Add a remote source")}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
{t("remoteSources.fieldLabel", "Label")} *
|
||||
</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
placeholder="Dev box"
|
||||
value={form.label}
|
||||
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
{t("remoteSources.fieldHost", "SSH host")} *
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="user@host or config-alias"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
{t("remoteSources.fieldPort", "Port (optional)")}
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
type="number"
|
||||
placeholder="22"
|
||||
value={form.ssh_port ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
ssh_port: e.target.value ? Number(e.target.value) : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
value={form.identity_file ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
{t("remoteSources.fieldRemoteHome", "Remote Claude home (optional)")}
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.claude or wsl:~/.claude"
|
||||
value={form.remote_home ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remote_home: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-gray-500 leading-snug">
|
||||
{t(
|
||||
"remoteSources.fieldRemoteHomeHint",
|
||||
"Linux/macOS: default ~/.claude (or an absolute path like /home/you/.claude). Windows SSH + Claude in WSL: leave blank (auto-detect) or use wsl:~/.claude. Native Windows: C:/Users/you/.claude."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={!!form.enabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||
</span>
|
||||
</label>
|
||||
{formError && (
|
||||
<div className="text-xs text-red-300 bg-red-500/10 border border-red-500/25 rounded-lg px-3 py-2">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={submitForm}
|
||||
disabled={saving || !form.label.trim() || !form.host.trim()}
|
||||
className="btn-primary text-xs disabled:opacity-40"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("common:save", "Save")}
|
||||
</button>
|
||||
<button onClick={closeForm} className="btn-ghost text-xs">
|
||||
<X className="w-3.5 h-3.5" /> {t("common:cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sources list */}
|
||||
{loading ? (
|
||||
<div className="text-xs text-gray-500">{t("common:loading", "Loading…")}</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className="card p-6 text-center">
|
||||
<Server className="w-6 h-6 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("remoteSources.empty", "No remote sources yet.")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{t(
|
||||
"remoteSources.emptyHint",
|
||||
"Add a machine you reach over SSH to pull its Claude Code usage in."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map((s) => {
|
||||
const test = testResults[s.id];
|
||||
const busy = busyId === s.id;
|
||||
return (
|
||||
<div key={s.id} className="card p-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Server className="w-4 h-4 text-accent shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-200">{s.label}</span>
|
||||
<StatusPill status={s.status} />
|
||||
{!s.enabled && (
|
||||
<span className="text-[10px] text-gray-500 bg-surface-2 border border-border px-1.5 py-0.5 rounded-full">
|
||||
{t("remoteSources.paused", "Auto-sync off")}
|
||||
</span>
|
||||
)}
|
||||
{s.session_count != null && s.session_count > 0 && (
|
||||
<span
|
||||
className="text-[10px] text-gray-400 bg-surface-2 border border-border px-1.5 py-0.5 rounded-full"
|
||||
title={t(
|
||||
"remoteSources.sessionCountHint",
|
||||
"Sessions linked to this source (not every session visible when data scope is All)"
|
||||
)}
|
||||
>
|
||||
{t("remoteSources.sessionCountLinked", "{{n}} linked", {
|
||||
n: s.session_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500 font-mono mt-1 truncate">
|
||||
{s.host}
|
||||
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
||||
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-600 mt-1">
|
||||
{s.last_sync_at
|
||||
? t("remoteSources.lastSync", "Last sync: {{when}}", {
|
||||
when: new Date(s.last_sync_at).toLocaleString(),
|
||||
})
|
||||
: t("remoteSources.neverSynced", "Never synced")}
|
||||
{s.last_sync_counts?.imported != null &&
|
||||
` · ${t("remoteSources.syncNew", "{{n}} new", {
|
||||
n: s.last_sync_counts.imported,
|
||||
})}`}
|
||||
{s.last_sync_counts?.sessions_tagged != null &&
|
||||
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
|
||||
n: s.last_sync_counts.sessions_tagged,
|
||||
})}`}
|
||||
</div>
|
||||
{s.status === "error" && s.last_error && (
|
||||
<div className="text-[11px] text-red-300 mt-1 break-words">
|
||||
{s.last_error}
|
||||
</div>
|
||||
)}
|
||||
{test && test.message && (
|
||||
<div
|
||||
className={`flex items-start gap-1.5 text-[11px] mt-2 ${
|
||||
test.ok ? "text-emerald-300" : "text-red-300"
|
||||
}`}
|
||||
>
|
||||
{test.ok ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||
)}
|
||||
<span className="break-words">{test.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => testSource(s)}
|
||||
disabled={busy}
|
||||
className="btn-ghost text-xs disabled:opacity-40"
|
||||
title={t("remoteSources.test", "Test connection")}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Wifi className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("remoteSources.test", "Test")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => syncNow(s)}
|
||||
disabled={busy}
|
||||
className="btn-ghost text-xs disabled:opacity-40"
|
||||
title={t("remoteSources.syncNow", "Sync now")}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy ? "animate-spin" : ""}`} />
|
||||
{t("remoteSources.syncNow", "Sync")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleEnabled(s)}
|
||||
disabled={busy}
|
||||
className="btn-ghost text-xs disabled:opacity-40"
|
||||
>
|
||||
{s.enabled
|
||||
? t("remoteSources.disable", "Disable")
|
||||
: t("remoteSources.enable", "Enable")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(s)}
|
||||
className="btn-ghost text-xs"
|
||||
title={t("common:edit", "Edit")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete({ id: s.id, purge: false })}
|
||||
className="btn-ghost text-xs text-red-300 hover:text-red-200"
|
||||
title={t("common:delete", "Delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline delete confirmation */}
|
||||
{confirmDelete?.id === s.id && (
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<p className="text-xs text-gray-300 mb-2">
|
||||
{t("remoteSources.confirmDelete", "Remove this source?")}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-red-500"
|
||||
checked={confirmDelete.purge}
|
||||
onChange={(e) =>
|
||||
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
{t(
|
||||
"remoteSources.purgeData",
|
||||
"Also delete the sessions imported from this source (cannot be undone)"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={doDelete}
|
||||
disabled={busy}
|
||||
className="btn-primary text-xs bg-red-600 hover:bg-red-500 disabled:opacity-40"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{confirmDelete.purge
|
||||
? t("remoteSources.deleteAndPurge", "Remove + delete data")
|
||||
: t("remoteSources.removeKeep", "Remove (keep data)")}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn-ghost text-xs">
|
||||
{t("common:cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @file Select.tsx
|
||||
* @description Generic styled dropdown that replaces native `<select>` elements
|
||||
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
||||
* checkmark space inconsistently; this control aligns labels, supports arrow-key
|
||||
* navigation, flips above the trigger when viewport space is tight, and marks
|
||||
* the active option with accent color plus a Lucide check.
|
||||
*
|
||||
* ## Consumers
|
||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||
* enum picker is needed.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SelectOption` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SelectProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `Select` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SelectOption**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SelectProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **Select**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
|
||||
/** Single option in a {@link Select} list. */
|
||||
export interface SelectOption<T extends string> {
|
||||
/** Stored value emitted via `onChange`. */
|
||||
value: T;
|
||||
/** Primary label shown in the trigger and list. */
|
||||
label: string;
|
||||
/** Optional secondary line (smaller gray text). */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
export interface SelectProps<T extends string> {
|
||||
/** Currently selected value. */
|
||||
value: T;
|
||||
/** Called when the user picks a new option. */
|
||||
onChange: (v: T) => void;
|
||||
/** All choices; must be non-empty for sensible rendering. */
|
||||
options: SelectOption<T>[];
|
||||
/** Disables opening the list. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible custom select control.
|
||||
* @typeParam T - string union of allowed option values.
|
||||
*/
|
||||
export function Select<T extends string>({ value, onChange, options, disabled }: SelectProps<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((o) => o.value === value)
|
||||
)
|
||||
);
|
||||
const [openUp, setOpenUp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
// When opening, decide whether to render the popover above the trigger if
|
||||
// the viewport doesn't have room below (common when this select sits at
|
||||
// the bottom of a form). 288 px = `max-h-72`.
|
||||
useEffect(() => {
|
||||
if (!open || !buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
const above = rect.top;
|
||||
setOpenUp(below < 288 && above > below);
|
||||
}, [open]);
|
||||
|
||||
// Sync active highlight with current value when reopening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
}
|
||||
}, [open, value, options]);
|
||||
|
||||
const choose = (opt: SelectOption<T>) => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (
|
||||
!open &&
|
||||
(e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(options.length - 1, a + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const opt = options[active];
|
||||
if (opt) choose(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const current = options.find((o) => o.value === value) || options[0];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKey}
|
||||
className="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-gray-100 focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="truncate">{current?.label ?? "-"}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute z-30 left-0 right-0 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
{options.map((opt, idx) => {
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={opt.value || "__default__"}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full text-left px-3 py-1.5 transition-colors ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-gray-500 truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @file SessionCard.tsx
|
||||
* @description Compact session card for the Kanban board's "Sessions" view.
|
||||
* Mirrors AgentCard's information hierarchy (icon · title · meta line) but
|
||||
* surfaces session-relevant fields: model, agent count, cost, last activity.
|
||||
* Clicking the card navigates to the session detail page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./StatusBadge`
|
||||
* - `../lib/types`
|
||||
* - `../lib/format`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SessionCard` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SessionCard**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FolderOpen, Bot, Clock, Coins, Cpu } from "lucide-react";
|
||||
import { SessionStatusBadge } from "./StatusBadge";
|
||||
import {
|
||||
effectiveSessionStatus,
|
||||
isSessionAwaitingInput,
|
||||
sessionAwaitingReason,
|
||||
} from "../lib/types";
|
||||
import type { Session } from "../lib/types";
|
||||
import { formatDuration, timeAgo, formatModelName } from "../lib/format";
|
||||
|
||||
interface SessionCardProps {
|
||||
session: Session;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
if (!Number.isFinite(cost) || cost <= 0) return "$0";
|
||||
if (cost >= 1) return `$${cost.toFixed(2)}`;
|
||||
if (cost >= 0.01) return `$${cost.toFixed(3)}`;
|
||||
return `$${cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function SessionCard({ session, onClick }: SessionCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("kanban");
|
||||
const isActive = session.status === "active";
|
||||
const isWaiting = isSessionAwaitingInput(session);
|
||||
const status = effectiveSessionStatus(session);
|
||||
const title = session.name?.trim() || t("session.anonymous");
|
||||
const agentCount = session.agent_count ?? 0;
|
||||
const model = formatModelName(session.model);
|
||||
const lastActivity = session.last_activity || session.ended_at || session.started_at;
|
||||
|
||||
function handleClick() {
|
||||
if (onClick) onClick();
|
||||
else navigate(`/sessions/${session.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`card-hover p-4 cursor-pointer animate-fade-in overflow-hidden ${
|
||||
isWaiting
|
||||
? "border-l-2 border-l-yellow-500/60"
|
||||
: isActive
|
||||
? "border-l-2 border-l-emerald-500/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3 min-w-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<div className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-accent/15 text-accent">
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<p className="text-sm font-medium text-gray-200 truncate">{title}</p>
|
||||
<p className="text-[11px] text-gray-500 font-mono truncate">
|
||||
{session.id.slice(0, 12)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* compact: cards are narrow — inline reason chip would squeeze the
|
||||
title, so the reason stays hover-tooltip-only here. */}
|
||||
<SessionStatusBadge status={status} reason={sessionAwaitingReason(session)} compact />
|
||||
</div>
|
||||
|
||||
{session.cwd && (
|
||||
<p className="text-xs text-gray-400 mb-3 truncate font-mono leading-relaxed">
|
||||
{session.cwd}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-[11px] text-gray-500 min-w-0 overflow-hidden flex-wrap">
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Bot className="w-3 h-3" />
|
||||
{t("session.agentSummary", { count: agentCount })}
|
||||
</span>
|
||||
{model && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0 truncate">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span className="truncate">{model}</span>
|
||||
</span>
|
||||
)}
|
||||
{typeof session.cost === "number" && session.cost > 0 && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Coins className="w-3 h-3" />
|
||||
{formatCost(session.cost)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Clock className="w-3 h-3" />
|
||||
{session.ended_at
|
||||
? `${t("ran")}${formatDuration(session.started_at, session.ended_at)}`
|
||||
: `${t("running")}${formatDuration(session.started_at, new Date().toISOString())}`}
|
||||
</span>
|
||||
<span className="text-gray-600 flex-shrink-0 ml-auto">
|
||||
{timeAgo(session.ended_at || lastActivity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* @file SessionOverview.tsx
|
||||
* @description Real-time stats panel rendered at the top of the Agents tab on the
|
||||
* Session detail page. Shows tile counters (events, tool calls, subagents, errors,
|
||||
* compactions, duration), top-tool usage bars, subagent-type breakdown, and a token
|
||||
* flow strip. Live-refreshes on `new_event` (debounced) so counters track the running
|
||||
* session without spamming the backend.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/api`
|
||||
* - `../lib/eventBus`
|
||||
* - `../lib/format`
|
||||
* - `./conversation/toolStyle`
|
||||
* - `../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SessionOverview` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SessionOverview**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Wrench,
|
||||
GitBranch,
|
||||
AlertTriangle,
|
||||
Layers,
|
||||
Clock,
|
||||
Coins,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { fmt, formatDuration } from "../lib/format";
|
||||
import { styleForTool } from "./conversation/toolStyle";
|
||||
import type { Agent, Session, SessionStats } from "../lib/types";
|
||||
|
||||
interface SessionOverviewProps {
|
||||
session: Session;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||
const REFRESH_DEBOUNCE_MS = 600;
|
||||
|
||||
/** Compact tile used in the top stat row. */
|
||||
function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
hint?: string;
|
||||
icon: React.ReactNode;
|
||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||
}) {
|
||||
const palette = {
|
||||
default: "border-surface-3 bg-surface-2 text-gray-200",
|
||||
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
|
||||
emerald: "border-emerald-500/20 bg-emerald-500/5 text-emerald-200",
|
||||
amber: "border-amber-500/20 bg-amber-500/5 text-amber-200",
|
||||
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
|
||||
blue: "border-blue-500/20 bg-blue-500/5 text-blue-200",
|
||||
}[tone];
|
||||
|
||||
const iconTone = {
|
||||
default: "text-gray-500",
|
||||
violet: "text-violet-400",
|
||||
emerald: "text-emerald-400",
|
||||
amber: "text-amber-400",
|
||||
rose: "text-rose-400",
|
||||
cyan: "text-cyan-400",
|
||||
blue: "text-blue-400",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-gray-500">
|
||||
<span className={iconTone}>{icon}</span>
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-lg font-semibold text-gray-100 leading-tight">
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="text-[10px] text-gray-500 mt-0.5">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolUsageRow({ toolName, count, max }: { toolName: string; count: number; max: number }) {
|
||||
const style = styleForTool(toolName);
|
||||
const Icon = style.Icon;
|
||||
const pct = max > 0 ? Math.max(2, Math.round((count / max) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 group/tool">
|
||||
<div className={`flex items-center gap-2 w-32 flex-shrink-0 ${style.text}`}>
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded ${style.chip} flex-shrink-0`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
</span>
|
||||
<span className="font-mono text-xs truncate" title={toolName}>
|
||||
{toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-surface-3/60 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${style.bar} transition-all duration-500`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-400 w-14 text-right flex-shrink-0">
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (session.status !== "active") return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [session.status]);
|
||||
|
||||
const fetchStats = async () => {
|
||||
if (fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
try {
|
||||
const result = await api.sessions.stats(session.id);
|
||||
setStats(result);
|
||||
} catch {
|
||||
// Non-fatal - overview just won't update this round.
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load + reload when the session id changes.
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
|
||||
// Live refresh on websocket events (debounced).
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
fetchStats();
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
const isRelevant =
|
||||
msg.type === "new_event" ||
|
||||
msg.type === "agent_created" ||
|
||||
msg.type === "agent_updated" ||
|
||||
msg.type === "session_updated";
|
||||
if (!isRelevant) return;
|
||||
const data = msg.data as { session_id?: string; id?: string };
|
||||
// Match either by session_id (events) or by id (session_updated)
|
||||
const matchesSession = data.session_id === session.id || data.id === session.id;
|
||||
if (!matchesSession) return;
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
fetchStats();
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
|
||||
// Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name).
|
||||
// We approximate "tool calls" as half of that (each call produces a Pre + Post event).
|
||||
const toolCallCount = useMemo(() => {
|
||||
if (!stats) return 0;
|
||||
const total = stats.tools_used.reduce((s, t) => s + t.count, 0);
|
||||
return Math.round(total / 2);
|
||||
}, [stats]);
|
||||
|
||||
const maxToolCount = useMemo(() => {
|
||||
if (!stats) return 0;
|
||||
return stats.tools_used.reduce((m, t) => Math.max(m, t.count), 0);
|
||||
}, [stats]);
|
||||
|
||||
// Active agent (if any)
|
||||
const activeAgent = useMemo(() => agents.find((a) => a.status === "working") ?? null, [agents]);
|
||||
|
||||
// Duration: ended_at - started_at, or now - started_at if active
|
||||
const durationLabel = useMemo(() => {
|
||||
if (!session.started_at) return "-";
|
||||
const end = session.ended_at ?? new Date(now).toISOString();
|
||||
return formatDuration(session.started_at, end);
|
||||
}, [session.started_at, session.ended_at, now]);
|
||||
|
||||
// Avg event rate (events / minute)
|
||||
const eventRate = useMemo(() => {
|
||||
if (!stats || !session.started_at) return null;
|
||||
const start = new Date(session.started_at).getTime();
|
||||
const end = session.ended_at
|
||||
? new Date(session.ended_at).getTime()
|
||||
: stats.last_event_at
|
||||
? new Date(stats.last_event_at).getTime()
|
||||
: now;
|
||||
const minutes = Math.max(1, (end - start) / 60_000);
|
||||
return stats.total_events / minutes;
|
||||
}, [stats, session.started_at, session.ended_at, now]);
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5 mb-6 animate-pulse">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-[68px] rounded-lg border border-surface-3 bg-surface-2/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = stats.tokens;
|
||||
const totalTokens =
|
||||
tokens.input_tokens +
|
||||
tokens.output_tokens +
|
||||
tokens.cache_read_tokens +
|
||||
tokens.cache_write_tokens;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 mb-6">
|
||||
{/* Active-agent banner - only shows when session is running */}
|
||||
{activeAgent && (
|
||||
<div className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5">
|
||||
<span className="relative flex h-2 w-2 flex-shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400" />
|
||||
</span>
|
||||
<Bot className="w-3.5 h-3.5 text-emerald-300 flex-shrink-0" />
|
||||
<span className="text-xs text-emerald-200 font-medium flex-shrink-0">
|
||||
{activeAgent.name || "Agent"}
|
||||
</span>
|
||||
{activeAgent.current_tool && (
|
||||
<span className="text-[11px] text-gray-400 font-mono inline-flex items-center gap-1">
|
||||
<span className="text-gray-600">running</span>
|
||||
<span className="text-emerald-300">{activeAgent.current_tool}</span>
|
||||
</span>
|
||||
)}
|
||||
{activeAgent.task && (
|
||||
<span className="text-[11px] text-gray-400 truncate min-w-0" title={activeAgent.task}>
|
||||
· {activeAgent.task}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stat tiles */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||
<StatTile
|
||||
label="Events"
|
||||
value={stats.total_events.toLocaleString()}
|
||||
hint={
|
||||
eventRate !== null && eventRate > 0
|
||||
? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min`
|
||||
: undefined
|
||||
}
|
||||
icon={<Activity className="w-3 h-3" />}
|
||||
/>
|
||||
<StatTile
|
||||
label="Tool calls"
|
||||
value={toolCallCount.toLocaleString()}
|
||||
hint={stats.tools_used.length > 0 ? `${stats.tools_used.length} unique` : undefined}
|
||||
icon={<Wrench className="w-3 h-3" />}
|
||||
tone="violet"
|
||||
/>
|
||||
<StatTile
|
||||
label="Subagents"
|
||||
value={stats.agents.subagent.toLocaleString()}
|
||||
hint={stats.agents.main > 0 ? `+${stats.agents.main} main` : undefined}
|
||||
icon={<GitBranch className="w-3 h-3" />}
|
||||
tone="cyan"
|
||||
/>
|
||||
<StatTile
|
||||
label="Compactions"
|
||||
value={stats.agents.compaction.toLocaleString()}
|
||||
icon={<Layers className="w-3 h-3" />}
|
||||
tone="blue"
|
||||
/>
|
||||
<StatTile
|
||||
label="Errors"
|
||||
value={stats.error_count.toLocaleString()}
|
||||
icon={<AlertTriangle className="w-3 h-3" />}
|
||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Duration"
|
||||
value={durationLabel}
|
||||
hint={session.status === "active" ? "running" : "completed"}
|
||||
icon={<Clock className="w-3 h-3" />}
|
||||
tone={session.status === "active" ? "emerald" : "default"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout: tools + subagent breakdown */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Tool usage */}
|
||||
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-400" />
|
||||
Top tools
|
||||
</h3>
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
{stats.tools_used.length} total
|
||||
</span>
|
||||
</div>
|
||||
{stats.tools_used.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-gray-500">No tool calls yet.</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{stats.tools_used.slice(0, 8).map((t) => (
|
||||
<ToolUsageRow
|
||||
key={t.tool_name}
|
||||
toolName={t.tool_name}
|
||||
count={t.count}
|
||||
max={maxToolCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subagent breakdown.
|
||||
*
|
||||
* The /api/sessions/:id/stats endpoint deliberately strips compaction
|
||||
* agents from `subagent_types` so the workflow analytics don't lump
|
||||
* them in. But on this overview a session with only compactions still
|
||||
* has *something* to show - surfacing zero subagents while the agents
|
||||
* tab below clearly lists "Context Compaction" cards is confusing.
|
||||
*
|
||||
* We synthesize a compaction row from `stats.agents.compaction` and
|
||||
* render it alongside any real subagent types, distinguished by an
|
||||
* amber bar (matching the compaction iconography elsewhere in the
|
||||
* app) instead of cyan. */}
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
{(() => {
|
||||
type SubRow = { key: string; label: string; count: number; isCompaction: boolean };
|
||||
const rows: SubRow[] = stats.subagent_types.map((s) => ({
|
||||
key: s.subagent_type,
|
||||
label: s.subagent_type,
|
||||
count: s.count,
|
||||
isCompaction: false,
|
||||
}));
|
||||
if (stats.agents.compaction > 0) {
|
||||
rows.push({
|
||||
key: "__compaction__",
|
||||
label: "Context Compaction",
|
||||
count: stats.agents.compaction,
|
||||
isCompaction: true,
|
||||
});
|
||||
}
|
||||
const totalRuns = rows.reduce((s, r) => s + r.count, 0);
|
||||
const max = rows.reduce((m, r) => Math.max(m, r.count), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Subagents
|
||||
</h3>
|
||||
<span className="text-[10px] text-gray-500 font-mono">{totalRuns} runs</span>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-gray-500">
|
||||
No subagents in this session.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{rows.slice(0, 8).map((r) => {
|
||||
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
|
||||
const barClass = r.isCompaction ? "bg-amber-500/60" : "bg-cyan-500/60";
|
||||
return (
|
||||
<div key={r.key} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-mono text-xs truncate flex-1 min-w-0 ${
|
||||
r.isCompaction ? "text-amber-300" : "text-gray-300"
|
||||
}`}
|
||||
title={r.label}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
<div className="w-16 h-1.5 rounded-full bg-surface-3/60 overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className={`h-full rounded-full ${barClass}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-400 w-8 text-right flex-shrink-0">
|
||||
{r.count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token flow strip */}
|
||||
{totalTokens > 0 && (
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-amber-400" />
|
||||
Token flow
|
||||
</h3>
|
||||
<span className="text-[10px] text-gray-500 font-mono">{fmt(totalTokens)} total</span>
|
||||
</div>
|
||||
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event-type breakdown - secondary, only top 6 */}
|
||||
{stats.events_by_type.length > 0 && (
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<Activity className="w-3.5 h-3.5 text-gray-400" />
|
||||
Event mix
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.events_by_type.slice(0, 12).map((e) => (
|
||||
<span
|
||||
key={e.event_type}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-mono bg-surface-3/60 border border-surface-3 rounded-md px-2 py-0.5"
|
||||
>
|
||||
<span className="text-gray-400">{e.event_type}</span>
|
||||
<span className="text-gray-500">·</span>
|
||||
<span className="text-gray-200">{e.count.toLocaleString()}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) {
|
||||
const segments = [
|
||||
{
|
||||
key: "cache_read",
|
||||
label: "Cache read",
|
||||
value: tokens.cache_read_tokens,
|
||||
cls: "bg-sky-500",
|
||||
text: "text-sky-300",
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
label: "Cache write",
|
||||
value: tokens.cache_write_tokens,
|
||||
cls: "bg-violet-500",
|
||||
text: "text-violet-300",
|
||||
},
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
value: tokens.input_tokens,
|
||||
cls: "bg-emerald-500",
|
||||
text: "text-emerald-300",
|
||||
},
|
||||
{
|
||||
key: "output",
|
||||
label: "Output",
|
||||
value: tokens.output_tokens,
|
||||
cls: "bg-orange-500",
|
||||
text: "text-orange-300",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full h-2 rounded-full overflow-hidden bg-surface-3/60 mb-3">
|
||||
{segments.map((s) => {
|
||||
const pct = total > 0 ? (s.value / total) * 100 : 0;
|
||||
if (pct === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={s.key}
|
||||
className={`${s.cls} opacity-80 hover:opacity-100 transition-opacity`}
|
||||
style={{ width: `${pct}%` }}
|
||||
title={`${s.label}: ${s.value.toLocaleString()} (${pct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
{segments.map((s) => {
|
||||
const pct = total > 0 ? (s.value / total) * 100 : 0;
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-2">
|
||||
<span className={`block w-2 h-2 rounded-full ${s.cls}`} />
|
||||
<span className="text-gray-500 text-[11px]">{s.label}</span>
|
||||
<span className={`font-mono ml-auto ${s.text}`}>
|
||||
{fmt(s.value)}
|
||||
{pct > 0 && (
|
||||
<span className="text-gray-600 text-[10px] ml-1">
|
||||
{pct >= 1 ? Math.round(pct) : pct.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @file Skeleton.tsx
|
||||
* @description Loading skeleton primitives. Use these in place of "-", "0", or
|
||||
* empty bodies while data is still being fetched so the UI never flashes
|
||||
* placeholder values that the user might read as real zeros. All variants are
|
||||
* pure presentational and respect prefers-reduced-motion (animate-pulse is a
|
||||
* native Tailwind utility that already honors the OS setting).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `CardSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Skeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **StatValueSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TextSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TableRowSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **CardSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Rounded shape variant. Defaults to "md". */
|
||||
rounded?: "sm" | "md" | "lg" | "full";
|
||||
/** Aria label for screen readers. Defaults to "Loading". */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const ROUNDED_CLASS = {
|
||||
sm: "rounded",
|
||||
md: "rounded-md",
|
||||
lg: "rounded-lg",
|
||||
full: "rounded-full",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Base shimmer block. Compose with width/height utilities for any shape:
|
||||
* <Skeleton className="h-6 w-24" />
|
||||
*/
|
||||
export function Skeleton({
|
||||
className = "",
|
||||
style,
|
||||
rounded = "md",
|
||||
label = "Loading",
|
||||
}: SkeletonProps) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
aria-busy="true"
|
||||
className={`inline-block bg-surface-3/70 animate-pulse ${ROUNDED_CLASS[rounded]} ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat-card value placeholder. Sized to match the 2xl semibold number that
|
||||
* StatCard renders so layout doesn't jump when real data arrives.
|
||||
*/
|
||||
export function StatValueSkeleton({ className = "" }: { className?: string }) {
|
||||
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text placeholder. Good for trend/sub labels.
|
||||
*/
|
||||
export function TextSkeleton({
|
||||
className = "",
|
||||
width = "w-16",
|
||||
}: {
|
||||
className?: string;
|
||||
width?: string;
|
||||
}) {
|
||||
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full table-row skeleton for tabular pages. Caller passes the column count so
|
||||
* the skeleton lines up under existing headers.
|
||||
*/
|
||||
export function TableRowSkeleton({ columns, widths }: { columns: number; widths?: string[] }) {
|
||||
return (
|
||||
<tr className="border-b border-border/40">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<td key={i} className="px-5 py-4">
|
||||
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block placeholder for card-shaped content (kanban cards, list items).
|
||||
*/
|
||||
export function CardSkeleton({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`card p-4 space-y-3 ${className}`} aria-busy="true" aria-label="Loading">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* @file SplashScreen.tsx
|
||||
* @description Branding splash shown once per browser session on app load. A
|
||||
* dark-tech "constellation" overlay built around the node-graph brand mark:
|
||||
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
|
||||
* in a staggered cascade. The tagline and the subtext pair are picked at
|
||||
* random (per mount) from localized pools in `splash.json`, so the copy is
|
||||
* fresh each session. The overlay holds for ~2.5s, then fades out and
|
||||
* unmounts. Clicking anywhere skips it; honors
|
||||
* `prefers-reduced-motion`. CSS-only animations (no extra deps).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SplashScreen` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SplashScreen**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SESSION_KEY = "splash-shown-v1";
|
||||
const HOLD_MS = 2500; // visible dwell after the entrance settles
|
||||
const EXIT_MS = 600; // fade-out duration
|
||||
|
||||
/** Map the local hour to a greeting bucket. */
|
||||
function greetingKey(hour: number): "morning" | "afternoon" | "evening" | "night" {
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 17) return "afternoon";
|
||||
if (hour >= 17 && hour < 22) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
export function SplashScreen() {
|
||||
const { t } = useTranslation("splash");
|
||||
// Show at most once per tab session. Read synchronously so we never flash an
|
||||
// empty overlay on a repeat mount (StrictMode double-invoke, refresh, etc.).
|
||||
const [mounted, setMounted] = useState(() => {
|
||||
try {
|
||||
return !sessionStorage.getItem(SESSION_KEY);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const exitTimer = useRef<number | null>(null);
|
||||
const doneTimer = useRef<number | null>(null);
|
||||
|
||||
// Pick the tagline + subtext pair ONCE per mount from the localized pools.
|
||||
// Falls back to the singular keys if a locale ships no array. Must run as an
|
||||
// unconditional hook (before the early return below).
|
||||
const [copy] = useState(() => {
|
||||
const pick = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]!;
|
||||
const taglines = t("taglines", { returnObjects: true }) as unknown as string[];
|
||||
const subs = t("subs", { returnObjects: true }) as unknown as string[][];
|
||||
const tagline = Array.isArray(taglines) && taglines.length > 0 ? pick(taglines) : t("tagline");
|
||||
const pair = Array.isArray(subs) && subs.length > 0 ? pick(subs) : [t("sub1"), t("sub2")];
|
||||
return {
|
||||
tagline,
|
||||
sub1: pair?.[0] ?? t("sub1"),
|
||||
sub2: pair?.[1] ?? t("sub2"),
|
||||
};
|
||||
});
|
||||
|
||||
const beginExit = () => {
|
||||
if (exiting) return;
|
||||
setExiting(true);
|
||||
doneTimer.current = window.setTimeout(() => setMounted(false), EXIT_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_KEY, "1");
|
||||
} catch {
|
||||
/* sessionStorage may be unavailable (private mode) - show anyway */
|
||||
}
|
||||
exitTimer.current = window.setTimeout(beginExit, HOLD_MS);
|
||||
return () => {
|
||||
if (exitTimer.current) window.clearTimeout(exitTimer.current);
|
||||
if (doneTimer.current) window.clearTimeout(doneTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mounted]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const hour = new Date().getHours();
|
||||
const greeting = t(`greeting.${greetingKey(hour)}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={`${greeting}. ${copy.tagline}`}
|
||||
onClick={beginExit}
|
||||
className={`splash-root ${exiting ? "splash-exit" : ""}`}
|
||||
>
|
||||
<style>{SPLASH_CSS}</style>
|
||||
|
||||
{/* Atmosphere: layered radial glows + drifting constellation + grain */}
|
||||
<div className="splash-bg" aria-hidden="true" />
|
||||
<ConstellationField />
|
||||
<div className="splash-grain" aria-hidden="true" />
|
||||
|
||||
<div className="splash-content">
|
||||
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
|
||||
<div className="splash-mark" aria-hidden="true">
|
||||
<span className="splash-mark-glow" />
|
||||
<BrandMark />
|
||||
</div>
|
||||
|
||||
<div className="splash-greeting">
|
||||
<span className="splash-rule" />
|
||||
<span className="splash-dot" />
|
||||
<span className="splash-greeting-text">{greeting}</span>
|
||||
<span className="splash-rule splash-rule-right" />
|
||||
</div>
|
||||
|
||||
<h1 className="splash-tagline">{copy.tagline}</h1>
|
||||
|
||||
<p className="splash-sub splash-sub-1">{copy.sub1}</p>
|
||||
<p className="splash-sub splash-sub-2">{copy.sub2}</p>
|
||||
|
||||
<div className="splash-brand">{t("brand")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
|
||||
* with animated connector lines and pulsing outer nodes. */
|
||||
function BrandMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
|
||||
<defs>
|
||||
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6366f1" />
|
||||
<stop offset="100%" stopColor="#818cf8" />
|
||||
</linearGradient>
|
||||
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
className="splash-hex"
|
||||
points="16,2 28,9 28,23 16,30 4,23 4,9"
|
||||
fill="url(#splashBg)"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
|
||||
<line
|
||||
className="splash-line"
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="16"
|
||||
y2="7"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-2"
|
||||
x1="18.6"
|
||||
y1="17.5"
|
||||
x2="24"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-3"
|
||||
x1="13.4"
|
||||
y1="17.5"
|
||||
x2="8"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
|
||||
<circle
|
||||
className="splash-node splash-node-2"
|
||||
cx="24.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
<circle
|
||||
className="splash-node splash-node-3"
|
||||
cx="7.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Faint background constellation: a handful of nodes joined by thin lines that
|
||||
* drift slowly behind the content for depth. Purely decorative. */
|
||||
function ConstellationField() {
|
||||
return (
|
||||
<svg
|
||||
className="splash-constellation"
|
||||
viewBox="0 0 1200 800"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g stroke="#6366f1" strokeOpacity="0.18" strokeWidth="1">
|
||||
<line x1="120" y1="160" x2="340" y2="90" />
|
||||
<line x1="340" y1="90" x2="520" y2="240" />
|
||||
<line x1="980" y1="120" x2="1080" y2="320" />
|
||||
<line x1="220" y1="620" x2="430" y2="540" />
|
||||
<line x1="780" y1="660" x2="1010" y2="560" />
|
||||
<line x1="520" y1="240" x2="660" y2="430" />
|
||||
</g>
|
||||
<g fill="#818cf8">
|
||||
{[
|
||||
[120, 160],
|
||||
[340, 90],
|
||||
[520, 240],
|
||||
[980, 120],
|
||||
[1080, 320],
|
||||
[220, 620],
|
||||
[430, 540],
|
||||
[780, 660],
|
||||
[1010, 560],
|
||||
[660, 430],
|
||||
].map(([cx, cy], i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={i % 3 === 0 ? 2.5 : 1.6}
|
||||
opacity={0.5}
|
||||
style={{ animationDelay: `${(i % 5) * 0.6}s` }}
|
||||
className="splash-star"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const SPLASH_CSS = `
|
||||
.splash-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #06060a;
|
||||
cursor: pointer;
|
||||
/* Opaque from the very first paint - the overlay must NOT fade in, or the
|
||||
app rendered behind it flashes through for the fade duration. Only the
|
||||
content cascades in (below); the dark backdrop is solid immediately. */
|
||||
}
|
||||
.splash-root.splash-exit {
|
||||
animation: splashFadeOut ${EXIT_MS}ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
.splash-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(60% 50% at 50% 42%, rgba(99, 102, 241, 0.22) 0%, rgba(99, 102, 241, 0) 70%),
|
||||
radial-gradient(40% 40% at 78% 80%, rgba(129, 140, 248, 0.12) 0%, rgba(129, 140, 248, 0) 70%),
|
||||
radial-gradient(45% 45% at 18% 18%, rgba(165, 180, 252, 0.10) 0%, rgba(165, 180, 252, 0) 70%),
|
||||
linear-gradient(180deg, #07070d 0%, #06060a 60%, #05050a 100%);
|
||||
}
|
||||
.splash-constellation {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
animation: splashConstellation 1.2s ease 0.2s forwards, splashDrift 26s ease-in-out infinite alternate;
|
||||
}
|
||||
.splash-star { animation: splashTwinkle 3.2s ease-in-out infinite; transform-origin: center; }
|
||||
.splash-grain {
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
.splash-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
padding: 0 1.5rem;
|
||||
max-width: 42rem;
|
||||
}
|
||||
.splash-mark {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-bottom: 2.75rem;
|
||||
opacity: 0;
|
||||
animation: splashMark 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s forwards;
|
||||
}
|
||||
.splash-mark-glow {
|
||||
position: absolute;
|
||||
inset: -18%;
|
||||
border-radius: 9999px;
|
||||
background: radial-gradient(circle, rgba(129, 140, 248, 0.4) 0%, rgba(129, 140, 248, 0) 66%);
|
||||
filter: blur(3px);
|
||||
animation: splashGlowPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
|
||||
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
|
||||
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
|
||||
.splash-line-2 { animation-delay: 0.62s; }
|
||||
.splash-line-3 { animation-delay: 0.74s; }
|
||||
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
|
||||
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
|
||||
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
|
||||
|
||||
.splash-greeting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 1.6rem;
|
||||
opacity: 0;
|
||||
animation: splashRise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.45s forwards;
|
||||
}
|
||||
.splash-greeting-text {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: uppercase;
|
||||
color: #a5b4fc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.splash-rule {
|
||||
height: 1px;
|
||||
width: clamp(1.75rem, 8vw, 4rem);
|
||||
background: linear-gradient(90deg, transparent, rgba(165, 180, 252, 0.55));
|
||||
}
|
||||
.splash-rule-right {
|
||||
background: linear-gradient(90deg, rgba(165, 180, 252, 0.55), transparent);
|
||||
}
|
||||
.splash-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
background: #818cf8;
|
||||
box-shadow: 0 0 10px 2px rgba(129, 140, 248, 0.7);
|
||||
animation: splashGlowPulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.splash-tagline {
|
||||
font-size: clamp(1.7rem, 4.6vw, 3.1rem);
|
||||
line-height: 1.12;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f4f4f8;
|
||||
margin: 0 0 1.5rem;
|
||||
text-wrap: balance;
|
||||
opacity: 0;
|
||||
animation: splashRise 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.62s forwards;
|
||||
}
|
||||
.splash-tagline::selection { background: rgba(129, 140, 248, 0.3); }
|
||||
.splash-sub {
|
||||
font-size: clamp(0.85rem, 1.6vw, 1rem);
|
||||
line-height: 1.6;
|
||||
color: #9a9ab0;
|
||||
margin: 0 auto;
|
||||
max-width: 32rem;
|
||||
opacity: 0;
|
||||
animation: splashRise 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
.splash-sub-1 { animation-delay: 0.82s; }
|
||||
.splash-sub-2 { animation-delay: 0.94s; color: #6f6f86; margin-top: 0.35rem; }
|
||||
.splash-brand {
|
||||
margin-top: 2.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: uppercase;
|
||||
color: #4a4a60;
|
||||
opacity: 0;
|
||||
animation: splashRise 0.7s ease 1.1s forwards;
|
||||
}
|
||||
|
||||
@keyframes splashFadeOut {
|
||||
from { opacity: 1; transform: scale(1); filter: blur(0); }
|
||||
to { opacity: 0; transform: scale(1.04); filter: blur(4px); }
|
||||
}
|
||||
@keyframes splashConstellation { to { opacity: 1; } }
|
||||
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
|
||||
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
|
||||
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
|
||||
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
|
||||
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
|
||||
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.splash-root,
|
||||
.splash-root.splash-exit,
|
||||
.splash-constellation,
|
||||
.splash-star,
|
||||
.splash-mark,
|
||||
.splash-mark-glow,
|
||||
.splash-hex,
|
||||
.splash-line,
|
||||
.splash-node,
|
||||
.splash-dot,
|
||||
.splash-greeting,
|
||||
.splash-tagline,
|
||||
.splash-sub,
|
||||
.splash-brand {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
opacity: 1 !important;
|
||||
stroke-dashoffset: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @file StatCard.tsx
|
||||
* @description Compact metric tile for dashboard and analytics grids. Shows a
|
||||
* label, large formatted value, Lucide icon, and optional trend suffix. When
|
||||
* the displayed value is abbreviated (e.g. "1.2k"), pass the full precision
|
||||
* string via `raw` so {@link Tip} can reveal it on hover.
|
||||
*
|
||||
* ## Loading state
|
||||
* Set `loading` while fetching so the card renders {@link StatValueSkeleton}
|
||||
* instead of flashing placeholder dashes or zeros before real data arrives.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./Tip`
|
||||
* - `./Skeleton`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `StatCard` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **StatCard**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Tip } from "./Tip";
|
||||
import { StatValueSkeleton } from "./Skeleton";
|
||||
|
||||
/** Props for {@link StatCard}. */
|
||||
interface StatCardProps {
|
||||
/** Uppercase label above the value (usually a translated metric name). */
|
||||
label: string;
|
||||
/** Primary metric — pre-formatted by the caller. */
|
||||
value: string | number;
|
||||
/** Decorative icon in the card header. */
|
||||
icon: LucideIcon;
|
||||
/** Optional secondary line (e.g. "+12% vs last week"). */
|
||||
trend?: string;
|
||||
/** Tailwind text-color class for the icon. Defaults to `text-accent`. */
|
||||
accentColor?: string;
|
||||
/** Full-precision value shown in the hover tooltip when `value` is abbreviated. */
|
||||
raw?: string;
|
||||
/** When true, render skeletons in place of value/trend. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single statistic inside the shared `card` chrome.
|
||||
* @param props See {@link StatCardProps}.
|
||||
*/
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
trend,
|
||||
accentColor = "text-accent",
|
||||
raw,
|
||||
loading = false,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider truncate">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 min-w-0">
|
||||
{loading ? (
|
||||
<StatValueSkeleton />
|
||||
) : (
|
||||
<Tip raw={raw}>
|
||||
<span className="text-2xl font-semibold text-gray-100 truncate">{value}</span>
|
||||
</Tip>
|
||||
)}
|
||||
{!loading && trend && (
|
||||
<span className="text-xs text-gray-500 mb-1 flex-shrink-0">{trend}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file StatusBadge.tsx
|
||||
* @description Defines reusable React components for displaying the status of agents and sessions in a visually distinct way using badges. The AgentStatusBadge component shows the current status of an agent with an optional pulsing effect for active states, while the SessionStatusBadge component indicates the status of a session. When a row is in the yellow "Waiting" overlay state, both badges can additionally render WHY it waits (the server's awaiting_reason: needs input / turn done / at prompt / interrupted) as a nested icon+label chip with a hover tooltip carrying the full explanation — or, in `compact` mode for tight card layouts, as the hover tooltip alone. Both components utilize predefined configurations for consistent styling across the application.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/types`
|
||||
* - `./Tip`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `REASON_ICONS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AgentStatusBadge` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SessionStatusBadge` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **REASON_ICONS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **AgentStatusBadge**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SessionStatusBadge**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BellRing, MessageSquareReply, Terminal, OctagonPause } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { STATUS_CONFIG, SESSION_STATUS_CONFIG, AWAITING_REASON_CONFIG } from "../lib/types";
|
||||
import type { EffectiveAgentStatus, EffectiveSessionStatus, AwaitingReason } from "../lib/types";
|
||||
import { Tip } from "./Tip";
|
||||
|
||||
/** Per-reason icon, kept here (not in types.ts) so the presentation lookup in
|
||||
* lib/ stays JSX-free. Matches the semantics documented on {@link AwaitingReason}.
|
||||
* Exported so richer surfaces (e.g. SessionDetail's waiting banner) reuse the
|
||||
* same icon per reason as the badges. */
|
||||
export const REASON_ICONS: Record<AwaitingReason, LucideIcon> = {
|
||||
notification: BellRing, // blocked on a permission/input prompt - ring the bell
|
||||
stop: MessageSquareReply, // Claude replied; your reply is the next move
|
||||
session_start: Terminal, // fresh CLI sitting at an empty prompt
|
||||
interrupted: OctagonPause, // turn cut short (Esc / recovered hook)
|
||||
};
|
||||
|
||||
/**
|
||||
* The "why" chip nested inside a Waiting badge: a small rounded pill with the
|
||||
* reason's icon and short label. Urgent reasons (permission prompts,
|
||||
* interruptions) get a hotter amber fill than the calm idle-between-turns
|
||||
* ones so a scan of a list surfaces the rows that actually block on the human.
|
||||
*/
|
||||
function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
||||
const { t } = useTranslation();
|
||||
const cfg = AWAITING_REASON_CONFIG[reason];
|
||||
const Icon = REASON_ICONS[reason];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
|
||||
cfg.urgent
|
||||
? "bg-amber-500/15 border-amber-500/25 text-amber-300"
|
||||
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
|
||||
{t(cfg.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentStatusBadgeProps {
|
||||
status: EffectiveAgentStatus;
|
||||
pulse?: boolean;
|
||||
/** WHY the agent is waiting (from `agentAwaitingReason`); rendered as a
|
||||
* nested icon+label chip with a tooltip. Ignored unless `status` is "waiting". */
|
||||
reason?: AwaitingReason | null;
|
||||
/** Tooltip-only mode for tight layouts (Kanban/Dashboard cards): keeps the
|
||||
* hover explanation but suppresses the inline reason chip so the badge
|
||||
* never squeezes the card title. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function AgentStatusBadge({ status, pulse, reason, compact }: AgentStatusBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = STATUS_CONFIG[status];
|
||||
// "waiting" pulses by default so the user's eye is drawn to sessions that
|
||||
// need their attention, matching the pulsing for active/working states.
|
||||
const shouldPulse = pulse ?? (status === "working" || status === "waiting");
|
||||
// Only decorate the Waiting overlay - a reason on any other status is stale.
|
||||
const shownReason = status === "waiting" && reason ? reason : null;
|
||||
|
||||
return (
|
||||
// Tip renders children unwrapped when raw is undefined (non-waiting rows).
|
||||
<Tip raw={shownReason ? t(AWAITING_REASON_CONFIG[shownReason].descKey) : undefined}>
|
||||
<span className={`badge ${config.bg} ${config.color}`}>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${config.dot} ${
|
||||
shouldPulse ? "animate-pulse-dot" : ""
|
||||
}`}
|
||||
/>
|
||||
{t(config.labelKey)}
|
||||
{shownReason && !compact && <ReasonChip reason={shownReason} />}
|
||||
</span>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionStatusBadgeProps {
|
||||
status: EffectiveSessionStatus;
|
||||
pulse?: boolean;
|
||||
/** WHY the session is waiting (from `sessionAwaitingReason`); rendered as a
|
||||
* nested icon+label chip with a tooltip. Ignored unless `status` is "waiting". */
|
||||
reason?: AwaitingReason | null;
|
||||
/** Tooltip-only mode for tight layouts (Kanban/Dashboard cards): keeps the
|
||||
* hover explanation but suppresses the inline reason chip so the badge
|
||||
* never squeezes the card title. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SessionStatusBadge({ status, pulse, reason, compact }: SessionStatusBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = SESSION_STATUS_CONFIG[status];
|
||||
const shouldPulse = pulse ?? status === "waiting";
|
||||
const shownReason = status === "waiting" && reason ? reason : null;
|
||||
return (
|
||||
<Tip raw={shownReason ? t(AWAITING_REASON_CONFIG[shownReason].descKey) : undefined}>
|
||||
<span className={`badge ${config.bg} ${config.color}`}>
|
||||
{shouldPulse && (
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${config.dot} animate-pulse-dot`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{t(config.labelKey)}
|
||||
{shownReason && !compact && <ReasonChip reason={shownReason} />}
|
||||
</span>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @file CatAvatar.tsx
|
||||
* @description Pure presentational SVG cat - Tabby. Given a mood it renders the
|
||||
* matching expression (ears, eyes, cheeks, mouth, tail, paws) via a `data-mood`
|
||||
* attribute that drives the CSS in tabby.css. When motion is allowed, the
|
||||
* pupils track the cursor. No data access - fully testable / reusable in
|
||||
* isolation. Geometry tuned for max cuteness: big round head, oversized
|
||||
* sparkly eyes, pink ear-insides + cheek blush, classic tabby forehead
|
||||
* stripes, a fluffy tail, and little paws peeking at the bottom.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `CatAvatar` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **CatAvatar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
interface CatAvatarProps {
|
||||
mood: Mood;
|
||||
reducedMotion: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const MAX_PUPIL_SHIFT = 2.8; // px in the 100x100 viewBox
|
||||
|
||||
// Module-level last-known cursor position, tracked from the moment this module
|
||||
// first loads (well before any avatar mounts). This is what makes eye tracking
|
||||
// feel *immediate*: as soon as the cat mounts it aims at wherever the cursor
|
||||
// already is, instead of sitting centered until the next mousemove. `null`
|
||||
// until the very first pointer event of the page's life.
|
||||
let lastCursor: { x: number; y: number } | null = null;
|
||||
if (typeof window !== "undefined") {
|
||||
const remember = (e: MouseEvent) => {
|
||||
lastCursor = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
// capture phase + passive so we never interfere with anything else.
|
||||
window.addEventListener("mousemove", remember, { capture: true, passive: true });
|
||||
window.addEventListener("pointermove", remember as EventListener, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function CatAvatar({ mood, reducedMotion, size = 60 }: CatAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
const [pupil, setPupil] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
// group via CSS, so tracking there is harmless and keeps eyes pre-aimed for
|
||||
// the instant the connection returns.)
|
||||
const tracking = !reducedMotion && mood !== "sleeping";
|
||||
|
||||
useEffect(() => {
|
||||
if (!tracking) {
|
||||
setPupil({ x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const aimAt = (clientX: number, clientY: number) => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2;
|
||||
const cy = r.top + r.height / 2;
|
||||
const dx = clientX - cx;
|
||||
const dy = clientY - cy;
|
||||
const dist = Math.hypot(dx, dy) || 1;
|
||||
// Normalize then clamp to the eye socket range.
|
||||
const nx = (dx / dist) * Math.min(1, dist / 240);
|
||||
const ny = (dy / dist) * Math.min(1, dist / 240);
|
||||
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
|
||||
};
|
||||
|
||||
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
||||
// - no waiting for the user to move the mouse first.
|
||||
let initRaf = 0;
|
||||
if (lastCursor) {
|
||||
const c = lastCursor;
|
||||
initRaf = requestAnimationFrame(() => aimAt(c.x, c.y));
|
||||
}
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (rafRef.current) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = undefined;
|
||||
aimAt(e.clientX, e.clientY);
|
||||
});
|
||||
};
|
||||
window.addEventListener("mousemove", onMove, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
if (initRaf) cancelAnimationFrame(initRaf);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = undefined;
|
||||
};
|
||||
}, [tracking]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={rootRef}
|
||||
className="tabby-cat"
|
||||
data-mood={mood}
|
||||
data-reduced={reducedMotion ? "1" : "0"}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
role="img"
|
||||
aria-label={`Tabby (${mood})`}
|
||||
>
|
||||
<defs>
|
||||
{/* Soft top-lit gradient for the body/head - gives a rounded, plush feel. */}
|
||||
<radialGradient id="tabbyFur" cx="50%" cy="34%" r="72%">
|
||||
<stop offset="0%" stopColor="#5b5b86" />
|
||||
<stop offset="60%" stopColor="#43436a" />
|
||||
<stop offset="100%" stopColor="#343352" />
|
||||
</radialGradient>
|
||||
<linearGradient id="tabbyHalo" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#a5b4fc" />
|
||||
<stop offset="100%" stopColor="#6366f1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* soft glow halo */}
|
||||
<circle className="tabby-halo" cx="50" cy="56" r="33" />
|
||||
|
||||
{/* tail - fluffy curl to the right */}
|
||||
<path
|
||||
className="tabby-tail"
|
||||
d="M78 74 q24 4 19 -22 q-3 -14 -12 -11 q8 4 6 14 q-3 12 -16 9 z"
|
||||
/>
|
||||
|
||||
{/* body / chest peeking up from the bottom */}
|
||||
<ellipse className="tabby-body" cx="50" cy="92" rx="27" ry="18" />
|
||||
|
||||
{/* paws */}
|
||||
<g className="tabby-paws">
|
||||
<ellipse className="tabby-paw" cx="38" cy="98" rx="8" ry="6" />
|
||||
<ellipse className="tabby-paw" cx="62" cy="98" rx="8" ry="6" />
|
||||
<path className="tabby-toe" d="M35 96 v4 M38 96.5 v4 M41 96 v4" />
|
||||
<path className="tabby-toe" d="M59 96 v4 M62 96.5 v4 M65 96 v4" />
|
||||
</g>
|
||||
|
||||
{/* ears - rounded, with pink inner */}
|
||||
<g className="tabby-ears">
|
||||
<path className="tabby-ear" d="M30 33 Q20 8 44 24 Q38 28 34 33 Z" />
|
||||
<path className="tabby-ear-inner" d="M31 30 Q26 16 39 25 Q35 27 33 30 Z" />
|
||||
<path className="tabby-ear" d="M70 33 Q80 8 56 24 Q62 28 66 33 Z" />
|
||||
<path className="tabby-ear-inner" d="M69 30 Q74 16 61 25 Q65 27 67 30 Z" />
|
||||
</g>
|
||||
|
||||
{/* head - big and round */}
|
||||
<ellipse className="tabby-head" cx="50" cy="49" rx="33" ry="30" />
|
||||
|
||||
{/* classic tabby forehead stripes */}
|
||||
<g className="tabby-stripes">
|
||||
<path d="M50 22 L50 31" />
|
||||
<path d="M43 24 L45 32" />
|
||||
<path d="M57 24 L55 32" />
|
||||
</g>
|
||||
|
||||
{/* cheek blush */}
|
||||
<g className="tabby-cheeks">
|
||||
<ellipse cx="26" cy="57" rx="6.5" ry="4" />
|
||||
<ellipse cx="74" cy="57" rx="6.5" ry="4" />
|
||||
</g>
|
||||
|
||||
{/* eyes - open state (big + sparkly) */}
|
||||
<g className="tabby-eyes-open">
|
||||
<ellipse className="tabby-eye" cx="37" cy="50" rx="9.5" ry="11.5" />
|
||||
<ellipse className="tabby-eye" cx="63" cy="50" rx="9.5" ry="11.5" />
|
||||
{/* Outer group carries the eye-tracking translate; the inner group
|
||||
carries the blink (scaleY) animation. They MUST be separate
|
||||
elements - a CSS animation on `transform` overrides an inline
|
||||
`transform`, so putting both on one node makes the blink clobber
|
||||
the tracking (the original "eyes only track after a while" bug). */}
|
||||
<g className="tabby-pupils" style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}>
|
||||
<g className="tabby-pupils-blink">
|
||||
<circle className="tabby-pupil" cx="37" cy="51" r="5.4" />
|
||||
<circle className="tabby-pupil" cx="63" cy="51" r="5.4" />
|
||||
<circle className="tabby-glint" cx="39.4" cy="48" r="2.1" />
|
||||
<circle className="tabby-glint" cx="65.4" cy="48" r="2.1" />
|
||||
<circle className="tabby-glint tabby-glint-sm" cx="34.8" cy="53" r="1.1" />
|
||||
<circle className="tabby-glint tabby-glint-sm" cx="60.8" cy="53" r="1.1" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* eyes - happy (^ ^) */}
|
||||
<g className="tabby-eyes-happy">
|
||||
<path d="M29 52 q8 -10 16 0" />
|
||||
<path d="M55 52 q8 -10 16 0" />
|
||||
</g>
|
||||
|
||||
{/* eyes - closed (sleeping / offline) */}
|
||||
<g className="tabby-eyes-closed">
|
||||
<path d="M29 51 q8 7 16 0" />
|
||||
<path d="M55 51 q8 7 16 0" />
|
||||
</g>
|
||||
|
||||
{/* worried brows */}
|
||||
<g className="tabby-brows">
|
||||
<path d="M29 39 L45 45" />
|
||||
<path d="M71 39 L55 45" />
|
||||
</g>
|
||||
|
||||
{/* nose - tiny heart */}
|
||||
<path
|
||||
className="tabby-nose"
|
||||
d="M50 64 C47 60 43 62 45 65 C46 67 50 69 50 69 C50 69 54 67 55 65 C57 62 53 60 50 64 Z"
|
||||
/>
|
||||
|
||||
{/* mouths */}
|
||||
<path className="tabby-mouth-idle" d="M50 67 q-4 4 -8 1 M50 67 q4 4 8 1" />
|
||||
<path className="tabby-mouth-happy" d="M42 66 q8 7 16 0" />
|
||||
<path className="tabby-mouth-worried" d="M44 71 q6 -5 12 0" />
|
||||
|
||||
{/* whiskers */}
|
||||
<g className="tabby-whiskers">
|
||||
<path d="M14 55 Q24 55 33 58" />
|
||||
<path d="M13 62 Q24 63 33 63" />
|
||||
<path d="M86 55 Q76 55 67 58" />
|
||||
<path d="M87 62 Q76 63 67 63" />
|
||||
</g>
|
||||
|
||||
{/* zzz for sleeping */}
|
||||
<g className="tabby-zzz">
|
||||
<text x="76" y="28">
|
||||
z
|
||||
</text>
|
||||
<text x="84" y="19">
|
||||
z
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* alert bang for stuck */}
|
||||
<g className="tabby-bang">
|
||||
<text x="80" y="28">
|
||||
!
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* sparkle for happy */}
|
||||
<g className="tabby-sparkle">
|
||||
<path d="M82 40 l1.4 3.6 l3.6 1.4 l-3.6 1.4 l-1.4 3.6 l-1.4 -3.6 l-3.6 -1.4 l3.6 -1.4 z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file SpeechBubble.tsx
|
||||
* @description Transient speech bubble rendered above the Tabby cat mascot.
|
||||
* Pure presentation — visibility timing and quip selection live in
|
||||
* {@link useTabbyBrain}; this component only paints the bubble and handles
|
||||
* user dismissal.
|
||||
*
|
||||
* ## Accessibility
|
||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
||||
* to dismiss early.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SpeechBubble` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SpeechBubble**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
text: string;
|
||||
/** Called when the user clicks to dismiss. */
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated speech bubble above Tabby.
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
return (
|
||||
<div
|
||||
className="tabby-bubble tabby-bubble-enter"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file Tabby.tsx
|
||||
* @description Floating cat companion shell. Mounts once (next to UpdateNotifier
|
||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useTabbyBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useTabbyPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the cat is docked.
|
||||
*
|
||||
* The "do the job" path reuses the existing Run page: unmatched Ask queries
|
||||
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./CatAvatar`
|
||||
* - `./SpeechBubble`
|
||||
* - `./TabbyPanel`
|
||||
* - `./useTabbyBrain`
|
||||
* - `./useTabbyPosition`
|
||||
* - `./intents`
|
||||
* - `./prefs`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Tabby` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Tabby**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CatAvatar } from "./CatAvatar";
|
||||
import { SpeechBubble } from "./SpeechBubble";
|
||||
import { TabbyPanel } from "./TabbyPanel";
|
||||
import { useTabbyBrain } from "./useTabbyBrain";
|
||||
import { useTabbyPosition, TABBY_SIZE } from "./useTabbyPosition";
|
||||
import { matchIntent } from "./intents";
|
||||
import { tabbyPrefs } from "./prefs";
|
||||
import "./tabby.css";
|
||||
|
||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
|
||||
|
||||
interface Anchor {
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
side: "left" | "right";
|
||||
openUp: boolean;
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
if (!mq) return;
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
mq.addEventListener?.("change", onChange);
|
||||
return () => mq.removeEventListener?.("change", onChange);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-position wrapper that places its content next to the avatar and clamps
|
||||
* it inside the viewport. It measures itself (and re-measures on content/size
|
||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||
* into view instead of being cropped.
|
||||
*/
|
||||
function TabbyFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
|
||||
const place = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
||||
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
|
||||
// Vertical: prefer above the cat (feels natural). Only drop below when
|
||||
// there isn't room above - i.e. the cat is near the top edge.
|
||||
const above = anchor.top - h - FLYOUT_GAP;
|
||||
const below = anchor.top + anchor.size + FLYOUT_GAP;
|
||||
let top = above >= VIEWPORT_MARGIN ? above : below;
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
place();
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => place());
|
||||
ro.observe(el);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}, [place]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="tabby-flyout" style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tabby() {
|
||||
const [enabled, setEnabled] = useState(() => tabbyPrefs.getEnabled());
|
||||
const [open, setOpen] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const brain = useTabbyBrain();
|
||||
const place = useTabbyPosition();
|
||||
|
||||
// Keep enabled in sync with Settings / other tabs.
|
||||
useEffect(() => tabbyPrefs.subscribe(() => setEnabled(tabbyPrefs.getEnabled())), []);
|
||||
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
navigate(route);
|
||||
setOpen(false);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const onAsk = useCallback(
|
||||
(query: string): string | null => {
|
||||
const result = matchIntent(query, brain.status);
|
||||
if (result.kind === "answer") return result.text;
|
||||
// Handoff: spawn a real claude via the existing Run page. `autostart=1`
|
||||
// tells Run to fire the prompt automatically once it's prefilled, so the
|
||||
// question is actually sent instead of just dropped into the composer.
|
||||
navigate(`/run?prompt=${encodeURIComponent(result.prompt)}&autostart=1`);
|
||||
setOpen(false);
|
||||
return null;
|
||||
},
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
size: place.size,
|
||||
side: place.side,
|
||||
openUp: place.openUp,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Flyouts are hidden while dragging so they don't chase the cat. */}
|
||||
{!place.dragging && open && (
|
||||
<TabbyFlyout anchor={anchor}>
|
||||
<TabbyPanel
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
onAsk={onAsk}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</TabbyFlyout>
|
||||
)}
|
||||
|
||||
{!place.dragging && !open && brain.bubble && (
|
||||
<TabbyFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
</TabbyFlyout>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="tabby-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: TABBY_SIZE, height: TABBY_SIZE }}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerUp={place.onPointerUp}
|
||||
onClick={() => {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
aria-label={open ? "Close Tabby" : "Open Tabby companion"}
|
||||
aria-expanded={open}
|
||||
title="Tabby - ⌘B · drag to move"
|
||||
>
|
||||
<CatAvatar mood={brain.mood} reducedMotion={reducedMotion} />
|
||||
{brain.status.errorCount > 0 && (
|
||||
<span className="tabby-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @file TabbyPanel.tsx
|
||||
* @description Expanded Tabby panel: a live status strip (live / waiting /
|
||||
* errored stat chips + connection state), quick navigation actions, and a
|
||||
* local "Ask" box. Pure presentational - all data and the ask/navigation
|
||||
* behavior are injected by the container.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `TabbyPanel` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **TabbyPanel**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Play,
|
||||
Activity,
|
||||
LayoutList,
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
X,
|
||||
Send,
|
||||
AlertTriangle,
|
||||
Hourglass,
|
||||
Radio,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { TabbyStatus } from "./brain";
|
||||
|
||||
interface TabbyPanelProps {
|
||||
status: TabbyStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
onClearAlerts: () => void;
|
||||
onNavigate: (route: string) => void;
|
||||
/** Returns an answer to display, or null when the query was handed off. */
|
||||
onAsk: (query: string) => string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TabbyPanel({
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
onClearAlerts,
|
||||
onNavigate,
|
||||
onAsk,
|
||||
onClose,
|
||||
}: TabbyPanelProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const result = onAsk(query);
|
||||
setAnswer(result); // null means it handed off (container navigates/closes)
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl shadow-black/50 backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label="Tabby companion"
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-100">Tabby</span>
|
||||
<span
|
||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected ? "bg-emerald-500/15 text-emerald-300" : "bg-red-500/15 text-red-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
status.connected ? "bg-emerald-400" : "bg-red-500"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{status.connected ? "Live" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-surface-4 hover:text-gray-200"
|
||||
onClick={onClose}
|
||||
aria-label="Close Tabby"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{/* status stat chips */}
|
||||
<div className="mb-3 grid grid-cols-3 gap-1.5">
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
label="live"
|
||||
value={status.liveCount}
|
||||
tone={status.liveCount > 0 ? "accent" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={Hourglass}
|
||||
label="waiting"
|
||||
value={status.waitingCount}
|
||||
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={AlertTriangle}
|
||||
label="errored"
|
||||
value={status.errorCount}
|
||||
tone={status.errorCount > 0 ? "red" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton icon={Play} label="Run Claude" onClick={() => onNavigate("/run")} />
|
||||
<ActionButton icon={Activity} label="Activity" onClick={() => onNavigate("/activity")} />
|
||||
<ActionButton
|
||||
icon={LayoutList}
|
||||
label="Sessions"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label="Errored"
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? "Unmute" : "Mute"}
|
||||
onClick={onToggleMute}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
label="Clear alerts"
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={onClearAlerts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ask */}
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-gray-200 placeholder-gray-500 transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder="Ask Tabby… (e.g. any errors?)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Ask Tabby"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center justify-center rounded-lg bg-accent px-2.5 py-2 text-white transition-colors hover:bg-accent-hover"
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</form>
|
||||
{answer && (
|
||||
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-gray-300">
|
||||
{answer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Tone {
|
||||
wrap: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const TONE_MUTED: Tone = {
|
||||
wrap: "border-border bg-surface-1",
|
||||
value: "text-gray-300",
|
||||
icon: "text-gray-500",
|
||||
};
|
||||
|
||||
const TONES: Record<string, Tone> = {
|
||||
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-gray-100", icon: "text-accent" },
|
||||
amber: {
|
||||
wrap: "border-amber-500/30 bg-amber-500/10",
|
||||
value: "text-amber-200",
|
||||
icon: "text-amber-400",
|
||||
},
|
||||
red: { wrap: "border-red-500/30 bg-red-500/10", value: "text-red-200", icon: "text-red-400" },
|
||||
muted: TONE_MUTED,
|
||||
};
|
||||
|
||||
function StatChip({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
tone: string;
|
||||
}): ReactNode {
|
||||
const t = TONES[tone] ?? TONE_MUTED;
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-0.5 rounded-xl border py-1.5 ${t.wrap}`}>
|
||||
<Icon size={13} className={t.icon} aria-hidden />
|
||||
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-gray-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-gray-300 transition-colors hover:bg-surface-4 hover:text-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file Tabby.test.tsx
|
||||
* @description Render tests for the Tabby companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Tabby } from "../Tabby";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import type { WSMessage, Session } from "../../../lib/types";
|
||||
|
||||
function renderTabby() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Tabby />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionMsg = (id: string, status: Session["status"]): WSMessage => ({
|
||||
type: "session_updated",
|
||||
data: { id, status } as Session,
|
||||
timestamp: "t",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
eventBus.setConnected(true);
|
||||
// Freeze timers so the brain's 1s heartbeat tick can't fire a state update
|
||||
// outside act() mid-assertion. We never advance them in these tests.
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Tabby widget", () => {
|
||||
it("renders the avatar button by default", () => {
|
||||
renderTabby();
|
||||
expect(screen.getByRole("button", { name: /open tabby companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: /tabby/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the panel on click and answers a local status question", () => {
|
||||
renderTabby();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /tabby companion/i });
|
||||
expect(panel).toBeInTheDocument();
|
||||
|
||||
const input = within(panel).getByLabelText(/ask tabby/i);
|
||||
fireEvent.change(input, { target: { value: "status" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
||||
renderTabby();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "b", metaKey: true });
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error badge when a session errors", () => {
|
||||
renderTabby();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /open tabby companion/i });
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects the live count in the panel status", () => {
|
||||
renderTabby();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "active"));
|
||||
eventBus.publish(sessionMsg("b", "active"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /tabby companion/i });
|
||||
// The "live" stat chip shows value 2 next to its label.
|
||||
const liveChip = within(panel).getByText("live").closest("div")!;
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects the enabled preference", () => {
|
||||
localStorage.setItem("agent-dashboard-tabby-enabled", "false");
|
||||
renderTabby();
|
||||
expect(screen.queryByRole("button", { name: /open tabby companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a tap (no movement) still opens the panel", () => {
|
||||
renderTabby();
|
||||
const btn = screen.getByRole("button", { name: /open tabby companion/i });
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /tabby companion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||
renderTabby();
|
||||
const btn = screen.getByRole("button", { name: /open tabby companion/i });
|
||||
// Default dock is bottom-right. Drag far to the left past the threshold.
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerMove(btn, { clientX: 80, clientY: 300 });
|
||||
fireEvent.pointerUp(btn, { clientX: 80, clientY: 300 });
|
||||
});
|
||||
// The synthetic click that follows a drag must be swallowed.
|
||||
fireEvent.click(btn);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
const saved = JSON.parse(localStorage.getItem("agent-dashboard-tabby-pos") || "{}");
|
||||
expect(saved.side).toBe("left");
|
||||
expect(typeof saved.y).toBe("number");
|
||||
});
|
||||
|
||||
it("a sub-threshold pointer move is treated as a tap, not a drag", () => {
|
||||
renderTabby();
|
||||
const btn = screen.getByRole("button", { name: /open tabby companion/i });
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerMove(btn, { clientX: 992, clientY: 701 }); // < 5px threshold
|
||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /tabby companion/i })).toBeInTheDocument();
|
||||
// No position was persisted because no real drag happened.
|
||||
expect(localStorage.getItem("agent-dashboard-tabby-pos")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a persisted left-edge position on mount", () => {
|
||||
localStorage.setItem("agent-dashboard-tabby-pos", JSON.stringify({ side: "left", y: 0.2 }));
|
||||
renderTabby();
|
||||
const btn = screen.getByRole("button", { name: /open tabby companion/i }) as HTMLElement;
|
||||
// Left-docked → inline left equals the edge margin (16px).
|
||||
expect(btn.style.left).toBe("16px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @file brain.test.ts
|
||||
* @description Unit tests for the Tabby state brain — initial state, mood transitions driven by dashboard events, and status derivation.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
initialTabbyState,
|
||||
reduceTabby,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
HAPPY_MS,
|
||||
WORRIED_MS,
|
||||
STUCK_MS,
|
||||
SLEEP_MS,
|
||||
type TabbyState,
|
||||
} from "../brain";
|
||||
import type {
|
||||
WSMessage,
|
||||
Session,
|
||||
Agent,
|
||||
DashboardEvent,
|
||||
RunStatusPayload,
|
||||
} from "../../../lib/types";
|
||||
|
||||
const T0 = 1_000_000;
|
||||
|
||||
function sessionMsg(id: string, status: Session["status"], ts = T0): WSMessage {
|
||||
return { type: "session_updated", data: { id, status } as Session, timestamp: String(ts) };
|
||||
}
|
||||
function agentMsg(status: Agent["status"]): WSMessage {
|
||||
return { type: "agent_updated", data: { status } as Agent, timestamp: String(T0) };
|
||||
}
|
||||
function agentCreatedMsg(type: Agent["type"], status: Agent["status"] = "working"): WSMessage {
|
||||
return { type: "agent_created", data: { type, status } as Agent, timestamp: String(T0) };
|
||||
}
|
||||
function waitingMsg(id: string): WSMessage {
|
||||
return {
|
||||
type: "session_updated",
|
||||
data: { id, status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" } as Session,
|
||||
timestamp: String(T0),
|
||||
};
|
||||
}
|
||||
function eventMsg(event_type: string): WSMessage {
|
||||
return { type: "new_event", data: { event_type } as DashboardEvent, timestamp: String(T0) };
|
||||
}
|
||||
function runStatusMsg(d: Partial<RunStatusPayload>): WSMessage {
|
||||
return { type: "run_status", data: d as RunStatusPayload, timestamp: String(T0) };
|
||||
}
|
||||
|
||||
describe("deriveMood priority", () => {
|
||||
it("disconnected outranks everything", () => {
|
||||
const s: TabbyState = { ...initialTabbyState(T0), connected: false, worriedUntil: T0 + 9999 };
|
||||
expect(deriveMood(s, T0)).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("worried outranks stuck", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // live
|
||||
s = { ...s, lastActivityAt: T0 - STUCK_MS - 1, worriedUntil: T0 + 100 };
|
||||
expect(deriveMood(s, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("stuck when a live session goes silent past STUCK_MS", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
||||
expect(deriveMood(s, T0 + STUCK_MS + 1)).toBe("stuck");
|
||||
});
|
||||
|
||||
it("happy is transient then falls back to idle when nothing live", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "completed"), T0)); // no longer live
|
||||
expect(deriveMood(s, T0 + 10)).toBe("happy");
|
||||
expect(deriveMood(s, T0 + HAPPY_MS + 1)).toBe("idle");
|
||||
});
|
||||
|
||||
it("thinking shows when set and nothing higher applies", () => {
|
||||
const s = { ...initialTabbyState(T0), thinking: true };
|
||||
expect(deriveMood(s, T0)).toBe("thinking");
|
||||
});
|
||||
|
||||
it("watching when a session is live and recent", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
||||
expect(deriveMood(s, T0 + 1000)).toBe("watching");
|
||||
});
|
||||
|
||||
it("sleeping after SLEEP_MS of no activity and nothing live", () => {
|
||||
const s = initialTabbyState(T0);
|
||||
expect(deriveMood(s, T0 + SLEEP_MS + 1)).toBe("sleeping");
|
||||
});
|
||||
|
||||
it("idle by default", () => {
|
||||
expect(deriveMood(initialTabbyState(T0), T0)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceTabby counts and pulses", () => {
|
||||
it("tracks live count accurately across transitions", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceTabby(s, sessionMsg("b", "active"), T0));
|
||||
expect(statusOf(s).liveCount).toBe(2);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "completed"), T0));
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
});
|
||||
|
||||
it("counts errored sessions and emits error pulse", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
const r = reduceTabby(s, sessionMsg("a", "error"), T0);
|
||||
s = r.state;
|
||||
expect(r.pulse).toBe("error");
|
||||
expect(statusOf(s).errorCount).toBe(1);
|
||||
expect(deriveMood(s, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("session_start pulse only on first active transition", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
const r1 = reduceTabby(s, sessionMsg("a", "active"), T0);
|
||||
expect(r1.pulse).toBe("session_start");
|
||||
const r2 = reduceTabby(r1.state, sessionMsg("a", "active"), T0);
|
||||
expect(r2.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("session_done pulse only when the session was tracked", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
const untracked = reduceTabby(s, sessionMsg("ghost", "completed"), T0);
|
||||
expect(untracked.pulse).toBe(null);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
||||
const done = reduceTabby(s, sessionMsg("a", "completed"), T0);
|
||||
expect(done.pulse).toBe("session_done");
|
||||
});
|
||||
|
||||
it("agent error triggers worried via pulse", () => {
|
||||
const r = reduceTabby(initialTabbyState(T0), agentMsg("error"), T0);
|
||||
expect(r.pulse).toBe("error");
|
||||
expect(deriveMood(r.state, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("a newly created subagent emits subagent_spawn, a main agent does not", () => {
|
||||
expect(reduceTabby(initialTabbyState(T0), agentCreatedMsg("subagent"), T0).pulse).toBe(
|
||||
"subagent_spawn"
|
||||
);
|
||||
expect(reduceTabby(initialTabbyState(T0), agentCreatedMsg("main"), T0).pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("waiting transition emits a waiting pulse once and still counts as live", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // active
|
||||
const first = reduceTabby(s, waitingMsg("a"), T0);
|
||||
expect(first.pulse).toBe("waiting");
|
||||
expect(statusOf(first.state).liveCount).toBe(1);
|
||||
// A repeat waiting update does not re-announce.
|
||||
const second = reduceTabby(first.state, waitingMsg("a"), T0);
|
||||
expect(second.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("failure event types set worried, normal events do not", () => {
|
||||
const fail = reduceTabby(initialTabbyState(T0), eventMsg("toolError"), T0);
|
||||
expect(fail.pulse).toBe("error");
|
||||
const ok = reduceTabby(initialTabbyState(T0), eventMsg("postToolUse"), T0);
|
||||
expect(ok.pulse).toBe(null);
|
||||
expect(ok.state.worriedUntil).toBe(0);
|
||||
});
|
||||
|
||||
it("run_status completed exit 0 is happy, nonzero/error/killed is worried", () => {
|
||||
const good = reduceTabby(
|
||||
initialTabbyState(T0),
|
||||
runStatusMsg({ status: "completed", exitCode: 0 }),
|
||||
T0
|
||||
);
|
||||
expect(good.pulse).toBe("run_done");
|
||||
expect(deriveMood(good.state, T0)).toBe("happy");
|
||||
const bad = reduceTabby(
|
||||
initialTabbyState(T0),
|
||||
runStatusMsg({ status: "completed", exitCode: 1 }),
|
||||
T0
|
||||
);
|
||||
expect(bad.pulse).toBe("error");
|
||||
const err = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "error" }), T0);
|
||||
expect(err.pulse).toBe("error");
|
||||
const killed = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "killed" }), T0);
|
||||
expect(killed.pulse).toBe("error");
|
||||
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
|
||||
expect(running.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("any handled message refreshes lastActivityAt", () => {
|
||||
const s = { ...initialTabbyState(T0), lastActivityAt: T0 - 99999 };
|
||||
const { state } = reduceTabby(s, eventMsg("postToolUse"), T0 + 5);
|
||||
expect(state.lastActivityAt).toBe(T0 + 5);
|
||||
});
|
||||
|
||||
it("ignores unrelated message types without mutating", () => {
|
||||
const s = initialTabbyState(T0);
|
||||
const r = reduceTabby(s, { type: "import.progress", data: {} as never, timestamp: "x" }, T0);
|
||||
expect(r.state).toBe(s);
|
||||
expect(r.pulse).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedSessions", () => {
|
||||
it("hydrates live/waiting/errored counts from a REST snapshot", () => {
|
||||
const s = seedSessions(
|
||||
initialTabbyState(T0),
|
||||
[
|
||||
{ id: "a", status: "active" },
|
||||
{ id: "b", status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" },
|
||||
{ id: "c", status: "error" },
|
||||
{ id: "d", status: "completed" }, // ignored
|
||||
],
|
||||
T0
|
||||
);
|
||||
const st = statusOf(s);
|
||||
expect(st.liveCount).toBe(2); // a + b
|
||||
expect(st.waitingCount).toBe(1); // b
|
||||
expect(st.errorCount).toBe(1); // c
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusOf waiting", () => {
|
||||
it("counts a waiting session as both live and waiting", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
({ state: s } = reduceTabby(s, waitingMsg("a"), T0));
|
||||
expect(statusOf(s)).toMatchObject({ liveCount: 1, waitingCount: 1, errorCount: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearErrors", () => {
|
||||
it("drops errored sessions but keeps active ones", () => {
|
||||
let s = initialTabbyState(T0);
|
||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceTabby(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
expect(statusOf(s).errorCount).toBe(0);
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
expect(s.worriedUntil).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Reference the imported constant so it is exercised and tsc-clean.
|
||||
it("WORRIED_MS is a positive window", () => {
|
||||
expect(WORRIED_MS).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file intents.test.ts
|
||||
* @description Unit tests for Tabby's intent matcher — natural-language queries mapped to dashboard navigation and status intents.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { matchIntent } from "../intents";
|
||||
import type { TabbyStatus } from "../brain";
|
||||
|
||||
const status = (over: Partial<TabbyStatus> = {}): TabbyStatus => ({
|
||||
liveCount: 0,
|
||||
waitingCount: 0,
|
||||
errorCount: 0,
|
||||
connected: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("matchIntent", () => {
|
||||
it("reports live sessions", () => {
|
||||
const r = matchIntent("what's running?", status({ liveCount: 3 }));
|
||||
expect(r).toEqual({ kind: "answer", text: expect.stringContaining("3 sessions live") });
|
||||
});
|
||||
|
||||
it("singularizes correctly", () => {
|
||||
const r = matchIntent("anything active", status({ liveCount: 1 }));
|
||||
expect(r.kind).toBe("answer");
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session live");
|
||||
});
|
||||
|
||||
it("says all quiet when nothing live", () => {
|
||||
const r = matchIntent("what is running", status());
|
||||
if (r.kind === "answer") expect(r.text).toContain("nothing's running");
|
||||
});
|
||||
|
||||
it("reports errors and prioritizes error intent over live", () => {
|
||||
const r = matchIntent("any failed runs?", status({ liveCount: 2, errorCount: 1 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session errored");
|
||||
});
|
||||
|
||||
it("clean when no errors", () => {
|
||||
const r = matchIntent("are there errors", status({ liveCount: 2 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("all clean");
|
||||
});
|
||||
|
||||
it("gives a combined status summary", () => {
|
||||
const r = matchIntent(
|
||||
"status",
|
||||
status({ liveCount: 2, waitingCount: 1, errorCount: 1, connected: true })
|
||||
);
|
||||
if (r.kind === "answer") expect(r.text).toBe("2 live · 1 waiting · 1 errored · connected.");
|
||||
});
|
||||
|
||||
it("reports sessions waiting on the user", () => {
|
||||
const r = matchIntent("anything waiting on me?", status({ liveCount: 2, waitingCount: 1 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session waiting on you");
|
||||
});
|
||||
|
||||
it("reflects offline in summary", () => {
|
||||
const r = matchIntent("overview", status({ connected: false }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("offline");
|
||||
});
|
||||
|
||||
it("explains itself on help", () => {
|
||||
const r = matchIntent("help", status());
|
||||
if (r.kind === "answer") expect(r.text.toLowerCase()).toContain("watch your sessions");
|
||||
});
|
||||
|
||||
it("empty query nudges the user", () => {
|
||||
const r = matchIntent(" ", status());
|
||||
expect(r.kind).toBe("answer");
|
||||
});
|
||||
|
||||
it("hands unknown questions to Claude, preserving original casing", () => {
|
||||
const r = matchIntent("Refactor my auth module", status());
|
||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file quips.test.ts
|
||||
* @description Unit tests for Tabby's quip picker — full key coverage and stable selection behavior for the speech-bubble lines.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pickQuip, ALL_QUIP_KEYS } from "../quips";
|
||||
|
||||
describe("pickQuip", () => {
|
||||
it("returns a non-empty string for every known key", () => {
|
||||
for (const key of ALL_QUIP_KEYS) {
|
||||
expect(pickQuip(key, () => 0).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic given an injected rand", () => {
|
||||
expect(pickQuip("session_done", () => 0)).toBe(pickQuip("session_done", () => 0));
|
||||
});
|
||||
|
||||
it("rand=0.999 stays within bounds (no out-of-range index)", () => {
|
||||
for (const key of ALL_QUIP_KEYS) {
|
||||
expect(typeof pickQuip(key, () => 0.999)).toBe("string");
|
||||
expect(pickQuip(key, () => 0.999).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty string for an unknown key without throwing", () => {
|
||||
// @ts-expect-error intentionally passing an invalid key
|
||||
expect(pickQuip("nope", () => 0)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* @file brain.ts
|
||||
* @description Pure, framework-free core of the Tabby companion. Reduces the
|
||||
* dashboard's live WebSocket stream into a small mood model and derives the
|
||||
* current cat mood from that model plus the wall clock. Kept side-effect free
|
||||
* so it can be unit-tested without React, timers, or the DOM. The React hook
|
||||
* (`useTabbyBrain`) wires this to the event bus and to real timers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Mood` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TabbyPulse` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TabbyStatus` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TabbyState` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `HAPPY_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `WORRIED_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `STUCK_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SLEEP_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `FAILURE_EVENT_TYPES` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `initialTabbyState` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `statusOf` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `deriveMood` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `reduceTabby` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `seedSessions` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `clearErrors` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Mood**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TabbyPulse**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TabbyStatus**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TabbyState**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **HAPPY_MS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **WORRIED_MS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **STUCK_MS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SLEEP_MS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **FAILURE_EVENT_TYPES**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **initialTabbyState**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **statusOf**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **deriveMood**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **reduceTabby**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **seedSessions**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **clearErrors**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from "../../lib/types";
|
||||
|
||||
/** All moods Tabby can express, highest priority first (see `deriveMood`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceTabby`.
|
||||
* The hook turns pulses into transient speech bubbles. `null` means the message
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type TabbyPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
|
||||
export interface TabbyStatus {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
liveCount: number;
|
||||
/** Subset of liveCount currently blocked on user input. */
|
||||
waitingCount: number;
|
||||
errorCount: number;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface TabbyState {
|
||||
connected: boolean;
|
||||
/** Latest status per session id we still care about. "waiting" = active but
|
||||
* blocked on user input; counts as live for the status line. */
|
||||
sessions: Record<string, "active" | "error" | "waiting">;
|
||||
/** Epoch ms of the last meaningful activity; drives stuck/sleeping. */
|
||||
lastActivityAt: number;
|
||||
/** While `now < happyUntil`, mood can be `happy`. */
|
||||
happyUntil: number;
|
||||
/** While `now < worriedUntil`, mood can be `worried`. */
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
thinking: boolean;
|
||||
}
|
||||
|
||||
// Tunable timing constants (ms).
|
||||
export const HAPPY_MS = 4000;
|
||||
export const WORRIED_MS = 4500;
|
||||
export const STUCK_MS = 10 * 60_000;
|
||||
export const SLEEP_MS = 3 * 60_000;
|
||||
|
||||
/** Event types from the hook ingestion that represent a genuine failure. */
|
||||
export const FAILURE_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
"error",
|
||||
"toolError",
|
||||
"agentError",
|
||||
"subagentError",
|
||||
"errorEvent",
|
||||
"errorReport",
|
||||
"errorBoundary",
|
||||
"crashReport",
|
||||
"diagnosticError",
|
||||
]);
|
||||
|
||||
export function initialTabbyState(now: number): TabbyState {
|
||||
return {
|
||||
connected: true,
|
||||
sessions: {},
|
||||
lastActivityAt: now,
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function statusOf(state: TabbyState): TabbyStatus {
|
||||
let liveCount = 0;
|
||||
let waitingCount = 0;
|
||||
let errorCount = 0;
|
||||
for (const s of Object.values(state.sessions)) {
|
||||
if (s === "active" || s === "waiting") {
|
||||
liveCount++;
|
||||
if (s === "waiting") waitingCount++;
|
||||
} else if (s === "error") errorCount++;
|
||||
}
|
||||
return { liveCount, waitingCount, errorCount, connected: state.connected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mood resolver. Highest-priority matching state wins. `now` is injected
|
||||
* so callers (and tests) control the clock; transient windows (happy/worried)
|
||||
* and inactivity windows (stuck/sleeping) are evaluated against it.
|
||||
*/
|
||||
export function deriveMood(state: TabbyState, now: number): Mood {
|
||||
if (!state.connected) return "disconnected";
|
||||
if (now < state.worriedUntil) return "worried";
|
||||
|
||||
const { liveCount } = statusOf(state);
|
||||
const silent = now - state.lastActivityAt;
|
||||
|
||||
if (liveCount > 0 && silent > STUCK_MS) return "stuck";
|
||||
if (now < state.happyUntil) return "happy";
|
||||
if (state.thinking) return "thinking";
|
||||
if (liveCount > 0) return "watching";
|
||||
if (silent > SLEEP_MS) return "sleeping";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a single WebSocket message into the Tabby state. Returns the next state
|
||||
* (new object) and a one-shot pulse describing what happened. Unknown or
|
||||
* irrelevant message types pass through unchanged with a `null` pulse.
|
||||
*/
|
||||
export function reduceTabby(
|
||||
state: TabbyState,
|
||||
msg: WSMessage,
|
||||
now: number
|
||||
): { state: TabbyState; pulse: TabbyPulse } {
|
||||
switch (msg.type) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
const sessions = { ...state.sessions };
|
||||
let pulse: TabbyPulse = null;
|
||||
let happyUntil = state.happyUntil;
|
||||
let worriedUntil = state.worriedUntil;
|
||||
|
||||
if (s.status === "active") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
// or sitting at a fresh prompt). Announce the transition once each way.
|
||||
const isWaiting = !!s.awaiting_input_since;
|
||||
const prev = sessions[s.id];
|
||||
if (isWaiting) {
|
||||
sessions[s.id] = "waiting";
|
||||
if (prev !== "waiting") pulse = "waiting";
|
||||
} else {
|
||||
sessions[s.id] = "active";
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} else if (s.status === "error") {
|
||||
sessions[s.id] = "error";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
pulse,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
const pulse: TabbyPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
||||
}
|
||||
|
||||
case "agent_updated": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
|
||||
case "new_event": {
|
||||
const e = msg.data as DashboardEvent;
|
||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
lastActivityAt: now,
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
};
|
||||
}
|
||||
|
||||
case "run_status": {
|
||||
const r = msg.data as RunStatusPayload;
|
||||
if (!r) return { state, pulse: null };
|
||||
// A run that finished cleanly (exit 0, or no exit code reported) → happy.
|
||||
if (r.status === "completed" && (r.exitCode == null || r.exitCode === 0)) {
|
||||
return {
|
||||
state: { ...state, happyUntil: now + HAPPY_MS, lastActivityAt: now },
|
||||
pulse: "run_done",
|
||||
};
|
||||
}
|
||||
// Errored, killed, or completed with a nonzero exit code → worried.
|
||||
if (
|
||||
r.status === "error" ||
|
||||
r.status === "killed" ||
|
||||
(r.status === "completed" && r.exitCode != null && r.exitCode !== 0)
|
||||
) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
// spawning / running → activity only.
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
|
||||
case "run_stream":
|
||||
// Streaming output counts as activity but is not itself notable.
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
|
||||
default:
|
||||
return { state, pulse: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate session tracking from a REST snapshot (the same data the dashboard
|
||||
* fetches on load). Without this, the brain only learns about sessions from
|
||||
* live WS deltas that arrive *after* it mounts, so a freshly-loaded page shows
|
||||
* "0 live" even when sessions already exist. Merges in non-finished sessions;
|
||||
* never clears the error window. Live WS deltas continue to refine this.
|
||||
*/
|
||||
export function seedSessions(
|
||||
state: TabbyState,
|
||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||
now: number
|
||||
): TabbyState {
|
||||
const sessions = { ...state.sessions };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
}
|
||||
return { ...state, sessions, lastActivityAt: now };
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||
export function clearErrors(state: TabbyState): TabbyState {
|
||||
const sessions: TabbyState["sessions"] = {};
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== "error") sessions[id] = s;
|
||||
}
|
||||
return { ...state, sessions, worriedUntil: 0 };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @file intents.ts
|
||||
* @description Tabby's local "Ask" brain. Matches a free-text question against a
|
||||
* small set of intents answerable from cached dashboard status. Anything it
|
||||
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
||||
* Pure function - no network, no DOM - so it's fully unit-testable.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **AskResult**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **matchIntent**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { TabbyStatus } from "./brain";
|
||||
|
||||
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
|
||||
|
||||
const plural = (n: number) => (n === 1 ? "" : "s");
|
||||
|
||||
export function matchIntent(query: string, status: TabbyStatus): AskResult {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||
};
|
||||
}
|
||||
|
||||
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
||||
|
||||
if (has("help", "what can you", "what do you do")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
||||
};
|
||||
}
|
||||
|
||||
// Errors first: "any failed runs" should report errors, not live count.
|
||||
if (has("error", "broke", "broken", "fail", "wrong", "crash")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.errorCount > 0
|
||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
||||
: "no errors - all clean 🐾",
|
||||
};
|
||||
}
|
||||
|
||||
if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.waitingCount > 0
|
||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
||||
: "nothing's waiting on you right now 🐾",
|
||||
};
|
||||
}
|
||||
|
||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.liveCount > 0
|
||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
||||
: "nothing's running right now - all quiet.",
|
||||
};
|
||||
}
|
||||
|
||||
if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
||||
status.connected ? "connected" : "offline"
|
||||
}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "handoff", prompt: query.trim() };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @file prefs.ts
|
||||
* @description Tiny localStorage-backed preference store for Tabby (enabled +
|
||||
* muted). Broadcasts changes via a window CustomEvent so the Settings toggle
|
||||
* and the live widget stay in sync within the same tab without a reload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `TabbyPos` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `tabbyPrefs` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **TabbyPos**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **tabbyPrefs**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
const ENABLED_KEY = "agent-dashboard-tabby-enabled";
|
||||
const MUTED_KEY = "agent-dashboard-tabby-muted";
|
||||
const POS_KEY = "agent-dashboard-tabby-pos";
|
||||
const EVENT = "tabby:prefs";
|
||||
|
||||
/**
|
||||
* Persisted resting position, AssistiveTouch-style: the widget always docks to
|
||||
* the left or right edge, remembering its vertical offset. `y` is stored as a
|
||||
* fraction of the viewport height (0–1) so it survives window resizes.
|
||||
*/
|
||||
export interface TabbyPos {
|
||||
side: "left" | "right";
|
||||
y: number;
|
||||
}
|
||||
|
||||
function readBool(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(key);
|
||||
return v === null ? fallback : v === "true";
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeBool(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
// Ignore storage failures (private mode, quota) - prefs are best-effort.
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(EVENT));
|
||||
} catch {
|
||||
// SSR / non-DOM contexts: nothing to notify.
|
||||
}
|
||||
}
|
||||
|
||||
function readPos(): TabbyPos | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(POS_KEY);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as Partial<TabbyPos>;
|
||||
if ((p.side === "left" || p.side === "right") && typeof p.y === "number") {
|
||||
return { side: p.side, y: Math.min(1, Math.max(0, p.y)) };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePos(pos: TabbyPos): void {
|
||||
try {
|
||||
localStorage.setItem(POS_KEY, JSON.stringify(pos));
|
||||
} catch {
|
||||
// Ignore storage failures - position is best-effort.
|
||||
}
|
||||
// Note: intentionally does NOT dispatch the prefs event - position changes
|
||||
// are local to the widget and shouldn't churn the Settings toggle listeners.
|
||||
}
|
||||
|
||||
export const tabbyPrefs = {
|
||||
getEnabled: () => readBool(ENABLED_KEY, true),
|
||||
setEnabled: (v: boolean) => writeBool(ENABLED_KEY, v),
|
||||
getMuted: () => readBool(MUTED_KEY, false),
|
||||
setMuted: (v: boolean) => writeBool(MUTED_KEY, v),
|
||||
getPos: readPos,
|
||||
setPos: writePos,
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
window.addEventListener(EVENT, listener);
|
||||
// Also react to changes from other tabs.
|
||||
window.addEventListener("storage", listener);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, listener);
|
||||
window.removeEventListener("storage", listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file quips.ts
|
||||
* @description Tabby's personality: pools of short phrases keyed by pulse/mood,
|
||||
* plus a deterministic-by-injection picker. Pure data + a pure function so it
|
||||
* can be unit-tested without randomness leaking in.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **QuipKey**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **pickQuip**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ALL_QUIP_KEYS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { Mood, TabbyPulse } from "./brain";
|
||||
|
||||
export type QuipKey = NonNullable<TabbyPulse> | Mood;
|
||||
|
||||
const QUIPS: Record<QuipKey, string[]> = {
|
||||
// Pulses (event-driven, transient bubbles)
|
||||
session_done: [
|
||||
"a session just wrapped up! 🐾",
|
||||
"a session finished - nice work! ✨",
|
||||
"that session's all done 😺",
|
||||
"clean finish on that one 💜",
|
||||
],
|
||||
session_start: [
|
||||
"a new session started! 👀",
|
||||
"a fresh session just landed 🐾",
|
||||
"ooh, a new session to watch 😻",
|
||||
"something new is cooking 🍲",
|
||||
],
|
||||
subagent_spawn: [
|
||||
"a subagent just spawned! 🐾",
|
||||
"a little helper joined in 🤝",
|
||||
"a subagent's on the job 🚀",
|
||||
"reinforcements - new subagent! 😺",
|
||||
],
|
||||
waiting: [
|
||||
"a session needs your input 👀",
|
||||
"a session is waiting on you ⏳",
|
||||
"a session paused for your reply 💬",
|
||||
"your turn - a session's waiting 🐾",
|
||||
],
|
||||
error: [
|
||||
"uh oh, a session hit an error 😿",
|
||||
"something broke - wanna peek? 🙀",
|
||||
"a hook tripped on something ⚠️",
|
||||
"hiss… an error popped up 💢",
|
||||
],
|
||||
run_done: [
|
||||
"your run just finished! 🐾",
|
||||
"the run's all wrapped up ✨",
|
||||
"run complete - that's a wrap 😸",
|
||||
"all done with that run 💜",
|
||||
],
|
||||
// Moods (steady-state flavor, used by the panel / idle bubbles)
|
||||
disconnected: [
|
||||
"lost the connection… 😴",
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤",
|
||||
],
|
||||
worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
stuck: [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾",
|
||||
],
|
||||
happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
const pool = QUIPS[key];
|
||||
if (!pool || pool.length === 0) return "";
|
||||
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
|
||||
return pool[i] ?? "";
|
||||
}
|
||||
|
||||
export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[];
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* tabby.css - animations + mood expressions for the Tabby companion.
|
||||
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
|
||||
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
.tabby-cat {
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
/* ── palette ── */
|
||||
.tabby-head,
|
||||
.tabby-body {
|
||||
fill: url(#tabbyFur);
|
||||
stroke: #8b93f9;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.tabby-ear {
|
||||
fill: url(#tabbyFur);
|
||||
stroke: #8b93f9;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.tabby-ear-inner {
|
||||
fill: #f7a8c8;
|
||||
}
|
||||
.tabby-paw {
|
||||
fill: #4a4a72;
|
||||
stroke: #8b93f9;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.tabby-toe {
|
||||
fill: none;
|
||||
stroke: #2c2c46;
|
||||
stroke-width: 1.2;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tabby-stripes path {
|
||||
fill: none;
|
||||
stroke: #8b93f9;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.tabby-cheeks ellipse {
|
||||
fill: #f7849f;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tabby-eye {
|
||||
fill: #f3f4ff;
|
||||
}
|
||||
.tabby-pupil {
|
||||
fill: #15131f;
|
||||
}
|
||||
.tabby-glint {
|
||||
fill: #ffffff;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.tabby-glint-sm {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.tabby-nose {
|
||||
fill: #f7849f;
|
||||
}
|
||||
.tabby-tail {
|
||||
fill: url(#tabbyFur);
|
||||
stroke: #8b93f9;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
transform-origin: 80px 68px;
|
||||
}
|
||||
.tabby-whiskers path,
|
||||
.tabby-brows path,
|
||||
.tabby-eyes-happy path,
|
||||
.tabby-eyes-closed path,
|
||||
.tabby-mouth-idle,
|
||||
.tabby-mouth-happy,
|
||||
.tabby-mouth-worried {
|
||||
fill: none;
|
||||
stroke: #dfe1ff;
|
||||
stroke-width: 2.2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.tabby-whiskers path {
|
||||
stroke: #9aa0c8;
|
||||
stroke-width: 1.4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.tabby-brows path {
|
||||
stroke: #8b93f9;
|
||||
}
|
||||
.tabby-halo {
|
||||
fill: url(#tabbyHalo);
|
||||
opacity: 0.18;
|
||||
}
|
||||
.tabby-sparkle path {
|
||||
fill: #fde68a;
|
||||
}
|
||||
.tabby-zzz text,
|
||||
.tabby-bang text {
|
||||
fill: #a5b4fc;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── default visibility: show open eyes + idle mouth, hide the rest ── */
|
||||
.tabby-eyes-happy,
|
||||
.tabby-eyes-closed,
|
||||
.tabby-brows,
|
||||
.tabby-mouth-happy,
|
||||
.tabby-mouth-worried,
|
||||
.tabby-zzz,
|
||||
.tabby-bang,
|
||||
.tabby-sparkle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── happy ── */
|
||||
.tabby-cat[data-mood="happy"] .tabby-eyes-open {
|
||||
display: none;
|
||||
}
|
||||
.tabby-cat[data-mood="happy"] .tabby-eyes-happy,
|
||||
.tabby-cat[data-mood="happy"] .tabby-mouth-happy,
|
||||
.tabby-cat[data-mood="happy"] .tabby-sparkle {
|
||||
display: block;
|
||||
}
|
||||
.tabby-cat[data-mood="happy"] .tabby-mouth-idle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── watching ── (alert, smiling, tail flick) */
|
||||
.tabby-cat[data-mood="watching"] .tabby-mouth-idle {
|
||||
display: none;
|
||||
}
|
||||
.tabby-cat[data-mood="watching"] .tabby-mouth-happy {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── worried ── (brows down, frown) */
|
||||
.tabby-cat[data-mood="worried"] .tabby-brows,
|
||||
.tabby-cat[data-mood="worried"] .tabby-mouth-worried {
|
||||
display: block;
|
||||
}
|
||||
.tabby-cat[data-mood="worried"] .tabby-mouth-idle {
|
||||
display: none;
|
||||
}
|
||||
.tabby-cat[data-mood="worried"] .tabby-cheeks ellipse {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── stuck ── (alert bang, ears up) */
|
||||
.tabby-cat[data-mood="stuck"] .tabby-bang {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── thinking ── keeps open eyes + idle mouth; head tilt handled below */
|
||||
|
||||
/* ── sleeping / disconnected ── (closed eyes) */
|
||||
.tabby-cat[data-mood="sleeping"] .tabby-eyes-open,
|
||||
.tabby-cat[data-mood="disconnected"] .tabby-eyes-open {
|
||||
display: none;
|
||||
}
|
||||
.tabby-cat[data-mood="sleeping"] .tabby-eyes-closed,
|
||||
.tabby-cat[data-mood="disconnected"] .tabby-eyes-closed {
|
||||
display: block;
|
||||
}
|
||||
.tabby-cat[data-mood="sleeping"] .tabby-zzz {
|
||||
display: block;
|
||||
}
|
||||
.tabby-cat[data-mood="disconnected"] {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.65) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
/* ════════ animations ════════ */
|
||||
@keyframes tabby-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
}
|
||||
@keyframes tabby-blink {
|
||||
0%,
|
||||
92%,
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
96% {
|
||||
transform: scaleY(0.1);
|
||||
}
|
||||
}
|
||||
@keyframes tabby-shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
@keyframes tabby-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
@keyframes tabby-tail-flick {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
}
|
||||
@keyframes tabby-ears-perk {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-1.5px);
|
||||
}
|
||||
}
|
||||
@keyframes tabby-sparkle-twinkle {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* idle: gentle breathing + occasional blink */
|
||||
.tabby-cat[data-mood="idle"] {
|
||||
animation: tabby-breathe 4s ease-in-out infinite;
|
||||
transform-origin: 50px 60px;
|
||||
}
|
||||
/* Blink lives on the inner group so it never overrides the outer group's
|
||||
eye-tracking translate (which would freeze the eyes - see CatAvatar). */
|
||||
.tabby-cat[data-mood="idle"] .tabby-pupils-blink {
|
||||
animation: tabby-blink 5s ease-in-out infinite;
|
||||
transform-origin: 50px 50px;
|
||||
}
|
||||
/* watching: tail flicks, ears perk */
|
||||
.tabby-cat[data-mood="watching"] .tabby-tail {
|
||||
animation: tabby-tail-flick 1.8s ease-in-out infinite;
|
||||
}
|
||||
.tabby-cat[data-mood="watching"] .tabby-ears {
|
||||
animation: tabby-ears-perk 1.8s ease-in-out infinite;
|
||||
transform-origin: 50px 24px;
|
||||
}
|
||||
/* happy: head bob + twinkling sparkle */
|
||||
.tabby-cat[data-mood="happy"] {
|
||||
animation: tabby-bob 0.5s ease-in-out 0s 4;
|
||||
transform-origin: 50px 60px;
|
||||
}
|
||||
.tabby-cat[data-mood="happy"] .tabby-sparkle {
|
||||
animation: tabby-sparkle-twinkle 0.9s ease-in-out infinite;
|
||||
transform-origin: 84px 45px;
|
||||
}
|
||||
/* worried: shake + puff (halo grows) */
|
||||
.tabby-cat[data-mood="worried"] {
|
||||
animation: tabby-shake 0.35s ease-in-out 0s 3;
|
||||
transform-origin: 50px 60px;
|
||||
}
|
||||
.tabby-cat[data-mood="worried"] .tabby-halo {
|
||||
opacity: 0.32;
|
||||
}
|
||||
/* stuck: ears stay perked, slow breathe */
|
||||
.tabby-cat[data-mood="stuck"] {
|
||||
animation: tabby-breathe 2.4s ease-in-out infinite;
|
||||
transform-origin: 50px 60px;
|
||||
}
|
||||
.tabby-cat[data-mood="stuck"] .tabby-ears {
|
||||
transform: translateY(-2px);
|
||||
transform-origin: 50px 24px;
|
||||
}
|
||||
/* thinking: subtle head tilt */
|
||||
.tabby-cat[data-mood="thinking"] {
|
||||
transform: rotate(-6deg);
|
||||
transform-origin: 50px 60px;
|
||||
}
|
||||
/* sleeping: slow breathe, droop */
|
||||
.tabby-cat[data-mood="sleeping"] {
|
||||
animation: tabby-breathe 5s ease-in-out infinite;
|
||||
transform-origin: 50px 60px;
|
||||
}
|
||||
|
||||
/* ── reduced motion: kill all continuous animation ── */
|
||||
.tabby-cat[data-reduced="1"],
|
||||
.tabby-cat[data-reduced="1"] * {
|
||||
animation: none !important;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tabby-cat,
|
||||
.tabby-cat * {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ shell (avatar button, bubble, panel) ════════ */
|
||||
|
||||
/* The draggable avatar. Fixed-positioned; left/top are set inline by the drag
|
||||
hook. It docks to an edge (AssistiveTouch-style) and remembers where. */
|
||||
.tabby-avatar-btn {
|
||||
position: fixed;
|
||||
z-index: 41; /* above the flyout */
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
border-radius: 9999px;
|
||||
cursor: grab;
|
||||
touch-action: none; /* pointer drives the drag instead of scrolling on touch */
|
||||
/* Glide to the snapped edge after a drag; killed mid-drag for 1:1 tracking. */
|
||||
transition:
|
||||
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top 0.28s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 0.15s ease;
|
||||
}
|
||||
.tabby-avatar-btn:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.tabby-avatar-btn:focus-visible {
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.tabby-avatar-btn[data-dragging="1"] {
|
||||
cursor: grabbing;
|
||||
transition: transform 0.15s ease; /* no left/top easing while dragging */
|
||||
}
|
||||
.tabby-avatar-btn[data-dragging="1"]:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Self-clamping flyout that holds the bubble / panel next to the avatar. Its
|
||||
left/top are computed and set inline by TabbyFlyout so it never crops. */
|
||||
.tabby-flyout {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tabby-avatar-btn:focus-visible {
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.tabby-error-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
}
|
||||
.tabby-bubble {
|
||||
max-width: 16rem;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #363650;
|
||||
color: #e8e8f0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.2rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.tabby-bubble-enter {
|
||||
animation: tabby-bubble-in 0.22s ease-out;
|
||||
}
|
||||
@keyframes tabby-bubble-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.tabby-cat[data-reduced="1"] ~ * .tabby-bubble-enter,
|
||||
.tabby-bubble.tabby-no-anim {
|
||||
animation: none;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @file useTabbyBrain.ts
|
||||
* @description React hook that wires the pure Tabby brain to the live event bus
|
||||
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
||||
* exposes the derived mood, a status summary, the current speech bubble, and
|
||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/eventBus`
|
||||
* - `../../lib/api`
|
||||
* - `../../lib/types`
|
||||
* - `./brain`
|
||||
* - `./quips`
|
||||
* - `./prefs`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `TabbyBrain` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `useTabbyBrain` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **TabbyBrain**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **useTabbyBrain**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { api } from "../../lib/api";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
import {
|
||||
initialTabbyState,
|
||||
reduceTabby,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
type Mood,
|
||||
type TabbyState,
|
||||
type TabbyStatus,
|
||||
} from "./brain";
|
||||
import { pickQuip } from "./quips";
|
||||
import { tabbyPrefs } from "./prefs";
|
||||
|
||||
const BUBBLE_MS = 4500;
|
||||
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||
const BUBBLE_THROTTLE_MS = 3000;
|
||||
|
||||
export interface TabbyBrain {
|
||||
mood: Mood;
|
||||
status: TabbyStatus;
|
||||
bubble: string | null;
|
||||
dismissBubble: () => void;
|
||||
muted: boolean;
|
||||
toggleMute: () => void;
|
||||
clearAlerts: () => void;
|
||||
setThinking: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function useTabbyBrain(): TabbyBrain {
|
||||
const now0 = Date.now();
|
||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
||||
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||
const [state, setState] = useState<TabbyState>(() => ({
|
||||
...initialTabbyState(now0),
|
||||
connected: true,
|
||||
}));
|
||||
const [tick, setTick] = useState(now0);
|
||||
const [bubble, setBubble] = useState<string | null>(null);
|
||||
const [muted, setMuted] = useState<boolean>(() => tabbyPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastBubbleAt = useRef(0);
|
||||
const mutedRef = useRef(muted);
|
||||
mutedRef.current = muted;
|
||||
|
||||
// Keep mute in sync with the Settings page / other tabs.
|
||||
useEffect(() => tabbyPrefs.subscribe(() => setMuted(tabbyPrefs.getMuted())), []);
|
||||
|
||||
const showBubble = useCallback((text: string, force: boolean) => {
|
||||
if (!text) return;
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubble(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), BUBBLE_MS);
|
||||
}, []);
|
||||
|
||||
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||
// the brain otherwise only learns about sessions from WS deltas that arrive
|
||||
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
||||
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
const t = Date.now();
|
||||
setState((prev) => {
|
||||
const { state: next, pulse } = reduceTabby(prev, msg, t);
|
||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
||||
return next;
|
||||
});
|
||||
});
|
||||
const unsubConn = eventBus.onConnection((connected) => {
|
||||
setState((prev) => ({ ...prev, connected }));
|
||||
});
|
||||
return () => {
|
||||
unsubMsg();
|
||||
unsubConn();
|
||||
};
|
||||
}, [showBubble]);
|
||||
|
||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
|
||||
|
||||
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
||||
const status = useMemo(() => statusOf(state), [state]);
|
||||
|
||||
const dismissBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubble(null);
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const next = !mutedRef.current;
|
||||
tabbyPrefs.setMuted(next);
|
||||
setMuted(next);
|
||||
if (next) dismissBubble();
|
||||
}, [dismissBubble]);
|
||||
|
||||
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
|
||||
|
||||
const setThinking = useCallback(
|
||||
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[]
|
||||
);
|
||||
|
||||
return { mood, status, bubble, dismissBubble, muted, toggleMute, clearAlerts, setThinking };
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @file useTabbyPosition.ts
|
||||
* @description AssistiveTouch-style draggable docking for the Tabby avatar. The
|
||||
* avatar follows the pointer 1:1 while dragging (via Pointer Capture, so it
|
||||
* keeps tracking even if the cursor outruns it), and on release snaps to the
|
||||
* nearest left/right edge, remembering its vertical offset (persisted as a
|
||||
* viewport fraction so it survives resizes). A small movement threshold tells
|
||||
* a drag apart from a tap so dragging never opens the panel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./prefs`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `TABBY_SIZE` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TABBY_MARGIN` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TabbyPlacement` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `useTabbyPosition` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **TABBY_SIZE**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TABBY_MARGIN**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TabbyPlacement**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **useTabbyPosition**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { tabbyPrefs, type TabbyPos } from "./prefs";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
// Avatar footprint + edge gap, in px. SIZE matches CatAvatar's default size.
|
||||
export const TABBY_SIZE = 60;
|
||||
export const TABBY_MARGIN = 16;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||
|
||||
function defaultPos(): TabbyPos {
|
||||
return { side: "right", y: 0.5 }; // right edge, vertically centered
|
||||
}
|
||||
|
||||
/** Resting top-left screen coords for a docked position. */
|
||||
function restingScreen(pos: TabbyPos) {
|
||||
const avail = Math.max(0, vh() - TABBY_SIZE - 2 * TABBY_MARGIN);
|
||||
const left = pos.side === "left" ? TABBY_MARGIN : vw() - TABBY_SIZE - TABBY_MARGIN;
|
||||
const top = TABBY_MARGIN + pos.y * avail;
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
export interface TabbyPlacement {
|
||||
/** Avatar top-left, in screen px. */
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
side: "left" | "right";
|
||||
/** True when the avatar sits in the lower half - flyouts open upward. */
|
||||
openUp: boolean;
|
||||
dragging: boolean;
|
||||
onPointerDown: (e: ReactPointerEvent) => void;
|
||||
onPointerMove: (e: ReactPointerEvent) => void;
|
||||
onPointerUp: (e: ReactPointerEvent) => void;
|
||||
/** Returns true (once) if a drag just ended, so the click handler can skip. */
|
||||
consumeDrag: () => boolean;
|
||||
}
|
||||
|
||||
export function useTabbyPosition(): TabbyPlacement {
|
||||
const [pos, setPos] = useState<TabbyPos>(() => tabbyPrefs.getPos() ?? defaultPos());
|
||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||
const [, force] = useState(0); // re-derive resting coords on resize
|
||||
|
||||
const draggedRef = useRef(false);
|
||||
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
||||
const movedRef = useRef(false);
|
||||
// Latest dragged coords, mirrored in a ref so pointerup can read them
|
||||
// synchronously - the setDrag state may not have committed yet under React's
|
||||
// event batching, so we never rely on its functional-updater `cur`.
|
||||
const liveRef = useRef<{ left: number; top: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => force((n) => n + 1);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const resting = restingScreen(pos);
|
||||
const screen = drag ?? resting;
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
// Capture so the avatar keeps receiving move/up events even when the
|
||||
// pointer leaves it - essential for a fast, 1:1 drag.
|
||||
try {
|
||||
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* capture unsupported - window-free fallback still works via props */
|
||||
}
|
||||
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
||||
movedRef.current = false;
|
||||
},
|
||||
[screen.left, screen.top]
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((e: ReactPointerEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.px;
|
||||
const dy = e.clientY - start.py;
|
||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
movedRef.current = true;
|
||||
const left = Math.min(
|
||||
vw() - TABBY_SIZE - TABBY_MARGIN,
|
||||
Math.max(TABBY_MARGIN, start.left + dx)
|
||||
);
|
||||
const top = Math.min(vh() - TABBY_SIZE - TABBY_MARGIN, Math.max(TABBY_MARGIN, start.top + dy));
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback((e: ReactPointerEvent) => {
|
||||
try {
|
||||
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const live = liveRef.current;
|
||||
if (live) {
|
||||
draggedRef.current = true;
|
||||
const side: "left" | "right" = live.left + TABBY_SIZE / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - TABBY_SIZE - 2 * TABBY_MARGIN);
|
||||
const y = Math.min(1, Math.max(0, (live.top - TABBY_MARGIN) / avail));
|
||||
const next: TabbyPos = { side, y };
|
||||
tabbyPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
const was = draggedRef.current;
|
||||
draggedRef.current = false;
|
||||
return was;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
left: screen.left,
|
||||
top: screen.top,
|
||||
size: TABBY_SIZE,
|
||||
side: pos.side,
|
||||
openUp: screen.top + TABBY_SIZE / 2 > vh() / 2,
|
||||
dragging: drag !== null,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
consumeDrag,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file Tip.tsx
|
||||
* @description Cursor-following tooltip for revealing extra detail on hover — used
|
||||
* by {@link StatCard} for raw metric values and anywhere a compact display needs
|
||||
* a full-precision expansion without cluttering the layout.
|
||||
*
|
||||
* ## Portal rendering
|
||||
* Tooltip content is portaled to `document.body` with `position: fixed` so
|
||||
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
|
||||
* cursor is near viewport edges.
|
||||
*
|
||||
* ## No-op mode
|
||||
* When `raw` is omitted the component returns `children` unchanged — callers
|
||||
* do not need conditional wrappers.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Tip` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Tip**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** Props for {@link Tip}. */
|
||||
interface TipProps {
|
||||
/** Tooltip body; when absent, only `children` are rendered. */
|
||||
raw?: string;
|
||||
/** Element that triggers the tooltip on hover. */
|
||||
children: React.ReactNode;
|
||||
/** Max tooltip width in pixels. Default `320`. */
|
||||
maxWidth?: number;
|
||||
/** Use a block-level wrapper instead of inline `span` for full-width targets. */
|
||||
block?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover tooltip anchored to the mouse cursor.
|
||||
* @param props See {@link TipProps}.
|
||||
*/
|
||||
export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [pos, setPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updatePos = useCallback((e: React.MouseEvent) => {
|
||||
setPos({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
if (!raw) return <>{children}</>;
|
||||
|
||||
const Wrapper = block ? "div" : "span";
|
||||
const wrapperClass = block ? "cursor-default" : "relative inline-block cursor-default";
|
||||
|
||||
// Compute tooltip placement avoiding screen edges
|
||||
let tipStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
zIndex: 99999,
|
||||
maxWidth,
|
||||
visibility: "hidden",
|
||||
};
|
||||
if (show) {
|
||||
const tipW = tipRef.current?.offsetWidth ?? 200;
|
||||
const tipH = tipRef.current?.offsetHeight ?? 32;
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = window.innerHeight;
|
||||
const pad = 12;
|
||||
|
||||
// Default: below-right of cursor
|
||||
let left = pos.x + pad;
|
||||
let top = pos.y + pad;
|
||||
|
||||
// If goes off right edge, flip to left of cursor
|
||||
if (left + tipW > vw - pad) {
|
||||
left = pos.x - tipW - pad;
|
||||
}
|
||||
// If goes off left edge, clamp
|
||||
if (left < pad) left = pad;
|
||||
|
||||
// If goes off bottom, show above cursor
|
||||
if (top + tipH > vh - pad) {
|
||||
top = pos.y - tipH - pad;
|
||||
}
|
||||
// If goes off top, clamp
|
||||
if (top < pad) top = pad;
|
||||
|
||||
tipStyle = { position: "fixed", left, top, zIndex: 99999, maxWidth, visibility: "visible" };
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
className={wrapperClass}
|
||||
onMouseEnter={(e: React.MouseEvent) => {
|
||||
setShow(true);
|
||||
updatePos(e);
|
||||
}}
|
||||
onMouseMove={updatePos}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
>
|
||||
{children}
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
style={tipStyle}
|
||||
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-gray-100 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
|
||||
>
|
||||
{raw}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* @file UpdateNotifier.tsx
|
||||
* @description Modal surfaced when the dashboard's git checkout is behind its
|
||||
* remote tracking branch. Shows how many commits behind, the exact terminal
|
||||
* command to update, and copy-to-clipboard — the dashboard never pulls or
|
||||
* restarts itself.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||
*
|
||||
* ## Dismissal persistence
|
||||
* Dismissals are keyed by `remote_sha` in `localStorage` so a new upstream
|
||||
* commit re-opens the prompt. Settings can reset dismissal via the
|
||||
* `dashboard:reset-update-dismissal` window event.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/api`
|
||||
* - `../lib/eventBus`
|
||||
* - `../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `UpdateNotifier` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **UpdateNotifier**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X, Copy, Check, RefreshCw } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
/** `localStorage` key storing the dismissed upstream SHA. */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
|
||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||
}
|
||||
|
||||
/** Read the last dismissed upstream SHA from `localStorage`, or null. */
|
||||
function loadDismissedSha(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git update availability modal — mounted once in {@link Layout}.
|
||||
* @returns `null` when no update is available or the current SHA was dismissed.
|
||||
*/
|
||||
export function UpdateNotifier() {
|
||||
const { t } = useTranslation("updates");
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
setStatus(s);
|
||||
if (!s.fetch_error) setError(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
if (cancelled) return;
|
||||
syncFromPayload(s);
|
||||
eventBus.publish({
|
||||
type: "update_status",
|
||||
data: s,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [syncFromPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type !== "update_status") return;
|
||||
if (isUpdatePayload(msg.data)) syncFromPayload(msg.data);
|
||||
});
|
||||
}, [syncFromPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setDismissedSha(null);
|
||||
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
||||
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
||||
}, []);
|
||||
|
||||
const show = Boolean(
|
||||
status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (!status?.remote_sha) return;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissedSha(status.remote_sha);
|
||||
}, [status?.remote_sha]);
|
||||
|
||||
// Escape to dismiss - standard modal affordance.
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [show, dismiss]);
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!status?.manual_command) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(status.manual_command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const checkNow = async () => {
|
||||
if (checking) return;
|
||||
setError(null);
|
||||
setChecking(true);
|
||||
try {
|
||||
const fresh = await api.updates.check();
|
||||
syncFromPayload(fresh);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("checkError"));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!show || !status) return null;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
const behind = status.commits_behind ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-notifier-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-accent-muted border border-accent/30 flex items-center justify-center flex-shrink-0">
|
||||
<Download className="w-4 h-4 text-accent" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
id="update-notifier-title"
|
||||
className="text-sm font-semibold text-gray-100 truncate"
|
||||
>
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5 font-mono truncate">
|
||||
{t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label={t("dismiss")}
|
||||
className="p-1.5 -m-1 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-gray-300 leading-relaxed">{t("lead")}</p>
|
||||
|
||||
{status.fetch_error ? (
|
||||
<div className="text-xs text-amber-300/90 bg-amber-500/5 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||
{t("fetchError")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!status.git_repo ? (
|
||||
<div className="text-xs text-gray-400 bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||
{t("notGit")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.situation_note ? (
|
||||
<div className="text-xs text-amber-200/90 bg-amber-500/5 border border-amber-500/20 rounded-lg px-3 py-2 leading-relaxed">
|
||||
{status.situation_note}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.manual_command ? (
|
||||
<pre
|
||||
className="bg-surface-1 border border-border rounded-lg px-3 py-2.5 text-[11px] font-mono text-gray-200 whitespace-pre-wrap break-all leading-relaxed"
|
||||
aria-label={t("commandLabel")}
|
||||
>
|
||||
{status.manual_command}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
{/* The restart hint only applies when the printed command actually
|
||||
* rewrites the working tree. Feature-branch / detached-HEAD commands
|
||||
* are fetch-only - restarting the dashboard would change nothing. */}
|
||||
{status.situation === "tracking_canonical" ||
|
||||
status.situation === "fork_or_diverged_tracking" ? (
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">{t("restartNote")}</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkNow}
|
||||
disabled={checking}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
||||
{checking ? t("checking") : t("checkNow")}
|
||||
</button>
|
||||
<button type="button" onClick={dismiss} className="btn-ghost">
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
{status.manual_command ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCmd}
|
||||
disabled={copied}
|
||||
className="btn-primary disabled:opacity-70"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
/**
|
||||
* @file WebhookSettings.tsx
|
||||
* @description Settings-page panel for universal webhook notifications across 14
|
||||
* first-class providers (Slack, Discord, Teams, Google Chat, Mattermost,
|
||||
* Rocket.Chat, Telegram, PagerDuty, Opsgenie, Splunk On-Call, Zapier, Make, n8n,
|
||||
* Pipedream) plus a generic endpoint. The form is driven by provider metadata
|
||||
* fetched from the server (`/api/webhooks/providers`): each provider declares
|
||||
* whether it needs a URL and which credential fields to render, so adding a
|
||||
* provider server-side surfaces here with no UI change. Secrets are never
|
||||
* returned by the API - URLs are masked and re-entered to change.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/api`
|
||||
* - `./Select`
|
||||
* - `./ConfirmModal`
|
||||
* - `./Checkbox`
|
||||
* - `./webhookGuides`
|
||||
* - `../lib/format`
|
||||
* - `../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `WebhookSettings` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **WebhookSettings**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Webhook,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
Pencil,
|
||||
Zap,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { Select } from "./Select";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { WEBHOOK_DOCS } from "./webhookGuides";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import type {
|
||||
AlertRule,
|
||||
WebhookProvider,
|
||||
WebhookTarget,
|
||||
WebhookType,
|
||||
WebhookTestResult,
|
||||
} from "../lib/types";
|
||||
|
||||
// Brand-ish accent per provider type; anything unmapped falls back to neutral.
|
||||
const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
|
||||
slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20",
|
||||
discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20",
|
||||
teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20",
|
||||
google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20",
|
||||
mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20",
|
||||
rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20",
|
||||
telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20",
|
||||
pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20",
|
||||
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
|
||||
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
|
||||
};
|
||||
const NEUTRAL_STYLE = "text-gray-300 bg-surface-2 border-border";
|
||||
|
||||
interface HeaderRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
id: string | null;
|
||||
name: string;
|
||||
type: WebhookType;
|
||||
url: string;
|
||||
secret: string;
|
||||
headerRows: HeaderRow[];
|
||||
replaceHeaders: boolean;
|
||||
config: Record<string, string>;
|
||||
scopeAll: boolean;
|
||||
ruleIds: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function defaultsFor(provider: WebhookProvider | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!provider) return out;
|
||||
for (const f of provider.fields) if (f.default != null) out[f.key] = f.default;
|
||||
return out;
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
|
||||
checked ? "bg-blue-500" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform"
|
||||
style={{ transform: checked ? "translateX(18px)" : "translateX(3px)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [targets, setTargets] = useState<WebhookTarget[]>([]);
|
||||
const [providers, setProviders] = useState<WebhookProvider[]>([]);
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<Record<string, WebhookTestResult>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
|
||||
const providerOf = useCallback(
|
||||
(type: WebhookType) => providers.find((p) => p.type === type),
|
||||
[providers]
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.webhooks.list();
|
||||
setTargets(res.targets);
|
||||
} catch (err) {
|
||||
console.error("Failed to load webhook targets:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
api.webhooks
|
||||
.providers()
|
||||
.then((res) => setProviders(res.providers))
|
||||
.catch(() => setProviders([]));
|
||||
api.alerts.rules
|
||||
.list()
|
||||
.then((res) => setRules(res.rules))
|
||||
.catch(() => setRules([]));
|
||||
}, []);
|
||||
|
||||
const set = (patch: Partial<FormState>) =>
|
||||
setForm((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
|
||||
const openCreate = () => {
|
||||
const first = providers[0];
|
||||
setForm({
|
||||
id: null,
|
||||
name: "",
|
||||
type: (first?.type as WebhookType) || "slack",
|
||||
url: "",
|
||||
secret: "",
|
||||
headerRows: [],
|
||||
replaceHeaders: true,
|
||||
config: defaultsFor(first),
|
||||
scopeAll: true,
|
||||
ruleIds: [],
|
||||
enabled: true,
|
||||
});
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (target: WebhookTarget) => {
|
||||
const provider = providerOf(target.type);
|
||||
// Prefill non-secret config (region, chat_id, severity, …); leave secret
|
||||
// fields blank - they're redacted and re-entered only to change.
|
||||
const config: Record<string, string> = {};
|
||||
for (const f of provider?.fields || []) {
|
||||
if (f.secret) continue;
|
||||
const v = target.config?.[f.key];
|
||||
config[f.key] = v != null ? String(v) : (f.default ?? "");
|
||||
}
|
||||
setForm({
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
url: "",
|
||||
secret: "",
|
||||
headerRows: [],
|
||||
replaceHeaders: false,
|
||||
config,
|
||||
scopeAll: !target.rule_ids || target.rule_ids.length === 0,
|
||||
ruleIds: target.rule_ids || [],
|
||||
enabled: target.enabled,
|
||||
});
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false);
|
||||
setForm(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const provider = form ? providerOf(form.type) : undefined;
|
||||
const isEdit = !!form?.id;
|
||||
const showUrl = !!provider && (provider.url_required || provider.has_default_url);
|
||||
const urlOptional = !!provider && !provider.url_required;
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!form || !provider) return false;
|
||||
if (!form.name.trim()) return false;
|
||||
if (isEdit) return true; // server merge - existing values fill the gaps
|
||||
if (provider.url_required && !form.url.trim()) return false;
|
||||
for (const f of provider.fields) {
|
||||
if (f.required && f.type !== "enum" && !(form.config[f.key] || "").trim()) return false;
|
||||
}
|
||||
return true;
|
||||
}, [form, provider, isEdit]);
|
||||
|
||||
const buildConfigObj = (): Record<string, string> | undefined => {
|
||||
if (!form || !provider || provider.fields.length === 0) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const f of provider.fields) {
|
||||
const v = (form.config[f.key] ?? "").toString();
|
||||
if (f.type === "enum") {
|
||||
if (v) out[f.key] = v;
|
||||
} else if (v.trim()) {
|
||||
out[f.key] = v.trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const buildHeaders = (): Record<string, string> => {
|
||||
if (!form) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const r of form.headerRows) if (r.key.trim()) out[r.key.trim()] = r.value;
|
||||
return out;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form || !provider || saving || !canSubmit) return;
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const ruleIds = form.scopeAll ? [] : form.ruleIds;
|
||||
const config = buildConfigObj();
|
||||
const genericFamily = provider.supports_secret || provider.supports_headers;
|
||||
if (isEdit && form.id) {
|
||||
const patch: Parameters<typeof api.webhooks.update>[1] = {
|
||||
name: form.name.trim(),
|
||||
enabled: form.enabled,
|
||||
rule_ids: ruleIds,
|
||||
};
|
||||
if (form.url.trim()) patch.url = form.url.trim();
|
||||
if (config) patch.config = config;
|
||||
if (genericFamily && form.secret.trim()) patch.secret = form.secret.trim();
|
||||
if (provider.supports_headers && form.replaceHeaders) patch.headers = buildHeaders();
|
||||
await api.webhooks.update(form.id, patch);
|
||||
} else {
|
||||
await api.webhooks.create({
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
url: form.url.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
secret: genericFamily && form.secret.trim() ? form.secret.trim() : undefined,
|
||||
headers: provider.supports_headers ? buildHeaders() : undefined,
|
||||
config,
|
||||
rule_ids: ruleIds.length ? ruleIds : undefined,
|
||||
});
|
||||
}
|
||||
closeForm();
|
||||
load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onToggle = async (target: WebhookTarget) => {
|
||||
try {
|
||||
await api.webhooks.update(target.id, { enabled: !target.enabled });
|
||||
load();
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle webhook:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
try {
|
||||
await api.webhooks.remove(id);
|
||||
setConfirmDelete(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete webhook:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
setTesting(id);
|
||||
setTestResult((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.webhooks.test(id);
|
||||
setTestResult((prev) => ({ ...prev, [id]: result }));
|
||||
} catch (err) {
|
||||
setTestResult((prev) => ({
|
||||
...prev,
|
||||
[id]: { ok: false, status: null, attempts: 0, error: String(err) },
|
||||
}));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const labelOf = (type: WebhookType) => providerOf(type)?.label || type;
|
||||
|
||||
return (
|
||||
<div className="card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Webhook className="w-3.5 h-3.5" />
|
||||
{t("webhooks.count", { count: targets.length })}
|
||||
</div>
|
||||
{!formOpen && (
|
||||
<button
|
||||
onClick={openCreate}
|
||||
disabled={providers.length === 0}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t("webhooks.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target list */}
|
||||
{loading ? (
|
||||
<p className="text-xs text-gray-500">{t("webhooks.loading")}</p>
|
||||
) : targets.length === 0 && !formOpen ? (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 py-2">
|
||||
<Webhook className="w-3.5 h-3.5" />
|
||||
{t("webhooks.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{targets.map((target) => {
|
||||
const result = testResult[target.id];
|
||||
return (
|
||||
<div
|
||||
key={target.id}
|
||||
className="bg-surface-2 border border-border rounded-lg px-3.5 py-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border ${
|
||||
TYPE_STYLES[target.type] || NEUTRAL_STYLE
|
||||
}`}
|
||||
>
|
||||
{labelOf(target.type)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-200 font-medium">{target.name}</span>
|
||||
<code className="text-[11px] text-gray-500 font-mono truncate max-w-[220px]">
|
||||
{target.url_preview}
|
||||
</code>
|
||||
{target.rule_ids && target.rule_ids.length > 0 && (
|
||||
<span className="text-[10px] text-amber-400/80">
|
||||
{t("webhooks.scopedTo", { count: target.rule_ids.length })}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{target.last_delivery && (
|
||||
<span
|
||||
title={target.last_delivery.error || undefined}
|
||||
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||
target.last_delivery.status === "success"
|
||||
? "text-emerald-400 bg-emerald-500/10"
|
||||
: "text-red-400 bg-red-500/10"
|
||||
}`}
|
||||
>
|
||||
{target.last_delivery.status === "success" ? (
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3" />
|
||||
)}
|
||||
{timeAgo(target.last_delivery.created_at)}
|
||||
</span>
|
||||
)}
|
||||
<Toggle
|
||||
checked={target.enabled}
|
||||
onChange={() => onToggle(target)}
|
||||
label={t("webhooks.enabled")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => onTest(target.id)}
|
||||
disabled={testing === target.id}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing === target.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-3 h-3" />
|
||||
)}
|
||||
{t("webhooks.test")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(target)}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
{t("webhooks.edit")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(target.id)}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 border border-border transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t("webhooks.delete")}
|
||||
</button>
|
||||
{result && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[11px] ${
|
||||
result.ok ? "text-emerald-400" : "text-red-400"
|
||||
}`}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
{result.ok
|
||||
? t("webhooks.testOk", { status: result.status ?? 200 })
|
||||
: t("webhooks.testFail", {
|
||||
error: result.error || `HTTP ${result.status ?? "?"}`,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / edit form */}
|
||||
{formOpen && form && provider && (
|
||||
<div className="border border-border rounded-lg p-4 space-y-3 bg-surface-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
|
||||
{isEdit ? t("webhooks.editTitle") : t("webhooks.addTitle")}
|
||||
</h4>
|
||||
<button onClick={closeForm} className="text-gray-500 hover:text-gray-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-gray-500">{t("webhooks.fieldName")}</span>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
placeholder={t("webhooks.fieldNamePlaceholder")}
|
||||
className="input w-full mt-1 py-1.5 text-[11px] leading-normal"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-gray-500">{t("webhooks.fieldType")}</span>
|
||||
<div className="mt-1">
|
||||
<Select<WebhookType>
|
||||
value={form.type}
|
||||
disabled={isEdit}
|
||||
onChange={(type) =>
|
||||
set({
|
||||
type,
|
||||
config: defaultsFor(providerOf(type)),
|
||||
url: "",
|
||||
secret: "",
|
||||
headerRows: [],
|
||||
})
|
||||
}
|
||||
options={providers.map((p) => ({ value: p.type, label: p.label }))}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* URL (hidden for providers that derive their own URL) */}
|
||||
{showUrl && (
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-gray-500">
|
||||
{t("webhooks.fieldUrl")}
|
||||
{isEdit ? (
|
||||
<span className="text-gray-600"> - {t("webhooks.urlKeepHint")}</span>
|
||||
) : urlOptional ? (
|
||||
<span className="text-gray-600"> - {t("webhooks.urlOptional")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<input
|
||||
value={form.url}
|
||||
onChange={(e) => set({ url: e.target.value })}
|
||||
placeholder={
|
||||
isEdit ? t("webhooks.urlKeepPlaceholder") : provider.url_hint || "https://…"
|
||||
}
|
||||
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{!showUrl && (
|
||||
<p className="text-[11px] text-gray-600 flex items-center gap-1.5">
|
||||
<Webhook className="w-3 h-3" />
|
||||
{t("webhooks.urlAuto")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Collapsible per-provider setup guide */}
|
||||
<div className="rounded-lg border border-border bg-surface-2/40 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGuideOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-[11px] text-gray-300 hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<BookOpen className="w-3.5 h-3.5 text-gray-500" />
|
||||
{t("webhooks.guideToggle", { provider: provider.label })}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-gray-500 transition-transform ${guideOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{guideOpen && (
|
||||
<div className="px-3 pb-3 pt-2 space-y-2.5 border-t border-border">
|
||||
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed text-gray-400 marker:text-gray-600">
|
||||
{(t(`webhookGuides.${form.type}.steps`, { returnObjects: true }) as string[]).map(
|
||||
(s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
)
|
||||
)}
|
||||
</ol>
|
||||
{WEBHOOK_DOCS[form.type] && (
|
||||
<a
|
||||
href={WEBHOOK_DOCS[form.type]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{t("webhooks.guideDocs", { provider: provider.label })}
|
||||
</a>
|
||||
)}
|
||||
<p className="flex items-start gap-1.5 text-[10px] text-gray-500 leading-relaxed pt-2 border-t border-border">
|
||||
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
{t("webhooks.guideStaleNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider-specific config fields */}
|
||||
{provider.fields.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
|
||||
{provider.fields.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="text-[11px] text-gray-500">
|
||||
{f.label}
|
||||
{f.required && <span className="text-red-400"> *</span>}
|
||||
</span>
|
||||
{f.type === "enum" && f.options ? (
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={String(form.config[f.key] ?? f.default ?? "")}
|
||||
onChange={(v) => set({ config: { ...form.config, [f.key]: v } })}
|
||||
options={f.options.map((o) => ({ value: o, label: o }))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={f.secret ? "password" : "text"}
|
||||
value={form.config[f.key] ?? ""}
|
||||
onChange={(e) => set({ config: { ...form.config, [f.key]: e.target.value } })}
|
||||
placeholder={f.secret && isEdit ? t("webhooks.secretKeepPlaceholder") : ""}
|
||||
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generic family: HMAC secret + custom headers */}
|
||||
{provider.supports_secret && (
|
||||
<div className="space-y-3 pt-1 border-t border-border">
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-gray-500">{t("webhooks.fieldSecret")}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={form.secret}
|
||||
onChange={(e) => set({ secret: e.target.value })}
|
||||
placeholder={
|
||||
isEdit ? t("webhooks.secretKeepPlaceholder") : t("webhooks.secretPlaceholder")
|
||||
}
|
||||
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600">{t("webhooks.secretHint")}</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-gray-500">{t("webhooks.fieldHeaders")}</span>
|
||||
{isEdit && (
|
||||
<Checkbox
|
||||
checked={form.replaceHeaders}
|
||||
onChange={(v) => set({ replaceHeaders: v })}
|
||||
label={t("webhooks.replaceHeaders")}
|
||||
labelClassName="text-[10px] text-gray-500 group-hover:text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(!isEdit || form.replaceHeaders) && (
|
||||
<div className="space-y-1.5 mt-1.5">
|
||||
{form.headerRows.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={row.key}
|
||||
onChange={(e) =>
|
||||
set({
|
||||
headerRows: form.headerRows.map((r, j) =>
|
||||
j === i ? { ...r, key: e.target.value } : r
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder={t("webhooks.headerKey")}
|
||||
className="input flex-1 py-1.5 text-[11px] leading-normal font-mono"
|
||||
/>
|
||||
<input
|
||||
value={row.value}
|
||||
onChange={(e) =>
|
||||
set({
|
||||
headerRows: form.headerRows.map((r, j) =>
|
||||
j === i ? { ...r, value: e.target.value } : r
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder={t("webhooks.headerValue")}
|
||||
className="input flex-1 py-1.5 text-[11px] leading-normal font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
set({ headerRows: form.headerRows.filter((_, j) => j !== i) })
|
||||
}
|
||||
className="text-gray-600 hover:text-red-400 p-1"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
set({ headerRows: [...form.headerRows, { key: "", value: "" }] })
|
||||
}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("webhooks.addHeader")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional per-rule scoping */}
|
||||
{rules.length > 0 && (
|
||||
<div className="pt-1 border-t border-border">
|
||||
<Checkbox
|
||||
checked={form.scopeAll}
|
||||
onChange={(v) => set({ scopeAll: v })}
|
||||
label={t("webhooks.scopeAll")}
|
||||
labelClassName="text-[11px] text-gray-400 group-hover:text-gray-300"
|
||||
/>
|
||||
{!form.scopeAll && (
|
||||
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
|
||||
{rules.map((rule) => (
|
||||
<Checkbox
|
||||
key={rule.id}
|
||||
checked={form.ruleIds.includes(rule.id)}
|
||||
onChange={(checked) =>
|
||||
set({
|
||||
ruleIds: checked
|
||||
? [...form.ruleIds, rule.id]
|
||||
: form.ruleIds.filter((id) => id !== rule.id),
|
||||
})
|
||||
}
|
||||
label={rule.name}
|
||||
labelClassName="text-[11px] text-gray-400 group-hover:text-gray-300 truncate"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<label className="inline-flex items-center gap-2 text-[11px] text-gray-400 cursor-pointer">
|
||||
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
||||
{t("webhooks.enabledOnSave")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-red-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{isEdit ? t("webhooks.save") : t("webhooks.create")}
|
||||
</button>
|
||||
<button onClick={closeForm} className="btn-ghost border border-border text-xs">
|
||||
{t("webhooks.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
title={t("webhooks.deleteTitle")}
|
||||
message={t("webhooks.deleteMessage", {
|
||||
name: targets.find((x) => x.id === confirmDelete)?.name ?? "",
|
||||
})}
|
||||
confirmLabel={t("webhooks.delete")}
|
||||
cancelLabel={t("webhooks.cancel")}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
onConfirm={() => confirmDelete && onDelete(confirmDelete)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* @file AgentCard.test.tsx
|
||||
* @description Unit tests for the AgentCard component, which displays information about an agent in the application. The tests cover rendering of agent details such as name, status, subagent type, task, and current tool, as well as interaction handling like click events. The tests use React Testing Library and Vitest for assertions and mocking.
|
||||
* @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";
|
||||
// render is used inside renderCard helper
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
|
||||
function renderCard(element: JSX.Element) {
|
||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||
}
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
session_id: "sess-1",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
subagent_type: null,
|
||||
status: "working",
|
||||
task: null,
|
||||
current_tool: null,
|
||||
started_at: "2026-03-05T10:00:00.000Z",
|
||||
ended_at: null,
|
||||
updated_at: "2026-03-05T10:00:00.000Z",
|
||||
parent_agent_id: null,
|
||||
metadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentCard", () => {
|
||||
it("should render agent name", () => {
|
||||
renderCard(<AgentCard agent={makeAgent({ name: "Test Agent" })} />);
|
||||
expect(screen.getByText("Test Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render status badge", () => {
|
||||
renderCard(<AgentCard agent={makeAgent({ status: "working" })} />);
|
||||
expect(screen.getByText("Working")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render subagent_type when present", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({
|
||||
type: "subagent",
|
||||
subagent_type: "Explore",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Explore")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the subagent's own model from metadata, not the session model (issue #185)", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({
|
||||
type: "subagent",
|
||||
subagent_type: "qa",
|
||||
metadata: JSON.stringify({ model: "claude-haiku-4-5-20251001" }),
|
||||
})}
|
||||
// Session is Opus, but the subagent card must read Haiku from metadata.
|
||||
session={
|
||||
{
|
||||
id: "sess-1",
|
||||
name: "S",
|
||||
status: "active",
|
||||
cwd: "/x",
|
||||
model: "claude-opus-4-8",
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
);
|
||||
// Subtitle is the subagent type + project (cwd); the model badge shows the
|
||||
// subagent's OWN model.
|
||||
expect(screen.getByText("qa · x")).toBeInTheDocument();
|
||||
expect(screen.getByText(formatModelName("claude-haiku-4-5-20251001")!)).toBeInTheDocument();
|
||||
// The Opus session model must NOT appear on a subagent card.
|
||||
expect(screen.queryByText(formatModelName("claude-opus-4-8")!)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("main agent subtitle shows project + subagent count, with the model only once (#185)", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({ type: "main", name: "Main" })}
|
||||
session={
|
||||
{
|
||||
id: "s",
|
||||
name: "S",
|
||||
status: "active",
|
||||
cwd: "/Users/dev/proj",
|
||||
model: "claude-opus-4-8",
|
||||
agent_count: 4,
|
||||
metadata: JSON.stringify({ turn_count: 12 }),
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
);
|
||||
// Subtitle: project basename + SUBAGENT count + turn count (model excluded).
|
||||
// agent_count includes the main agent itself, so 4 agents => 3 subagents.
|
||||
// Showing subagents (not agents) reconciles the card with the "Active
|
||||
// Subagents" dashboard stat, which excludes main agents.
|
||||
expect(screen.getByText("proj · 3 subagents · 12 turns")).toBeInTheDocument();
|
||||
// The model appears exactly once — in the footer badge, not duplicated in
|
||||
// the subtitle the way main cards used to.
|
||||
expect(screen.getAllByText(formatModelName("claude-opus-4-8")!)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows a subagent's OWN cost, not the session total (avoids misleading spend)", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({ type: "subagent", subagent_type: "qa", cost: 3.5 })}
|
||||
session={
|
||||
{
|
||||
id: "s",
|
||||
name: "S",
|
||||
status: "active",
|
||||
cwd: "/x",
|
||||
model: "claude-opus-4-8",
|
||||
cost: 646.5,
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(fmtCost(3.5))).toBeInTheDocument();
|
||||
// The session total must NOT appear on a subagent card.
|
||||
expect(screen.queryByText(fmtCost(646.5))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the session total on a main-agent card", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({ type: "main" })}
|
||||
session={
|
||||
{
|
||||
id: "s",
|
||||
name: "S",
|
||||
status: "active",
|
||||
cwd: "/x",
|
||||
model: "claude-opus-4-8",
|
||||
cost: 646.5,
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(fmtCost(646.5))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no cost on a subagent card with no recorded usage", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({ type: "subagent", subagent_type: "qa" })}
|
||||
session={{ id: "s", name: "S", status: "active", cwd: "/x", cost: 646.5 } as never}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText(fmtCost(646.5))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps the real session title into the hook-style placeholder (Session <id8>)", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({ type: "main", name: "Main Agent - Session 329c4d24" })}
|
||||
session={{ id: "s", name: "Resumable runs UI", status: "active" } as never}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Main Agent - Resumable runs UI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps the real session title into the import-style placeholder (<folder> - <id8>)", () => {
|
||||
// Regression: imported / background-synced main agents are named
|
||||
// "Main Agent - <cwd-folder> - <id8>", which the old Session-only regex
|
||||
// could not rewrite, so they kept showing "work - e3f8e613" forever even
|
||||
// though the session title was known.
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({ type: "main", name: "Main Agent - work - e3f8e613" })}
|
||||
session={
|
||||
{ id: "s", name: "Implement in-process libdocs MCP server", status: "active" } as never
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Main Agent - Implement in-process libdocs MCP server")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Main Agent - work - e3f8e613")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the placeholder when the session name is still auto-generated", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({ type: "main", name: "Main Agent - work - e3f8e613" })}
|
||||
session={{ id: "s", name: "Session e3f8e613", status: "active" } as never}
|
||||
/>
|
||||
);
|
||||
// "Session <id8>" is suppressed as a non-name, so nothing to swap in.
|
||||
expect(screen.getByText("Main Agent - work - e3f8e613")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render subagent_type when null", () => {
|
||||
const { container } = renderCard(<AgentCard agent={makeAgent({ subagent_type: null })} />);
|
||||
// Only the name should be in the name container, no subagent type
|
||||
expect(container.querySelectorAll(".text-\\[11px\\].text-gray-500.truncate")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should render task when present", () => {
|
||||
renderCard(<AgentCard agent={makeAgent({ task: "Searching for patterns" })} />);
|
||||
expect(screen.getByText("Searching for patterns")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render task when null", () => {
|
||||
renderCard(<AgentCard agent={makeAgent({ task: null })} />);
|
||||
expect(screen.queryByText("Searching for patterns")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render current_tool when present", () => {
|
||||
renderCard(<AgentCard agent={makeAgent({ current_tool: "Bash", status: "working" })} />);
|
||||
expect(screen.getByText("Bash")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render current_tool when null", () => {
|
||||
renderCard(<AgentCard agent={makeAgent({ current_tool: null })} />);
|
||||
expect(screen.queryByText("Bash")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply active border for working agents", () => {
|
||||
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "working" })} />);
|
||||
const card = container.querySelector(".card-hover");
|
||||
expect(card?.className).toContain("border-l-2");
|
||||
});
|
||||
|
||||
it("should apply yellow border for waiting agents even without awaiting_input_since", () => {
|
||||
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "waiting" })} />);
|
||||
const card = container.querySelector(".card-hover");
|
||||
expect(card?.className).toContain("border-l-2");
|
||||
expect(card?.className).toContain("border-l-yellow-500/60");
|
||||
});
|
||||
|
||||
it("should not apply active border for completed agents", () => {
|
||||
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "completed" })} />);
|
||||
const card = container.querySelector(".card-hover");
|
||||
expect(card?.className).not.toContain("border-l-2");
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", () => {
|
||||
const onClick = vi.fn();
|
||||
renderCard(<AgentCard agent={makeAgent()} onClick={onClick} />);
|
||||
fireEvent.click(screen.getByText("Main Agent"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders waiting badge and yellow accent when awaiting_input_since is set", () => {
|
||||
const { container } = renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({
|
||||
status: "waiting",
|
||||
awaiting_input_since: "2026-03-05T10:01:00.000Z",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
const card = container.querySelector(".card-hover");
|
||||
expect(card?.className).toContain("border-l-yellow-500/60");
|
||||
});
|
||||
|
||||
it("keeps the card badge compact: reason is tooltip-only, no inline chip", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({
|
||||
status: "waiting",
|
||||
awaiting_input_since: "2026-03-05T10:01:00.000Z",
|
||||
awaiting_reason: "notification",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
// Cards are narrow — the inline chip would squeeze the title, so the
|
||||
// reason must NOT render inline here (hover tooltip only).
|
||||
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("degrades to a plain Waiting badge on an unknown awaiting_reason", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({
|
||||
status: "waiting",
|
||||
awaiting_input_since: "2026-03-05T10:01:00.000Z",
|
||||
awaiting_reason: "some_future_reason",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores awaiting_input_since once the agent has completed", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({
|
||||
status: "completed",
|
||||
awaiting_input_since: "2026-03-05T10:01:00.000Z",
|
||||
ended_at: "2026-03-05T10:02:00.000Z",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Waiting")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show duration for completed agents with ended_at", () => {
|
||||
renderCard(
|
||||
<AgentCard
|
||||
agent={makeAgent({
|
||||
status: "completed",
|
||||
started_at: "2026-03-05T10:00:00.000Z",
|
||||
ended_at: "2026-03-05T10:05:30.000Z",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/ran 5m 30s/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file EmptyState.test.tsx
|
||||
* @description Unit tests for the EmptyState component, which is a reusable React component that displays an empty state with an icon, title, description, and an optional action. The tests cover rendering of the title, description, icon, and action button when provided. The tests use React Testing Library and Vitest for assertions and mocking.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("should render title", () => {
|
||||
render(
|
||||
<EmptyState icon={Bot} title="No agents" description="Start a session to see agents." />
|
||||
);
|
||||
expect(screen.getByText("No agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render description", () => {
|
||||
render(
|
||||
<EmptyState icon={Bot} title="No agents" description="Start a session to see agents." />
|
||||
);
|
||||
expect(screen.getByText("Start a session to see agents.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the icon", () => {
|
||||
const { container } = render(<EmptyState icon={Bot} title="Title" description="Desc" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render action when provided", () => {
|
||||
render(
|
||||
<EmptyState icon={Bot} title="Title" description="Desc" action={<button>Retry</button>} />
|
||||
);
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render action when not provided", () => {
|
||||
render(<EmptyState icon={Bot} title="Title" description="Desc" />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @file EventDetail.test.tsx
|
||||
* @description Unit tests for the EventDetail component. Verifies the uniform
|
||||
* label/value row rendering: event-level fields appear first, payload keys
|
||||
* follow, scalars render inline, objects/arrays/multiline strings render in a
|
||||
* terminal-styled code view, and JSON parse failures fall back to showing the
|
||||
* raw data as a single row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { EventDetail } from "../EventDetail";
|
||||
import type { DashboardEvent } from "../../lib/types";
|
||||
|
||||
const baseEvent: DashboardEvent = {
|
||||
id: 42,
|
||||
session_id: "sess-123",
|
||||
agent_id: "agent-abc",
|
||||
event_type: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
summary: "Using tool: Bash",
|
||||
data: JSON.stringify({
|
||||
cwd: "/tmp",
|
||||
permission_mode: "bypassPermissions",
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls -la", description: "list files" },
|
||||
stop_hook_active: false,
|
||||
}),
|
||||
created_at: "2026-04-22T10:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("EventDetail", () => {
|
||||
it("renders event-level fields first: event_id, session_id, agent_id", () => {
|
||||
render(<EventDetail event={baseEvent} />);
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
expect(screen.getByText("sess-123")).toBeInTheDocument();
|
||||
expect(screen.getByText("agent-abc")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders scalar payload fields with humanized i18n labels", () => {
|
||||
render(<EventDetail event={baseEvent} />);
|
||||
// Translated labels from common:eventDetail.* - never raw snake_case keys.
|
||||
expect(screen.getByText("Working directory")).toBeInTheDocument();
|
||||
expect(screen.getByText("/tmp")).toBeInTheDocument();
|
||||
expect(screen.getByText("Permission mode")).toBeInTheDocument();
|
||||
expect(screen.getByText("bypassPermissions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hook Event Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders boolean values as pills", () => {
|
||||
render(<EventDetail event={baseEvent} />);
|
||||
expect(screen.getByText("false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Bash tool_input as a terminal block (command + description)", () => {
|
||||
render(<EventDetail event={baseEvent} />);
|
||||
expect(screen.getByText("Tool Input")).toBeInTheDocument();
|
||||
// Terminal renderer shows the raw command and the `# description` line,
|
||||
// not the pretty-printed JSON. Description appears both in the Summary
|
||||
// block and in the terminal - `getAllByText` allows both.
|
||||
expect(screen.getByText("ls -la")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/list files/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders multiline strings in a text code view", () => {
|
||||
const event = {
|
||||
...baseEvent,
|
||||
data: JSON.stringify({ last_assistant_message: "line 1\nline 2\nline 3" }),
|
||||
};
|
||||
render(<EventDetail event={event} />);
|
||||
expect(screen.getByText("Last Assistant Message")).toBeInTheDocument();
|
||||
expect(screen.getByText(/line 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not duplicate session_id or agent_id from payload", () => {
|
||||
const event = {
|
||||
...baseEvent,
|
||||
data: JSON.stringify({ session_id: "sess-123", agent_id: "agent-abc", cwd: "/tmp" }),
|
||||
};
|
||||
render(<EventDetail event={event} />);
|
||||
// "sess-123" should appear exactly once (from event-level row).
|
||||
expect(screen.getAllByText("sess-123")).toHaveLength(1);
|
||||
expect(screen.getAllByText("agent-abc")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("humanizes unknown payload keys instead of showing raw snake_case", () => {
|
||||
const event = {
|
||||
...baseEvent,
|
||||
// A key that's NOT in PAYLOAD_LABEL_KEYS should still get a tidy
|
||||
// Title-Cased label rather than appearing as `some_unknown_field`.
|
||||
data: JSON.stringify({ some_unknown_field: "hello" }),
|
||||
};
|
||||
render(<EventDetail event={event} />);
|
||||
expect(screen.getByText("Some Unknown Field")).toBeInTheDocument();
|
||||
expect(screen.queryByText("some_unknown_field")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("translates known payload keys (tool_use_id → Tool Use ID)", () => {
|
||||
const event = {
|
||||
...baseEvent,
|
||||
data: JSON.stringify({ tool_use_id: "toolu_01ABC", tool_name: "Bash" }),
|
||||
};
|
||||
render(<EventDetail event={event} />);
|
||||
expect(screen.getByText("Tool Use ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tool")).toBeInTheDocument();
|
||||
expect(screen.queryByText("tool_use_id")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("tool_name")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to a raw-payload row when JSON parsing fails", () => {
|
||||
const event = { ...baseEvent, data: "not-json-at-all" };
|
||||
render(<EventDetail event={event} />);
|
||||
expect(screen.getByText(/raw payload/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/not-json-at-all/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles null `data` gracefully without crashing", () => {
|
||||
const event = { ...baseEvent, data: null };
|
||||
render(<EventDetail event={event} />);
|
||||
// Still renders event-level rows.
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
expect(screen.getByText("sess-123")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file EventFilters.test.tsx
|
||||
* @description Smoke tests for the EventFilters toolbar. Verifies that the
|
||||
* toolbar renders its inputs, emits debounced text search changes, toggles
|
||||
* selected chips, fires the clear-all handler, and fetches facet options on
|
||||
* mount via the events API (mocked).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { EventFilters, EMPTY_FILTERS, isEmptyFilters } from "../EventFilters";
|
||||
import type { EventFiltersValue } from "../EventFilters";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
describe("EventFilters", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api.events, "facets").mockResolvedValue({
|
||||
event_types: ["PreToolUse", "PostToolUse", "Stop"],
|
||||
tool_names: ["Bash", "Edit"],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("isEmptyFilters treats EMPTY_FILTERS as empty", () => {
|
||||
expect(isEmptyFilters(EMPTY_FILTERS)).toBe(true);
|
||||
expect(isEmptyFilters({ ...EMPTY_FILTERS, q: "curl" })).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the search input with a translated placeholder", () => {
|
||||
render(<EventFilters value={EMPTY_FILTERS} onChange={() => {}} />);
|
||||
expect(screen.getByPlaceholderText(/search summary/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches facets on mount and opens the event-type dropdown", async () => {
|
||||
render(<EventFilters value={EMPTY_FILTERS} onChange={() => {}} />);
|
||||
await waitFor(() => expect(api.events.facets).toHaveBeenCalledTimes(1));
|
||||
fireEvent.click(screen.getByRole("button", { name: /event type/i }));
|
||||
expect(await screen.findByText("PreToolUse")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("debounces text search by 300ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onChange = vi.fn();
|
||||
render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search summary/i), {
|
||||
target: { value: "curl" },
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ q: "curl" }));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles an event_type chip and emits the updated array", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
|
||||
await waitFor(() => expect(api.events.facets).toHaveBeenCalled());
|
||||
fireEvent.click(screen.getByRole("button", { name: /event type/i }));
|
||||
const option = await screen.findByText("PreToolUse");
|
||||
fireEvent.click(option);
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ event_type: ["PreToolUse"] }));
|
||||
});
|
||||
|
||||
it("shows the clear-all button only when filters are non-empty", () => {
|
||||
const withFilter: EventFiltersValue = { ...EMPTY_FILTERS, q: "curl" };
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
|
||||
expect(screen.queryByRole("button", { name: /clear filters/i })).not.toBeInTheDocument();
|
||||
|
||||
rerender(<EventFilters value={withFilter} onChange={onChange} />);
|
||||
const clear = screen.getByRole("button", { name: /clear filters/i });
|
||||
fireEvent.click(clear);
|
||||
expect(onChange).toHaveBeenCalledWith(EMPTY_FILTERS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Sidebar.test.tsx
|
||||
* @description Unit tests for the Sidebar component, which is responsible for rendering the application's sidebar navigation. The tests cover rendering of the brand name, subtitle, navigation links, WebSocket connection status, and version number. The tests use React Testing Library and Vitest for assertions and mocking.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
|
||||
function renderSidebar(wsConnected: boolean, collapsed = false) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={() => {}} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("should render the brand name", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Agent Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Claude Code Monitor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all navigation links", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kanban Board")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Activity Feed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Live" when WebSocket is connected', () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Disconnected" when WebSocket is not connected', () => {
|
||||
renderSidebar(false);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show version number", () => {
|
||||
// `__APP_VERSION__` is injected by Vite from the repo-root package.json
|
||||
// (see vite.config.ts) and replaced at transform time in tests too, so this
|
||||
// stays correct as the project version changes.
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have correct navigation hrefs", () => {
|
||||
renderSidebar(true);
|
||||
const links = screen.getAllByRole("link");
|
||||
const hrefs = links.map((link) => link.getAttribute("href"));
|
||||
expect(hrefs).toContain("/");
|
||||
expect(hrefs).toContain("/kanban");
|
||||
expect(hrefs).toContain("/sessions");
|
||||
expect(hrefs).toContain("/activity");
|
||||
});
|
||||
|
||||
it("should render both language options in expanded mode", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByRole("button", { name: "English" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Vietnamese" })).toBeInTheDocument();
|
||||
// Chinese and Korean were dropped; offering them would render raw keys.
|
||||
expect(screen.queryByRole("button", { name: "Chinese" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Korean" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should switch to Vietnamese when Vietnamese option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Vietnamese" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tổng quan")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bảng Kanban")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should cycle language in collapsed mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar(true, true);
|
||||
|
||||
expect(screen.getByText("EN")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Switch to Vietnamese" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VI")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file StatCard.test.tsx
|
||||
* @description Unit tests for the StatCard component, which is a reusable React component that displays a statistic with a label, value, icon, and optional trend information. The tests cover rendering of the label, value (both numeric and string), trend information, and the icon. The tests also verify that custom accent colors are applied correctly. The tests use React Testing Library and Vitest for assertions and mocking.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
describe("StatCard", () => {
|
||||
it("should render label", () => {
|
||||
render(<StatCard label="Total Sessions" value={42} icon={Activity} />);
|
||||
expect(screen.getByText("Total Sessions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render numeric value", () => {
|
||||
render(<StatCard label="Events" value={156} icon={Activity} />);
|
||||
expect(screen.getByText("156")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render string value", () => {
|
||||
render(<StatCard label="Status" value="-" icon={Activity} />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render trend when provided", () => {
|
||||
render(<StatCard label="Sessions" value={10} icon={Activity} trend="3 active" />);
|
||||
expect(screen.getByText("3 active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render trend when not provided", () => {
|
||||
render(<StatCard label="Sessions" value={10} icon={Activity} />);
|
||||
expect(screen.queryByText("active")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the icon", () => {
|
||||
const { container } = render(<StatCard label="Test" value={0} icon={Activity} />);
|
||||
// Lucide renders as SVG
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply custom accent color", () => {
|
||||
const { container } = render(
|
||||
<StatCard label="Test" value={0} icon={Activity} accentColor="text-emerald-400" />
|
||||
);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-emerald-400");
|
||||
});
|
||||
|
||||
it("should apply default accent color when not specified", () => {
|
||||
const { container } = render(<StatCard label="Test" value={0} icon={Activity} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-accent");
|
||||
});
|
||||
|
||||
it("should render a skeleton placeholder when loading and hide the real value", () => {
|
||||
const { container } = render(<StatCard label="Total" value="" icon={Activity} loading />);
|
||||
// value text should NOT appear so users never see a flash of "-" or 0
|
||||
expect(screen.queryByText("-")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("0")).not.toBeInTheDocument();
|
||||
// skeleton primitive renders an aria-busy node
|
||||
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should swap from skeleton to value when loading flips false", () => {
|
||||
const { rerender } = render(<StatCard label="Total" value="" icon={Activity} loading />);
|
||||
expect(screen.queryByText("42")).not.toBeInTheDocument();
|
||||
rerender(<StatCard label="Total" value={42} icon={Activity} loading={false} />);
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file StatusBadge.test.tsx
|
||||
* @description Unit tests for the StatusBadge component, which includes AgentStatusBadge and SessionStatusBadge. These components are responsible for displaying the status of agents and sessions in the dashboard. The tests cover rendering of different statuses, application of pulse animation based on status, respect for explicit pulse overrides, and the awaiting-reason suffix (icon + short label + hover tooltip) that explains WHY a row is in the Waiting state. The tests use React Testing Library and Vitest for assertions and mocking.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { AgentStatusBadge, SessionStatusBadge } from "../StatusBadge";
|
||||
|
||||
describe("AgentStatusBadge", () => {
|
||||
it("should render waiting status", () => {
|
||||
render(<AgentStatusBadge status="waiting" />);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render working status", () => {
|
||||
render(<AgentStatusBadge status="working" />);
|
||||
expect(screen.getByText("Working")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render completed status", () => {
|
||||
render(<AgentStatusBadge status="completed" />);
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error status", () => {
|
||||
render(<AgentStatusBadge status="error" />);
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply pulse animation for working status by default", () => {
|
||||
const { container } = render(<AgentStatusBadge status="working" />);
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not apply pulse for connected status (now working - has pulse)", () => {
|
||||
const { container } = render(<AgentStatusBadge status="working" />);
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply pulse animation for waiting status by default", () => {
|
||||
const { container } = render(<AgentStatusBadge status="waiting" />);
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should respect explicit pulse=false override", () => {
|
||||
const { container } = render(<AgentStatusBadge status="working" pulse={false} />);
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should respect explicit pulse=true override", () => {
|
||||
const { container } = render(<AgentStatusBadge status="waiting" pulse={true} />);
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render waiting status with yellow dot and pulse by default", () => {
|
||||
const { container } = render(<AgentStatusBadge status="waiting" />);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionStatusBadge", () => {
|
||||
it("should render active status", () => {
|
||||
render(<SessionStatusBadge status="active" />);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render completed status", () => {
|
||||
render(<SessionStatusBadge status="completed" />);
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error status", () => {
|
||||
render(<SessionStatusBadge status="error" />);
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render abandoned status", () => {
|
||||
render(<SessionStatusBadge status="abandoned" />);
|
||||
expect(screen.getByText("Abandoned")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render waiting status with pulsing yellow dot", () => {
|
||||
const { container } = render(<SessionStatusBadge status="waiting" />);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("awaiting-reason suffix", () => {
|
||||
it("renders the reason label next to Waiting on AgentStatusBadge", () => {
|
||||
render(<AgentStatusBadge status="waiting" reason="notification" />);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
expect(screen.getByText("Needs input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the reason label next to Waiting on SessionStatusBadge", () => {
|
||||
render(<SessionStatusBadge status="waiting" reason="stop" />);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
expect(screen.getByText("Turn done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores the reason on non-waiting statuses", () => {
|
||||
render(<AgentStatusBadge status="working" reason="notification" />);
|
||||
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
|
||||
render(<SessionStatusBadge status="active" reason="stop" />);
|
||||
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no suffix when reason is null/omitted", () => {
|
||||
render(<AgentStatusBadge status="waiting" reason={null} />);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the full reason description in a tooltip on hover", () => {
|
||||
const { container } = render(<AgentStatusBadge status="waiting" reason="interrupted" />);
|
||||
expect(screen.getByText("Interrupted")).toBeInTheDocument();
|
||||
// Tip attaches its handlers to the wrapper element and portals the tooltip
|
||||
// body into document.body.
|
||||
fireEvent.mouseEnter(container.firstElementChild!, { clientX: 10, clientY: 10 });
|
||||
expect(screen.getByText(/The last turn was interrupted/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks urgent reasons with the hotter amber tint", () => {
|
||||
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
|
||||
expect(container.querySelector(".text-amber-300")).toBeInTheDocument();
|
||||
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
|
||||
expect(calm.querySelector(".text-amber-300")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
|
||||
const { container } = render(
|
||||
<AgentStatusBadge status="waiting" reason="notification" compact />
|
||||
);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
|
||||
fireEvent.mouseEnter(container.firstElementChild!, { clientX: 10, clientY: 10 });
|
||||
expect(screen.getByText(/Blocked on a permission prompt/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("compact mode works on SessionStatusBadge too", () => {
|
||||
render(<SessionStatusBadge status="waiting" reason="stop" compact />);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @file CodeBlock.tsx
|
||||
* @description Reusable, syntax-highlighted code block with a chrome bar (language pill,
|
||||
* optional filename, copy-to-clipboard, line count) and optional gutter line numbers.
|
||||
* Used by MarkdownContent for fenced code blocks and by ToolCallBlock for tool I/O.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/highlight`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `CodeBlock` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **CodeBlock**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, Copy, FileCode } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass, type Token } from "../../lib/highlight";
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
lang?: string;
|
||||
/** Optional filename to display in the chrome bar. */
|
||||
filename?: string;
|
||||
/** Render compact (no chrome bar). */
|
||||
compact?: boolean;
|
||||
/** Override for the right-side label (e.g. "Output", "Error"). */
|
||||
label?: string;
|
||||
/** Tone - "default" matches the surface, "danger" tints red, "success" tints emerald. */
|
||||
tone?: "default" | "danger" | "success";
|
||||
/** Cap the rendered height; pass null to disable. Default 24rem. */
|
||||
maxHeight?: string | null;
|
||||
/** Show a left gutter with line numbers. Default true for >= 4 lines. */
|
||||
showLineNumbers?: boolean;
|
||||
}
|
||||
|
||||
const LANG_DISPLAY: Record<string, string> = {
|
||||
js: "JavaScript",
|
||||
ts: "TypeScript",
|
||||
python: "Python",
|
||||
json: "JSON",
|
||||
bash: "Shell",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
sql: "SQL",
|
||||
yaml: "YAML",
|
||||
diff: "Diff",
|
||||
plain: "Text",
|
||||
};
|
||||
|
||||
function langDisplay(lang: string): string {
|
||||
const canon = canonicalLang(lang);
|
||||
return LANG_DISPLAY[canon] ?? (lang || "Text");
|
||||
}
|
||||
|
||||
/**
|
||||
* Split tokens that span multiple lines so we can render one line at a time
|
||||
* (necessary for the gutter line-number column to align).
|
||||
*/
|
||||
function splitTokensByLine(tokens: Token[]): Token[][] {
|
||||
const lines: Token[][] = [[]];
|
||||
for (const t of tokens) {
|
||||
const parts = t.text.split("\n");
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i > 0) lines.push([]);
|
||||
const piece = parts[i]!;
|
||||
if (piece.length > 0) {
|
||||
lines[lines.length - 1]!.push({ type: t.type, text: piece });
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
lang = "",
|
||||
filename,
|
||||
compact = false,
|
||||
label,
|
||||
tone = "default",
|
||||
maxHeight = "24rem",
|
||||
showLineNumbers,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const tokens = useMemo(() => highlight(code, lang), [code, lang]);
|
||||
const lineTokens = useMemo(() => splitTokensByLine(tokens), [tokens]);
|
||||
const totalLines = lineTokens.length;
|
||||
const gutter = showLineNumbers ?? totalLines >= 4;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard may be unavailable in some contexts - fail silently.
|
||||
}
|
||||
};
|
||||
|
||||
const palette =
|
||||
tone === "danger"
|
||||
? {
|
||||
wrapper: "border-red-500/30 bg-red-500/5",
|
||||
chrome: "bg-red-500/10 border-b border-red-500/20",
|
||||
label: "text-red-300",
|
||||
}
|
||||
: tone === "success"
|
||||
? {
|
||||
wrapper: "border-emerald-500/30 bg-emerald-500/5",
|
||||
chrome: "bg-emerald-500/10 border-b border-emerald-500/20",
|
||||
label: "text-emerald-300",
|
||||
}
|
||||
: {
|
||||
wrapper: "border-surface-3 bg-surface-4/50",
|
||||
chrome: "bg-surface-3/70 border-b border-surface-3",
|
||||
label: "text-gray-400",
|
||||
};
|
||||
|
||||
const preStyle: React.CSSProperties = {};
|
||||
if (maxHeight) preStyle.maxHeight = maxHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group/code rounded-md border overflow-hidden shadow-[0_1px_0_rgba(255,255,255,0.02)_inset,0_2px_8px_-4px_rgba(0,0,0,0.4)] ${palette.wrapper}`}
|
||||
>
|
||||
{!compact && (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 text-[11px] ${palette.chrome}`}>
|
||||
{/* Language pill */}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 font-mono uppercase tracking-wider ${palette.label}`}
|
||||
>
|
||||
{filename ? <FileCode className="w-3 h-3 opacity-70" /> : null}
|
||||
{filename ?? label ?? langDisplay(lang)}
|
||||
</span>
|
||||
|
||||
{/* Filename + lang together when both are set */}
|
||||
{filename && !label && (
|
||||
<span className="text-gray-600 font-mono lowercase">{langDisplay(lang)}</span>
|
||||
)}
|
||||
{filename && label && (
|
||||
<span className={`font-mono uppercase tracking-wider ${palette.label}`}>· {label}</span>
|
||||
)}
|
||||
|
||||
{/* Right side: line count + copy */}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{totalLines > 1 && (
|
||||
<span className="text-gray-600 font-mono">
|
||||
{totalLines} {totalLines === 1 ? "line" : "lines"}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`inline-flex items-center gap-1 transition-colors ${
|
||||
copied ? "text-emerald-300" : "text-gray-500 hover:text-gray-200"
|
||||
}`}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3" /> Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" /> Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-auto" style={preStyle}>
|
||||
<pre className="font-mono text-[12.5px] leading-[1.6]">
|
||||
<code>
|
||||
{gutter ? (
|
||||
<table className="border-collapse" style={{ width: "max-content", minWidth: "100%" }}>
|
||||
<tbody>
|
||||
{lineTokens.map((line, i) => (
|
||||
<tr key={i} className="align-top">
|
||||
<td
|
||||
className="select-none text-right pl-3 pr-3 text-gray-600 font-mono text-[11px] leading-[1.6] sticky left-0 bg-inherit"
|
||||
style={{ width: "1%", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="pl-0 pr-3 whitespace-pre">
|
||||
{line.length === 0 ? (
|
||||
<span> </span>
|
||||
) : (
|
||||
line.map((t, j) => (
|
||||
<span key={j} className={tokenClass(t.type)}>
|
||||
{t.text}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="px-3 py-2 whitespace-pre">
|
||||
{tokens.map((t, i) => (
|
||||
<span key={i} className={tokenClass(t.type)}>
|
||||
{t.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* @file ConversationView.tsx
|
||||
* @description Conversation tab on the Session detail page. Loads a session
|
||||
* (or sub-agent) JSONL transcript, paginates it incrementally, and renders
|
||||
* the message stream via MessageList. Combines a WebSocket subscription, a
|
||||
* visibility-gated polling fallback, and a manual refresh button so the view
|
||||
* stays caught up even when hooks miss frames or the user is mid-text-only
|
||||
* turn (no PreToolUse fires until Stop).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/api`
|
||||
* - `../../lib/eventBus`
|
||||
* - `./MessageList`
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ConversationView` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ConversationView**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { ChevronDown, Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList } from "./MessageList";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
|
||||
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
||||
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
||||
// assistant text written between two hook fires is invisible until the next
|
||||
// hook event. A short visibility-gated poll closes that gap and also rescues
|
||||
// the conversation from missed/late WebSocket frames.
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
// Rescan the transcripts list periodically so new subagents that spawn
|
||||
// mid-session appear in the dropdown without a page reload.
|
||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
initialTranscriptId?: string | null;
|
||||
}
|
||||
|
||||
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [selectedTranscript, setSelectedTranscript] = useState<string | null>(
|
||||
initialTranscriptId ?? null
|
||||
);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||
|
||||
// Track JSONL line numbers for incremental requests and history loading
|
||||
const lastLineRef = useRef(0);
|
||||
const firstLineRef = useRef(0);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const fetchingRef = useRef(false);
|
||||
// When a fetch is in flight and a new trigger arrives (WS event, poll,
|
||||
// manual refresh), we queue exactly one re-fetch so events that landed
|
||||
// during the in-flight request aren't silently dropped.
|
||||
const pendingFetchRef = useRef(false);
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Load available transcript list (also rescanned on a short interval so
|
||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadTranscripts() {
|
||||
try {
|
||||
const result = await api.sessions.transcripts(sessionId);
|
||||
if (cancelled) return;
|
||||
setTranscripts(result.transcripts);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
loadTranscripts();
|
||||
const interval = window.setInterval(loadTranscripts, TRANSCRIPTS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// Sync external initialTranscriptId to internal state
|
||||
useEffect(() => {
|
||||
if (initialTranscriptId != null) {
|
||||
setSelectedTranscript(initialTranscriptId);
|
||||
}
|
||||
}, [initialTranscriptId]);
|
||||
|
||||
// Initial load: fetch the latest N messages
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setShowNewMsg(false);
|
||||
const result = await api.sessions.transcript(sessionId, {
|
||||
agent_id: selectedTranscript || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setMessages(result.messages);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
lastLineRef.current = result.last_line;
|
||||
firstLineRef.current = result.first_line;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load transcript");
|
||||
setMessages([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, selectedTranscript]);
|
||||
|
||||
// Incrementally load new messages. Two modes:
|
||||
// - bootstrap (lastLineRef === 0): the initial load saw an empty
|
||||
// transcript, so we pull the latest 50 to seed the view. This unblocks
|
||||
// fresh sessions where the JSONL hadn't been written yet at mount.
|
||||
// - incremental (lastLineRef > 0): tail-fetch lines after the highest
|
||||
// parsed message we've seen. The server already de-overlaps via
|
||||
// afterLine, so we can safely append.
|
||||
const fetchNewMessages = useCallback(async () => {
|
||||
if (fetchingRef.current) {
|
||||
// Coalesce: remember a trigger arrived during this fetch and re-run
|
||||
// exactly once when the in-flight request settles.
|
||||
pendingFetchRef.current = true;
|
||||
return;
|
||||
}
|
||||
fetchingRef.current = true;
|
||||
pendingFetchRef.current = false;
|
||||
|
||||
const wasBootstrap = lastLineRef.current === 0;
|
||||
try {
|
||||
const result = await api.sessions.transcript(sessionId, {
|
||||
agent_id: selectedTranscript || undefined,
|
||||
...(wasBootstrap ? {} : { after: lastLineRef.current }),
|
||||
limit: 50,
|
||||
});
|
||||
if (result.messages.length === 0) return;
|
||||
|
||||
lastLineRef.current = result.last_line;
|
||||
|
||||
if (wasBootstrap) {
|
||||
// Seed the view in a single render so the user sees the whole
|
||||
// catch-up batch instead of a blank panel followed by a partial one.
|
||||
setMessages(result.messages);
|
||||
firstLineRef.current = result.first_line;
|
||||
setHasMore(result.has_more);
|
||||
} else {
|
||||
setMessages((prev) => [...prev, ...result.messages]);
|
||||
}
|
||||
setTotal(result.total);
|
||||
|
||||
// Auto-scroll if user is at bottom; otherwise show "new messages" indicator
|
||||
if (isAtBottomRef.current) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
setShowNewMsg(true);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
// Drain a queued trigger if one arrived during the fetch.
|
||||
if (pendingFetchRef.current) {
|
||||
pendingFetchRef.current = false;
|
||||
// Defer one tick so React state updates from this call commit first.
|
||||
setTimeout(() => fetchNewMessages(), 0);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, selectedTranscript]);
|
||||
|
||||
// WebSocket subscription: refetch on every new_event for this session.
|
||||
// Hook coverage isn't complete (a user-typed message fires no hook), so we
|
||||
// also poll below to catch what WS misses.
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
fetchNewMessages();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchNewMessages();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchNewMessages]);
|
||||
|
||||
// Resync on WebSocket reconnect: events that landed during a transient
|
||||
// disconnect are gone from the bus, but the JSONL still has them, so a
|
||||
// single tail-fetch on reconnect catches the conversation up.
|
||||
useEffect(() => {
|
||||
return eventBus.onConnection((connected) => {
|
||||
if (connected) fetchNewMessages();
|
||||
});
|
||||
}, [fetchNewMessages]);
|
||||
|
||||
// Visibility-gated polling fallback. Covers:
|
||||
// 1. User-typed messages (no Claude Code hook fires for those).
|
||||
// 2. Long assistant turns where text streams between hook fires.
|
||||
// 3. Late JSONL flushes that arrive after the triggering hook's fetch.
|
||||
// 4. Dropped/missed WebSocket frames.
|
||||
useEffect(() => {
|
||||
let interval: number | null = null;
|
||||
function start() {
|
||||
if (interval !== null) return;
|
||||
interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") fetchNewMessages();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
function stop() {
|
||||
if (interval !== null) {
|
||||
window.clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
}
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Tab just became visible - fire a one-shot catch-up immediately
|
||||
// and resume polling. Backgrounded tabs throttle setInterval, so
|
||||
// restarting on focus avoids a stale conversation.
|
||||
fetchNewMessages();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
if (document.visibilityState === "visible") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchNewMessages]);
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await fetchNewMessages();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [fetchNewMessages]);
|
||||
|
||||
// Scroll-up to load history
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (loadingHistory || !hasMore) return;
|
||||
// Need the first message's line number
|
||||
// Since message objects don't have a _line field, we track it via firstLineRef
|
||||
// firstLineRef is updated on initial load and each history load
|
||||
try {
|
||||
setLoadingHistory(true);
|
||||
const container = scrollContainerRef.current;
|
||||
const prevScrollHeight = container?.scrollHeight ?? 0;
|
||||
|
||||
const result = await api.sessions.transcript(sessionId, {
|
||||
agent_id: selectedTranscript || undefined,
|
||||
before: firstLineRef.current || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
if (result.messages.length === 0) {
|
||||
// Nothing older exists - clear hasMore so the hint stops showing
|
||||
// even if the server still claims more is available.
|
||||
setHasMore(false);
|
||||
setLoadingHistory(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update firstLineRef to the oldest message's line number in the history batch
|
||||
firstLineRef.current = result.first_line;
|
||||
|
||||
setMessages((prev) => [...result.messages, ...prev]);
|
||||
setHasMore(result.has_more);
|
||||
|
||||
// Preserve scroll position (don't jump to top)
|
||||
requestAnimationFrame(() => {
|
||||
if (container) {
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
container.scrollTop = newScrollHeight - prevScrollHeight;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}, [sessionId, selectedTranscript, loadingHistory, hasMore]);
|
||||
|
||||
// Scroll to bottom
|
||||
const scrollToBottom = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Listen for scroll events: detect bottom position + trigger history load
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Detect if at bottom
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||
isAtBottomRef.current = atBottom;
|
||||
|
||||
// Hide "new messages" indicator when scrolled to bottom
|
||||
if (atBottom) {
|
||||
setShowNewMsg(false);
|
||||
}
|
||||
|
||||
// Load history when scrolled to top
|
||||
if (container.scrollTop < 50 && hasMore && !loadingHistory) {
|
||||
loadHistory();
|
||||
}
|
||||
}, [hasMore, loadingHistory, loadHistory]);
|
||||
|
||||
// Auto-scroll to bottom after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
|
||||
{/* Toolbar - always rendered after the initial load so users can
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedTranscript || ""}
|
||||
onChange={(e) => setSelectedTranscript(e.target.value || null)}
|
||||
className="appearance-none bg-surface-2 border border-surface-3 rounded-lg px-3 py-1.5 pr-8 text-sm text-gray-300 focus:outline-none focus:border-violet-500/50 hover:border-violet-500/30 cursor-pointer transition-colors"
|
||||
>
|
||||
{transcripts.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-gray-500 absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
</div>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500 font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1">
|
||||
<MessagesSquare className="w-3 h-3" />
|
||||
{total} message{total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={refreshing || loading}
|
||||
title="Refresh conversation"
|
||||
aria-label="Refresh conversation"
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-gray-400 hover:text-gray-200 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"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 flex-shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message list container */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="w-4 h-4 text-gray-500 animate-spin" />
|
||||
<span className="text-xs text-gray-500 ml-2">Loading history...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll-up for history hint */}
|
||||
{hasMore && !loadingHistory && !loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[11px] text-gray-600">↑ Scroll up for older messages</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-gray-500 text-sm">
|
||||
Loading conversation...
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-gray-400">No conversation records found.</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-gray-500">
|
||||
This session's metadata was imported, but its transcript file is no longer on disk.
|
||||
Claude Code automatically deletes inactive session transcripts after a retention
|
||||
period (<code className="text-gray-400">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>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} loading={false} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New messages indicator */}
|
||||
{showNewMsg && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
setShowNewMsg(false);
|
||||
}}
|
||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-violet-600 hover:bg-violet-500 text-white text-xs font-medium px-3 py-1.5 rounded-full shadow-lg transition-colors z-10"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
New messages
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* @file MarkdownContent.tsx
|
||||
* @description Lightweight markdown renderer for conversation messages. Supports the
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs.
|
||||
*
|
||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||
* copy-to-clipboard.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./CodeBlock`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `MarkdownContent` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **MarkdownContent**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import React from "react";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
|
||||
type Block =
|
||||
| { kind: "code"; lang: string; code: string }
|
||||
| { kind: "heading"; level: number; text: string }
|
||||
| { kind: "list"; ordered: boolean; items: string[] }
|
||||
| { kind: "quote"; text: string }
|
||||
| { kind: "hr" }
|
||||
| {
|
||||
kind: "table";
|
||||
header: string[];
|
||||
aligns: ("left" | "center" | "right" | null)[];
|
||||
rows: string[][];
|
||||
}
|
||||
| { kind: "para"; text: string };
|
||||
|
||||
const FENCE_RE = /^([ \t]*)(```|~~~)(\s*[\w+-]*)\s*$/;
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
||||
const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
|
||||
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
|
||||
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
|
||||
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
||||
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
||||
|
||||
function splitTableRow(line: string): string[] {
|
||||
// Trim leading/trailing pipes, then split, respecting escaped pipes.
|
||||
let s = line.trim();
|
||||
if (s.startsWith("|")) s = s.slice(1);
|
||||
if (s.endsWith("|")) s = s.slice(0, -1);
|
||||
// Split on unescaped pipes
|
||||
const parts: string[] = [];
|
||||
let cur = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "\\" && s[i + 1] === "|") {
|
||||
cur += "|";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (s[i] === "|") {
|
||||
parts.push(cur.trim());
|
||||
cur = "";
|
||||
} else {
|
||||
cur += s[i];
|
||||
}
|
||||
}
|
||||
parts.push(cur.trim());
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseAlignments(divider: string): ("left" | "center" | "right" | null)[] {
|
||||
return splitTableRow(divider).map((cell) => {
|
||||
const left = cell.startsWith(":");
|
||||
const right = cell.endsWith(":");
|
||||
if (left && right) return "center";
|
||||
if (right) return "right";
|
||||
if (left) return "left";
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function parseBlocks(src: string): Block[] {
|
||||
const lines = src.split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
|
||||
// Fenced code block
|
||||
const fence = line.match(FENCE_RE);
|
||||
if (fence) {
|
||||
const fenceMarker = fence[2]!;
|
||||
const lang = (fence[3] ?? "").trim();
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const closing = lines[i]!.match(/^([ \t]*)(```|~~~)\s*$/);
|
||||
if (closing && closing[2] === fenceMarker) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
codeLines.push(lines[i]!);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "code", lang, code: codeLines.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank line
|
||||
if (line.trim() === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX heading
|
||||
const heading = line.match(HEADING_RE);
|
||||
if (heading) {
|
||||
blocks.push({ kind: "heading", level: heading[1]!.length, text: heading[2]! });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (HR_RE.test(line)) {
|
||||
blocks.push({ kind: "hr" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: header line followed by an alignment divider
|
||||
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) {
|
||||
const header = splitTableRow(line);
|
||||
const aligns = parseAlignments(lines[i + 1]!);
|
||||
i += 2;
|
||||
const rows: string[][] = [];
|
||||
while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim() !== "") {
|
||||
rows.push(splitTableRow(lines[i]!));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "table", header, aligns, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lists
|
||||
const ulMatch = line.match(UL_RE);
|
||||
const olMatch = line.match(OL_RE);
|
||||
if (ulMatch || olMatch) {
|
||||
const ordered = !!olMatch;
|
||||
const itemRe = ordered ? OL_RE : UL_RE;
|
||||
const items: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const m = lines[i]!.match(itemRe);
|
||||
if (m) {
|
||||
items.push(m[3]!);
|
||||
i++;
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i]!.trim() !== "" &&
|
||||
!lines[i]!.match(UL_RE) &&
|
||||
!lines[i]!.match(OL_RE) &&
|
||||
/^\s+\S/.test(lines[i]!)
|
||||
) {
|
||||
items[items.length - 1] += "\n" + lines[i]!.trim();
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
blocks.push({ kind: "list", ordered, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (QUOTE_RE.test(line)) {
|
||||
const qLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const m = lines[i]!.match(QUOTE_RE);
|
||||
if (!m) break;
|
||||
qLines.push(m[1]!);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "quote", text: qLines.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: collect until a blank line or the start of another block
|
||||
const paraLines: string[] = [line];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const nl = lines[i]!;
|
||||
if (
|
||||
nl.trim() === "" ||
|
||||
FENCE_RE.test(nl) ||
|
||||
HEADING_RE.test(nl) ||
|
||||
HR_RE.test(nl) ||
|
||||
UL_RE.test(nl) ||
|
||||
OL_RE.test(nl) ||
|
||||
QUOTE_RE.test(nl)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
paraLines.push(nl);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "para", text: paraLines.join("\n") });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
|
||||
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
let buf = "";
|
||||
let n = 0;
|
||||
const flush = () => {
|
||||
if (buf) {
|
||||
out.push(buf);
|
||||
buf = "";
|
||||
}
|
||||
};
|
||||
const push = (node: React.ReactNode) => {
|
||||
flush();
|
||||
out.push(<React.Fragment key={`${baseKey}-${n++}`}>{node}</React.Fragment>);
|
||||
};
|
||||
|
||||
while (i < text.length) {
|
||||
const rest = text.slice(i);
|
||||
|
||||
// Inline code: `...`
|
||||
const codeM = rest.match(/^`([^`\n]+)`/);
|
||||
if (codeM) {
|
||||
push(
|
||||
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-amber-200">
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bold: **...** or __...__
|
||||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||||
if (boldM) {
|
||||
push(
|
||||
<strong className="font-semibold text-gray-50">
|
||||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||||
</strong>
|
||||
);
|
||||
i += boldM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Italic: *...* or _..._
|
||||
const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/);
|
||||
if (italicM) {
|
||||
push(
|
||||
<em className="italic text-gray-200">{renderInline(italicM[2]!, `${baseKey}-i${n}`)}</em>
|
||||
);
|
||||
i += italicM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strikethrough
|
||||
const strikeM = rest.match(/^~~(.+?)~~/);
|
||||
if (strikeM) {
|
||||
push(
|
||||
<span className="line-through text-gray-500">
|
||||
{renderInline(strikeM[1]!, `${baseKey}-s${n}`)}
|
||||
</span>
|
||||
);
|
||||
i += strikeM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown link
|
||||
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
|
||||
if (linkM) {
|
||||
push(
|
||||
<a
|
||||
href={linkM[2]!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
</a>
|
||||
);
|
||||
i += linkM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-link
|
||||
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
|
||||
if (urlM) {
|
||||
push(
|
||||
<a
|
||||
href={urlM[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
>
|
||||
{urlM[0]}
|
||||
</a>
|
||||
);
|
||||
i += urlM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
buf += text[i]!;
|
||||
i++;
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render a single list item, handling [ ] / [x] task list prefixes. */
|
||||
function renderListItem(item: string, key: string): React.ReactNode {
|
||||
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
|
||||
if (taskMatch) {
|
||||
const checked = taskMatch[1]!.toLowerCase() === "x";
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-2">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-sm border flex-shrink-0 translate-y-0.5 ${
|
||||
checked ? "bg-emerald-500/40 border-emerald-400/60" : "bg-surface-4 border-surface-3"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={checked ? "text-gray-500 line-through" : ""}>
|
||||
{renderInline(taskMatch[2]!, key)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return renderInline(item, key);
|
||||
}
|
||||
|
||||
interface MarkdownContentProps {
|
||||
text: string;
|
||||
/** Tighter spacing for nested contexts (list items, quotes). */
|
||||
dense?: boolean;
|
||||
}
|
||||
|
||||
const HEADING_STYLES = [
|
||||
"text-[18px] font-semibold text-gray-50 mt-2 pb-1 border-b border-surface-3",
|
||||
"text-[16px] font-semibold text-gray-50 mt-2",
|
||||
"text-[15px] font-semibold text-gray-100",
|
||||
"text-sm font-semibold text-gray-100",
|
||||
"text-sm font-medium text-gray-200",
|
||||
"text-xs font-medium text-gray-300 uppercase tracking-wider",
|
||||
];
|
||||
|
||||
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
const blocks = parseBlocks(text);
|
||||
const gap = dense ? "space-y-1.5" : "space-y-2.5";
|
||||
|
||||
return (
|
||||
<div className={`text-sm text-gray-300 leading-relaxed ${gap}`}>
|
||||
{blocks.map((b, idx) => {
|
||||
switch (b.kind) {
|
||||
case "code":
|
||||
return <CodeBlock key={idx} code={b.code} lang={b.lang} />;
|
||||
|
||||
case "heading": {
|
||||
const cls = HEADING_STYLES[b.level - 1] ?? HEADING_STYLES[5];
|
||||
return (
|
||||
<div key={idx} className={cls}>
|
||||
{renderInline(b.text, `h${idx}`)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "list":
|
||||
if (b.ordered) {
|
||||
return (
|
||||
<ol
|
||||
key={idx}
|
||||
className="list-decimal pl-5 space-y-1 marker:text-gray-500 marker:font-mono marker:text-xs"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-gray-300">
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-gray-300">
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote
|
||||
key={idx}
|
||||
className="relative border-l-2 border-violet-400/50 pl-3 pr-2 py-1 text-gray-400 italic bg-violet-500/[0.04] rounded-r"
|
||||
>
|
||||
{renderInline(b.text, `q${idx}`)}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
case "hr":
|
||||
return (
|
||||
<hr
|
||||
key={idx}
|
||||
className="border-0 h-px bg-gradient-to-r from-transparent via-surface-3 to-transparent my-2"
|
||||
/>
|
||||
);
|
||||
|
||||
case "table": {
|
||||
const alignClass = (a: "left" | "center" | "right" | null) =>
|
||||
a === "center" ? "text-center" : a === "right" ? "text-right" : "text-left";
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="overflow-x-auto rounded-md border border-surface-3 bg-surface-4/40"
|
||||
>
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="bg-surface-3/60">
|
||||
<tr>
|
||||
{b.header.map((cell, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={`px-3 py-1.5 font-semibold text-gray-200 border-b border-surface-3 ${alignClass(b.aligns[i] ?? null)}`}
|
||||
>
|
||||
{renderInline(cell, `th${idx}-${i}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{b.rows.map((row, ri) => (
|
||||
<tr
|
||||
key={ri}
|
||||
className="border-b border-surface-3/50 last:border-b-0 hover:bg-surface-3/30"
|
||||
>
|
||||
{row.map((cell, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
className={`px-3 py-1.5 text-gray-300 align-top ${alignClass(b.aligns[ci] ?? null)}`}
|
||||
>
|
||||
{renderInline(cell, `td${idx}-${ri}-${ci}`)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "para":
|
||||
return (
|
||||
<p key={idx} className="text-sm text-gray-300 whitespace-pre-wrap break-words">
|
||||
{renderInline(b.text, `p${idx}`)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* @file MessageList.tsx
|
||||
* @description Renders the chronological message stream of a Claude Code
|
||||
* transcript: alternating user / assistant rows with collapsible thinking
|
||||
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
|
||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||
* the Conversation tab on the Session detail page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
* - `./ToolCallBlock`
|
||||
* - `./MarkdownContent`
|
||||
* - `../../lib/format`
|
||||
* - `./tuiSegments`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `MessageList` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **MessageList**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Bot,
|
||||
User,
|
||||
Brain,
|
||||
ScrollText,
|
||||
Terminal,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
Pencil,
|
||||
Workflow,
|
||||
Cog,
|
||||
} from "lucide-react";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
|
||||
/** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line
|
||||
* is not always the human (tool results, harness task-notifications, the
|
||||
* orchestrator's task to a subagent) — each sender gets its own label, icon,
|
||||
* and accent so attribution is unambiguous. */
|
||||
const SENDER_STYLES: Record<
|
||||
TranscriptSender,
|
||||
{ label: string; icon: typeof User; avatarRing: string; accentBar: string; headerText: string }
|
||||
> = {
|
||||
user: {
|
||||
label: "User",
|
||||
icon: User,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-blue-500/30 to-cyan-500/20 text-blue-200 ring-1 ring-blue-400/30",
|
||||
accentBar: "before:bg-blue-500/40",
|
||||
headerText: "text-blue-200",
|
||||
},
|
||||
assistant: {
|
||||
label: "Assistant",
|
||||
icon: Bot,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30",
|
||||
accentBar: "before:bg-violet-500/40",
|
||||
headerText: "text-violet-200",
|
||||
},
|
||||
orchestrator: {
|
||||
label: "Main agent",
|
||||
icon: Workflow,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-teal-500/30 to-emerald-500/20 text-teal-200 ring-1 ring-teal-400/30",
|
||||
accentBar: "before:bg-teal-500/40",
|
||||
headerText: "text-teal-200",
|
||||
},
|
||||
system: {
|
||||
label: "System",
|
||||
icon: Cog,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-gray-300 ring-1 ring-slate-400/30",
|
||||
accentBar: "before:bg-slate-500/40",
|
||||
headerText: "text-gray-300",
|
||||
},
|
||||
tool: {
|
||||
label: "Tool",
|
||||
icon: Terminal,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-amber-500/30 to-orange-500/20 text-amber-200 ring-1 ring-amber-400/30",
|
||||
accentBar: "before:bg-amber-500/40",
|
||||
headerText: "text-amber-200",
|
||||
},
|
||||
};
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
import { fmt, formatModelName } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: TranscriptMessage[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** Build a map from tool_use id → tool_result for matching */
|
||||
function buildToolResultMap(messages: TranscriptMessage[]): Map<string, TranscriptContent> {
|
||||
const map = new Map<string, TranscriptContent>();
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== "user") continue;
|
||||
for (const c of msg.content) {
|
||||
if (c.type === "tool_result" && c.id) {
|
||||
map.set(c.id, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */
|
||||
function isSkillContent(text: string): boolean {
|
||||
return text.startsWith("Base directory for this skill:");
|
||||
}
|
||||
|
||||
/** Detect if text is a task notification (contains <task-notification> tag) */
|
||||
function isTaskNotification(text: string): boolean {
|
||||
return text.includes("<task-notification>") || text.includes("<task-id>");
|
||||
}
|
||||
|
||||
/** Format a timestamp as compact local time (e.g. "14:23:01"). */
|
||||
function formatLocalTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Centered marker for a session rename (/rename, `claude -n`, picker Ctrl+R).
|
||||
* These TUI-only commands write no conversation turn, so without this they're
|
||||
* invisible in the transcript. */
|
||||
function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<div className="inline-flex items-center gap-2 text-[11px] text-gray-400 bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
|
||||
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
|
||||
<span className="text-gray-500">Renamed session →</span>
|
||||
<span className="text-gray-200 font-medium truncate">{title || "(untitled)"}</span>
|
||||
{timestamp && (
|
||||
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">
|
||||
{formatLocalTime(timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact pill for /command invocations parsed out of TUI markup. */
|
||||
function CommandPill({ display }: { display: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 text-sm text-emerald-300 font-mono bg-emerald-500/10 border border-emerald-500/20 rounded-md px-3 py-1.5 max-w-full">
|
||||
<span className="text-emerald-500/70">›</span>
|
||||
<span className="break-all">{display}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Terminal-style fenced block for stdout/stderr captured from local commands. */
|
||||
function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "stderr" }) {
|
||||
const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, "");
|
||||
const isErr = stream === "stderr";
|
||||
const accent = isErr
|
||||
? "border-red-500/30 bg-red-950/30 text-red-200/90"
|
||||
: "border-surface-3 bg-surface-4/60 text-gray-200";
|
||||
const labelColor = isErr ? "text-red-300/80" : "text-gray-400";
|
||||
return (
|
||||
<div className={`rounded-lg border ${accent} overflow-hidden`}>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-[10px] uppercase tracking-wider border-b border-current/10 ${labelColor}`}
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
<span>{stream}</span>
|
||||
</div>
|
||||
<pre className="px-3 py-2 text-xs font-mono whitespace-pre-wrap break-words leading-relaxed max-h-96 overflow-y-auto">
|
||||
{cleaned}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Subtle inline note for the local-command-caveat banner. */
|
||||
function CaveatBlock({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/15 bg-amber-500/[0.05] px-3 py-1.5 text-[11px] text-amber-200/70">
|
||||
<Info className="w-3.5 h-3.5 mt-px flex-shrink-0 opacity-60" />
|
||||
<span className="leading-relaxed italic">{stripAnsi(text).trim()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render a single segment produced by parseTuiSegments. */
|
||||
function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
||||
switch (seg.kind) {
|
||||
case "command":
|
||||
return <CommandPill key={key} display={seg.display} />;
|
||||
case "stdout":
|
||||
return <TerminalBlock key={key} text={seg.text} stream="stdout" />;
|
||||
case "stderr":
|
||||
return <TerminalBlock key={key} text={seg.text} stream="stderr" />;
|
||||
case "caveat":
|
||||
return <CaveatBlock key={key} text={seg.text} />;
|
||||
case "system-reminder":
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={seg.text}
|
||||
icon={<AlertTriangle className="w-3.5 h-3.5 text-amber-400/70 flex-shrink-0" />}
|
||||
title="System reminder"
|
||||
borderClass="border-amber-500/20"
|
||||
bgClass="bg-amber-500/5"
|
||||
textClass="text-amber-300/80"
|
||||
/>
|
||||
);
|
||||
case "persisted-output":
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={seg.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-violet-400/60 flex-shrink-0" />}
|
||||
title="Persisted output"
|
||||
borderClass="border-violet-500/20"
|
||||
bgClass="bg-violet-500/5"
|
||||
textClass="text-violet-300/80"
|
||||
/>
|
||||
);
|
||||
case "text": {
|
||||
const cleaned = stripAnsi(seg.text);
|
||||
if (!cleaned.trim()) return null;
|
||||
return (
|
||||
<div key={key} className="min-w-0">
|
||||
<MarkdownContent text={cleaned} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generic collapsible content block */
|
||||
function CollapsibleBlock({
|
||||
text,
|
||||
icon,
|
||||
title,
|
||||
borderClass,
|
||||
bgClass,
|
||||
textClass,
|
||||
}: {
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
borderClass: string;
|
||||
bgClass: string;
|
||||
textClass: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${borderClass} ${bgClass} overflow-hidden`}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:opacity-80 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-60 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 opacity-60 flex-shrink-0" />
|
||||
)}
|
||||
{icon}
|
||||
<span className={`text-xs ${textClass} truncate`}>{title}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-current/10 px-3 py-2">
|
||||
<pre className="text-xs opacity-60 whitespace-pre-wrap break-words leading-relaxed max-h-96 overflow-y-auto">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading }: MessageListProps) {
|
||||
const [expandedThinking, setExpandedThinking] = useState<Set<number>>(() => new Set());
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-gray-500 text-sm">
|
||||
Loading conversation...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500 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 (
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg, idx) => {
|
||||
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
||||
// not as a user/assistant row.
|
||||
if (msg.type === "session_event") {
|
||||
return <SessionEventRow key={idx} title={msg.title} timestamp={msg.timestamp} />;
|
||||
}
|
||||
|
||||
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
|
||||
if (msg.type === "user" && !userMsgHasText.get(idx)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAssistant = msg.type === "assistant";
|
||||
// The true sender (classified server-side) drives the label + styling.
|
||||
// Falls back to the coarse type for older payloads without `sender`.
|
||||
const sender: TranscriptSender = msg.sender ?? (isAssistant ? "assistant" : "user");
|
||||
const style = SENDER_STYLES[sender] ?? SENDER_STYLES.user;
|
||||
const SenderIcon = style.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`relative flex gap-3 rounded-xl px-3 py-2.5 hover:bg-surface-2/30 transition-colors before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full before:opacity-60 ${style.accentBar}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-0.5 shadow-sm ${style.avatarRing}`}
|
||||
>
|
||||
<SenderIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Message body */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Header line */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold tracking-wide ${style.headerText}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
{msg.model && (
|
||||
<span className="text-[10px] text-gray-400 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-gray-500 font-mono inline-flex items-center gap-1">
|
||||
<span className="text-emerald-300/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span className="text-gray-700">·</span>
|
||||
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-gray-600 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-400/60 flex-shrink-0" />}
|
||||
title="Task Notification"
|
||||
borderClass="border-cyan-500/20"
|
||||
bgClass="bg-cyan-500/5"
|
||||
textClass="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-400/60 flex-shrink-0" />}
|
||||
title={skillPath}
|
||||
borderClass="border-blue-500/20"
|
||||
bgClass="bg-blue-500/5"
|
||||
textClass="text-blue-400/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-amber-500/20 bg-amber-500/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-amber-500/10 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-amber-500/60 transition-transform duration-150 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<Brain className="w-3.5 h-3.5 text-amber-400/80" />
|
||||
<span className="text-xs text-amber-200/90 font-medium">Thinking</span>
|
||||
{!isExpanded && (
|
||||
<span className="text-[10px] text-amber-300/40 font-mono ml-auto">
|
||||
{block.text.length.toLocaleString()} chars
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-amber-500/10 px-3 py-2 text-amber-100/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @file ToolCallBlock.tsx
|
||||
* @description Collapsible block rendered inside an assistant message for each
|
||||
* tool_use / tool_result pair. Shows tool icon + name in the header, with the
|
||||
* paired result inline when present. Per-tool styling comes from toolStyle.ts;
|
||||
* the tool's input/output payload is delegated to <CodeBlock /> for syntax
|
||||
* highlighting.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
* - `./CodeBlock`
|
||||
* - `./toolStyle`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolCallBlock` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolCallBlock**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, AlertCircle, FileText, CheckCircle2 } from "lucide-react";
|
||||
import type { TranscriptContent } from "../../lib/types";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { styleForTool } from "./toolStyle";
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
toolUse: TranscriptContent;
|
||||
toolResult?: TranscriptContent | null;
|
||||
}
|
||||
|
||||
/** Detect a likely language from a file path's extension. */
|
||||
function langFromPath(path: string): string {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
ts: "ts",
|
||||
tsx: "ts",
|
||||
js: "js",
|
||||
jsx: "js",
|
||||
mjs: "js",
|
||||
cjs: "js",
|
||||
py: "python",
|
||||
json: "json",
|
||||
yml: "yaml",
|
||||
yaml: "yaml",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
html: "html",
|
||||
htm: "html",
|
||||
css: "css",
|
||||
scss: "css",
|
||||
sql: "sql",
|
||||
md: "plain",
|
||||
txt: "plain",
|
||||
diff: "diff",
|
||||
patch: "diff",
|
||||
};
|
||||
return map[ext] ?? "plain";
|
||||
}
|
||||
|
||||
/** Build a one-line summary of the tool call to show in the collapsed header. */
|
||||
function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
const input = toolUse.input;
|
||||
if (!input || typeof input !== "object" || "_truncated" in input) return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
if (typeof obj.file_path === "string") return obj.file_path;
|
||||
if (typeof obj.path === "string") return obj.path;
|
||||
if (typeof obj.command === "string") return obj.command.slice(0, 200);
|
||||
if (typeof obj.pattern === "string") return obj.pattern;
|
||||
if (typeof obj.query === "string") return obj.query;
|
||||
if (typeof obj.url === "string") return obj.url;
|
||||
if (typeof obj.description === "string") return obj.description;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Render the input pane with tool-aware formatting. */
|
||||
function renderInput(toolUse: TranscriptContent) {
|
||||
const input = toolUse.input;
|
||||
if (!input) return null;
|
||||
|
||||
// Truncated payload from the backend
|
||||
if (typeof input === "object" && "_truncated" in input) {
|
||||
return (
|
||||
<CodeBlock
|
||||
code={String((input as { _truncated: string })._truncated)}
|
||||
lang="plain"
|
||||
label="Input (truncated)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const obj = input as Record<string, unknown>;
|
||||
const tool = (toolUse.name ?? "").toLowerCase();
|
||||
|
||||
// Bash: show the command with shell highlighting
|
||||
if (tool === "bash" && typeof obj.command === "string") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CodeBlock code={obj.command} lang="bash" label="Command" />
|
||||
{typeof obj.description === "string" && (
|
||||
<p className="text-xs text-gray-500 italic px-1">{obj.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Write: render new content as code with the file path as the chrome label
|
||||
if (tool === "write" && typeof obj.file_path === "string" && typeof obj.content === "string") {
|
||||
return (
|
||||
<CodeBlock
|
||||
code={obj.content}
|
||||
lang={langFromPath(obj.file_path)}
|
||||
filename={obj.file_path}
|
||||
label="New file"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit: side-by-side old/new
|
||||
if (tool === "edit" && typeof obj.file_path === "string") {
|
||||
const lang = langFromPath(obj.file_path);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<FileText className="w-3.5 h-3.5 text-violet-400" />
|
||||
<span className="font-mono">{obj.file_path}</span>
|
||||
{obj.replace_all === true && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-amber-300/80 bg-amber-500/10 border border-amber-500/20 rounded px-1.5 py-0.5">
|
||||
replace all
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof obj.old_string === "string" && (
|
||||
<CodeBlock code={obj.old_string} lang={lang} label="Removed" tone="danger" />
|
||||
)}
|
||||
{typeof obj.new_string === "string" && (
|
||||
<CodeBlock code={obj.new_string} lang={lang} label="Added" tone="success" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read: just show the path with offset/limit
|
||||
if (tool === "read" && typeof obj.file_path === "string") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-300 bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
|
||||
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
|
||||
<span className="font-mono break-all">{obj.file_path}</span>
|
||||
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
||||
<span className="text-gray-500 font-mono ml-auto flex-shrink-0">
|
||||
{typeof obj.offset === "number" ? `:${obj.offset}` : ""}
|
||||
{typeof obj.limit === "number" ? `+${obj.limit}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grep: pattern + path
|
||||
if (tool === "grep" && typeof obj.pattern === "string") {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
|
||||
Pattern
|
||||
</span>
|
||||
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{obj.pattern}
|
||||
</code>
|
||||
</div>
|
||||
{typeof obj.path === "string" && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
|
||||
Path
|
||||
</span>
|
||||
<code className="font-mono text-gray-300">{obj.path}</code>
|
||||
</div>
|
||||
)}
|
||||
{typeof obj.glob === "string" && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
|
||||
Glob
|
||||
</span>
|
||||
<code className="font-mono text-gray-300">{obj.glob}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: pretty JSON
|
||||
return <CodeBlock code={JSON.stringify(obj, null, 2)} lang="json" label="Input" />;
|
||||
}
|
||||
|
||||
/** Render the result pane: detect diff/json/text. */
|
||||
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||
const text = toolResult.output ?? "";
|
||||
if (text.length === 0) return <div className="text-xs text-gray-500 italic px-1">(empty)</div>;
|
||||
|
||||
const isError = !!toolResult.is_error;
|
||||
const tool = toolName.toLowerCase();
|
||||
const label = isError ? "Error" : "Output";
|
||||
|
||||
// Heuristics for language
|
||||
let lang = "plain";
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
lang = "json";
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
} else if (/^(\+\+\+|---|@@) /m.test(text) || /^diff --git /m.test(text)) {
|
||||
lang = "diff";
|
||||
} else if (tool === "bash") {
|
||||
lang = "bash";
|
||||
}
|
||||
|
||||
return <CodeBlock code={text} lang={lang} label={label} tone={isError ? "danger" : "default"} />;
|
||||
}
|
||||
|
||||
export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isError = toolResult?.is_error;
|
||||
const hasResult = toolResult != null;
|
||||
const summary = buildSummary(toolUse);
|
||||
const style = styleForTool(toolUse.name);
|
||||
const Icon = style.Icon;
|
||||
|
||||
const wrapperBorder = isError ? "border-red-500/30" : style.border;
|
||||
const wrapperBg = isError ? "bg-red-500/5" : "bg-surface-2/60";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border ${wrapperBorder} ${wrapperBg} overflow-hidden transition-colors`}
|
||||
>
|
||||
{/* Collapsed/expanded toggle */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-gray-500 flex-shrink-0 transition-transform duration-150 ${
|
||||
expanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center justify-center w-5 h-5 rounded ${style.chip}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
</span>
|
||||
<span className={`font-mono font-medium text-[13px] flex-shrink-0 ${style.text}`}>
|
||||
{toolUse.name}
|
||||
</span>
|
||||
{summary && (
|
||||
<span className="text-gray-500 text-xs font-mono truncate min-w-0" title={summary}>
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex-shrink-0">
|
||||
{isError ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-red-300 bg-red-500/15 border border-red-500/20 rounded px-1.5 py-0.5">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
error
|
||||
</span>
|
||||
) : hasResult ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-emerald-300/80 bg-emerald-500/10 border border-emerald-500/20 rounded px-1.5 py-0.5">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
ok
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-[10px] uppercase tracking-wider font-mono">
|
||||
pending
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded body */}
|
||||
{expanded && (
|
||||
<div className="border-t border-surface-3 bg-surface-1/40 px-3 py-3 space-y-2.5 animate-fade-in">
|
||||
{renderInput(toolUse)}
|
||||
{hasResult && renderResult(toolResult, toolUse.name ?? "")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file MarkdownContent.test.tsx
|
||||
* @description Tests for the lightweight markdown renderer used by the conversation viewer.
|
||||
* Focuses on the block parser since the inline parser is well-exercised by snapshot-style
|
||||
* DOM assertions.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
|
||||
describe("<MarkdownContent />", () => {
|
||||
it("renders fenced code blocks with the language label", () => {
|
||||
render(<MarkdownContent text={"Here is some code:\n```js\nconst x = 1;\n```"} />);
|
||||
// The CodeBlock header shows the language
|
||||
expect(screen.getByText(/javascript/i)).toBeInTheDocument();
|
||||
// The code text is present (split across syntax-highlighted spans, so use a substring)
|
||||
expect(screen.getByText(/const/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders headings as semantic-looking elements", () => {
|
||||
render(<MarkdownContent text={"# Title\n\nbody"} />);
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders unordered and ordered lists", () => {
|
||||
const { container } = render(<MarkdownContent text={"- one\n- two\n\n1. first\n2. second"} />);
|
||||
expect(container.querySelectorAll("ul li")).toHaveLength(2);
|
||||
expect(container.querySelectorAll("ol li")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders blockquotes", () => {
|
||||
const { container } = render(<MarkdownContent text={"> a quote"} />);
|
||||
expect(container.querySelector("blockquote")).not.toBeNull();
|
||||
expect(screen.getByText("a quote")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders inline code, bold, and italic", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent text={"This has `code`, **bold**, and *italic*."} />
|
||||
);
|
||||
expect(container.querySelector("code")).not.toBeNull();
|
||||
expect(container.querySelector("strong")).not.toBeNull();
|
||||
expect(container.querySelector("em")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("auto-links bare URLs and renders explicit markdown links", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent text={"See https://example.com or [docs](https://example.com/docs)."} />
|
||||
);
|
||||
const links = container.querySelectorAll("a");
|
||||
expect(links.length).toBe(2);
|
||||
expect(links[0]!.getAttribute("href")).toBe("https://example.com");
|
||||
expect(links[1]!.getAttribute("href")).toBe("https://example.com/docs");
|
||||
// Both should open in a new tab safely
|
||||
for (const a of links) {
|
||||
expect(a.getAttribute("target")).toBe("_blank");
|
||||
expect(a.getAttribute("rel")).toContain("noopener");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders plain text without any markdown features", () => {
|
||||
render(<MarkdownContent text={"just a normal sentence."} />);
|
||||
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles empty input safely", () => {
|
||||
const { container } = render(<MarkdownContent text="" />);
|
||||
// Wrapper exists but no block elements
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file MessageList.sender.test.tsx
|
||||
* @description Verifies the transcript renders each message under its TRUE
|
||||
* sender label — User / Assistant / Main agent / System — instead of labeling
|
||||
* every `type:"user"` line "User" (reported transcript mis-attribution).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
return {
|
||||
type: "user",
|
||||
timestamp: "2026-06-26T08:14:00.000Z",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
...partial,
|
||||
} as TranscriptMessage;
|
||||
}
|
||||
|
||||
describe("MessageList — sender attribution", () => {
|
||||
it("labels each row by its sender, not blanket 'User'", () => {
|
||||
const messages: TranscriptMessage[] = [
|
||||
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "text", text: "on it" }],
|
||||
}),
|
||||
msg({
|
||||
sender: "system",
|
||||
content: [{ type: "text", text: "<task-notification>\n<task-id>x</task-id>\n" }],
|
||||
}),
|
||||
msg({ sender: "orchestrator", content: [{ type: "text", text: "Light research task…" }] }),
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByText("System")).toBeInTheDocument();
|
||||
expect(screen.getByText("Main agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to type-based labels when sender is absent (legacy payloads)", () => {
|
||||
const messages: TranscriptMessage[] = [
|
||||
msg({ content: [{ type: "text", text: "hi there" }] }), // no sender → "User"
|
||||
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @file toolStyle.ts
|
||||
* @description Per-tool visual styling - icon component, accent colour, and tinted
|
||||
* surface classes. Keeps the conversation viewer's tool blocks visually distinct so
|
||||
* users can scan a long transcript quickly.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolStyle` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `styleForTool` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolStyle**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **styleForTool**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
Wrench,
|
||||
Terminal,
|
||||
FileText,
|
||||
FilePlus2,
|
||||
FilePen,
|
||||
Search,
|
||||
Globe,
|
||||
Bot,
|
||||
ListTodo,
|
||||
Clock,
|
||||
Sparkles,
|
||||
FolderTree,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface ToolStyle {
|
||||
Icon: LucideIcon;
|
||||
/** Tailwind text colour for the icon and tool name. */
|
||||
text: string;
|
||||
/** Tailwind tinted background for the icon chip (15% opacity - sits behind
|
||||
* the icon glyph; staying low-saturation keeps the icon legible). */
|
||||
chip: string;
|
||||
/** Tailwind background for solid fills like progress bars (60% opacity -
|
||||
* high enough to read at a glance against the dark surface, distinct
|
||||
* from the chip used for the icon backdrop). */
|
||||
bar: string;
|
||||
/** Tailwind border colour for the tool block when not in error state. */
|
||||
border: string;
|
||||
}
|
||||
|
||||
const VIOLET: ToolStyle = {
|
||||
Icon: Wrench,
|
||||
text: "text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-300",
|
||||
bar: "bg-violet-500/60",
|
||||
border: "border-violet-500/20",
|
||||
};
|
||||
|
||||
const STYLES: Record<string, ToolStyle> = {
|
||||
bash: {
|
||||
Icon: Terminal,
|
||||
text: "text-emerald-300",
|
||||
chip: "bg-emerald-500/15 text-emerald-300",
|
||||
bar: "bg-emerald-500/60",
|
||||
border: "border-emerald-500/20",
|
||||
},
|
||||
read: {
|
||||
Icon: FileText,
|
||||
text: "text-sky-300",
|
||||
chip: "bg-sky-500/15 text-sky-300",
|
||||
bar: "bg-sky-500/60",
|
||||
border: "border-sky-500/20",
|
||||
},
|
||||
write: {
|
||||
Icon: FilePlus2,
|
||||
text: "text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-300",
|
||||
bar: "bg-violet-500/60",
|
||||
border: "border-violet-500/20",
|
||||
},
|
||||
edit: {
|
||||
Icon: FilePen,
|
||||
text: "text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-300",
|
||||
bar: "bg-amber-500/60",
|
||||
border: "border-amber-500/20",
|
||||
},
|
||||
multiedit: {
|
||||
Icon: FilePen,
|
||||
text: "text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-300",
|
||||
bar: "bg-amber-500/60",
|
||||
border: "border-amber-500/20",
|
||||
},
|
||||
grep: {
|
||||
Icon: Search,
|
||||
text: "text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-300",
|
||||
bar: "bg-cyan-500/60",
|
||||
border: "border-cyan-500/20",
|
||||
},
|
||||
glob: {
|
||||
Icon: FolderTree,
|
||||
text: "text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-300",
|
||||
bar: "bg-cyan-500/60",
|
||||
border: "border-cyan-500/20",
|
||||
},
|
||||
webfetch: {
|
||||
Icon: Globe,
|
||||
text: "text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-300",
|
||||
bar: "bg-blue-500/60",
|
||||
border: "border-blue-500/20",
|
||||
},
|
||||
websearch: {
|
||||
Icon: Globe,
|
||||
text: "text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-300",
|
||||
bar: "bg-blue-500/60",
|
||||
border: "border-blue-500/20",
|
||||
},
|
||||
task: {
|
||||
Icon: Bot,
|
||||
text: "text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-300",
|
||||
bar: "bg-pink-500/60",
|
||||
border: "border-pink-500/20",
|
||||
},
|
||||
agent: {
|
||||
Icon: Bot,
|
||||
text: "text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-300",
|
||||
bar: "bg-pink-500/60",
|
||||
border: "border-pink-500/20",
|
||||
},
|
||||
todowrite: {
|
||||
Icon: ListTodo,
|
||||
text: "text-rose-300",
|
||||
chip: "bg-rose-500/15 text-rose-300",
|
||||
bar: "bg-rose-500/60",
|
||||
border: "border-rose-500/20",
|
||||
},
|
||||
schedulewakeup: {
|
||||
Icon: Clock,
|
||||
text: "text-orange-300",
|
||||
chip: "bg-orange-500/15 text-orange-300",
|
||||
bar: "bg-orange-500/60",
|
||||
border: "border-orange-500/20",
|
||||
},
|
||||
skill: {
|
||||
Icon: Sparkles,
|
||||
text: "text-fuchsia-300",
|
||||
chip: "bg-fuchsia-500/15 text-fuchsia-300",
|
||||
bar: "bg-fuchsia-500/60",
|
||||
border: "border-fuchsia-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
export function styleForTool(toolName: string | undefined | null): ToolStyle {
|
||||
if (!toolName) return VIOLET;
|
||||
const key = toolName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
return STYLES[key] ?? VIOLET;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file tuiSegments.ts
|
||||
* @description Parses Claude TUI tag markup that appears in user messages -
|
||||
* caveats, command invocations, captured stdout/stderr, system reminders -
|
||||
* into a flat segment list the renderer can lay out inline. Also strips bare
|
||||
* ANSI/SGR escape sequences (e.g. "[1m...[22m") that survive the JSONL pipe
|
||||
* so messages render as plain text instead of leaking codes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `TuiSegment` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `stripAnsi` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `parseTuiSegments` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `hasTuiTags` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **TuiSegment**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **stripAnsi**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **parseTuiSegments**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **hasTuiTags**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
export type TuiSegment =
|
||||
| { kind: "caveat"; text: string }
|
||||
| { kind: "stdout"; text: string }
|
||||
| { kind: "stderr"; text: string }
|
||||
| { kind: "system-reminder"; text: string }
|
||||
| { kind: "persisted-output"; text: string }
|
||||
| { kind: "command"; display: string }
|
||||
| { kind: "text"; text: string };
|
||||
|
||||
const SIMPLE_TAGS: Record<string, TuiSegment["kind"]> = {
|
||||
"local-command-caveat": "caveat",
|
||||
"local-command-stdout": "stdout",
|
||||
"local-command-stderr": "stderr",
|
||||
"system-reminder": "system-reminder",
|
||||
"persisted-output": "persisted-output",
|
||||
};
|
||||
|
||||
const COMMAND_TAGS = ["command-name", "command-message", "command-args"] as const;
|
||||
|
||||
const KNOWN_TAG_RE = new RegExp(
|
||||
`<(?:${[...Object.keys(SIMPLE_TAGS), ...COMMAND_TAGS].join("|")})\\b`
|
||||
);
|
||||
|
||||
// Strip both real ESC-prefixed SGR codes and the bare "[Nm" forms that show up
|
||||
// when the ESC byte is dropped during JSON encoding. Only matches when followed
|
||||
// by `m` (the SGR terminator), so it does not eat ordinary bracketed text.
|
||||
const ANSI_RE = /\[[\d;]*m|\[\d+(?:;\d+)*m/g;
|
||||
|
||||
export function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_RE, "");
|
||||
}
|
||||
|
||||
interface MatchSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
segment: TuiSegment;
|
||||
}
|
||||
|
||||
function findSimpleTagMatches(input: string): MatchSpan[] {
|
||||
const matches: MatchSpan[] = [];
|
||||
for (const [tag, kind] of Object.entries(SIMPLE_TAGS)) {
|
||||
const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g");
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(input)) !== null) {
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
segment: { kind, text: m[1] ?? "" } as TuiSegment,
|
||||
});
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function findCommandBlocks(input: string): MatchSpan[] {
|
||||
// A command block is one or more <command-name|message|args> tags possibly
|
||||
// separated by whitespace. Group them so a single pill renders even when
|
||||
// the tags arrive in name -> message -> args order.
|
||||
const re = /(?:<command-(?:name|message|args)>[^<]*<\/command-(?:name|message|args)>\s*){1,3}/g;
|
||||
const out: MatchSpan[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(input)) !== null) {
|
||||
const block = m[0];
|
||||
const name = /<command-name>([^<]*)<\/command-name>/.exec(block)?.[1] ?? "";
|
||||
const args = /<command-args>([^<]*)<\/command-args>/.exec(block)?.[1] ?? "";
|
||||
if (!name) continue;
|
||||
const trimmedArgs = args.trim();
|
||||
out.push({
|
||||
start: m.index,
|
||||
end: m.index + block.length,
|
||||
segment: {
|
||||
kind: "command",
|
||||
display: trimmedArgs ? `${name} ${trimmedArgs}` : name,
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a message text and splits out recognized TUI/command segments while
|
||||
* preserving the surrounding prose as `text` segments. Returns a single
|
||||
* `text` segment for inputs that contain no recognized markup.
|
||||
*/
|
||||
export function parseTuiSegments(input: string): TuiSegment[] {
|
||||
if (!KNOWN_TAG_RE.test(input)) {
|
||||
return [{ kind: "text", text: input }];
|
||||
}
|
||||
|
||||
const matches = [...findSimpleTagMatches(input), ...findCommandBlocks(input)].sort(
|
||||
(a, b) => a.start - b.start
|
||||
);
|
||||
|
||||
const segments: TuiSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const m of matches) {
|
||||
if (m.start < cursor) continue;
|
||||
if (m.start > cursor) {
|
||||
const between = input.slice(cursor, m.start);
|
||||
if (between.trim()) {
|
||||
segments.push({ kind: "text", text: between });
|
||||
}
|
||||
}
|
||||
segments.push(m.segment);
|
||||
cursor = m.end;
|
||||
}
|
||||
if (cursor < input.length) {
|
||||
const tail = input.slice(cursor);
|
||||
if (tail.trim()) segments.push({ kind: "text", text: tail });
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ kind: "text", text: input }];
|
||||
}
|
||||
|
||||
/** True if any recognized TUI tag would alter the rendering of this text. */
|
||||
export function hasTuiTags(input: string): boolean {
|
||||
return KNOWN_TAG_RE.test(input);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* @file primitives.tsx
|
||||
* @description Presentational building blocks used by the per-tool input and
|
||||
* response renderers. Each primitive is a pure component with a narrow,
|
||||
* typed contract so they can be composed freely (Terminal + TerminalOutput for
|
||||
* Bash; Terminal + UnifiedDiff for Edit; LineNumberedCode for Read/Write;
|
||||
* FileList/MatchList for Grep/Glob; KeyValueCard for MCP tools).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `CopyButton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `Terminal` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TerminalOutput` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `LineNumberedCode` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `DiffHunk` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `UnifiedDiff` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `KeyValueCard` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `FileList` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `GrepMatch` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `MatchList` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **CopyButton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **Terminal**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TerminalOutput**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **LineNumberedCode**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **DiffHunk**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **UnifiedDiff**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **KeyValueCard**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **FileList**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **GrepMatch**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **MatchList**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
// ───────────────────────── Copy button ─────────────────────────
|
||||
|
||||
export function CopyButton({ text }: { text: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API can fail in insecure contexts - silently ignore.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer"
|
||||
aria-label={t("eventDetail.copy")}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
{copied ? t("eventDetail.copied") : t("eventDetail.copy")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Terminal (command) ─────────────────────────
|
||||
|
||||
export function Terminal({ command, description }: { command: string; description?: string }) {
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">terminal</span>
|
||||
<CopyButton text={command} />
|
||||
</div>
|
||||
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words">
|
||||
{description && <div className="text-gray-500 mb-1"># {description}</div>}
|
||||
<div>
|
||||
<span className="text-emerald-400 select-none">$ </span>
|
||||
{command}
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Terminal output (stdout/stderr) ─────────────────────────
|
||||
|
||||
export function TerminalOutput({
|
||||
stdout,
|
||||
stderr,
|
||||
interrupted,
|
||||
exitCode,
|
||||
}: {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
interrupted?: boolean;
|
||||
exitCode?: number;
|
||||
}) {
|
||||
const hasStdout = typeof stdout === "string" && stdout.length > 0;
|
||||
const hasStderr = typeof stderr === "string" && stderr.length > 0;
|
||||
const flag =
|
||||
interrupted === true
|
||||
? { label: "interrupted", color: "text-red-400 border-red-500/40 bg-red-500/10" }
|
||||
: typeof exitCode === "number" && exitCode !== 0
|
||||
? {
|
||||
label: `exit ${exitCode}`,
|
||||
color: "text-red-400 border-red-500/40 bg-red-500/10",
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{hasStdout && <OutputBlock label="stdout" text={stdout!} variant="out" />}
|
||||
{hasStderr && <OutputBlock label="stderr" text={stderr!} variant="err" />}
|
||||
{flag && (
|
||||
<span
|
||||
className={`inline-block text-[10px] px-2 py-0.5 rounded border font-mono ${flag.color}`}
|
||||
>
|
||||
{flag.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputBlock({
|
||||
label,
|
||||
text,
|
||||
variant,
|
||||
}: {
|
||||
label: string;
|
||||
text: string;
|
||||
variant: "out" | "err";
|
||||
}) {
|
||||
const color = variant === "err" ? "text-red-300" : "text-gray-200";
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
|
||||
<CopyButton text={text} />
|
||||
</div>
|
||||
<pre className={`px-3 py-2 whitespace-pre-wrap break-words max-h-96 overflow-auto ${color}`}>
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Line-numbered code ─────────────────────────
|
||||
|
||||
export function LineNumberedCode({
|
||||
text,
|
||||
maxHeight = "24rem",
|
||||
startLine = 1,
|
||||
label,
|
||||
}: {
|
||||
text: string;
|
||||
maxHeight?: string;
|
||||
startLine?: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
{label && (
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
|
||||
<CopyButton text={text} />
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto" style={{ maxHeight }}>
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 text-right text-gray-600 select-none bg-black/40 border-r border-border align-top">
|
||||
{i + startLine}
|
||||
</td>
|
||||
<td className="px-3 text-gray-200 whitespace-pre-wrap break-words">{line}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Unified diff ─────────────────────────
|
||||
|
||||
export type DiffHunk = {
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
oldLines: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
|
||||
if (hunks.length === 0) {
|
||||
return <p className="text-[11px] text-gray-500 italic">no diff</p>;
|
||||
}
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="overflow-auto max-h-96">
|
||||
{hunks.map((hunk, i) => (
|
||||
<HunkView key={i} hunk={hunk} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HunkView({ hunk }: { hunk: DiffHunk }) {
|
||||
let oldLine = hunk.oldStart;
|
||||
let newLine = hunk.newStart;
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
|
||||
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
|
||||
</div>
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{hunk.lines.map((line, i) => {
|
||||
const kind = line.startsWith("+") ? "add" : line.startsWith("-") ? "remove" : "ctx";
|
||||
const body = line.slice(kind === "ctx" ? 0 : 1);
|
||||
const showOld = kind !== "add";
|
||||
const showNew = kind !== "remove";
|
||||
const rowBg =
|
||||
kind === "add"
|
||||
? "bg-green-500/10 text-green-200"
|
||||
: kind === "remove"
|
||||
? "bg-red-500/10 text-red-200"
|
||||
: "text-gray-300";
|
||||
const oldCell = showOld ? oldLine++ : "";
|
||||
const newCell = showNew ? newLine++ : "";
|
||||
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
||||
return (
|
||||
<tr key={i} className={rowBg}>
|
||||
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
|
||||
{oldCell}
|
||||
</td>
|
||||
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
|
||||
{newCell}
|
||||
</td>
|
||||
<td className="px-1 text-center select-none w-[16px]">{sign}</td>
|
||||
<td className="px-2 whitespace-pre-wrap break-words">{body}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── Key-value card ─────────────────────────
|
||||
|
||||
export function KeyValueCard({
|
||||
data,
|
||||
priority = [],
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
priority?: string[];
|
||||
}) {
|
||||
const entries = Object.entries(data);
|
||||
const priorityEntries = priority
|
||||
.map((k) => [k, data[k]] as [string, unknown])
|
||||
.filter(([, v]) => v !== undefined);
|
||||
const restEntries = entries.filter(([k]) => !priority.includes(k));
|
||||
const ordered = [...priorityEntries, ...restEntries];
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return <p className="text-[11px] text-gray-500 italic">empty</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full text-[11px] border border-border rounded overflow-hidden">
|
||||
<tbody>
|
||||
{ordered.map(([k, v], i) => (
|
||||
<tr key={k} className={i > 0 ? "border-t border-border" : ""}>
|
||||
<td className="text-gray-500 align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all">
|
||||
{k}
|
||||
</td>
|
||||
<td className="text-gray-300 align-top py-1.5 px-2">
|
||||
<ValueCell value={v} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueCell({ value }: { value: unknown }) {
|
||||
if (value == null) return <span className="text-gray-500 italic">null</span>;
|
||||
if (typeof value === "boolean")
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
|
||||
value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-gray-400 border-gray-500/30 bg-gray-500/10"
|
||||
}`}
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
if (typeof value === "number") return <span className="font-mono text-gray-300">{value}</span>;
|
||||
if (typeof value === "string") {
|
||||
if (value.length > 120 || value.includes("\n")) {
|
||||
return (
|
||||
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-gray-300 break-all">{value}</span>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-gray-500 italic">[]</span>;
|
||||
return (
|
||||
<ol className="list-decimal pl-4 space-y-1">
|
||||
{value.map((item, i) => (
|
||||
<li key={i}>
|
||||
<ValueCell value={item} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
||||
{safeStringify(value)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── File list / match list ─────────────────────────
|
||||
|
||||
export function FileList({ paths }: { paths: string[] }) {
|
||||
if (paths.length === 0) return <p className="text-[11px] text-gray-500 italic">no files</p>;
|
||||
return (
|
||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto">
|
||||
{paths.map((p, i) => (
|
||||
<li key={i} className="px-3 py-1 font-mono text-gray-300 break-all">
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export type GrepMatch = {
|
||||
file?: string;
|
||||
line?: number;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export function MatchList({ matches }: { matches: GrepMatch[] }) {
|
||||
if (matches.length === 0) return <p className="text-[11px] text-gray-500 italic">no matches</p>;
|
||||
return (
|
||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
|
||||
{matches.map((m, i) => (
|
||||
<li key={i} className="px-3 py-1 text-gray-300 break-all">
|
||||
{m.file && <span className="text-cyan-300">{m.file}</span>}
|
||||
{m.line != null && <span className="text-gray-500">:{m.line}</span>}
|
||||
{m.text && <span className="text-gray-400">: {m.text}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* @file tool-views.tsx
|
||||
* @description Per-tool renderers for `tool_input` and `tool_response` fields.
|
||||
* Dispatched from EventDetail when the event's `tool_name` is recognised.
|
||||
* Unknown tools fall back to the caller's generic JSON code view.
|
||||
*
|
||||
* Handled tools:
|
||||
* - Bash / PowerShell → Terminal + TerminalOutput
|
||||
* - Edit / NotebookEdit → Terminal + UnifiedDiff (from old/new + structuredPatch)
|
||||
* - Read → Terminal + LineNumberedCode
|
||||
* - Write → Terminal + LineNumberedCode
|
||||
* - Grep → Terminal + MatchList
|
||||
* - Glob → Terminal + FileList
|
||||
* - WebFetch → Terminal + LineNumberedCode
|
||||
* - Task / Agent → metadata + prompt
|
||||
* - mcp__* → KeyValueCard with known semantic fields promoted
|
||||
* - AskUserQuestion → formatted Q with options
|
||||
* - Any other → returns null (caller falls back to generic JSON)
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./primitives`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolInputView` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ToolResponseView` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolInputView**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ToolResponseView**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
FileList,
|
||||
KeyValueCard,
|
||||
LineNumberedCode,
|
||||
MatchList,
|
||||
Terminal,
|
||||
TerminalOutput,
|
||||
UnifiedDiff,
|
||||
} from "./primitives";
|
||||
import type { DiffHunk, GrepMatch } from "./primitives";
|
||||
|
||||
// ───────────────────────── Helpers ─────────────────────────
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
function obj(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function isMcp(toolName: string): boolean {
|
||||
return toolName.startsWith("mcp__");
|
||||
}
|
||||
|
||||
/** Builds a unified-diff hunk from a bare old_string / new_string pair. One
|
||||
* hunk, minimal context - good enough for the input preview before the
|
||||
* actual structuredPatch comes back in the response. */
|
||||
function diffFromStrings(oldStr: string, newStr: string): DiffHunk[] {
|
||||
if (!oldStr && !newStr) return [];
|
||||
const oldLines = oldStr ? oldStr.split(/\r?\n/) : [];
|
||||
const newLines = newStr ? newStr.split(/\r?\n/) : [];
|
||||
const lines: string[] = [];
|
||||
for (const l of oldLines) lines.push(`-${l}`);
|
||||
for (const l of newLines) lines.push(`+${l}`);
|
||||
return [
|
||||
{
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
oldLines: oldLines.length,
|
||||
newLines: newLines.length,
|
||||
lines,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Normalises the `structuredPatch` array that shows up in Edit/NotebookEdit
|
||||
* tool_response into DiffHunk shape. Tolerates missing fields. */
|
||||
function parseStructuredPatch(value: unknown): DiffHunk[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const hunks: DiffHunk[] = [];
|
||||
for (const raw of value) {
|
||||
const r = obj(raw);
|
||||
if (!r) continue;
|
||||
const lines = Array.isArray(r.lines)
|
||||
? (r.lines.filter((l) => typeof l === "string") as string[])
|
||||
: [];
|
||||
hunks.push({
|
||||
oldStart: typeof r.oldStart === "number" ? r.oldStart : 1,
|
||||
newStart: typeof r.newStart === "number" ? r.newStart : 1,
|
||||
oldLines: typeof r.oldLines === "number" ? r.oldLines : lines.length,
|
||||
newLines: typeof r.newLines === "number" ? r.newLines : lines.length,
|
||||
lines,
|
||||
});
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** Best-effort match list from a Grep tool_response. Supports the common
|
||||
* shapes: array of strings, array of {file,line,text}, or an object with
|
||||
* `matches` / `files` keys. */
|
||||
function parseGrepMatches(value: unknown): GrepMatch[] {
|
||||
if (Array.isArray(value)) return value.map(toMatch).filter(Boolean) as GrepMatch[];
|
||||
const o = obj(value);
|
||||
if (!o) return [];
|
||||
if (Array.isArray(o.matches)) return o.matches.map(toMatch).filter(Boolean) as GrepMatch[];
|
||||
if (Array.isArray(o.files))
|
||||
return (o.files as unknown[])
|
||||
.map((f) => (typeof f === "string" ? ({ file: f } as GrepMatch) : null))
|
||||
.filter(Boolean) as GrepMatch[];
|
||||
return [];
|
||||
}
|
||||
|
||||
function toMatch(raw: unknown): GrepMatch | null {
|
||||
if (typeof raw === "string") {
|
||||
const m = raw.match(/^(.+?):(\d+):(.*)$/);
|
||||
if (m) return { file: m[1], line: Number(m[2]), text: m[3] };
|
||||
return { text: raw };
|
||||
}
|
||||
const o = obj(raw);
|
||||
if (!o) return null;
|
||||
const match: GrepMatch = {};
|
||||
if (typeof o.file === "string") match.file = o.file;
|
||||
if (typeof o.path === "string" && !match.file) match.file = o.path;
|
||||
if (typeof o.line === "number") match.line = o.line;
|
||||
if (typeof o.line_number === "number" && match.line == null) match.line = o.line_number;
|
||||
if (typeof o.text === "string") match.text = o.text;
|
||||
if (typeof o.match === "string" && !match.text) match.text = o.match;
|
||||
if (typeof o.content === "string" && !match.text) match.text = o.content;
|
||||
return match;
|
||||
}
|
||||
|
||||
function parseFileList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string");
|
||||
const o = obj(value);
|
||||
if (!o) return [];
|
||||
if (Array.isArray(o.files))
|
||||
return (o.files as unknown[]).filter((v): v is string => typeof v === "string");
|
||||
if (Array.isArray(o.paths))
|
||||
return (o.paths as unknown[]).filter((v): v is string => typeof v === "string");
|
||||
return [];
|
||||
}
|
||||
|
||||
// ───────────────────────── Top-level dispatchers ─────────────────────────
|
||||
|
||||
/** Returns a rendered view for the tool's input, or null when the tool isn't
|
||||
* specifically handled (caller renders JSON fallback). */
|
||||
export function ToolInputView({
|
||||
toolName,
|
||||
input,
|
||||
}: {
|
||||
toolName: string | null;
|
||||
input: unknown;
|
||||
}): React.ReactNode | null {
|
||||
if (!toolName) return null;
|
||||
const i = obj(input);
|
||||
if (!i) return null;
|
||||
|
||||
// MCP tools - show the input as a key-value card with URL/query/id promoted.
|
||||
if (isMcp(toolName)) {
|
||||
return (
|
||||
<KeyValueCard
|
||||
data={i}
|
||||
priority={[
|
||||
"url",
|
||||
"query",
|
||||
"q",
|
||||
"title",
|
||||
"name",
|
||||
"id",
|
||||
"page_id",
|
||||
"task_id",
|
||||
"merge_request_iid",
|
||||
"issue_iid",
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
case "PowerShell": {
|
||||
const cmd = str(i.command);
|
||||
const desc = str(i.description);
|
||||
if (!cmd) return null;
|
||||
return <Terminal command={cmd} description={desc || undefined} />;
|
||||
}
|
||||
case "Read": {
|
||||
const path = str(i.file_path);
|
||||
if (!path) return null;
|
||||
const flags: string[] = [];
|
||||
if (i.offset != null) flags.push(`--offset=${i.offset}`);
|
||||
if (i.limit != null) flags.push(`--limit=${i.limit}`);
|
||||
return <Terminal command={`read ${path}${flags.length ? " " + flags.join(" ") : ""}`} />;
|
||||
}
|
||||
case "Write": {
|
||||
const path = str(i.file_path);
|
||||
const content = str(i.content);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{path && <Terminal command={`write ${path}`} />}
|
||||
{content && <LineNumberedCode text={content} label="content" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Edit":
|
||||
case "NotebookEdit": {
|
||||
const path = str(i.file_path);
|
||||
const oldStr = str(i.old_string);
|
||||
const newStr = str(i.new_string);
|
||||
const hunks = diffFromStrings(oldStr, newStr);
|
||||
const replaceAll = i.replace_all === true ? " --replace-all" : "";
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{path && <Terminal command={`edit ${path}${replaceAll}`} />}
|
||||
{hunks.length > 0 && <UnifiedDiff hunks={hunks} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Grep": {
|
||||
const pattern = str(i.pattern);
|
||||
const path = str(i.path);
|
||||
const flags = [
|
||||
i.glob ? `--glob=${str(i.glob)}` : null,
|
||||
i.type ? `--type=${str(i.type)}` : null,
|
||||
i.output_mode ? `--mode=${str(i.output_mode)}` : null,
|
||||
i["-i"] ? "-i" : null,
|
||||
i["-n"] ? "-n" : null,
|
||||
].filter(Boolean) as string[];
|
||||
const cmd = `grep "${pattern}"${path ? " " + path : ""}${flags.length ? " " + flags.join(" ") : ""}`;
|
||||
return <Terminal command={cmd} />;
|
||||
}
|
||||
case "Glob": {
|
||||
const pattern = str(i.pattern);
|
||||
const path = str(i.path);
|
||||
return <Terminal command={`glob "${pattern}"${path ? " " + path : ""}`} />;
|
||||
}
|
||||
case "WebFetch": {
|
||||
const url = str(i.url);
|
||||
const prompt = str(i.prompt);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{url && <Terminal command={`fetch ${url}`} description={prompt || undefined} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Task":
|
||||
case "Agent": {
|
||||
const desc = str(i.description);
|
||||
const subtype = str(i.subagent_type);
|
||||
const prompt = str(i.prompt);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<KeyValueCard
|
||||
data={{
|
||||
...(desc ? { description: desc } : {}),
|
||||
...(subtype ? { subagent_type: subtype } : {}),
|
||||
}}
|
||||
/>
|
||||
{prompt && <LineNumberedCode text={prompt} label="prompt" maxHeight="16rem" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "TaskCreate":
|
||||
case "TaskUpdate":
|
||||
case "TaskGet":
|
||||
case "TaskStop":
|
||||
case "TaskOutput":
|
||||
case "TaskList":
|
||||
return <KeyValueCard data={i} priority={["description", "id", "status", "activeForm"]} />;
|
||||
case "AskUserQuestion": {
|
||||
const questions = Array.isArray(i.questions) ? i.questions : null;
|
||||
if (!questions) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{questions.map((q, idx) => {
|
||||
const qo = obj(q);
|
||||
if (!qo) return null;
|
||||
return (
|
||||
<KeyValueCard
|
||||
key={idx}
|
||||
data={qo}
|
||||
priority={["question", "options", "multiSelect", "header"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a rendered view for the tool's response, or null when the tool
|
||||
* isn't specifically handled (caller renders JSON fallback). */
|
||||
export function ToolResponseView({
|
||||
toolName,
|
||||
response,
|
||||
}: {
|
||||
toolName: string | null;
|
||||
response: unknown;
|
||||
}): React.ReactNode | null {
|
||||
if (!toolName) return null;
|
||||
|
||||
if (isMcp(toolName)) {
|
||||
const r = obj(response);
|
||||
if (r)
|
||||
return (
|
||||
<KeyValueCard
|
||||
data={r}
|
||||
priority={[
|
||||
"title",
|
||||
"name",
|
||||
"state",
|
||||
"status",
|
||||
"url",
|
||||
"id",
|
||||
"iid",
|
||||
"author",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]}
|
||||
/>
|
||||
);
|
||||
// Non-object responses (string, array) fall through to generic.
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
case "PowerShell": {
|
||||
const r = obj(response);
|
||||
if (!r) return null;
|
||||
return (
|
||||
<TerminalOutput
|
||||
stdout={typeof r.stdout === "string" ? r.stdout : undefined}
|
||||
stderr={typeof r.stderr === "string" ? r.stderr : undefined}
|
||||
interrupted={r.interrupted === true}
|
||||
exitCode={typeof r.exitCode === "number" ? r.exitCode : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "Edit":
|
||||
case "NotebookEdit": {
|
||||
const r = obj(response);
|
||||
if (!r) return null;
|
||||
const hunks = parseStructuredPatch(r.structuredPatch);
|
||||
const originalFile = typeof r.originalFile === "string" ? r.originalFile : "";
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{hunks.length > 0 && <UnifiedDiff hunks={hunks} />}
|
||||
{originalFile && (
|
||||
<details className="bg-surface-2/40 border border-border rounded overflow-hidden">
|
||||
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-gray-400 hover:text-gray-200 hover:bg-surface-2">
|
||||
<span className="font-semibold uppercase tracking-wide">original file</span>
|
||||
<span className="text-gray-500 font-normal ml-2">
|
||||
({originalFile.split(/\r?\n/).length} lines)
|
||||
</span>
|
||||
</summary>
|
||||
<div className="p-2">
|
||||
<LineNumberedCode text={originalFile} maxHeight="24rem" />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Read": {
|
||||
if (typeof response === "string") return <LineNumberedCode text={response} />;
|
||||
const r = obj(response);
|
||||
if (r && typeof r.content === "string") return <LineNumberedCode text={r.content} />;
|
||||
return null;
|
||||
}
|
||||
case "Write": {
|
||||
const r = obj(response);
|
||||
if (!r) return null;
|
||||
return <KeyValueCard data={r} priority={["filePath", "type", "bytes"]} />;
|
||||
}
|
||||
case "Grep": {
|
||||
const matches = parseGrepMatches(response);
|
||||
if (matches.length === 0) return null;
|
||||
return <MatchList matches={matches} />;
|
||||
}
|
||||
case "Glob": {
|
||||
const files = parseFileList(response);
|
||||
if (files.length === 0) return null;
|
||||
return <FileList paths={files} />;
|
||||
}
|
||||
case "WebFetch": {
|
||||
if (typeof response === "string") return <LineNumberedCode text={response} />;
|
||||
const r = obj(response);
|
||||
if (r && typeof r.content === "string") return <LineNumberedCode text={r.content} />;
|
||||
if (r) return <KeyValueCard data={r} priority={["url", "title", "status", "content"]} />;
|
||||
return null;
|
||||
}
|
||||
case "Task":
|
||||
case "Agent": {
|
||||
if (typeof response === "string") return <LineNumberedCode text={response} label="output" />;
|
||||
const r = obj(response);
|
||||
if (r) return <KeyValueCard data={r} />;
|
||||
return null;
|
||||
}
|
||||
case "TaskCreate":
|
||||
case "TaskUpdate":
|
||||
case "TaskGet":
|
||||
case "TaskStop":
|
||||
case "TaskOutput":
|
||||
case "TaskList": {
|
||||
const r = obj(response);
|
||||
if (r)
|
||||
return <KeyValueCard data={r} priority={["description", "status", "id", "activeForm"]} />;
|
||||
return null;
|
||||
}
|
||||
case "AskUserQuestion": {
|
||||
const r = obj(response);
|
||||
if (r) return <KeyValueCard data={r} priority={["answer", "option", "question"]} />;
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @file AddLaneModal.tsx
|
||||
* @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to
|
||||
* adopt), pick which of its branches to fork from, name the feature, and the
|
||||
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree`
|
||||
* — the dashboard invents the lane's own directory and branch name, the same
|
||||
* way Shipyard's "+ Add lane" never asks a human to name a folder. The lane
|
||||
* returned is `status: "provisioning"`; the existing `lane_update` WebSocket
|
||||
* subscription in the Workspace page flips it to idle when the worktree is
|
||||
* actually ready, so this component does not poll.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
|
||||
export function AddLaneModal({
|
||||
open,
|
||||
onClose,
|
||||
onAdded,
|
||||
cwdSuggestions,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the newly provisioned (still-provisioning) lane. */
|
||||
onAdded: (lane: Lane) => void;
|
||||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||||
* home, recently-used paths) — reused rather than fetched a second time. */
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [base, setBase] = useState("");
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setSourceRepo("");
|
||||
setTitle("");
|
||||
setBranches(null);
|
||||
setBase("");
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
// Look up the repo's branches once the path settles - debounced so every
|
||||
// keystroke while typing a path doesn't fire a request against a path that
|
||||
// isn't finished yet.
|
||||
const lookedUpFor = useRef<string>("");
|
||||
useEffect(() => {
|
||||
const path = sourceRepo.trim();
|
||||
if (!path) {
|
||||
setBranches(null);
|
||||
setBase("");
|
||||
setBranchesError(null);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(async () => {
|
||||
lookedUpFor.current = path;
|
||||
try {
|
||||
const r = await api.lanes.branches(path);
|
||||
if (lookedUpFor.current !== path) return; // a newer path superseded this one
|
||||
setBranches(r.branches);
|
||||
setBase(r.current || r.branches[0] || "");
|
||||
setBranchesError(null);
|
||||
} catch {
|
||||
if (lookedUpFor.current !== path) return;
|
||||
// Not yet a valid repo path (still being typed, or genuinely wrong) -
|
||||
// quiet by design, the same way CwdAutocomplete never errors either.
|
||||
setBranches(null);
|
||||
setBase("");
|
||||
setBranchesError(t("addLaneNotARepo"));
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sourceRepo, t]);
|
||||
|
||||
const submit = async () => {
|
||||
const repo = sourceRepo.trim();
|
||||
const name = title.trim();
|
||||
if (!repo || !branches || !name) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.lanes.worktree({
|
||||
sourceRepo: repo,
|
||||
title: name,
|
||||
base: base || undefined,
|
||||
});
|
||||
reset();
|
||||
onAdded(result.lane);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s
|
||||
// identity. Every keystroke in the fields below re-renders this component; an
|
||||
// inline `() => {...}` handed to `onCancel` would get a new identity each
|
||||
// time, re-running that effect and yanking focus off the field being typed
|
||||
// into after the very first character. useCallback keeps the identity stable
|
||||
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
||||
const handleCancel = useCallback(() => {
|
||||
reset();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("addLane")}
|
||||
confirmLabel={t("add")}
|
||||
cancelLabel={t("destructive.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!sourceRepo.trim() || !title.trim() || !branches}
|
||||
onConfirm={submit}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-repo">
|
||||
{t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
onChange={setSourceRepo}
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-neutral-500">{t("addLaneRepoHint")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-title">
|
||||
{t("addLaneTitleLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("addLaneTitlePlaceholder")}
|
||||
className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-600 focus:border-blue-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-neutral-500">{t("addLaneNoBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 focus:border-blue-400 focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{branchesError && !branches && (
|
||||
<p className="text-[10px] text-amber-400">{branchesError}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @file DestructiveLaneModal.tsx
|
||||
* @description Fetches and displays the exact lane lifecycle preflight facts
|
||||
* before delegating confirmation controls and accessibility to ConfirmModal.
|
||||
*
|
||||
* The server is the authority on what is permitted; this modal must never be
|
||||
* stricter than it. A blocker that does not actually stop the chosen action is
|
||||
* rendered as context (see HARD_BLOCKERS and noticesFor), not as a refusal —
|
||||
* treating every blocker as fatal is what left "Forget" dead for adopted lanes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane, LanePreflight } from "../../lib/types";
|
||||
|
||||
type DestructiveAction = "reset" | "remove" | "purge";
|
||||
|
||||
export interface DestructiveLaneModalProps {
|
||||
lane: Lane;
|
||||
action: DestructiveAction;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (body: { expect: Record<string, string | number | null>; force?: true }) => void;
|
||||
}
|
||||
|
||||
function expectFor(preflight: LanePreflight): Record<string, string | number | null> {
|
||||
if (preflight.action === "purge") {
|
||||
return {
|
||||
sessions: preflight.sessions,
|
||||
events: preflight.events,
|
||||
tokenRows: preflight.tokenRows,
|
||||
};
|
||||
}
|
||||
return {
|
||||
head: preflight.head,
|
||||
dirty: preflight.dirty,
|
||||
untracked: preflight.untracked,
|
||||
unpushed: preflight.unpushed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which preflight blockers genuinely prevent each action in the UI.
|
||||
*
|
||||
* The server is the authority, and it permits EVERY shape of `remove`: an
|
||||
* adopted lane's record is forgotten with its directory untouched, a
|
||||
* hand-deleted worktree takes the prune path, an unreadable one is force
|
||||
* -removed. So `remove` is blocked here by nothing — treating `adopted` or
|
||||
* `missing` as blockers left the Forget button permanently dead with no
|
||||
* fallback. `reset` really is impossible in all three shapes.
|
||||
*
|
||||
* `unpushed-commits` is force-overridable and is gated by the Force checkbox
|
||||
* instead of by this list.
|
||||
*/
|
||||
const HARD_BLOCKERS: Record<"reset" | "remove", readonly string[]> = {
|
||||
reset: ["adopted", "missing", "unreadable"],
|
||||
remove: [],
|
||||
};
|
||||
|
||||
function blockingReason(preflight: LanePreflight | null): string | null {
|
||||
if (!preflight || preflight.action === "purge") return null;
|
||||
const hard = HARD_BLOCKERS[preflight.action];
|
||||
return preflight.blocked.find((blocker) => hard.includes(blocker)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockers that do not prevent THIS action but still change what it does, shown
|
||||
* as context rather than as an obstacle — e.g. forgetting an adopted lane leaves
|
||||
* its directory alone. `unpushed-commits` is excluded: the Force copy covers it.
|
||||
*/
|
||||
function noticesFor(preflight: LanePreflight | null): string[] {
|
||||
if (!preflight || preflight.action === "purge") return [];
|
||||
const hard = HARD_BLOCKERS[preflight.action];
|
||||
return preflight.blocked.filter(
|
||||
(blocker) => blocker !== "unpushed-commits" && !hard.includes(blocker)
|
||||
);
|
||||
}
|
||||
|
||||
/** Rough byte estimate as a short human string; the server's own number is a guess. */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function warningsFor(preflight: LanePreflight | null): string[] {
|
||||
if (!preflight || preflight.action === "purge") return [];
|
||||
return preflight.warnings;
|
||||
}
|
||||
|
||||
export function DestructiveLaneModal({
|
||||
lane,
|
||||
action,
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DestructiveLaneModalProps) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [preflight, setPreflight] = useState<LanePreflight | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [force, setForce] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPreflight(null);
|
||||
setForce(false);
|
||||
void api.lanes
|
||||
.preflight(lane.id, action)
|
||||
.then((result) => current && setPreflight(result))
|
||||
.catch((err) => current && setError(err instanceof Error ? err.message : t("preflightError")))
|
||||
.finally(() => current && setLoading(false));
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [action, lane.id, open, t]);
|
||||
|
||||
const blocked = blockingReason(preflight);
|
||||
// Mirrors the server's gate exactly (server/routes/lanes.js): force is required
|
||||
// for `reset`, and for `remove` only when a real worktree is at risk. Forgetting
|
||||
// an adopted lane risks nothing on disk, so it needs no force even with unpushed
|
||||
// commits. Never offered alongside a hard blocker, where confirming is
|
||||
// impossible anyway.
|
||||
const requiresForce =
|
||||
!blocked &&
|
||||
preflight !== null &&
|
||||
preflight.action !== "purge" &&
|
||||
preflight.blocked.includes("unpushed-commits") &&
|
||||
(preflight.action === "reset" || preflight.kind === "managed");
|
||||
const disabled =
|
||||
loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force);
|
||||
const facts = preflight ? Object.entries(expectFor(preflight)) : [];
|
||||
const purge = preflight?.action === "purge" ? preflight : null;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t(`destructive.${action}.title`)}
|
||||
message={t(`destructive.${action}.message`)}
|
||||
confirmLabel={t(`destructive.${action}.confirm`)}
|
||||
cancelLabel={t("destructive.cancel")}
|
||||
busy={loading}
|
||||
disabled={disabled}
|
||||
onCancel={onClose}
|
||||
onConfirm={() => {
|
||||
if (!preflight || disabled) return;
|
||||
onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) });
|
||||
}}
|
||||
>
|
||||
{loading && <p className="mt-3 text-xs text-neutral-400">{t("destructive.loading")}</p>}
|
||||
{error && (
|
||||
<p className="mt-3 text-xs text-red-300">
|
||||
{t("preflightErrorWithMessage", { message: error })}
|
||||
</p>
|
||||
)}
|
||||
{preflight && (
|
||||
<table className="mt-3 w-full text-left text-xs text-neutral-300">
|
||||
<tbody>
|
||||
{facts.map(([name, value]) => (
|
||||
<tr key={name} className="border-t border-neutral-800">
|
||||
<th scope="row" className="py-1.5 font-medium text-neutral-400">
|
||||
{t(`destructive.count.${name}`)}
|
||||
</th>
|
||||
<td className="py-1.5 text-right tabular-nums">{value ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* Not part of `expect`: an estimate the server never verifies. */}
|
||||
{purge && (
|
||||
<tr className="border-t border-neutral-800">
|
||||
<th scope="row" className="py-1.5 font-medium text-neutral-400">
|
||||
{t("destructive.count.bytesEstimate")}
|
||||
</th>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{formatBytes(purge.bytesEstimate)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{blocked && (
|
||||
<p className="mt-3 rounded border border-amber-700/60 bg-amber-950/30 px-2 py-1.5 text-xs text-amber-200">
|
||||
{t(`destructive.blocked.${blocked}`)}
|
||||
</p>
|
||||
)}
|
||||
{purge?.activeSessionSkipped && (
|
||||
<p className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300">
|
||||
{t("destructive.notice.activeSessionSkipped")}
|
||||
</p>
|
||||
)}
|
||||
{noticesFor(preflight).map((notice) => (
|
||||
<p
|
||||
key={notice}
|
||||
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"
|
||||
>
|
||||
{t(`destructive.notice.${notice}`)}
|
||||
</p>
|
||||
))}
|
||||
{warningsFor(preflight).map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"
|
||||
>
|
||||
{t(`destructive.warning.${warning}`)}
|
||||
</p>
|
||||
))}
|
||||
{requiresForce && (
|
||||
<label className="mt-3 flex items-start gap-2 text-xs text-amber-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={force}
|
||||
onChange={(event) => setForce(event.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
{t("destructive.force")}
|
||||
</label>
|
||||
)}
|
||||
{action === "reset" && (
|
||||
<p className="mt-3 text-xs text-neutral-400">{t("destructive.reset.survives")}</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @file One lane's card: title, stage badge with time-on-phase, progress bar,
|
||||
* branch/CI/PR facts, the "needs you" banner sourced from Claude Code's
|
||||
* Notification hook, and the control row. A dead lane (its driving session went
|
||||
* silent while it should have been working) is called out loudly — that is the
|
||||
* failure this view exists to catch. An "auto: <stage>" chip appears only when
|
||||
* the server's detected stage is ahead of the agent's own declaration — when the
|
||||
* declaration leads or matches, it stays the sole headline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane, LaneGitFacts } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is three git subprocesses server-side, and a branch name does not
|
||||
* change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched per card rather than folded into the
|
||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders as a card without a git row.
|
||||
*/
|
||||
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(f);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a card that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts({ available: false });
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId]);
|
||||
|
||||
return facts;
|
||||
}
|
||||
|
||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-emerald-400",
|
||||
idle: "bg-neutral-500",
|
||||
dead: "bg-red-500",
|
||||
};
|
||||
|
||||
function since(sec: number | null): string {
|
||||
if (sec === null) return "—";
|
||||
if (sec < 60) return `${sec}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
||||
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
/** Whether `lane.detected_stage` is strictly ahead of `lane.stage` in the
|
||||
* pipeline's node order. Unknown ids sort as "not found" (-1), so an unmatched
|
||||
* detected stage never outranks a matched declaration. */
|
||||
function detectionLeadsDeclaration(lane: Lane): boolean {
|
||||
if (!lane.detected_stage) return false;
|
||||
const ids = lane.pipeline_nodes.map((n) => n.id);
|
||||
return ids.indexOf(lane.detected_stage) > ids.indexOf(lane.stage);
|
||||
}
|
||||
|
||||
export default function LaneCard({
|
||||
lane,
|
||||
onAction,
|
||||
}: {
|
||||
lane: Lane;
|
||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
||||
null
|
||||
);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
className="flex flex-col rounded-xl border border-neutral-800 bg-neutral-900/70 p-4 transition-colors hover:border-neutral-700"
|
||||
>
|
||||
{/* Identity strip: which lane, and is it alive. Kept on one line and in
|
||||
uppercase so a wall of cards can be scanned vertically. */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500">
|
||||
{t("cardId", { id: lane.id })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-neutral-300">
|
||||
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a
|
||||
non-standard status rendered as the literal "status.foo".
|
||||
defaultValue makes it degrade to the raw status instead. */}
|
||||
{lane.liveness === "dead"
|
||||
? t("statusDead")
|
||||
: t(`status.${lane.status}`, { defaultValue: lane.status })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-neutral-50">
|
||||
{lane.title || lane.cwd}
|
||||
</h3>
|
||||
|
||||
{/* Stage line: the declared stage, how far through, and how long it has
|
||||
been sitting there — the three facts that say whether a lane is
|
||||
moving. The inferred chip sits beside them, never instead of them. */}
|
||||
<div className="mb-2 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
data-testid="lane-stage"
|
||||
className="rounded bg-neutral-800 px-2 py-0.5 font-medium text-neutral-200"
|
||||
>
|
||||
{lane.stage}
|
||||
</span>
|
||||
<div
|
||||
className={`h-1 flex-1 overflow-hidden rounded-full ${
|
||||
lane.progress > 0 ? "bg-neutral-800" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
data-testid="lane-progress-fill"
|
||||
className="h-full rounded-full bg-blue-500 transition-[width]"
|
||||
style={{ width: `${lane.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{lane.progress > 0 && (
|
||||
<span className="tabular-nums text-neutral-400">{lane.progress}%</span>
|
||||
)}
|
||||
<span className="tabular-nums text-neutral-600">{since(lane.stage_seconds)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 ${
|
||||
lane.kind === "managed"
|
||||
? "bg-blue-500/15 text-blue-300"
|
||||
: "bg-violet-500/15 text-violet-300"
|
||||
}`}
|
||||
>
|
||||
{t(`kind.${lane.kind}`)}
|
||||
</span>
|
||||
{detectionLeadsDeclaration(lane) && (
|
||||
<span
|
||||
data-testid="lane-auto-stage"
|
||||
title={lane.detected_signal || undefined}
|
||||
className="rounded border border-dashed border-amber-500 px-1.5 py-0.5 text-amber-300"
|
||||
>
|
||||
{t("autoStage", { stage: lane.detected_stage })}
|
||||
</span>
|
||||
)}
|
||||
{lane.ci_status && (
|
||||
<span className="rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">
|
||||
CI {lane.ci_status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lane.needs_action && (
|
||||
<div className="mb-3 rounded border border-amber-600/50 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
||||
⚠ {lane.needs_action}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mb-3 space-y-1 font-mono text-[11px] text-neutral-400">
|
||||
{git?.available && (
|
||||
<div data-testid="lane-git" className="space-y-1">
|
||||
<div className="truncate">
|
||||
⑂ {git.branch}
|
||||
<span className="ml-2 text-neutral-500">{git.head}</span>
|
||||
</div>
|
||||
<div className="truncate text-neutral-500" title={git.subject}>
|
||||
{git.subject}
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<div className="text-amber-400/80">
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
||||
<div className="truncate text-neutral-500" title={lane.cwd}>
|
||||
{lane.cwd}
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* mt-auto pins the controls to the bottom so cards of differing height
|
||||
in one grid row still line their buttons up. */}
|
||||
<div className="mt-auto flex items-center gap-1 border-t border-neutral-800 pt-2 text-xs">
|
||||
{(["start", "stop", "clear"] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
data-testid={`lane-action-${a}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction(a);
|
||||
}}
|
||||
className={`rounded px-2 py-1 transition-colors ${
|
||||
a === "start"
|
||||
? "bg-blue-500/15 text-blue-300 hover:bg-blue-500/25"
|
||||
: "text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200"
|
||||
}`}
|
||||
title={a === "start" ? t("tooltipStart") : undefined}
|
||||
>
|
||||
{t(`action.${a}`)}
|
||||
</button>
|
||||
))}
|
||||
{/* Destructive verbs sit behind a menu so the card is not a wall of
|
||||
red. Red is kept for the items inside, where it means something. */}
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-more"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen((v) => !v);
|
||||
}}
|
||||
className="rounded px-2 py-1 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
|
||||
title={t("moreActions")}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 py-1 shadow-lg shadow-black/40"
|
||||
>
|
||||
{lane.kind === "managed" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-action-reset"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
setDestructiveAction("reset");
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-amber-300 hover:bg-amber-500/10"
|
||||
>
|
||||
{t("action.reset")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
setDestructiveAction("remove");
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-action-purge"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
setDestructiveAction("purge");
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
{t("action.purge")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{destructiveAction && (
|
||||
<DestructiveLaneModal
|
||||
lane={lane}
|
||||
action={destructiveAction}
|
||||
open
|
||||
onClose={() => setDestructiveAction(null)}
|
||||
onConfirm={(body) => {
|
||||
setDestructiveAction(null);
|
||||
onAction(destructiveAction, body);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @file The compact lane tile used in the Workspace carousel. It carries only
|
||||
* what you need to pick a lane — which lane, is it alive, what stage, how far —
|
||||
* because the full card, its controls and its working-copy facts live in the
|
||||
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
|
||||
* scannable in one horizontal row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
|
||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-emerald-400",
|
||||
idle: "bg-neutral-500",
|
||||
dead: "bg-red-500",
|
||||
};
|
||||
|
||||
/** Whether the inferred stage sits ahead of the declared one in node order. */
|
||||
function detectionLeads(lane: Lane): boolean {
|
||||
if (!lane.detected_stage) return false;
|
||||
const ids = lane.pipeline_nodes.map((n) => n.id);
|
||||
return ids.indexOf(lane.detected_stage) > ids.indexOf(lane.stage);
|
||||
}
|
||||
|
||||
export default function LaneStripCard({
|
||||
lane,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
lane: Lane;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`lane-tile-${lane.id}`}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
title={lane.cwd}
|
||||
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left transition-colors ${
|
||||
selected
|
||||
? "border-blue-400/70 bg-blue-500/[0.07]"
|
||||
: "border-neutral-800 bg-neutral-900/60 hover:border-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
|
||||
{t("cardId", { id: lane.id })}
|
||||
</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto text-amber-400" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 truncate text-[13px] font-medium text-neutral-100">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="truncate rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">
|
||||
{lane.stage}
|
||||
</span>
|
||||
{detectionLeads(lane) && (
|
||||
<span
|
||||
data-testid={`lane-tile-auto-${lane.id}`}
|
||||
title={lane.detected_signal || undefined}
|
||||
className="shrink-0 rounded border border-dashed border-amber-500 px-1 text-amber-300"
|
||||
>
|
||||
{lane.detected_stage}
|
||||
</span>
|
||||
)}
|
||||
{lane.progress > 0 && (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-neutral-500">{lane.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lane.progress > 0 && (
|
||||
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-neutral-800">
|
||||
<div className="h-full rounded-full bg-blue-500" style={{ width: `${lane.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @file The lane pipeline map: a horizontal chain of stage nodes coloured by
|
||||
* state. Layout is computed from the node list (flex + connectors), never from
|
||||
* hardcoded coordinates, so a lane can use a longer or shorter template without
|
||||
* touching this component. "passed without evidence" is deliberately its own
|
||||
* colour: a stage the agent claimed but left no artifact for is not the same as
|
||||
* a stage that is genuinely done. A `detected` node (the server's heuristic saw
|
||||
* tool-event evidence but the agent never declared it) gets a FOURTH treatment —
|
||||
* dashed amber, overriding whatever `state` it carries — because it must never
|
||||
* be mistaken for the solid green of a real "done".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { LaneNode } from "../../lib/types";
|
||||
|
||||
const STATE_CLASS: Record<LaneNode["state"], string> = {
|
||||
done: "border-emerald-500 text-emerald-400 bg-emerald-500/10",
|
||||
current: "border-blue-400 text-blue-300 bg-blue-500/20 ring-2 ring-blue-400/40",
|
||||
"passed-no-evidence": "border-amber-500 text-amber-400 bg-amber-500/10",
|
||||
failed: "border-red-500 text-red-400 bg-red-500/10",
|
||||
pending: "border-neutral-700 text-neutral-500 bg-transparent",
|
||||
};
|
||||
|
||||
// Dashed border distinguishes an inferred stage from every other class above,
|
||||
// including the solid amber of "passed-no-evidence" — never let it read as done.
|
||||
const DETECTED_CLASS = "border-dashed border-amber-400 text-amber-300 bg-amber-500/5";
|
||||
|
||||
export default function PipelineMap({
|
||||
nodes,
|
||||
detectedSignal,
|
||||
}: {
|
||||
nodes: LaneNode[];
|
||||
detectedSignal?: string | null;
|
||||
}) {
|
||||
if (!nodes.length) return <div className="text-xs text-neutral-500">no pipeline</div>;
|
||||
return (
|
||||
// The map spans the panel: every node takes an equal share and the
|
||||
// connectors absorb the slack, so the pipeline reads as one track across
|
||||
// the width rather than a short cluster hugging the left edge.
|
||||
<div className="flex w-full items-center py-1">
|
||||
{nodes.map((n, i) => (
|
||||
<div key={n.id} className="flex min-w-0 flex-1 items-center">
|
||||
<div
|
||||
data-testid={`pipeline-node-${n.id}`}
|
||||
data-state={n.state}
|
||||
data-detected={n.detected ? "true" : undefined}
|
||||
title={
|
||||
n.detected && detectedSignal
|
||||
? `${n.label} ← ${detectedSignal}`
|
||||
: `${n.label} — ${n.state}`
|
||||
}
|
||||
className={`flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-full border px-2 py-1.5 text-[11px] ${n.detected ? DETECTED_CLASS : STATE_CLASS[n.state]}`}
|
||||
>
|
||||
<span className="shrink-0 text-[13px] leading-none">{n.icon}</span>
|
||||
<span className="truncate">{n.label}</span>
|
||||
</div>
|
||||
{i < nodes.length - 1 && <div className="h-px w-2 shrink-0 bg-neutral-700 sm:w-3" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @file AddLaneModal.test.tsx
|
||||
* @description Pins the "+ Add lane" flow after it was rebuilt around a source
|
||||
* repo instead of an existing folder: picking or typing a repo path triggers a
|
||||
* branch lookup, the base-branch picker only appears once that lookup resolves,
|
||||
* confirm submits through the provisioning endpoint (not the adopt/ensure one),
|
||||
* an unresolvable path degrades to a quiet hint instead of blocking the form,
|
||||
* and a server error surfaces instead of closing the modal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { branches: vi.fn(), worktree: vi.fn() } },
|
||||
}));
|
||||
|
||||
function laneFixture(over: Partial<Lane> = {}): Lane {
|
||||
return {
|
||||
id: 9,
|
||||
title: "",
|
||||
cwd: "/lanes/repo__feature",
|
||||
branch: "feat/feature",
|
||||
kind: "managed",
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
stage: "idle",
|
||||
stage_since: null,
|
||||
status: "provisioning",
|
||||
gate_decision: null,
|
||||
ci_status: null,
|
||||
needs_action: null,
|
||||
links: {},
|
||||
stages: {},
|
||||
notes: null,
|
||||
pipeline_name: "Default",
|
||||
pipeline_nodes: [],
|
||||
progress: 0,
|
||||
stage_seconds: null,
|
||||
last_event_seconds: null,
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const SUGGESTIONS: CwdSuggestion[] = [
|
||||
{ kind: "home", path: "/Users/tester", label: "Home" },
|
||||
{ kind: "recent", path: "/Users/tester/projects/repo", label: "repo" },
|
||||
];
|
||||
|
||||
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
||||
return render(
|
||||
<AddLaneModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onAdded={vi.fn()}
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
{...over}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
|
||||
* races userEvent.type() and can eat the first keystroke. Let that timer fire,
|
||||
* then click the field to reclaim focus before typing. */
|
||||
async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElement) {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await user.click(el);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
});
|
||||
|
||||
describe("AddLaneModal", () => {
|
||||
it("disables confirm until a repo, a title, and a resolved branch list are all present", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("looks up branches once the repo path settles, and shows them as a picker", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main", "feat/other"],
|
||||
current: "main",
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
|
||||
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo"));
|
||||
const base = await screen.findByLabelText("Branch to fork from");
|
||||
expect(base).toHaveValue("main"); // the repo's current branch is preselected
|
||||
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => {
|
||||
vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/not/a/repo");
|
||||
|
||||
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalled());
|
||||
expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, not ensure", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose, onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "New feature");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith({
|
||||
sourceRepo: "/Users/tester/projects/repo",
|
||||
title: "New feature",
|
||||
base: "main",
|
||||
});
|
||||
});
|
||||
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: "provisioning" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "New feature");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
renderModal({ open: false });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* @file Tests for DestructiveLaneModal: the preflight-gated confirmation for
|
||||
* reset/remove/purge. Covers that the displayed counts are exactly the
|
||||
* preflight facts, that `reset` is refused for adopted/missing/unreadable
|
||||
* lanes while `remove` stays available for all of them (the server permits it,
|
||||
* so the UI must not be stricter), that the Force checkbox appears exactly when
|
||||
* the server would demand force, and that confirming echoes back exactly the
|
||||
* `expect` block the modal displayed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
const { preflightMock } = vi.hoisted(() => ({ preflightMock: vi.fn() }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { preflight: preflightMock } },
|
||||
}));
|
||||
|
||||
import { DestructiveLaneModal } from "../DestructiveLaneModal";
|
||||
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
|
||||
|
||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
return {
|
||||
id: 1,
|
||||
title: "demo",
|
||||
cwd: "/work/demo",
|
||||
branch: "lane/demo",
|
||||
kind: "managed",
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
stage: "plan",
|
||||
stage_since: null,
|
||||
status: "idle",
|
||||
gate_decision: null,
|
||||
ci_status: null,
|
||||
needs_action: null,
|
||||
links: {},
|
||||
stages: {},
|
||||
notes: null,
|
||||
pipeline_name: "Default",
|
||||
pipeline_nodes: [],
|
||||
progress: 0,
|
||||
stage_seconds: null,
|
||||
last_event_seconds: null,
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
|
||||
return {
|
||||
action: "reset",
|
||||
lane: 1,
|
||||
kind: "managed",
|
||||
branch: "lane/demo",
|
||||
head: "abc1234",
|
||||
dirty: 2,
|
||||
untracked: 3,
|
||||
unpushed: 0,
|
||||
blocked: [],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
preflightMock.mockReset();
|
||||
});
|
||||
|
||||
describe("DestructiveLaneModal", () => {
|
||||
it("renders exactly the counts the preflight returned", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ head: "deadbee", dirty: 4, untracked: 5, unpushed: 0 })
|
||||
);
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("deadbee")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables RESET for an adopted lane (a worktree action can never touch it)", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ action: "reset", kind: "adopted", blocked: ["adopted"] })
|
||||
);
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane({ kind: "adopted" })}
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove for an adopted lane and sends the payload — the server forgets the row and leaves the directory alone", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
kind: "adopted",
|
||||
head: "adopt01",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 0,
|
||||
blocked: ["adopted"],
|
||||
})
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane({ kind: "adopted" })}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
// "adopted" is shown as context, not as a refusal.
|
||||
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: "adopt01", dirty: 0, untracked: 0, unpushed: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("forgets an adopted lane with unpushed commits without offering Force — the server does not require it", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
kind: "adopted",
|
||||
head: "adopt02",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 7,
|
||||
blocked: ["adopted", "unpushed-commits"],
|
||||
})
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane({ kind: "adopted" })}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: "adopt02", dirty: 0, untracked: 0, unpushed: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it("disables RESET when the worktree directory is missing", async () => {
|
||||
preflightMock.mockResolvedValue(worktreePreflight({ action: "reset", blocked: ["missing"] }));
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove when the worktree directory is missing — the server takes the prune path", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
head: null,
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 0,
|
||||
blocked: ["missing"],
|
||||
})
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("disables RESET when the worktree directory is unreadable", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ action: "reset", blocked: ["unreadable"] })
|
||||
);
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove when the worktree directory is unreadable — the server attempts removal and falls back to deregistering it", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
head: null,
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 0,
|
||||
blocked: ["unreadable"],
|
||||
})
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the Force checkbox only when the sole blocker is unpushed commits, and ticking it enables confirm", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ action: "remove", unpushed: 3, blocked: ["unpushed-commits"] })
|
||||
);
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "reset",
|
||||
unpushed: 3,
|
||||
blocked: ["unpushed-commits"],
|
||||
warnings: ["no-remote"],
|
||||
})
|
||||
);
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// The warning is shown as context, not as an obstacle.
|
||||
expect(await screen.findByText(/No Git remote is configured/)).toBeInTheDocument();
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not offer the Force checkbox when a hard blocker makes confirming impossible anyway", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ action: "reset", unpushed: 3, blocked: ["unpushed-commits", "missing"] })
|
||||
);
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirming a worktree action passes back exactly the expect block that was displayed", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ head: "cafefeed", dirty: 1, untracked: 2, unpushed: 0, blocked: [] })
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: "cafefeed", dirty: 1, untracked: 2, unpushed: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("confirming an unpushed-commits removal with Force ticked sends force:true plus the same expect block", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
head: "abc0000",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 2,
|
||||
blocked: ["unpushed-commits"],
|
||||
})
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 2 },
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("confirming a purge passes back the purge-specific expect block", async () => {
|
||||
const purgePreflight: LanePurgePreflight = {
|
||||
action: "purge",
|
||||
lane: 1,
|
||||
sessions: 4,
|
||||
events: 120,
|
||||
tokenRows: 30,
|
||||
bytesEstimate: 4096,
|
||||
activeSessionSkipped: false,
|
||||
};
|
||||
preflightMock.mockResolvedValue(purgePreflight);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="purge"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Purge history" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { sessions: 4, events: 120, tokenRows: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the purge size estimate and says when a live session was spared", async () => {
|
||||
const purgePreflight: LanePurgePreflight = {
|
||||
action: "purge",
|
||||
lane: 1,
|
||||
sessions: 2,
|
||||
events: 8,
|
||||
tokenRows: 2,
|
||||
bytesEstimate: 5120,
|
||||
activeSessionSkipped: true,
|
||||
};
|
||||
preflightMock.mockResolvedValue(purgePreflight);
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="purge"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("5.0 KB")).toBeInTheDocument();
|
||||
expect(screen.getByText(/still active and will be kept/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not claim a session was spared when none was", async () => {
|
||||
const purgePreflight: LanePurgePreflight = {
|
||||
action: "purge",
|
||||
lane: 1,
|
||||
sessions: 1,
|
||||
events: 1,
|
||||
tokenRows: 0,
|
||||
bytesEstimate: 512,
|
||||
activeSessionSkipped: false,
|
||||
};
|
||||
preflightMock.mockResolvedValue(purgePreflight);
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="purge"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("512 B")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/still active and will be kept/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @file Regression test for the lane card's status badge. Every lane status
|
||||
* the server can set must have a real translated word behind
|
||||
* `t("status." + lane.status)`; before this test existed, no locale defined
|
||||
* any `status.*` key, and i18next's default missing-key behavior (return the
|
||||
* key itself) hid that from the `||` fallback, so the badge showed literal
|
||||
* text like `status.active`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard from "../LaneCard";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) },
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
});
|
||||
|
||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
return {
|
||||
id: 1,
|
||||
title: "demo",
|
||||
cwd: "/work/demo",
|
||||
branch: "lane/demo",
|
||||
kind: "adopted",
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
stage: "plan",
|
||||
stage_since: null,
|
||||
status: "idle",
|
||||
gate_decision: null,
|
||||
ci_status: null,
|
||||
needs_action: null,
|
||||
links: {},
|
||||
stages: {},
|
||||
notes: null,
|
||||
pipeline_name: "Default",
|
||||
pipeline_nodes: [],
|
||||
progress: 0,
|
||||
stage_seconds: null,
|
||||
last_event_seconds: null,
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("LaneCard status badge", () => {
|
||||
for (const status of ["idle", "running", "provisioning", "failed"] as const) {
|
||||
it(`renders a real word for status "${status}", not the raw key`, () => {
|
||||
render(<LaneCard lane={makeLane({ status })} onAction={vi.fn()} />);
|
||||
expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const pipelineNodes: Lane["pipeline_nodes"] = [
|
||||
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
||||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||||
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "current" },
|
||||
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
|
||||
];
|
||||
|
||||
describe("LaneCard auto: chip", () => {
|
||||
it("shows the auto chip when the detected stage is ahead of the declared stage", () => {
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "tests" })}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("auto: tests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the auto chip when the detected stage matches the declared stage", () => {
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "plan" })}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("auto: plan")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the auto chip when the detected stage trails the declared stage", () => {
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({
|
||||
stage: "implement",
|
||||
pipeline_nodes: pipelineNodes,
|
||||
detected_stage: "intake",
|
||||
})}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("auto: intake")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the auto chip when nothing is detected", () => {
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: null })}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText(/^auto:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard rebuilt layout", () => {
|
||||
const full = (over: Partial<Lane> = {}) =>
|
||||
makeLane({
|
||||
id: 7,
|
||||
title: "Rename Metric to Rule",
|
||||
kind: "managed",
|
||||
status: "running",
|
||||
liveness: "active",
|
||||
stage: "plan",
|
||||
stage_seconds: 152,
|
||||
progress: 48,
|
||||
ci_status: "green",
|
||||
pipeline_nodes: pipelineNodes,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("labels the card with the lane id", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the declared stage, the progress percentage and the time on stage", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-stage").textContent).toBe("plan");
|
||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||
expect(screen.getByText("2m 32s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the progress bar a width matching the lane's progress", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
|
||||
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires a plain action with its own name", async () => {
|
||||
const onAction = vi.fn();
|
||||
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||
await userEvent.setup().click(screen.getByTestId("lane-action-stop"));
|
||||
expect(onAction).toHaveBeenCalledWith("stop");
|
||||
});
|
||||
|
||||
it("keeps the destructive verbs out of the card until the menu is opened", async () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
// A wall of red buttons makes none of them read as the dangerous one, so
|
||||
// reset/remove/purge live behind the ⋯ menu.
|
||||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-action-remove")).toBeNull();
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes reset through the confirmation modal rather than firing it", async () => {
|
||||
const onAction = vi.fn();
|
||||
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("lane-more"));
|
||||
await user.click(screen.getByTestId("lane-action-reset"));
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers reset only for a managed lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
await user.click(screen.getByTestId("lane-more"));
|
||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
|
||||
await user.click(screen.getByTestId("lane-more"));
|
||||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard git block", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(block.textContent).toContain("2");
|
||||
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<LaneCard lane={makeLane({ title: "plain dir lane" })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("plain dir lane")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<LaneCard lane={makeLane({ title: "offline lane" })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("offline lane")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the card unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @file Rendering tests for the lane pipeline map: every node renders with a
|
||||
* state-specific class so "done", "current" and "passed without evidence" stay
|
||||
* visually distinguishable, and the amber state is never conflated with done.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import PipelineMap from "../PipelineMap";
|
||||
import type { LaneNode } from "../../../lib/types";
|
||||
|
||||
const nodes: LaneNode[] = [
|
||||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||||
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "passed-no-evidence" },
|
||||
{ id: "review", label: "review", icon: "👀", gate: true, state: "current" },
|
||||
{ id: "gate", label: "gate", icon: "🚦", gate: true, state: "failed" },
|
||||
{ id: "done", label: "done", icon: "✅", gate: false, state: "pending" },
|
||||
];
|
||||
|
||||
describe("PipelineMap", () => {
|
||||
it("renders one element per node, labelled by state", () => {
|
||||
render(<PipelineMap nodes={nodes} />);
|
||||
expect(screen.getAllByTestId(/^pipeline-node-/)).toHaveLength(5);
|
||||
expect(screen.getByTestId("pipeline-node-plan")).toHaveAttribute("data-state", "done");
|
||||
expect(screen.getByTestId("pipeline-node-implement")).toHaveAttribute(
|
||||
"data-state",
|
||||
"passed-no-evidence"
|
||||
);
|
||||
expect(screen.getByTestId("pipeline-node-review")).toHaveAttribute("data-state", "current");
|
||||
expect(screen.getByTestId("pipeline-node-gate")).toHaveAttribute("data-state", "failed");
|
||||
expect(screen.getByTestId("pipeline-node-done")).toHaveAttribute("data-state", "pending");
|
||||
});
|
||||
|
||||
it("gives amber nodes a different class from done nodes", () => {
|
||||
render(<PipelineMap nodes={nodes} />);
|
||||
const done = screen.getByTestId("pipeline-node-plan").className;
|
||||
const amber = screen.getByTestId("pipeline-node-implement").className;
|
||||
// The done node must contain emerald colour token and the amber node must contain amber token.
|
||||
expect(done).toContain("emerald");
|
||||
expect(amber).toContain("amber");
|
||||
expect(done).not.toEqual(amber);
|
||||
});
|
||||
|
||||
it("renders nothing but an empty hint when there are no nodes", () => {
|
||||
render(<PipelineMap nodes={[]} />);
|
||||
expect(screen.queryAllByTestId(/^pipeline-node-/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("detected (inferred) nodes", () => {
|
||||
const detectedNodes: LaneNode[] = [
|
||||
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "pending", detected: true },
|
||||
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending", detected: true },
|
||||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||||
{
|
||||
id: "implement",
|
||||
label: "implement",
|
||||
icon: "🛠",
|
||||
gate: false,
|
||||
state: "passed-no-evidence",
|
||||
},
|
||||
];
|
||||
|
||||
it("marks a detected node with data-detected and a dashed-border class token", () => {
|
||||
render(<PipelineMap nodes={detectedNodes} detectedSignal="npm run test:server" />);
|
||||
const node = screen.getByTestId("pipeline-node-tests");
|
||||
expect(node).toHaveAttribute("data-detected", "true");
|
||||
expect(node.className).toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("gives a detected node a class different from both done and plain passed-no-evidence", () => {
|
||||
render(<PipelineMap nodes={detectedNodes} detectedSignal="npm run test:server" />);
|
||||
const detected = screen.getByTestId("pipeline-node-tests").className;
|
||||
const done = screen.getByTestId("pipeline-node-plan").className;
|
||||
const amber = screen.getByTestId("pipeline-node-implement").className;
|
||||
expect(detected).not.toEqual(done);
|
||||
expect(detected).not.toEqual(amber);
|
||||
});
|
||||
|
||||
it("names the signal in the detected node's tooltip", () => {
|
||||
render(<PipelineMap nodes={detectedNodes} detectedSignal="npm run test:server" />);
|
||||
const node = screen.getByTestId("pipeline-node-tests");
|
||||
expect(node).toHaveAttribute("title", "tests ← npm run test:server");
|
||||
});
|
||||
|
||||
it("PREMISE GUARD: detected wins over state=done — dashed amber, never emerald", () => {
|
||||
// The server never emits this pair, and this is the guard that says the
|
||||
// component would not paint an inference green even if it did. Asserting
|
||||
// against a fixture whose detected nodes are already `pending` would only
|
||||
// re-assert the fixture.
|
||||
const impossible: LaneNode[] = [
|
||||
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "done", detected: true },
|
||||
];
|
||||
render(<PipelineMap nodes={impossible} detectedSignal="npm run test:server" />);
|
||||
const node = screen.getByTestId("pipeline-node-tests");
|
||||
expect(node.className).toContain("border-dashed");
|
||||
expect(node.className).toContain("amber");
|
||||
expect(node.className).not.toContain("emerald");
|
||||
});
|
||||
|
||||
it("non-detected nodes carry no data-detected attribute", () => {
|
||||
render(<PipelineMap nodes={detectedNodes} detectedSignal="npm run test:server" />);
|
||||
expect(screen.getByTestId("pipeline-node-plan")).not.toHaveAttribute("data-detected");
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* @file RunHistory.tsx
|
||||
* @description Past and live dashboard runs. Moved verbatim out of
|
||||
* `pages/Run.tsx` so the Run page and the Workspace page can both mount the
|
||||
* same run list.
|
||||
*
|
||||
* Two exported pieces, rendered exactly where the page rendered them before:
|
||||
* - `ActiveRunsSwitcher` — the header button (live count, or total when
|
||||
* nothing is running) that opens the modal;
|
||||
* - `RunsModal` — the merged list itself: live in-memory handles from
|
||||
* `activeRuns` deduped against persistent `runHistory`, newest first, with
|
||||
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||
* Resume / View actions.
|
||||
*
|
||||
* Props only: no API call of its own. The page passes `activeRuns` and
|
||||
* `runHistory` in and gets attach / resume / view / refresh back out through
|
||||
* callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Play,
|
||||
RefreshCw,
|
||||
Info,
|
||||
ExternalLink,
|
||||
X,
|
||||
ListOrdered,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import type { DashboardRunHistoryItem, RunListResponse, RunMode, RunStatus } from "../../lib/api";
|
||||
import { ModeBadge, StatusPill } from "./RunConsole";
|
||||
|
||||
type RunStatusFilter =
|
||||
| "all"
|
||||
| "running"
|
||||
| "spawning"
|
||||
| "completed"
|
||||
| "error"
|
||||
| "killed"
|
||||
| "abandoned";
|
||||
type RunModeFilter = "all" | "conversation" | "headless";
|
||||
|
||||
export interface UnifiedRunRow {
|
||||
id: string;
|
||||
sessionId: string | null;
|
||||
mode: RunMode;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
status: RunStatus;
|
||||
promptPreview: string;
|
||||
startedAt: number;
|
||||
endedAt: number | null;
|
||||
isLive: boolean;
|
||||
}
|
||||
|
||||
export function ActiveRunsSwitcher({
|
||||
activeRuns,
|
||||
currentHandleId,
|
||||
onAttach,
|
||||
runHistory,
|
||||
onResumeFromHistory,
|
||||
onViewFromHistory,
|
||||
onRefresh,
|
||||
}: {
|
||||
activeRuns: RunListResponse | null;
|
||||
currentHandleId: string | null;
|
||||
onAttach: (id: string) => void;
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Lock body scroll while the modal is open and let Esc close it.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Merge live in-memory handles + persistent history into one row list.
|
||||
// Live entries dedupe past-history entries with the same id.
|
||||
const rows: UnifiedRunRow[] = useMemo(() => {
|
||||
const out: UnifiedRunRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
if (activeRuns) {
|
||||
for (const r of activeRuns.items) {
|
||||
seen.add(r.id);
|
||||
out.push({
|
||||
id: r.id,
|
||||
sessionId: r.sessionId,
|
||||
mode: r.mode,
|
||||
cwd: r.cwd,
|
||||
model: r.model,
|
||||
status: r.status,
|
||||
promptPreview: r.prompt || "",
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
isLive: r.status === "running" || r.status === "spawning",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const h of runHistory) {
|
||||
if (seen.has(h.id)) continue;
|
||||
seen.add(h.id);
|
||||
const startedTs = new Date(h.started_at).getTime() || 0;
|
||||
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
||||
out.push({
|
||||
id: h.id,
|
||||
sessionId: h.session_id,
|
||||
mode: h.mode,
|
||||
cwd: h.cwd,
|
||||
model: h.model,
|
||||
status: h.status,
|
||||
promptPreview: h.prompt_preview || "",
|
||||
startedAt: startedTs,
|
||||
endedAt: endedTs,
|
||||
isLive: h.isLive,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.startedAt - a.startedAt);
|
||||
return out;
|
||||
}, [activeRuns, runHistory]);
|
||||
|
||||
const liveCount = activeRuns?.activeCount ?? 0;
|
||||
const totalCount = rows.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={totalCount === 0}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
liveCount > 0
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/15"
|
||||
: "border-border bg-surface-2 text-gray-300 hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5" />
|
||||
{liveCount > 0 ? (
|
||||
<>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
{t("runs.viewActive_other", { count: liveCount })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("runs.switcher")}
|
||||
{totalCount > 0 && <span className="text-gray-500 font-mono">{totalCount}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<RunsModal
|
||||
rows={rows}
|
||||
currentHandleId={currentHandleId}
|
||||
onAttach={(id) => {
|
||||
setOpen(false);
|
||||
onAttach(id);
|
||||
}}
|
||||
onResume={(item) => {
|
||||
setOpen(false);
|
||||
onResumeFromHistory(item);
|
||||
}}
|
||||
onView={(item) => {
|
||||
setOpen(false);
|
||||
onViewFromHistory(item);
|
||||
}}
|
||||
runHistory={runHistory}
|
||||
onClose={() => setOpen(false)}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunsModal({
|
||||
rows,
|
||||
currentHandleId,
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
runHistory,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
rows: UnifiedRunRow[];
|
||||
currentHandleId: string | null;
|
||||
onAttach: (id: string) => void;
|
||||
onResume: (item: DashboardRunHistoryItem) => void;
|
||||
onView: (item: DashboardRunHistoryItem) => void;
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
|
||||
const [modeFilter, setModeFilter] = useState<RunModeFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Snappy refresh while the modal is the foreground UI: pull immediately
|
||||
// on open + every 2 s after that. Combined with the page-level 5 s poll
|
||||
// and the WS run_status broadcasts, this guarantees that any state
|
||||
// change - lifecycle event, sibling tab, manual DB tweak, boot
|
||||
// reconciliation - surfaces here within a couple of seconds.
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
const tick = setInterval(onRefresh, 2000);
|
||||
return () => clearInterval(tick);
|
||||
}, [onRefresh]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const byStatus: Record<string, number> = { all: rows.length };
|
||||
const byMode: Record<string, number> = { all: rows.length };
|
||||
for (const r of rows) {
|
||||
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
||||
byMode[r.mode] = (byMode[r.mode] || 0) + 1;
|
||||
}
|
||||
return { byStatus, byMode };
|
||||
}, [rows]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
||||
if (modeFilter !== "all" && r.mode !== modeFilter) return false;
|
||||
if (!q) return true;
|
||||
const hay =
|
||||
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
|
||||
return hay.toLowerCase().includes(q);
|
||||
});
|
||||
}, [rows, statusFilter, modeFilter, search]);
|
||||
|
||||
const historyById = useMemo(() => {
|
||||
const m = new Map<string, DashboardRunHistoryItem>();
|
||||
for (const h of runHistory) m.set(h.id, h);
|
||||
return m;
|
||||
}, [runHistory]);
|
||||
|
||||
const STATUSES: RunStatusFilter[] = [
|
||||
"all",
|
||||
"running",
|
||||
"completed",
|
||||
"error",
|
||||
"killed",
|
||||
"abandoned",
|
||||
];
|
||||
const MODES: RunModeFilter[] = ["all", "conversation", "headless"];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center px-4 py-10 overflow-y-auto bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-4xl rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/60 flex flex-col max-h-[85vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-border flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/15 inline-flex items-center justify-center">
|
||||
<ListOrdered className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-gray-100">
|
||||
{t("runs.modalTitle", "Dashboard runs")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{t(
|
||||
"runs.modalSubtitle",
|
||||
"Every run started from this dashboard, regardless of status"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="w-7 h-7 rounded-md text-gray-500 hover:text-gray-200 hover:bg-surface-3 inline-flex items-center justify-center"
|
||||
aria-label={t("runs.refresh", "Refresh")}
|
||||
title={t("runs.refresh", "Refresh")}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Link
|
||||
to="/sessions"
|
||||
onClick={onClose}
|
||||
className="text-[11px] text-accent hover:text-accent/80 inline-flex items-center gap-1 mr-1"
|
||||
>
|
||||
{t("runs.allSessionsLink")}
|
||||
</Link>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-md text-gray-500 hover:text-gray-200 hover:bg-surface-3 inline-flex items-center justify-center"
|
||||
aria-label={t("limitations.dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-5 py-3 border-b border-border flex flex-col gap-2.5 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 bg-surface-2 border border-border rounded-md px-2.5 py-1.5">
|
||||
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")}
|
||||
className="flex-1 bg-transparent text-[12px] text-gray-100 placeholder:text-gray-600 focus:outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-gray-500 hover:text-gray-200 text-[10px]"
|
||||
aria-label="Clear"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<FilterChipGroup
|
||||
label={t("runs.filterStatus", "Status")}
|
||||
value={statusFilter}
|
||||
options={STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: s === "all" ? t("runs.allLabel", "All") : t(`status.${s}`),
|
||||
count: counts.byStatus[s] || 0,
|
||||
}))}
|
||||
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
|
||||
/>
|
||||
<FilterChipGroup
|
||||
label={t("runs.filterMode", "Mode")}
|
||||
value={modeFilter}
|
||||
options={MODES.map((m) => ({
|
||||
value: m,
|
||||
label: m === "all" ? t("runs.allLabel", "All") : t(`mode.${m}`),
|
||||
count: counts.byMode[m] || 0,
|
||||
}))}
|
||||
onChange={(v) => setModeFilter(v as RunModeFilter)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0 overflow-auto divide-y divide-border">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center text-[12px] text-gray-500">
|
||||
{rows.length === 0
|
||||
? t(
|
||||
"runs.modalEmpty",
|
||||
"No dashboard runs yet. Start one below to populate this list."
|
||||
)
|
||||
: t("runs.modalEmptyFiltered", "No runs match these filters.")}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((r) => {
|
||||
const isCurrent = r.id === currentHandleId;
|
||||
return (
|
||||
<UnifiedRunRowView
|
||||
key={r.id}
|
||||
row={r}
|
||||
isCurrent={isCurrent}
|
||||
onAttach={() => onAttach(r.id)}
|
||||
onResume={() => {
|
||||
const h = historyById.get(r.id);
|
||||
if (h) onResume(h);
|
||||
}}
|
||||
onView={() => {
|
||||
const h = historyById.get(r.id);
|
||||
if (h) onView(h);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-2.5 border-t border-border bg-surface-2/40 flex items-center gap-2 flex-shrink-0">
|
||||
<Info className="w-3 h-3 text-gray-500 flex-shrink-0" />
|
||||
<span className="text-[10.5px] text-gray-500 leading-relaxed flex-1">
|
||||
{t("runs.scopeNote")}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-gray-500 font-mono">
|
||||
{filtered.length} / {rows.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChipGroup<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: { value: T; label: string; count: number }[];
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold text-gray-500 mr-1">
|
||||
{label}
|
||||
</span>
|
||||
{options.map((opt) => {
|
||||
const active = value === opt.value;
|
||||
const dim = opt.count === 0 && opt.value !== "all";
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
disabled={dim}
|
||||
className={`text-[10.5px] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 ${
|
||||
active
|
||||
? "bg-accent/15 border-accent/50 text-accent"
|
||||
: "bg-surface-2 border-border text-gray-300 hover:bg-surface-3 hover:border-border-strong"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
<span className="ml-1 text-gray-500 font-mono">{opt.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedRunRowView({
|
||||
row,
|
||||
isCurrent,
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
}: {
|
||||
row: UnifiedRunRow;
|
||||
isCurrent: boolean;
|
||||
onAttach: () => void;
|
||||
onResume: () => void;
|
||||
onView: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const startedDate = new Date(row.startedAt);
|
||||
const startedLabel = isNaN(startedDate.getTime())
|
||||
? "-"
|
||||
: startedDate.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const canResume = row.mode === "conversation" && !!row.sessionId && !row.isLive;
|
||||
// Headless runs are single-shot, so resume doesn't apply - but the captured
|
||||
// transcript is still worth viewing. Link to the Session detail page.
|
||||
const canView = row.mode === "headless" && !!row.sessionId && !row.isLive;
|
||||
return (
|
||||
<div
|
||||
className={`px-5 py-3 transition-colors ${
|
||||
isCurrent ? "bg-accent/[0.06]" : "hover:bg-surface-2/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<StatusPill status={row.status} />
|
||||
<ModeBadge mode={row.mode} />
|
||||
{row.isLive && (
|
||||
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/10 border border-emerald-500/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
{t("runs.liveBadge", "live")}
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
|
||||
{t("runs.currentBadge", "current")}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1.5">
|
||||
{row.isLive && !isCurrent && (
|
||||
<button
|
||||
onClick={onAttach}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-200 px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
{t("runs.attachLabel", "Attach")}
|
||||
</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
{t("resume.resumeOption", "Resume")}
|
||||
</button>
|
||||
)}
|
||||
{canView && (
|
||||
<button
|
||||
onClick={onView}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-gray-300 hover:text-gray-100 px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
{t("runs.viewLabel", "View")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{row.promptPreview && (
|
||||
<div className="text-[12px] text-gray-300 line-clamp-2 leading-snug">
|
||||
{row.promptPreview}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-[10px] text-gray-500 truncate mt-1">{row.cwd}</div>
|
||||
<div className="text-[10px] text-gray-600 mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
<span>{startedLabel}</span>
|
||||
{row.model && <span className="font-mono text-gray-500">· {row.model}</span>}
|
||||
{row.sessionId && (
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(row.sessionId)}`}
|
||||
className="inline-flex items-center gap-1 text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title={t("actions.viewSession")}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
<span className="font-mono">{row.sessionId.slice(0, 8)}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* @file RunSetup.tsx
|
||||
* @description The pre-run setup panel: everything the user picks before a run
|
||||
* exists. Moved verbatim out of `pages/Run.tsx` (where it was `ConfigCard`) so
|
||||
* the Run page and the Workspace page can both mount the same panel.
|
||||
*
|
||||
* What lives here:
|
||||
* - `RunSetup` — mode (conversation / headless), fresh-vs-resume source, the
|
||||
* prompt editor, and the cwd / model / permission-mode / effort fields,
|
||||
* plus the concurrency hint and the Start button. Its disabled state is
|
||||
* driven by the `binaryFound` prop, so a missing `claude` binary is a
|
||||
* surfaced state here rather than a probe of its own.
|
||||
* above the panel, with its own localStorage-persisted minimized state.
|
||||
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
||||
* `ModelPicker`, and the small `ModeOption` / `Field` layout helpers.
|
||||
*
|
||||
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
|
||||
* lifecycle — the page owns `api.run.start` and hands the result back through
|
||||
* `onStart`. `SessionPicker` keeps the one API call it already owned (listing
|
||||
* past sessions to resume) and `PromptEditor` keeps its `@`-file lookup.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Play,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ShieldAlert,
|
||||
X,
|
||||
FolderOpen,
|
||||
Home,
|
||||
History as HistoryIcon,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES } from "../../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
DashboardRunHistoryItem,
|
||||
RunListResponse,
|
||||
EffortLevel,
|
||||
PermissionMode,
|
||||
RunMode,
|
||||
} from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { PromptEditor } from "./RunConsole";
|
||||
import type { SlashCommand } from "./RunConsole";
|
||||
|
||||
// ── Limitations banner (above the config card) ────────────────────────
|
||||
|
||||
|
||||
interface RunSetupProps {
|
||||
mode: RunMode;
|
||||
onModeChange: (m: RunMode) => void;
|
||||
prompt: string;
|
||||
onPromptChange: (s: string) => void;
|
||||
cwd: string;
|
||||
onCwdChange: (s: string) => void;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
model: string;
|
||||
onModelChange: (s: string) => void;
|
||||
permissionMode: PermissionMode;
|
||||
onPermissionModeChange: (m: PermissionMode) => void;
|
||||
effort: EffortLevel;
|
||||
onEffortChange: (e: EffortLevel) => void;
|
||||
binaryFound: boolean;
|
||||
busy: boolean;
|
||||
onStart: () => void;
|
||||
activeRuns: RunListResponse | null;
|
||||
resumeSession: Session | null;
|
||||
onResumeSessionChange: (s: Session | null) => void;
|
||||
/** The selected lane's cwd - narrows the resume picker to that lane's own
|
||||
* sessions. Undefined when no lane is selected (the picker then lists
|
||||
* everything, same as before lanes existed). */
|
||||
laneCwd?: string;
|
||||
slashCommands: SlashCommand[];
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
}
|
||||
|
||||
export function RunSetup(props: RunSetupProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const atCap =
|
||||
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
|
||||
const isResume = !!props.resumeSession;
|
||||
const [resumePicked, setResumePicked] = useState(isResume);
|
||||
// Keep "resume picked" in sync with the parent. Two cases:
|
||||
// 1. Parent set a resume session (e.g. user clicked Resume in the runs
|
||||
// modal) - flip the radio so the picker is shown and the selection
|
||||
// is visible.
|
||||
// 2. Parent cleared the session and mode flipped to headless - clear
|
||||
// the radio so the form is honest.
|
||||
useEffect(() => {
|
||||
if (isResume && !resumePicked) setResumePicked(true);
|
||||
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false);
|
||||
}, [isResume, resumePicked, props.mode]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{/* Mode and source on one line. Both are two-way choices made once at
|
||||
spawn time, so a segmented row carries them; the longer explanations
|
||||
live in each button's title rather than in a paragraph. */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={props.mode === "conversation"}
|
||||
label={t("mode.conversation")}
|
||||
title={t("mode.conversationHint")}
|
||||
onClick={() => props.onModeChange("conversation")}
|
||||
/>
|
||||
<Seg
|
||||
active={props.mode === "headless"}
|
||||
label={t("mode.headless")}
|
||||
title={`${t("mode.headlessHint")} — ${t("hint.headlessExplain")}`}
|
||||
onClick={() => {
|
||||
props.onModeChange("headless");
|
||||
setResumePicked(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.mode === "conversation" && (
|
||||
<>
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
title={t("resume.freshHint")}
|
||||
onClick={() => {
|
||||
setResumePicked(false);
|
||||
props.onResumeSessionChange(null);
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={resumePicked}
|
||||
label={t("resume.resumeOption")}
|
||||
title={t("resume.resumeHint")}
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
{resumePicked && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<SessionPicker
|
||||
selected={props.resumeSession}
|
||||
onSelect={props.onResumeSessionChange}
|
||||
cwd={props.laneCwd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
||||
{t("fields.prompt")}
|
||||
</label>
|
||||
<PromptEditor
|
||||
value={props.prompt}
|
||||
onChange={props.onPromptChange}
|
||||
onSubmit={props.onStart}
|
||||
placeholder={t("fields.promptPlaceholder")}
|
||||
rows={5}
|
||||
slashCommands={props.slashCommands}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-gray-600">
|
||||
{t("hint.shortcut")} · / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced fields */}
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-gray-300 flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-gray-500 flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} />
|
||||
</Field>
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{props.permissionMode === "bypassPermissions" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-[11px] text-red-200 flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("hint.permissionWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: contextual run-state hint + run button */}
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3 text-[11px] min-w-0">
|
||||
{atCap ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-amber-300">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })}
|
||||
</span>
|
||||
) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
{t("concurrency.active", { count: props.activeRuns.activeCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onStart}
|
||||
disabled={
|
||||
!props.binaryFound ||
|
||||
!props.prompt.trim() ||
|
||||
props.busy ||
|
||||
atCap ||
|
||||
(resumePicked && !props.resumeSession) ||
|
||||
// Resume locks cwd to the original session, so allow it then;
|
||||
// otherwise require a non-empty cwd so we never spawn at an
|
||||
// invisible default.
|
||||
(!props.resumeSession && !props.cwd.trim())
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{props.busy ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{props.busy ? t("actions.starting") : t("actions.start")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One segment of a two-way inline choice. The explanation rides on `title`
|
||||
* instead of a hint line, which is what keeps the row to one line. */
|
||||
function Seg({
|
||||
active,
|
||||
label,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
title?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-pressed={active}
|
||||
className={`rounded px-2 py-0.5 font-medium transition-colors ${
|
||||
active ? "bg-accent/20 text-accent" : "text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CWD autocomplete ──────────────────────────────────────────────────
|
||||
|
||||
export function CwdAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
inputId,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
suggestions: CwdSuggestion[];
|
||||
/** Sets the input's `id` so an external `<label htmlFor>` can target it. */
|
||||
inputId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = value.toLowerCase().trim();
|
||||
const out = suggestions.filter(
|
||||
(s) => !q || s.path.toLowerCase().includes(q) || s.label.toLowerCase().includes(q)
|
||||
);
|
||||
return out;
|
||||
}, [value, suggestions]);
|
||||
|
||||
// Group suggestions by kind preserving fixed order — home first, matching
|
||||
// the neutral default the page pre-fills (issue #202).
|
||||
const groups = useMemo(() => {
|
||||
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
|
||||
return order
|
||||
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [filtered]);
|
||||
|
||||
// Flat index for keyboard navigation
|
||||
const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]);
|
||||
|
||||
// Keep `active` clamped within bounds
|
||||
useEffect(() => {
|
||||
if (active >= flat.length) setActive(Math.max(0, flat.length - 1));
|
||||
}, [flat.length, active]);
|
||||
|
||||
const choose = (s: CwdSuggestion) => {
|
||||
onChange(s.path);
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
setOpen(true);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(flat.length - 1, a + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
if (open && flat[active]) {
|
||||
e.preventDefault();
|
||||
choose(flat[active]);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
<FolderOpen className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
setActive(0);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={t("fields.cwdPlaceholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
|
||||
{groups.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-gray-500">{t("fields.cwdNoMatches")}</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.kind}>
|
||||
<div className="px-3 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-gray-500 flex items-center gap-1.5">
|
||||
{g.kind === "dashboard" ? (
|
||||
<FolderOpen className="w-3 h-3" />
|
||||
) : g.kind === "home" ? (
|
||||
<Home className="w-3 h-3" />
|
||||
) : (
|
||||
<HistoryIcon className="w-3 h-3" />
|
||||
)}
|
||||
{t(`fields.cwdGroups.${g.kind}`)}
|
||||
</div>
|
||||
{g.items.map((s) => {
|
||||
const idx = flat.indexOf(s);
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={s.path}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault() /* keep input focused */}
|
||||
onClick={() => choose(s)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full text-left px-3 py-1.5 transition-colors ${
|
||||
isActive ? "bg-accent/15" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[11px] text-gray-200 truncate">{s.label}</div>
|
||||
<div className="font-mono text-[10px] text-gray-500 truncate">{s.path}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Model picker ──────────────────────────────────────────────────────
|
||||
|
||||
// ── Session picker (for resume) ───────────────────────────────────────
|
||||
|
||||
function SessionPicker({
|
||||
selected,
|
||||
onSelect,
|
||||
cwd,
|
||||
}: {
|
||||
selected: Session | null;
|
||||
onSelect: (s: Session | null) => void;
|
||||
/** Restricts the list to sessions under this exact working directory - the
|
||||
* lane a resume is started from should only offer that lane's own history. */
|
||||
cwd?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Lazily load sessions when the picker opens, and again whenever the lane
|
||||
// (and so its cwd filter) changes - otherwise switching lanes would leave
|
||||
// the picker showing the PREVIOUS lane's sessions until closed and reopened.
|
||||
const fetchedForCwd = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (sessions !== null && fetchedForCwd.current === cwd) return;
|
||||
fetchedForCwd.current = cwd;
|
||||
api.sessions
|
||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
|
||||
.then((r) => setSessions(r.sessions))
|
||||
.catch(() => setSessions([]));
|
||||
}, [open, cwd, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return sessions;
|
||||
return sessions.filter(
|
||||
(s) =>
|
||||
s.id.toLowerCase().includes(q) ||
|
||||
(s.cwd || "").toLowerCase().includes(q) ||
|
||||
(s.status || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [sessions, query]);
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-accent/30 bg-accent/5 px-3 py-2 flex items-start gap-2">
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
|
||||
{t("resume.selectedBadge")}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-gray-200 truncate">{selected.id}</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-gray-500 truncate mt-0.5">{selected.cwd}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-surface-2 hover:bg-surface-3 text-gray-300 inline-flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{t("resume.clear")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative mt-2">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 px-3 py-2 text-[11px] text-gray-400 inline-flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("resume.pickSession")}
|
||||
<ChevronDown className="w-3 h-3 opacity-70 ml-auto" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
|
||||
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("resume.search")}
|
||||
className="bg-transparent text-[11px] text-gray-100 placeholder:text-gray-500 focus:outline-none w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto py-1">
|
||||
{sessions === null ? (
|
||||
<div className="px-3 py-2 text-[11px] text-gray-500">…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-gray-500">{t("resume.noSessions")}</div>
|
||||
) : (
|
||||
filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
onSelect(s);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
||||
s.status === "active"
|
||||
? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30"
|
||||
: s.status === "completed"
|
||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||
: s.status === "error"
|
||||
? "bg-red-500/10 text-red-300 border-red-500/30"
|
||||
: "bg-surface-3 text-gray-400 border-border"
|
||||
}`}
|
||||
>
|
||||
{s.status}
|
||||
</span>
|
||||
{s.name?.trim() && (
|
||||
<span className="text-[11px] text-gray-200 truncate">{s.name.trim()}</span>
|
||||
)}
|
||||
<span className="font-mono text-[11px] text-gray-400 truncate flex-shrink-0">
|
||||
{s.id.slice(0, 12)}…
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600 ml-auto flex-shrink-0">
|
||||
{new Date(s.started_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-gray-500 truncate">{s.cwd}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The custom Select dropdown now lives in ../components/Select (shared with the
|
||||
// webhook settings form). Imported at the top of this file.
|
||||
|
||||
// Sentinel option value for "Custom model…". Empty string is already taken by
|
||||
// the "inherit from settings" choice, so use a non-empty marker.
|
||||
const MODEL_CUSTOM = "__custom__";
|
||||
|
||||
function ModelPicker({ value, onChange }: { value: string; onChange: (s: string) => void }) {
|
||||
const { t } = useTranslation("run");
|
||||
// "Custom" is selected when the value isn't one of our curated IDs.
|
||||
const knownIds = useMemo(() => RUN_MODEL_CHOICES.map((c) => c.id), []);
|
||||
const isCustom = value !== "" && !knownIds.includes(value);
|
||||
const [showCustom, setShowCustom] = useState(isCustom);
|
||||
|
||||
// Reuse the shared Select so the Model dropdown renders identically to the
|
||||
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
|
||||
// a browser-native <select>.
|
||||
const options = useMemo(
|
||||
() => [
|
||||
...RUN_MODEL_CHOICES.map((c) => ({
|
||||
value: c.id === "" ? "" : c.id,
|
||||
label: c.id === "" ? t("fields.modelInheritLabel") : c.label,
|
||||
hint: c.hint,
|
||||
})),
|
||||
{ value: MODEL_CUSTOM, label: t("fields.modelCustom") },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const onSelect = (v: string) => {
|
||||
if (v === MODEL_CUSTOM) {
|
||||
setShowCustom(true);
|
||||
return;
|
||||
}
|
||||
setShowCustom(false);
|
||||
onChange(v);
|
||||
};
|
||||
|
||||
const selectValue = showCustom || isCustom ? MODEL_CUSTOM : value;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Select<string> value={selectValue} onChange={onSelect} options={options} />
|
||||
{(showCustom || isCustom) && (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("fields.modelCustomPlaceholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @file RunConsole.test.tsx
|
||||
* @description Pins the props-only boundary of `RunConsole` after its move out
|
||||
* of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop
|
||||
* (no stream subscription of its own), the token meter rolls up usage from
|
||||
* those same envelopes, the prompt editor's `/` autocomplete filters and fills
|
||||
* the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the
|
||||
* send and stop controls.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RunConsole, type SlashCommand } from "../RunConsole";
|
||||
import type { Envelope } from "../../../hooks/useRunStream";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
|
||||
const HANDLE: RunHandle = {
|
||||
id: "run-1",
|
||||
pid: 4242,
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/project",
|
||||
model: "claude-opus-5",
|
||||
permissionMode: "acceptEdits",
|
||||
effort: "",
|
||||
prompt: "hi",
|
||||
argv: [],
|
||||
resumeSessionId: null,
|
||||
status: "running",
|
||||
startedAt: 1,
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
sessionId: null,
|
||||
envelopeCount: 0,
|
||||
stdoutTail: "",
|
||||
stderrTail: "",
|
||||
};
|
||||
|
||||
const COMMANDS: SlashCommand[] = [
|
||||
{ name: "code-review", description: "Review the working diff", source: "project" },
|
||||
{ name: "compact", description: "Compact the conversation context", source: "builtin" },
|
||||
{ name: "logout", description: "Sign out", source: "builtin" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mount the console with the parent-owned follow-up state it expects, so the
|
||||
* autocomplete assertions exercise the real controlled-input round trip.
|
||||
*/
|
||||
function renderConsole(
|
||||
props: Partial<React.ComponentProps<typeof RunConsole>> = {},
|
||||
onFollowUp?: (s: string) => void
|
||||
) {
|
||||
const seen = { followUp: "" };
|
||||
function Harness() {
|
||||
const [followUp, setFollowUp] = useState("");
|
||||
seen.followUp = followUp;
|
||||
return (
|
||||
<RunConsole
|
||||
handle={HANDLE}
|
||||
envelopes={[]}
|
||||
mode="conversation"
|
||||
isLive
|
||||
hasFinished={false}
|
||||
followUp={followUp}
|
||||
onFollowUpChange={(s) => {
|
||||
setFollowUp(s);
|
||||
onFollowUp?.(s);
|
||||
}}
|
||||
busy={null}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
onNewRun={() => {}}
|
||||
slashCommands={COMMANDS}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Harness />
|
||||
</MemoryRouter>
|
||||
);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("RunConsole", () => {
|
||||
it("renders assistant text from the envelopes prop", () => {
|
||||
const envelopes: Envelope[] = [
|
||||
{ type: "user", message: { content: "explain this repo" } },
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } },
|
||||
] as Envelope[];
|
||||
renderConsole({ envelopes });
|
||||
|
||||
expect(screen.getByText("explain this repo")).toBeInTheDocument();
|
||||
expect(screen.getByText("Here is the answer.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty-stream placeholder when there are no envelopes", () => {
|
||||
renderConsole({ isLive: false });
|
||||
expect(screen.getByText("Nothing yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the token totals computed from the envelopes", () => {
|
||||
// Transcript-shaped assistant envelope (no `message.id`), which is the
|
||||
// branch computeTokens folds into the running totals.
|
||||
const envelopes: Envelope[] = [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "done" }],
|
||||
usage: { input_tokens: 12_000, output_tokens: 2_500, cache_read_input_tokens: 8_000 },
|
||||
},
|
||||
},
|
||||
] as Envelope[];
|
||||
renderConsole({ envelopes });
|
||||
|
||||
// Context gauge: (input + cache read) / default 200k window.
|
||||
// The CLI-style meter is one status line: context usage as a single label,
|
||||
// then output and cache-hit figures with terminal glyphs. Input is implied
|
||||
// by the context total rather than listed separately.
|
||||
expect(screen.getByText("20.0k / 200k (10%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("↑2.5k")).toBeInTheDocument(); // Output
|
||||
expect(screen.getByText("⚡8.0k")).toBeInTheDocument(); // Cache hit
|
||||
});
|
||||
|
||||
it("filters slash commands as the user types and fills the prompt on pick", () => {
|
||||
const seen = renderConsole();
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/co" } });
|
||||
|
||||
expect(screen.getByText("/code-review")).toBeInTheDocument();
|
||||
expect(screen.getByText("/compact")).toBeInTheDocument();
|
||||
expect(screen.queryByText("/logout")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("/code-review"));
|
||||
|
||||
expect(seen.followUp).toBe("/code-review");
|
||||
expect(screen.queryByText("/compact")).not.toBeInTheDocument(); // dropdown closed
|
||||
});
|
||||
|
||||
it("fires onSend from the send button with the prompt the parent holds", () => {
|
||||
const onSend = vi.fn();
|
||||
const seen = renderConsole({ onSend });
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "follow up please" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(seen.followUp).toBe("follow up please");
|
||||
});
|
||||
|
||||
it("fires onStop from the stop control while live, and hides it when not", () => {
|
||||
const onStop = vi.fn();
|
||||
renderConsole({ onStop });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /stop/i }));
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the stop control and the follow-up editor once the run is not live", () => {
|
||||
renderConsole({ isLive: false, hasFinished: true });
|
||||
|
||||
expect(screen.queryByRole("button", { name: /stop/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file RunHistory.test.tsx
|
||||
* @description Pins the props-only boundary of `ActiveRunsSwitcher` / `RunsModal`
|
||||
* after their move out of `pages/Run.tsx`: the switcher counts live runs and
|
||||
* opens the list, the list merges live in-memory handles with persistent history
|
||||
* (live entries winning on a shared id) newest first, marks the live and current
|
||||
* rows, filters by status / mode / free text, and fires attach / resume / view
|
||||
* with the right run — attach by run id, resume and view with the matching
|
||||
* history item.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
||||
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
||||
|
||||
const LIVE_ID = "run-live";
|
||||
const PAST_ID = "run-past";
|
||||
const HEADLESS_ID = "run-headless";
|
||||
|
||||
const activeRuns = {
|
||||
activeCount: 1,
|
||||
maxConcurrent: 2,
|
||||
items: [
|
||||
{
|
||||
id: LIVE_ID,
|
||||
sessionId: "sess-live",
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/live",
|
||||
model: "claude-opus-5",
|
||||
status: "running",
|
||||
prompt: "the live prompt",
|
||||
startedAt: 3000,
|
||||
endedAt: null,
|
||||
},
|
||||
],
|
||||
} as unknown as RunListResponse;
|
||||
|
||||
function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistoryItem {
|
||||
return {
|
||||
id: PAST_ID,
|
||||
session_id: "sess-past",
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/past",
|
||||
model: "sonnet",
|
||||
status: "completed",
|
||||
prompt_preview: "the past prompt",
|
||||
started_at: new Date(2000).toISOString(),
|
||||
ended_at: new Date(2500).toISOString(),
|
||||
exit_code: 0,
|
||||
permission_mode: "acceptEdits",
|
||||
effort: "",
|
||||
resume_session_id: null,
|
||||
isLive: false,
|
||||
...over,
|
||||
} as DashboardRunHistoryItem;
|
||||
}
|
||||
|
||||
const PAST = historyItem({});
|
||||
const HEADLESS = historyItem({
|
||||
id: HEADLESS_ID,
|
||||
session_id: "sess-headless",
|
||||
mode: "headless",
|
||||
cwd: "/tmp/headless",
|
||||
prompt_preview: "the headless prompt",
|
||||
started_at: new Date(1000).toISOString(),
|
||||
});
|
||||
|
||||
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
||||
const spies = {
|
||||
onAttach: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
onViewFromHistory: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
currentHandleId={null}
|
||||
runHistory={[PAST, HEADLESS]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
|
||||
return {
|
||||
id,
|
||||
sessionId: `sess-${id}`,
|
||||
mode: "conversation",
|
||||
cwd: `/tmp/${id}`,
|
||||
model: "sonnet",
|
||||
status: "completed",
|
||||
promptPreview: `prompt of ${id}`,
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
isLive: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function renderModal(
|
||||
rows: UnifiedRunRow[],
|
||||
overrides: Partial<React.ComponentProps<typeof RunsModal>> = {}
|
||||
) {
|
||||
const spies = {
|
||||
onAttach: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<RunsModal
|
||||
rows={rows}
|
||||
currentHandleId={null}
|
||||
runHistory={[PAST, HEADLESS]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
/** The nth "All" chip — index 0 is the status group, 1 is the mode group. */
|
||||
function allChip(nth: number): HTMLElement {
|
||||
const hits = screen.getAllByText(i18n.t("run:runs.allLabel", "All"));
|
||||
const hit = hits[nth];
|
||||
if (!hit) throw new Error(`no "All" chip at index ${nth}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** A filter chip, told apart from the same word appearing in a row's status
|
||||
* pill or mode badge by being a `<button>`. */
|
||||
function chip(label: string): HTMLElement {
|
||||
const hit = screen.getAllByText(label).find((el) => el.tagName === "BUTTON");
|
||||
if (!hit) throw new Error(`no filter chip labelled ${label}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
const openModal = () =>
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ActiveRunsSwitcher", () => {
|
||||
it("labels the button with the live count and opens the list", () => {
|
||||
renderSwitcher();
|
||||
openModal();
|
||||
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||
expect(screen.getByText("the past prompt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the total count when nothing is live, and disables at zero", () => {
|
||||
const { unmount } = renderSwitcher({ activeRuns: null });
|
||||
expect(screen.getByText(i18n.t("run:runs.switcher"))).toBeTruthy();
|
||||
expect(screen.getByText("2")).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
renderSwitcher({ activeRuns: null, runHistory: [] });
|
||||
const button = screen.getByText(i18n.t("run:runs.switcher")).closest("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("lists live runs first and marks the live one", () => {
|
||||
renderSwitcher();
|
||||
openModal();
|
||||
const prompts = screen
|
||||
.getAllByText(/the (live|past|headless) prompt/)
|
||||
.map((el) => el.textContent);
|
||||
expect(prompts).toEqual(["the live prompt", "the past prompt", "the headless prompt"]);
|
||||
expect(screen.getAllByText("live")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prefers the live handle over a history row with the same id", () => {
|
||||
renderSwitcher({ runHistory: [historyItem({ id: LIVE_ID, prompt_preview: "stale copy" })] });
|
||||
openModal();
|
||||
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||
expect(screen.queryByText("stale copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("fires attach with the run id of the row that was clicked", () => {
|
||||
const { spies } = renderSwitcher();
|
||||
openModal();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
// Attaching closes the list, which is what the page relies on.
|
||||
expect(screen.queryByText("the past prompt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunsModal", () => {
|
||||
it("offers Attach only for a live row that is not the current one", () => {
|
||||
const { unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })], {
|
||||
currentHandleId: LIVE_ID,
|
||||
});
|
||||
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
||||
expect(screen.getByText(i18n.t("run:runs.currentBadge", "current"))).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
const { spies } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
});
|
||||
|
||||
it("fires resume with the history item behind a finished conversation row", () => {
|
||||
const { spies } = renderModal([row(PAST_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
||||
expect(spies.onView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires view — not resume — for a finished headless row", () => {
|
||||
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]);
|
||||
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
||||
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
||||
expect(spies.onResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by status, by mode and by free text", () => {
|
||||
const rows = [
|
||||
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
||||
row("b", { status: "error", promptPreview: "bravo" }),
|
||||
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }),
|
||||
];
|
||||
renderModal(rows);
|
||||
|
||||
fireEvent.click(chip(i18n.t("run:status.error")));
|
||||
expect(screen.getByText("bravo")).toBeTruthy();
|
||||
expect(screen.queryByText("alpha")).toBeNull();
|
||||
|
||||
fireEvent.click(allChip(0));
|
||||
fireEvent.click(chip(i18n.t("run:mode.headless")));
|
||||
expect(screen.getByText("charlie")).toBeTruthy();
|
||||
expect(screen.queryByText("bravo")).toBeNull();
|
||||
|
||||
fireEvent.click(allChip(1));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(
|
||||
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
||||
),
|
||||
{ target: { value: "alpha" } }
|
||||
);
|
||||
expect(screen.getByText("alpha")).toBeTruthy();
|
||||
expect(screen.queryByText("charlie")).toBeNull();
|
||||
});
|
||||
|
||||
it("polls onRefresh while it is the foreground UI", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { spies } = renderModal([row("a")]);
|
||||
expect(spies.onRefresh).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(spies.onRefresh).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @file RunSetup.test.tsx
|
||||
* @description Pins the props-only boundary of `RunSetup` after its move out of
|
||||
* `pages/Run.tsx` (where it was `ConfigCard`): every picker the panel owns —
|
||||
* mode, prompt, cwd, model, permission mode, effort — reports its selection
|
||||
* through the matching callback and nowhere else, `onStart` fires from the Run
|
||||
* button, and a missing `claude` binary is surfaced purely from the
|
||||
* `binaryFound` prop (the panel runs no probe of its own).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: { file: r({ text: "" }) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SUGGESTIONS: CwdSuggestion[] = [
|
||||
{ kind: "home", path: "/Users/tester", label: "Home" },
|
||||
{ kind: "recent", path: "/Users/tester/projects/other", label: "other" },
|
||||
];
|
||||
|
||||
type Spies = ReturnType<typeof renderSetup>["spies"];
|
||||
|
||||
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
||||
const spies = {
|
||||
onModeChange: vi.fn(),
|
||||
onPromptChange: vi.fn(),
|
||||
onCwdChange: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onPermissionModeChange: vi.fn(),
|
||||
onEffortChange: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onResumeSessionChange: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
mode="conversation"
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
model=""
|
||||
permissionMode="acceptEdits"
|
||||
effort=""
|
||||
binaryFound
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
/** Open the `Select` whose trigger currently shows `currentLabel`, then pick
|
||||
* the option labelled `optionLabel`. */
|
||||
function pickFromSelect(currentLabel: string, optionLabel: string) {
|
||||
fireEvent.click(screen.getByText(currentLabel));
|
||||
fireEvent.click(screen.getByText(optionLabel));
|
||||
}
|
||||
|
||||
/** Every callback except the named ones must stay untouched — a selection that
|
||||
* leaks into a sibling prop is exactly the wiring bug a move can introduce. */
|
||||
function onlyCalled(spies: Spies, ...called: (keyof Spies)[]) {
|
||||
for (const [name, spy] of Object.entries(spies)) {
|
||||
if (called.includes(name as keyof Spies)) continue;
|
||||
expect(spy, `${name} should not have fired`).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("RunSetup — selections report through callbacks", () => {
|
||||
it("reports the mode from the one-shot / conversation options", () => {
|
||||
const { spies } = renderSetup();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:mode.headless")));
|
||||
expect(spies.onModeChange).toHaveBeenCalledWith("headless");
|
||||
fireEvent.click(screen.getByText(i18n.t("run:mode.conversation")));
|
||||
expect(spies.onModeChange).toHaveBeenLastCalledWith("conversation");
|
||||
onlyCalled(spies, "onModeChange");
|
||||
});
|
||||
|
||||
it("reports the prompt from the editor", () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
||||
fireEvent.change(box, { target: { value: "review the diff" } });
|
||||
expect(spies.onPromptChange).toHaveBeenCalledWith("review the diff");
|
||||
onlyCalled(spies, "onPromptChange");
|
||||
});
|
||||
|
||||
it("reports the cwd from typing and from a suggestion", () => {
|
||||
const { spies } = renderSetup();
|
||||
const input = screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder"));
|
||||
fireEvent.change(input, { target: { value: "/tmp/pro" } });
|
||||
expect(spies.onCwdChange).toHaveBeenCalledWith("/tmp/pro");
|
||||
fireEvent.click(screen.getByText("/Users/tester/projects/other"));
|
||||
expect(spies.onCwdChange).toHaveBeenLastCalledWith("/Users/tester/projects/other");
|
||||
onlyCalled(spies, "onCwdChange");
|
||||
});
|
||||
|
||||
it("reports the model from the picker, including a custom id", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 4.6");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom")));
|
||||
expect(spies.onModelChange).toHaveBeenCalledTimes(1);
|
||||
fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), {
|
||||
target: { value: "claude-opus-5" },
|
||||
});
|
||||
expect(spies.onModelChange).toHaveBeenLastCalledWith("claude-opus-5");
|
||||
onlyCalled(spies, "onModelChange");
|
||||
});
|
||||
|
||||
it("reports the permission mode and the effort level", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.permissionAcceptEdits"), i18n.t("run:fields.permissionPlan"));
|
||||
expect(spies.onPermissionModeChange).toHaveBeenCalledWith("plan");
|
||||
pickFromSelect("Default (model decides)", "Medium");
|
||||
expect(spies.onEffortChange).toHaveBeenCalledWith("medium");
|
||||
onlyCalled(spies, "onPermissionModeChange", "onEffortChange");
|
||||
});
|
||||
|
||||
it("fires onStart from the Run button", () => {
|
||||
const { spies } = renderSetup();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledTimes(1);
|
||||
onlyCalled(spies, "onStart");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — missing binary and other blocked states", () => {
|
||||
/** The Run button, found by its label rather than by DOM position. */
|
||||
function runButton(): HTMLButtonElement {
|
||||
return screen.getByText(i18n.t("run:actions.start")).closest("button") as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it("disables Run when the claude binary was not found", () => {
|
||||
renderSetup({ binaryFound: false });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables Run when the binary is found and the form is complete", () => {
|
||||
renderSetup();
|
||||
expect(runButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("still disables Run without a prompt, without a cwd, or at the concurrency cap", () => {
|
||||
const { unmount } = renderSetup({ prompt: " " });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
unmount();
|
||||
|
||||
const noCwd = renderSetup({ cwd: "" });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
noCwd.unmount();
|
||||
|
||||
renderSetup({
|
||||
activeRuns: { items: [], activeCount: 2, maxConcurrent: 2 } as never,
|
||||
});
|
||||
expect(runButton().disabled).toBe(true);
|
||||
expect(screen.getByText(i18n.t("run:concurrency.atCap", { max: 2 }))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the Starting… label while busy", () => {
|
||||
renderSetup({ busy: true });
|
||||
expect(screen.getByText(i18n.t("run:actions.starting"))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — resume picker scopes sessions to the selected lane", () => {
|
||||
it("passes the lane's cwd as a filter and lists only that directory's sessions", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await screen.findByText("/Users/tester/lane-a");
|
||||
// The API call itself is what enforces the scope - the server filters by
|
||||
// cwd, so the picker must never fetch without one when a lane is selected.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester/lane-a" })
|
||||
);
|
||||
});
|
||||
|
||||
it("lists everything when no lane is selected", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
renderSetup({ laneCwd: undefined });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ cwd: undefined }));
|
||||
});
|
||||
|
||||
it("re-fetches with the new cwd when the selected lane changes", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
const { rerender } = renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
mode="conversation"
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
model=""
|
||||
permissionMode="acceptEdits"
|
||||
effort=""
|
||||
binaryFound
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
onModeChange={vi.fn()}
|
||||
onPromptChange={vi.fn()}
|
||||
onCwdChange={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPermissionModeChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onResumeSessionChange={vi.fn()}
|
||||
onResumeFromHistory={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester/lane-b" })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file webhookGuides.ts
|
||||
* @description Official-docs URLs per webhook provider, shown alongside the
|
||||
* step-by-step setup guide in the webhook form. The steps themselves are
|
||||
* localized in the i18n `settings` namespace (`webhookGuides.<type>.steps`);
|
||||
* URLs aren't translated so they live here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `WEBHOOK_DOCS` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **WEBHOOK_DOCS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { WebhookType } from "../lib/types";
|
||||
|
||||
export const WEBHOOK_DOCS: Partial<Record<WebhookType, string>> = {
|
||||
slack: "https://api.slack.com/messaging/webhooks",
|
||||
discord: "https://support.discord.com/hc/en-us/articles/228383668",
|
||||
teams: "https://learn.microsoft.com/en-us/microsoftteams/platform/workflow",
|
||||
google_chat: "https://developers.google.com/workspace/chat/quickstart/webhooks",
|
||||
mattermost: "https://developers.mattermost.com/integrate/webhooks/incoming/",
|
||||
rocketchat: "https://docs.rocket.chat/docs/integrations",
|
||||
telegram: "https://core.telegram.org/bots#how-do-i-create-a-bot",
|
||||
pagerduty: "https://support.pagerduty.com/docs/services-and-integrations",
|
||||
opsgenie: "https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/",
|
||||
splunk_oncall: "https://help.victorops.com/knowledge-base/rest-endpoint-integration-guide/",
|
||||
zapier: "https://zapier.com/apps/webhook/integrations",
|
||||
make: "https://www.make.com/en/help/tools/webhooks",
|
||||
n8n: "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/",
|
||||
pipedream: "https://pipedream.com/docs/workflows/triggers/",
|
||||
};
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* @file AgentCollaborationNetwork.tsx
|
||||
* @description Defines the AgentCollaborationNetwork React component that visualizes the collaboration between different agent types in a directed graph format using D3.js. The component takes in effectiveness data for each agent type and the edges representing their interactions, and renders an interactive force-directed graph where nodes represent agent types and edges represent the frequency of sequential runs. The graph includes tooltips for detailed information on hover and a legend for clarity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AgentCollaborationNetworkProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AgentCollaborationNetwork` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **AgentCollaborationNetworkProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **AgentCollaborationNetwork**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgentCollaborationNetworkProps {
|
||||
effectiveness: Array<{
|
||||
subagent_type: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
errors: number;
|
||||
sessions: number;
|
||||
successRate: number;
|
||||
}>;
|
||||
edges: Array<{ source: string; target: string; weight: number }>;
|
||||
}
|
||||
|
||||
interface PipelineNode extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
total: number;
|
||||
sessions: number;
|
||||
successRate: number;
|
||||
colorIndex: number;
|
||||
}
|
||||
|
||||
interface PipelineLink extends d3.SimulationLinkDatum<PipelineNode> {
|
||||
weight: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PALETTE = [
|
||||
"#6366f1",
|
||||
"#3b82f6",
|
||||
"#22c55e",
|
||||
"#a855f7",
|
||||
"#f59e0b",
|
||||
"#ec4899",
|
||||
"#06b6d4",
|
||||
"#f97316",
|
||||
"#ef4444",
|
||||
"#14b8a6",
|
||||
];
|
||||
|
||||
const STROKE_PALETTE = [
|
||||
"#818cf8",
|
||||
"#60a5fa",
|
||||
"#4ade80",
|
||||
"#c084fc",
|
||||
"#fbbf24",
|
||||
"#f472b6",
|
||||
"#22d3ee",
|
||||
"#fb923c",
|
||||
"#f87171",
|
||||
"#2dd4bf",
|
||||
];
|
||||
|
||||
const MIN_R = 20;
|
||||
const MAX_R = 44;
|
||||
|
||||
// ── Safe tooltip DOM builder ──
|
||||
|
||||
function appendTooltipRow(parent: HTMLElement, label: string, value: string) {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;justify-content:space-between;gap:16px;font-size:11px";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
parent.appendChild(row);
|
||||
}
|
||||
|
||||
function appendTooltipDescription(parent: HTMLElement, text: string) {
|
||||
const p = document.createElement("p");
|
||||
p.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;margin:8px 0 0;padding-top:8px;border-top:1px solid #2a2a4a";
|
||||
p.textContent = text;
|
||||
parent.appendChild(p);
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function describeNodeRole(d: PipelineNode, t: TFn): string {
|
||||
const sr = Math.round(d.successRate);
|
||||
let healthKey: string;
|
||||
if (sr >= 95) healthKey = "pipeline.tooltip.health.perfect";
|
||||
else if (sr >= 80) healthKey = "pipeline.tooltip.health.healthy";
|
||||
else if (sr >= 50) healthKey = "pipeline.tooltip.health.shaky";
|
||||
else healthKey = "pipeline.tooltip.health.failing";
|
||||
|
||||
return t("pipeline.tooltip.nodeDescFmt", {
|
||||
id: d.id,
|
||||
total: d.total,
|
||||
sessions: d.sessions,
|
||||
rate: sr,
|
||||
health: t(healthKey),
|
||||
});
|
||||
}
|
||||
|
||||
function showTooltip(
|
||||
el: HTMLDivElement,
|
||||
d: PipelineNode,
|
||||
x: number,
|
||||
y: number,
|
||||
t: TFn,
|
||||
totalSpawns: number
|
||||
) {
|
||||
el.textContent = "";
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0 0 2px";
|
||||
title.textContent = d.id;
|
||||
el.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = t("pipeline.tooltip.agentType");
|
||||
el.appendChild(subtitle);
|
||||
|
||||
const sharePct = totalSpawns > 0 ? `${((d.total / totalSpawns) * 100).toFixed(1)}%` : "-";
|
||||
|
||||
const rows: [string, string][] = [
|
||||
[t("pipeline.spawned"), String(d.total) + t("pipeline.spawns")],
|
||||
[t("pipeline.tooltip.shareOfAllSpawns"), sharePct],
|
||||
[t("pipeline.inSessions"), String(d.sessions)],
|
||||
[t("effectiveness.success"), Math.round(d.successRate) + "%"],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
appendTooltipRow(el, label, value);
|
||||
}
|
||||
|
||||
appendTooltipDescription(el, describeNodeRole(d, t));
|
||||
|
||||
el.style.maxWidth = "320px";
|
||||
positionTooltipAt(el, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position a tooltip so its top-right corner sits near (x, y), but clamped to
|
||||
* the viewport so it never disappears behind the sidebar or the right edge.
|
||||
* Sets opacity to 1 to fade the tooltip in via its CSS transition.
|
||||
*/
|
||||
function positionTooltipAt(el: HTMLDivElement, x: number, y: number) {
|
||||
el.style.opacity = "0";
|
||||
const w = el.offsetWidth || 280;
|
||||
const h = el.offsetHeight || 160;
|
||||
const margin = 8;
|
||||
|
||||
// Default: place just below-right of the cursor
|
||||
let left = x + 14;
|
||||
let top = y + 14;
|
||||
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
||||
if (left < margin) left = margin;
|
||||
if (top + h > window.innerHeight - margin) top = y - h - 14;
|
||||
if (top < margin) top = margin;
|
||||
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${top}px`;
|
||||
el.style.transform = "";
|
||||
// Trigger fade-in on the next frame for a smooth transition.
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = "1";
|
||||
});
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentCollaborationNetwork({
|
||||
effectiveness,
|
||||
edges,
|
||||
}: AgentCollaborationNetworkProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const simulationRef = useRef<d3.Simulation<PipelineNode, PipelineLink> | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Memoize so D3 effect only reruns when props change, not on every render
|
||||
const { nodes, links, isEmpty } = useMemo(() => {
|
||||
const nodeMap = new Map<string, PipelineNode>();
|
||||
effectiveness.forEach((item, i) => {
|
||||
nodeMap.set(item.subagent_type, {
|
||||
id: item.subagent_type,
|
||||
total: item.total,
|
||||
sessions: item.sessions,
|
||||
successRate: item.successRate,
|
||||
colorIndex: i % PALETTE.length,
|
||||
});
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const links: PipelineLink[] = [];
|
||||
for (const e of edges) {
|
||||
if (e.source === e.target) continue;
|
||||
if (!nodeMap.has(e.source) || !nodeMap.has(e.target)) continue;
|
||||
const key = `${e.source}→${e.target}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
links.push({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
weight: e.weight,
|
||||
label: `${e.weight}x`,
|
||||
});
|
||||
}
|
||||
|
||||
const nodes = [...nodeMap.values()];
|
||||
return { nodes, links, isEmpty: nodes.length === 0 || links.length === 0 };
|
||||
}, [effectiveness, edges]);
|
||||
|
||||
// D3 simulation - only depends on memoized data
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!svg || !container || isEmpty) return;
|
||||
|
||||
simulationRef.current?.stop();
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = Math.max(450, Math.min(650, width * 0.6));
|
||||
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
svg.style.width = `${width}px`;
|
||||
svg.style.height = `${height}px`;
|
||||
|
||||
const root = d3.select(svg);
|
||||
root.selectAll("*").remove();
|
||||
|
||||
// Arrow marker
|
||||
const defs = root.append("defs");
|
||||
defs
|
||||
.append("marker")
|
||||
.attr("id", "arrowhead")
|
||||
.attr("viewBox", "0 0 10 6")
|
||||
.attr("refX", 10)
|
||||
.attr("refY", 3)
|
||||
.attr("markerWidth", 8)
|
||||
.attr("markerHeight", 5)
|
||||
.attr("orient", "auto")
|
||||
.append("path")
|
||||
.attr("d", "M0,0 L10,3 L0,6 Z")
|
||||
.attr("fill", "#64748b");
|
||||
|
||||
// Clone data
|
||||
const simNodes = nodes.map((n) => ({ ...n }));
|
||||
const nodeById = new Map(simNodes.map((n) => [n.id, n]));
|
||||
const simLinks: PipelineLink[] = links.map((l) => ({
|
||||
...l,
|
||||
source: nodeById.get(l.source as string) ?? (l.source as string),
|
||||
target: nodeById.get(l.target as string) ?? (l.target as string),
|
||||
}));
|
||||
|
||||
// Scales
|
||||
const ext = d3.extent(simNodes, (n) => n.total) as [number, number];
|
||||
const rScale = d3
|
||||
.scaleSqrt()
|
||||
.domain([Math.max(1, ext[0] ?? 1), Math.max(2, ext[1] ?? 2)])
|
||||
.range([MIN_R, MAX_R])
|
||||
.clamp(true);
|
||||
|
||||
const wExt = d3.extent(simLinks, (l) => l.weight) as [number, number];
|
||||
const strokeScale = d3
|
||||
.scaleLinear()
|
||||
.domain([Math.max(1, wExt[0] ?? 1), Math.max(2, wExt[1] ?? 2)])
|
||||
.range([1.5, 5])
|
||||
.clamp(true);
|
||||
|
||||
// ── Links (paths for curves) ──
|
||||
const linkGroup = root.append("g");
|
||||
const linkEls = linkGroup
|
||||
.selectAll<SVGPathElement, PipelineLink>("path")
|
||||
.data(simLinks)
|
||||
.join("path")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#64748b")
|
||||
.attr("stroke-opacity", 0.55)
|
||||
.attr("stroke-width", (d) => Math.max(1.5, strokeScale(d.weight)))
|
||||
.attr("marker-end", "url(#arrowhead)");
|
||||
|
||||
// ── Edge labels ──
|
||||
const labelGroup = root.append("g");
|
||||
const edgeLabels = labelGroup
|
||||
.selectAll<SVGTextElement, PipelineLink>("text")
|
||||
.data(simLinks)
|
||||
.join("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#94a3b8")
|
||||
.attr("font-size", "9px")
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("pointer-events", "none")
|
||||
.text((d) => d.label);
|
||||
|
||||
// ── Nodes ──
|
||||
const nodeGroup = root.append("g");
|
||||
const nodeEls = nodeGroup
|
||||
.selectAll<SVGGElement, PipelineNode>("g")
|
||||
.data(simNodes, (d) => d.id)
|
||||
.join("g")
|
||||
.attr("cursor", "grab");
|
||||
|
||||
nodeEls
|
||||
.append("circle")
|
||||
.attr("r", (d) => rScale(d.total))
|
||||
.attr("fill", (d) => PALETTE[d.colorIndex] ?? "#6366f1")
|
||||
.attr("fill-opacity", 0.8)
|
||||
.attr("stroke", (d) => STROKE_PALETTE[d.colorIndex] ?? "#818cf8")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
nodeEls
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", (d) => rScale(d.total) + 14)
|
||||
.attr("fill", "#cbd5e1")
|
||||
.attr("font-size", "10px")
|
||||
.attr("font-weight", "500")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("pointer-events", "none")
|
||||
.text((d) => (d.id.length > 16 ? d.id.slice(0, 14) + "\u2026" : d.id));
|
||||
|
||||
// ── Invisible wider hit areas for edge hover ──
|
||||
const hitGroup = root.append("g");
|
||||
const linkHits = hitGroup
|
||||
.selectAll<SVGPathElement, PipelineLink>("path")
|
||||
.data(simLinks)
|
||||
.join("path")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "transparent")
|
||||
.attr("stroke-width", 16)
|
||||
.attr("cursor", "pointer");
|
||||
|
||||
// ── Hover - pure DOM, zero React re-renders ──
|
||||
const tipEl = tooltipRef.current;
|
||||
|
||||
// Edge hover
|
||||
const totalSpawns = simNodes.reduce((s, n) => s + n.total, 0);
|
||||
|
||||
linkHits
|
||||
.on("mouseenter", (event: MouseEvent, d: PipelineLink) => {
|
||||
const src = d.source as PipelineNode;
|
||||
const tgt = d.target as PipelineNode;
|
||||
// Highlight this edge
|
||||
linkEls
|
||||
.attr("stroke-opacity", (l) => (l === d ? 1 : 0.1))
|
||||
.attr("stroke-width", (l) =>
|
||||
l === d ? Math.max(3, strokeScale(l.weight) + 1) : Math.max(1.5, strokeScale(l.weight))
|
||||
);
|
||||
edgeLabels.attr("fill-opacity", (l) => (l === d ? 1 : 0.15));
|
||||
|
||||
if (tipEl) {
|
||||
tipEl.textContent = "";
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0 0 2px";
|
||||
title.textContent = `${src.id} \u2192 ${tgt.id}`;
|
||||
tipEl.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = t("pipeline.tooltip.edge");
|
||||
tipEl.appendChild(subtitle);
|
||||
|
||||
const shareOfSrc = src.total > 0 ? `${((d.weight / src.total) * 100).toFixed(1)}%` : "-";
|
||||
const shareOfTgt = tgt.total > 0 ? `${((d.weight / tgt.total) * 100).toFixed(1)}%` : "-";
|
||||
|
||||
const rows: [string, string][] = [
|
||||
[t("pipeline.tooltip.sequentialPairs"), `${d.weight}\u00d7`],
|
||||
[t("pipeline.tooltip.shareOfSrcFmt", { source: src.id }), shareOfSrc],
|
||||
[t("pipeline.tooltip.shareOfTgtFmt", { target: tgt.id }), shareOfTgt],
|
||||
[t("pipeline.tooltip.totalSpawnsFmt", { id: src.id }), String(src.total)],
|
||||
[t("pipeline.tooltip.totalSpawnsFmt", { id: tgt.id }), String(tgt.total)],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
appendTooltipRow(tipEl, label, value);
|
||||
}
|
||||
|
||||
appendTooltipDescription(
|
||||
tipEl,
|
||||
t("pipeline.tooltip.edgeDescFmt", {
|
||||
source: src.id,
|
||||
target: tgt.id,
|
||||
count: d.weight,
|
||||
})
|
||||
);
|
||||
|
||||
tipEl.style.maxWidth = "320px";
|
||||
positionTooltipAt(tipEl, event.clientX, event.clientY);
|
||||
}
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
linkEls
|
||||
.attr("stroke-opacity", 0.55)
|
||||
.attr("stroke-width", (d) => Math.max(1.5, strokeScale(d.weight)));
|
||||
edgeLabels.attr("fill-opacity", 1);
|
||||
if (tipEl) tipEl.style.opacity = "0";
|
||||
});
|
||||
|
||||
// Node hover
|
||||
nodeEls
|
||||
.on("mouseenter", (event: MouseEvent, d: PipelineNode) => {
|
||||
linkEls.attr("stroke-opacity", (l) => {
|
||||
const s = (l.source as PipelineNode).id;
|
||||
const t = (l.target as PipelineNode).id;
|
||||
return s === d.id || t === d.id ? 0.9 : 0.08;
|
||||
});
|
||||
edgeLabels.attr("fill-opacity", (l) => {
|
||||
const s = (l.source as PipelineNode).id;
|
||||
const t = (l.target as PipelineNode).id;
|
||||
return s === d.id || t === d.id ? 1 : 0.15;
|
||||
});
|
||||
d3.select(event.currentTarget as SVGGElement)
|
||||
.select("circle")
|
||||
.attr("stroke-width", 4);
|
||||
if (tipEl) showTooltip(tipEl, d, event.clientX, event.clientY, t, totalSpawns);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
linkEls.attr("stroke-opacity", 0.55);
|
||||
edgeLabels.attr("fill-opacity", 1);
|
||||
nodeEls.selectAll("circle").attr("stroke-width", 2);
|
||||
if (tipEl) tipEl.style.opacity = "0";
|
||||
});
|
||||
|
||||
// ── Drag ──
|
||||
const drag = d3
|
||||
.drag<SVGGElement, PipelineNode>()
|
||||
.on("start", (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.12).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on("drag", (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
});
|
||||
nodeEls.call(drag);
|
||||
|
||||
// ── Simulation ──
|
||||
const simulation = d3
|
||||
.forceSimulation<PipelineNode>(simNodes)
|
||||
.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink<PipelineNode, PipelineLink>(simLinks)
|
||||
.id((d) => d.id)
|
||||
.distance(250)
|
||||
)
|
||||
.force("charge", d3.forceManyBody<PipelineNode>().strength(-800))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force(
|
||||
"collision",
|
||||
d3.forceCollide<PipelineNode>().radius((d) => rScale(d.total) + 30)
|
||||
)
|
||||
.force("x", d3.forceX(width / 2).strength(0.03))
|
||||
.force("y", d3.forceY(height / 2).strength(0.03))
|
||||
.alpha(0.5)
|
||||
.on("tick", () => {
|
||||
// Clamp nodes inside viewBox
|
||||
for (const n of simNodes) {
|
||||
const r = rScale(n.total) + 16;
|
||||
n.x = Math.max(r, Math.min(width - r, n.x ?? width / 2));
|
||||
n.y = Math.max(r, Math.min(height - r, n.y ?? height / 2));
|
||||
}
|
||||
|
||||
// Build path for each edge (shared by visible + hit area)
|
||||
const pathFor = (d: PipelineLink) => {
|
||||
const s = d.source as PipelineNode;
|
||||
const t = d.target as PipelineNode;
|
||||
const sx = s.x ?? 0,
|
||||
sy = s.y ?? 0;
|
||||
const tx = t.x ?? 0,
|
||||
ty = t.y ?? 0;
|
||||
const dx = tx - sx,
|
||||
dy = ty - sy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const sr = rScale(s.total);
|
||||
const tr = rScale(t.total) + 8;
|
||||
const x1 = sx + (dx / dist) * sr;
|
||||
const y1 = sy + (dy / dist) * sr;
|
||||
const x2 = tx - (dx / dist) * tr;
|
||||
const y2 = ty - (dy / dist) * tr;
|
||||
const mx = (x1 + x2) / 2 - dy * 0.1;
|
||||
const my = (y1 + y2) / 2 + dx * 0.1;
|
||||
return `M${x1},${y1} Q${mx},${my} ${x2},${y2}`;
|
||||
};
|
||||
|
||||
linkEls.attr("d", pathFor);
|
||||
linkHits.attr("d", pathFor);
|
||||
|
||||
edgeLabels.each(function (d) {
|
||||
const s = d.source as PipelineNode;
|
||||
const t = d.target as PipelineNode;
|
||||
const sx = s.x ?? 0,
|
||||
sy = s.y ?? 0,
|
||||
tx = t.x ?? 0,
|
||||
ty = t.y ?? 0;
|
||||
const ddx = tx - sx,
|
||||
ddy = ty - sy;
|
||||
d3.select(this)
|
||||
.attr("x", (sx + tx) / 2 - ddy * 0.1)
|
||||
.attr("y", (sy + ty) / 2 + ddx * 0.1 - 4);
|
||||
});
|
||||
|
||||
nodeEls.attr("transform", (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
||||
});
|
||||
|
||||
simulationRef.current = simulation as d3.Simulation<PipelineNode, PipelineLink>;
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
}, [nodes, links, isEmpty, t]);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-sm font-medium text-gray-400">{t("pipeline.noData")}</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{t("pipeline.noDataDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleContainerLeave = () => {
|
||||
const tip = tooltipRef.current;
|
||||
if (tip) tip.style.opacity = "0";
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full relative" onMouseLeave={handleContainerLeave}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
style={{ display: "block", width: "100%", background: "transparent" }}
|
||||
aria-label={t("pipeline.ariaLabel")}
|
||||
role="img"
|
||||
onMouseLeave={handleContainerLeave}
|
||||
/>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
minWidth: 172,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3 px-1">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-widest font-medium">
|
||||
{t("pipeline.legend")}
|
||||
</span>
|
||||
{nodes.map((n) => (
|
||||
<div key={n.id} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: PALETTE[n.colorIndex] ?? PALETTE[0],
|
||||
border: `1.5px solid ${STROKE_PALETTE[n.colorIndex] ?? STROKE_PALETTE[0]}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] text-gray-500">{n.id}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<svg width="20" height="8" className="flex-shrink-0">
|
||||
<line x1="0" y1="4" x2="14" y2="4" stroke="#64748b" strokeWidth="1.5" />
|
||||
<polygon points="14,1 20,4 14,7" fill="#64748b" />
|
||||
</svg>
|
||||
<span className="text-[11px] text-gray-500">{t("pipeline.legendDesc")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* @file CompactionImpact.tsx
|
||||
* @description Visualizes how context compaction is spread across sessions.
|
||||
* Compaction is when Claude Code compresses older conversation history into a
|
||||
* summary once a session's context window fills up. This panel surfaces the
|
||||
* at-a-glance stats (total events, sessions affected, average and peak per
|
||||
* session) and a histogram answering "how many sessions compacted N times?"
|
||||
* so the distribution is legible regardless of how many sessions exist.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `CompactionImpactProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `CompactionImpact` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **CompactionImpactProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **CompactionImpact**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { CompactionImpactData } from "../../lib/types";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Roll the per-session list into a histogram: for each compaction count k
|
||||
* (1..peak), how many sessions compacted exactly k times. */
|
||||
function toHistogram(perSession: CompactionImpactData["perSession"]): Array<{
|
||||
count: number;
|
||||
sessions: number;
|
||||
}> {
|
||||
const peak = perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
|
||||
const buckets: Array<{ count: number; sessions: number }> = [];
|
||||
for (let k = 1; k <= peak; k++) {
|
||||
buckets.push({ count: k, sessions: perSession.filter((s) => s.compactions === k).length });
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
// ── Chart constants ───────────────────────────────────────────────────────────
|
||||
|
||||
const MARGIN = { top: 18, right: 16, bottom: 46, left: 48 };
|
||||
const CHART_HEIGHT = 200;
|
||||
|
||||
// ── D3 renderer ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface HistogramBucket {
|
||||
count: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
interface HistogramOpts {
|
||||
x: string;
|
||||
y: string;
|
||||
onHover: (e: MouseEvent, d: HistogramBucket) => void;
|
||||
onMove: (e: MouseEvent) => void;
|
||||
onLeave: () => void;
|
||||
}
|
||||
|
||||
function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: HistogramOpts): void {
|
||||
const container = svg.parentElement;
|
||||
const width = container ? container.clientWidth : 400;
|
||||
const innerW = width - MARGIN.left - MARGIN.right;
|
||||
const innerH = CHART_HEIGHT - MARGIN.top - MARGIN.bottom;
|
||||
|
||||
const root = d3.select(svg);
|
||||
root.selectAll("*").remove();
|
||||
root.attr("viewBox", `0 0 ${width} ${CHART_HEIGHT}`).attr("preserveAspectRatio", "xMidYMid meet");
|
||||
|
||||
const defs = root.append("defs");
|
||||
const grad = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", "compact-bar-grad")
|
||||
.attr("x1", "0%")
|
||||
.attr("y1", "0%")
|
||||
.attr("x2", "0%")
|
||||
.attr("y2", "100%");
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8");
|
||||
grad
|
||||
.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", "#3730a3")
|
||||
.attr("stop-opacity", 0.7);
|
||||
|
||||
const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
|
||||
|
||||
const maxSessions = d3.max(histo, (d) => d.sessions) ?? 1;
|
||||
|
||||
const xScale = d3
|
||||
.scaleBand<number>()
|
||||
.domain(histo.map((d) => d.count))
|
||||
.range([0, innerW])
|
||||
.padding(histo.length > 12 ? 0.18 : 0.34);
|
||||
|
||||
const yScale = d3.scaleLinear().domain([0, maxSessions]).nice().range([innerH, 0]);
|
||||
|
||||
// Horizontal grid lines (integer session counts)
|
||||
const yTicks = yScale.ticks(Math.min(4, maxSessions)).filter((d) => Number.isInteger(d));
|
||||
g.selectAll<SVGLineElement, number>(".grid-line")
|
||||
.data(yTicks)
|
||||
.join("line")
|
||||
.attr("class", "grid-line")
|
||||
.attr("x1", 0)
|
||||
.attr("x2", innerW)
|
||||
.attr("y1", (d) => yScale(d))
|
||||
.attr("y2", (d) => yScale(d))
|
||||
.attr("stroke", "#2a2a3d")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Y axis (sessions)
|
||||
g.append("g")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickValues(yTicks)
|
||||
.tickSize(0)
|
||||
.tickPadding(8)
|
||||
.tickFormat((d) => String(d))
|
||||
)
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
// X axis (compactions per session - one tick per bucket)
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${innerH})`)
|
||||
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#9ca3af")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
// Axis titles
|
||||
g.append("text")
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(opts.x);
|
||||
|
||||
g.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(opts.y);
|
||||
|
||||
// Bars + count labels + rich hover tooltip (full-height hit-area so every
|
||||
// column - including empty buckets - responds, matching the other charts).
|
||||
histo.forEach((d) => {
|
||||
const bx = xScale(d.count);
|
||||
if (bx === undefined) return;
|
||||
const bw = xScale.bandwidth();
|
||||
const by = yScale(d.sessions);
|
||||
const barH = innerH - by;
|
||||
|
||||
const bg = g.append("g");
|
||||
let bar: d3.Selection<SVGRectElement, unknown, null, undefined> | null = null;
|
||||
|
||||
if (d.sessions > 0) {
|
||||
bar = bg
|
||||
.append("rect")
|
||||
.attr("x", bx)
|
||||
.attr("y", by)
|
||||
.attr("width", bw)
|
||||
.attr("height", barH)
|
||||
.attr("rx", Math.min(4, bw / 2))
|
||||
.attr("fill", "url(#compact-bar-grad)")
|
||||
.style("transition", "fill 120ms ease");
|
||||
|
||||
bg.append("text")
|
||||
.attr("x", bx + bw / 2)
|
||||
.attr("y", by - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#a5b4fc")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("pointer-events", "none")
|
||||
.text(d.sessions);
|
||||
} else {
|
||||
// Empty bucket: faint baseline tick so the gap reads as "zero", not missing
|
||||
bg.append("rect")
|
||||
.attr("x", bx)
|
||||
.attr("y", innerH - 1)
|
||||
.attr("width", bw)
|
||||
.attr("height", 1)
|
||||
.attr("fill", "#2a2a3d");
|
||||
}
|
||||
|
||||
// Transparent, full-height hover target on top of the bar.
|
||||
bg.append("rect")
|
||||
.attr("x", bx)
|
||||
.attr("y", 0)
|
||||
.attr("width", bw)
|
||||
.attr("height", innerH)
|
||||
.attr("fill", "transparent")
|
||||
.style("cursor", "pointer")
|
||||
.on("mouseenter", (event: MouseEvent) => {
|
||||
if (bar) bar.attr("fill", "#a5b4fc");
|
||||
opts.onHover(event, d);
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) => opts.onMove(event))
|
||||
.on("mouseleave", () => {
|
||||
if (bar) bar.attr("fill", "url(#compact-bar-grad)");
|
||||
opts.onLeave();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Stat box ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatBoxProps {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
accent?: string;
|
||||
}
|
||||
|
||||
function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0">
|
||||
<span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span>
|
||||
<span className="text-[11px] font-medium text-gray-500 uppercase tracking-wider leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
{sub && <span className="text-[11px] text-gray-600 tabular-nums">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompactionImpactProps {
|
||||
data: CompactionImpactData;
|
||||
}
|
||||
|
||||
export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [tip, setTip] = useState<{ x: number; y: number; title: string; detail: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const hasData = data.totalCompactions > 0;
|
||||
const affected = data.sessionsWithCompactions;
|
||||
const sessionPct = data.totalSessions > 0 ? Math.round((affected / data.totalSessions) * 100) : 0;
|
||||
const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0;
|
||||
const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !hasData) return;
|
||||
const histo = toHistogram(data.perSession);
|
||||
const affectedN = data.sessionsWithCompactions;
|
||||
renderHistogram(svgRef.current, histo, {
|
||||
x: t("compaction.xAxis"),
|
||||
y: t("compaction.yAxis"),
|
||||
onHover: (e, d) => {
|
||||
const pct = affectedN > 0 ? Math.round((d.sessions / affectedN) * 100) : 0;
|
||||
setTip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
title: t("compaction.tipTitle", { count: d.count }),
|
||||
detail: t("compaction.tipDetail", { sessions: d.sessions, pct }),
|
||||
});
|
||||
},
|
||||
onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)),
|
||||
onLeave: () => setTip(null),
|
||||
});
|
||||
}, [data, hasData, t]);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3" />
|
||||
<path d="M13 21l2-2 4 4" />
|
||||
<path d="M17 21v-6" />
|
||||
<path d="M21 17h-6" />
|
||||
</svg>
|
||||
<span className="text-sm">{t("compaction.noData")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* What compaction is - one line so the numbers below make sense */}
|
||||
<p className="text-xs text-gray-500 leading-relaxed">{t("compaction.help")}</p>
|
||||
|
||||
{/* Stat tiles */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatBox
|
||||
label={t("compaction.totalCompactions")}
|
||||
value={data.totalCompactions.toLocaleString()}
|
||||
accent="text-accent-hover"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.sessionsAffected")}
|
||||
value={affected.toLocaleString()}
|
||||
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
|
||||
accent="text-violet-300"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.avgPerSession")}
|
||||
value={avgPerSession.toFixed(1)}
|
||||
accent="text-blue-300"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.peakSession")}
|
||||
value={peak.toLocaleString()}
|
||||
accent="text-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Histogram: sessions by compaction count */}
|
||||
<div className="w-full overflow-hidden">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
{t("compaction.distribution")}
|
||||
</p>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="w-full"
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
aria-label={t("compaction.ariaLabel")}
|
||||
role="img"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plain-English summary + (when present) tokens freed */}
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{t("compaction.summary", {
|
||||
affected: affected.toLocaleString(),
|
||||
total: data.totalSessions.toLocaleString(),
|
||||
pct: sessionPct,
|
||||
})}
|
||||
{data.tokensRecovered > 0 && (
|
||||
<> {t("compaction.tokensFreed", { tokens: fmtTokens(data.tokensRecovered) })}</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Hover tooltip (matches the app's other chart tooltips) */}
|
||||
{tip && (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
|
||||
style={{
|
||||
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
|
||||
top: tip.y - 10,
|
||||
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-gray-100">{tip.title}</div>
|
||||
<div className="mt-0.5 text-gray-400">{tip.detail}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* @file ConcurrencyTimeline.tsx
|
||||
* @description Defines the ConcurrencyTimeline component that visualizes concurrency data for agent sessions using horizontal bars. Each lane represents an agent type (main or subagent) with the bar width proportional to the number of sessions and timing indicated as a percentage of the session duration. The component handles empty states gracefully and assigns distinct colors to different agent types for clarity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ConcurrencyTimelineProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ConcurrencyTimeline` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `laneColor` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ConcurrencyTimelineProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ConcurrencyTimeline**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **laneColor**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ConcurrencyData, ConcurrencyLane } from "../../lib/types";
|
||||
|
||||
// ── Color palette ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MAIN_COLOR = "#6366f1"; // indigo
|
||||
|
||||
const SUBAGENT_PALETTE = [
|
||||
"#10b981", // emerald
|
||||
"#3b82f6", // blue
|
||||
"#f59e0b", // amber
|
||||
"#f43f5e", // rose
|
||||
"#06b6d4", // cyan
|
||||
"#f97316", // orange
|
||||
"#a855f7", // purple
|
||||
"#84cc16", // lime
|
||||
];
|
||||
|
||||
// ── Lane row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LaneRowProps {
|
||||
lane: ConcurrencyLane;
|
||||
color: string;
|
||||
maxCount: number;
|
||||
onShowTip: (lane: ConcurrencyLane, color: string, anchor: HTMLElement) => void;
|
||||
onHideTip: () => void;
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function describeLaneTiming(start: number, end: number, t: TFn): string {
|
||||
// start/end are 0–1 fractions of session timeline.
|
||||
const startPct = Math.round(start * 100);
|
||||
const endPct = Math.round(end * 100);
|
||||
const span = Math.max(0, endPct - startPct);
|
||||
|
||||
if (startPct < 15 && endPct > 85) return t("concurrency.tooltip.timing.wholeSession");
|
||||
if (startPct < 15) return t("concurrency.tooltip.timing.frontLoadedFmt", { end: endPct });
|
||||
if (endPct > 85) return t("concurrency.tooltip.timing.backLoadedFmt", { start: startPct });
|
||||
if (span < 15) return t("concurrency.tooltip.timing.tightFmt", { start: startPct, end: endPct });
|
||||
return t("concurrency.tooltip.timing.midSessionFmt", { start: startPct, end: endPct });
|
||||
}
|
||||
|
||||
function LaneRow({ lane, color, maxCount, onShowTip, onHideTip }: LaneRowProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const displayName = lane.name === "Main Agent" ? t("orchestration.mainAgent") : lane.name;
|
||||
// Bar width proportional to session count (the metric with meaningful variance)
|
||||
const barPct = maxCount > 0 ? (lane.count / maxCount) * 100 : 0;
|
||||
|
||||
// Duration as percentage of session (backend returns 0-1 fractions)
|
||||
const startPct = (lane.avgStart * 100).toFixed(0);
|
||||
const endPct = (lane.avgEnd * 100).toFixed(0);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5 group">
|
||||
{/* Label column */}
|
||||
<div className="flex-shrink-0 w-[140px] text-right" title={displayName}>
|
||||
<span className="text-xs font-medium text-gray-400 truncate block group-hover:text-gray-200 transition-colors">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar area */}
|
||||
<div
|
||||
className="relative flex-1 h-6 bg-surface-3 rounded overflow-hidden"
|
||||
onMouseEnter={(e) => onShowTip(lane, color, e.currentTarget)}
|
||||
onMouseLeave={onHideTip}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 rounded transition-all duration-300"
|
||||
style={{
|
||||
width: `${barPct}%`,
|
||||
minWidth: barPct > 0 ? "4px" : undefined,
|
||||
backgroundColor: color,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
{/* Count label inside bar if wide enough, outside if not */}
|
||||
<span
|
||||
className="absolute top-0 bottom-0 flex items-center text-[11px] font-medium tabular-nums"
|
||||
style={{
|
||||
left: barPct > 15 ? "8px" : `calc(${barPct}% + 6px)`,
|
||||
color: barPct > 15 ? "white" : "var(--color-gray-400)",
|
||||
}}
|
||||
>
|
||||
{lane.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timing range */}
|
||||
<div className="flex-shrink-0 w-[72px] text-[11px] text-gray-600 tabular-nums">
|
||||
{startPct}%–{endPct}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildLaneTooltip(
|
||||
el: HTMLDivElement,
|
||||
lane: ConcurrencyLane,
|
||||
displayName: string,
|
||||
color: string,
|
||||
t: TFn
|
||||
) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
|
||||
const startPct = (lane.avgStart * 100).toFixed(0);
|
||||
const endPct = (lane.avgEnd * 100).toFixed(0);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:4px";
|
||||
const dot = document.createElement("span");
|
||||
dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:9999px;background:${color}`;
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
|
||||
title.textContent = displayName;
|
||||
header.appendChild(dot);
|
||||
header.appendChild(title);
|
||||
el.appendChild(header);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = t("concurrency.tooltip.lane");
|
||||
el.appendChild(subtitle);
|
||||
|
||||
const addRow = (label: string, value: string) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
el.appendChild(row);
|
||||
};
|
||||
|
||||
addRow(t("concurrency.tooltip.sessionsWith"), String(lane.count));
|
||||
addRow(t("concurrency.tooltip.avgStart"), `${startPct}%`);
|
||||
addRow(t("concurrency.tooltip.avgEnd"), `${endPct}%`);
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:8px 0 0";
|
||||
desc.textContent = describeLaneTiming(lane.avgStart, lane.avgEnd, t);
|
||||
el.appendChild(desc);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
|
||||
hint.textContent = t("concurrency.tooltip.barHint");
|
||||
el.appendChild(hint);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="4" rx="1" />
|
||||
<rect x="3" y="10" width="12" height="4" rx="1" />
|
||||
<rect x="3" y="16" width="15" height="4" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-400">{t("concurrency.noData")}</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{t("concurrency.noDataDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public component ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ConcurrencyTimelineProps {
|
||||
data: ConcurrencyData;
|
||||
}
|
||||
|
||||
export function ConcurrencyTimeline({ data }: ConcurrencyTimelineProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const lanes = data.aggregateLanes;
|
||||
|
||||
const hideTip = useCallback(() => {
|
||||
const tip = tipRef.current;
|
||||
if (tip) tip.style.opacity = "0";
|
||||
}, []);
|
||||
|
||||
const showTip = useCallback(
|
||||
(lane: ConcurrencyLane, color: string, anchor: HTMLElement) => {
|
||||
const tip = tipRef.current;
|
||||
if (!tip) return;
|
||||
const displayName = lane.name === "Main Agent" ? t("orchestration.mainAgent") : lane.name;
|
||||
buildLaneTooltip(tip, lane, displayName, color, t);
|
||||
|
||||
const r = anchor.getBoundingClientRect();
|
||||
tip.style.opacity = "0";
|
||||
tip.style.display = "block";
|
||||
const tipW = tip.offsetWidth || 280;
|
||||
const tipH = tip.offsetHeight || 160;
|
||||
const margin = 8;
|
||||
|
||||
let left = r.left + r.width / 2 - tipW / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
||||
let top = r.top - tipH - 10;
|
||||
if (top < margin) top = r.bottom + 10;
|
||||
|
||||
tip.style.left = `${left}px`;
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.opacity = "1";
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
if (lanes.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
// Sort by session count descending so the most-used agent types are on top
|
||||
const sorted = [...lanes].sort((a, b) => b.count - a.count);
|
||||
const maxCount = sorted[0]?.count ?? 1;
|
||||
|
||||
// Assign colors
|
||||
let subagentIndex = 0;
|
||||
const coloredLanes = sorted.map((lane) => {
|
||||
const isMain = lane.name === "Main Agent";
|
||||
const color = isMain
|
||||
? MAIN_COLOR
|
||||
: (SUBAGENT_PALETTE[subagentIndex % SUBAGENT_PALETTE.length] ?? MAIN_COLOR);
|
||||
if (!isMain) subagentIndex++;
|
||||
return { lane, color };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full" onMouseLeave={hideTip}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex-shrink-0 w-[140px]" />
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider">
|
||||
{t("concurrency.sessions")}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600 tabular-nums">
|
||||
{maxCount}
|
||||
{t("concurrency.max")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-[72px] text-[10px] text-gray-600 uppercase tracking-wider">
|
||||
{t("concurrency.timing")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane rows */}
|
||||
<div className="flex flex-col divide-y divide-surface-4">
|
||||
{coloredLanes.map(({ lane, color }) => (
|
||||
<LaneRow
|
||||
key={lane.name}
|
||||
lane={lane}
|
||||
color={color}
|
||||
maxCount={maxCount}
|
||||
onShowTip={showTip}
|
||||
onHideTip={hideTip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
background: "#12121f",
|
||||
border: "1px solid #2a2a4a",
|
||||
color: "#e2e8f0",
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export helper so callers can import the color fn if needed
|
||||
export function laneColor(name: string, subagentIndex: number): string {
|
||||
if (name === "Main Agent") return MAIN_COLOR;
|
||||
return SUBAGENT_PALETTE[subagentIndex % SUBAGENT_PALETTE.length] ?? MAIN_COLOR;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @file ErrorPropagationMap.tsx
|
||||
* @description A React component that visualizes error propagation across agent hierarchies in a workflow system. It displays the distribution of errors by hierarchy depth, identifies error-prone agent types, and highlights API and session errors. The component uses horizontal bars to represent error counts at different depths and types, providing an intuitive overview of where errors are occurring within the agent structure.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ErrorPropagationMapProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ErrorPropagationMap` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ErrorPropagationMapProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ErrorPropagationMap**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ErrorPropagationData } from "../../lib/types";
|
||||
|
||||
const DEPTH_COLORS = ["#ef4444", "#f97316", "#eab308", "#a855f7"];
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ErrorPropagationMapProps {
|
||||
data: ErrorPropagationData;
|
||||
}
|
||||
|
||||
export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [hoveredDepth, setHoveredDepth] = useState<number | null>(null);
|
||||
|
||||
function depthLabel(depth: number): string {
|
||||
const keys = [
|
||||
t("errorPropagation.depthLabels.sessionMain"),
|
||||
t("errorPropagation.depthLabels.directSubagent"),
|
||||
t("errorPropagation.depthLabels.nested"),
|
||||
t("errorPropagation.depthLabels.deep"),
|
||||
];
|
||||
return keys[depth] ?? `${t("common:depth")} ${depth}`;
|
||||
}
|
||||
|
||||
const hasErrors =
|
||||
data.byDepth.some((d) => d.count > 0) ||
|
||||
data.byType.some((t) => t.count > 0) ||
|
||||
(data.eventErrors && data.eventErrors.length > 0) ||
|
||||
data.sessionsWithErrors > 0;
|
||||
|
||||
if (!hasErrors) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-emerald-400 font-medium">
|
||||
{t("errorPropagation.noErrors")}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600">{t("errorPropagation.allSuccess")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorRatePct = data.errorRate;
|
||||
const totalErrors = data.byDepth.reduce((s, d) => s + d.count, 0);
|
||||
const maxDepthCount = Math.max(...data.byDepth.map((d) => d.count), 1);
|
||||
const topTypes = [...data.byType].sort((a, b) => b.count - a.count).slice(0, 6);
|
||||
const hasDepthData = data.byDepth.some((d) => d.count > 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Error rate summary bar */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-red-500/5 border border-red-500/15">
|
||||
<div className="flex-shrink-0 min-w-[2.75rem] h-10 px-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center">
|
||||
<span className="text-[13px] font-bold text-red-400 tabular-nums whitespace-nowrap">
|
||||
{errorRatePct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-red-300">
|
||||
{t("errorPropagation.sessionsErrorSummary", {
|
||||
errorSessions: data.sessionsWithErrors,
|
||||
totalSessions: data.totalSessions,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
{totalErrors > 0
|
||||
? `${totalErrors}${t("errorPropagation.agentErrors")}`
|
||||
: t("errorPropagation.sessionErrorsOnly")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors by depth - horizontal bars */}
|
||||
{hasDepthData && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
|
||||
{t("errorPropagation.errorsByDepth")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.byDepth
|
||||
.filter((d) => d.count > 0)
|
||||
.map((d) => {
|
||||
const pct = (d.count / maxDepthCount) * 100;
|
||||
const color = DEPTH_COLORS[d.depth] ?? DEPTH_COLORS[DEPTH_COLORS.length - 1];
|
||||
const isHovered = hoveredDepth === d.depth;
|
||||
return (
|
||||
<div
|
||||
key={d.depth}
|
||||
className="flex items-center gap-2.5 group"
|
||||
onMouseEnter={() => setHoveredDepth(d.depth)}
|
||||
onMouseLeave={() => setHoveredDepth(null)}
|
||||
>
|
||||
<span className="text-[11px] text-gray-500 w-24 flex-shrink-0 text-right truncate">
|
||||
{depthLabel(d.depth)}
|
||||
</span>
|
||||
<div className="flex-1 h-5 bg-surface-3 rounded overflow-hidden relative">
|
||||
<div
|
||||
className="h-full rounded transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.max(pct, 4)}%`,
|
||||
backgroundColor: color,
|
||||
opacity: isHovered ? 1 : 0.75,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
|
||||
style={{ color: isHovered ? color : "#9ca3af" }}
|
||||
>
|
||||
{d.count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error-prone agent types */}
|
||||
{topTypes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
|
||||
{t("errorPropagation.errorProneTypes")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{topTypes.map((t, i) => {
|
||||
const maxCount = topTypes[0]?.count ?? 1;
|
||||
const pct = (t.count / maxCount) * 100;
|
||||
return (
|
||||
<div
|
||||
key={t.subagent_type}
|
||||
className="flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg hover:bg-surface-3/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: DEPTH_COLORS[Math.min(i, DEPTH_COLORS.length - 1)] }}
|
||||
/>
|
||||
<span className="text-xs text-gray-300 truncate flex-1 min-w-0">
|
||||
{t.subagent_type}
|
||||
</span>
|
||||
<div className="w-16 h-1.5 bg-surface-4 rounded-full overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-400/60"
|
||||
style={{ width: `${Math.max(pct, 8)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-red-300 tabular-nums w-5 text-right flex-shrink-0">
|
||||
{t.count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API & session errors */}
|
||||
{data.eventErrors && data.eventErrors.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
|
||||
{t("errorPropagation.apiSessionErrors")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{data.eventErrors.map((e) => (
|
||||
<div
|
||||
key={e.summary}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg bg-amber-500/5 border border-amber-500/10 hover:border-amber-500/20 transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<span className="text-xs text-gray-300 truncate flex-1 min-w-0" title={e.summary}>
|
||||
{e.summary}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[11px] font-semibold text-amber-400 tabular-nums">
|
||||
{e.count}x
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* @file ModelDelegationFlow.tsx
|
||||
* @description Defines the ModelDelegationFlow React component that visualizes the relationships between main models and subagent models in a flow diagram using D3.js. The component takes model delegation data as input and renders an SVG diagram that shows how different models are connected based on their usage in agents and sessions. It categorizes models into families (opus, sonnet, haiku, other) for color-coding and provides a clear visual representation of model delegation patterns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
* - `../../lib/format`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ModelDelegationFlowProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ModelDelegationFlow` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ModelDelegationFlowProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ModelDelegationFlow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { ModelDelegationData } from "../../lib/types";
|
||||
import { formatModelName } from "../../lib/format";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function modelFamily(name: string): "opus" | "sonnet" | "haiku" | "other" {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("opus")) return "opus";
|
||||
if (lower.includes("sonnet")) return "sonnet";
|
||||
if (lower.includes("haiku")) return "haiku";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Model formatting is handled by the shared formatModelName utility.
|
||||
|
||||
// ── Color palette per model family ───────────────────────────────────────────
|
||||
|
||||
const FAMILY_COLORS = {
|
||||
opus: {
|
||||
grad: ["#7c3aed", "#a855f7"] as [string, string],
|
||||
stroke: "#a855f7",
|
||||
text: "#e9d5ff",
|
||||
badge: "rgba(168,85,247,0.15)",
|
||||
},
|
||||
sonnet: {
|
||||
grad: ["#1d4ed8", "#3b82f6"] as [string, string],
|
||||
stroke: "#3b82f6",
|
||||
text: "#bfdbfe",
|
||||
badge: "rgba(59,130,246,0.15)",
|
||||
},
|
||||
haiku: {
|
||||
grad: ["#065f46", "#10b981"] as [string, string],
|
||||
stroke: "#10b981",
|
||||
text: "#a7f3d0",
|
||||
badge: "rgba(16,185,129,0.15)",
|
||||
},
|
||||
other: {
|
||||
grad: ["#374151", "#6b7280"] as [string, string],
|
||||
stroke: "#6b7280",
|
||||
text: "#d1d5db",
|
||||
badge: "rgba(107,114,128,0.15)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Types used internally ─────────────────────────────────────────────────────
|
||||
|
||||
interface NodeDatum {
|
||||
id: string;
|
||||
label: string;
|
||||
family: "opus" | "sonnet" | "haiku" | "other";
|
||||
agentCount: number;
|
||||
sessionCount: number;
|
||||
totalTokens: number;
|
||||
side: "main" | "sub";
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface EdgeDatum {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
type ShowTipFn = (node: NodeDatum, anchor: SVGGraphicsElement) => void;
|
||||
type HideTipFn = () => void;
|
||||
|
||||
// ── D3 chart renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
const NODE_W = 160;
|
||||
const NODE_H = 80;
|
||||
const NODE_RX = 10;
|
||||
const COL_GAP = 200;
|
||||
const ROW_GAP = 108;
|
||||
const PADDING = { top: 40, left: 24, right: 24, bottom: 24 };
|
||||
|
||||
function renderFlow(
|
||||
svg: SVGSVGElement,
|
||||
mainNodes: NodeDatum[],
|
||||
subNodes: NodeDatum[],
|
||||
edges: EdgeDatum[],
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
showTip: ShowTipFn,
|
||||
hideTip: HideTipFn
|
||||
): void {
|
||||
const allNodes = [...mainNodes, ...subNodes];
|
||||
|
||||
const totalRows = Math.max(mainNodes.length, subNodes.length);
|
||||
const chartH = totalRows * ROW_GAP + PADDING.top + PADDING.bottom;
|
||||
const chartW = NODE_W * 2 + COL_GAP + PADDING.left + PADDING.right;
|
||||
|
||||
const root = d3.select(svg);
|
||||
root.selectAll("*").remove();
|
||||
root.attr("viewBox", `0 0 ${chartW} ${chartH}`).attr("preserveAspectRatio", "xMidYMid meet");
|
||||
|
||||
const defs = root.append("defs");
|
||||
|
||||
// Gradient defs per family
|
||||
(["opus", "sonnet", "haiku", "other"] as const).forEach((fam) => {
|
||||
const colors = FAMILY_COLORS[fam];
|
||||
const grad = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", `flow-grad-${fam}`)
|
||||
.attr("x1", "0%")
|
||||
.attr("y1", "0%")
|
||||
.attr("x2", "100%")
|
||||
.attr("y2", "100%");
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", colors.grad[0]);
|
||||
grad.append("stop").attr("offset", "100%").attr("stop-color", colors.grad[1]);
|
||||
});
|
||||
|
||||
const g = root.append("g");
|
||||
|
||||
// Column labels
|
||||
const labelY = PADDING.top - 16;
|
||||
g.append("text")
|
||||
.attr("x", PADDING.left + NODE_W / 2)
|
||||
.attr("y", labelY)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 11)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
.text(t("modelDelegation.mainModels"));
|
||||
|
||||
g.append("text")
|
||||
.attr("x", PADDING.left + NODE_W + COL_GAP + NODE_W / 2)
|
||||
.attr("y", labelY)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 11)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
.text(t("modelDelegation.subagentModels"));
|
||||
|
||||
// Build lookup for node positions
|
||||
const nodeMap = new Map<string, NodeDatum>(allNodes.map((n) => [n.id, n]));
|
||||
|
||||
// Draw edges (cubic bezier curves)
|
||||
edges.forEach(({ sourceId, targetId }) => {
|
||||
const src = nodeMap.get(sourceId);
|
||||
const tgt = nodeMap.get(targetId);
|
||||
if (!src || !tgt) return;
|
||||
|
||||
const x1 = src.x + NODE_W;
|
||||
const y1 = src.y + NODE_H / 2;
|
||||
const x2 = tgt.x;
|
||||
const y2 = tgt.y + NODE_H / 2;
|
||||
const cx = (x1 + x2) / 2;
|
||||
|
||||
g.append("path")
|
||||
.attr("d", `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#2a2a3d")
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("opacity", 0.7);
|
||||
});
|
||||
|
||||
// Draw nodes
|
||||
allNodes.forEach((node) => {
|
||||
const colors = FAMILY_COLORS[node.family];
|
||||
const ng = g
|
||||
.append("g")
|
||||
.attr("transform", `translate(${node.x},${node.y})`)
|
||||
.style("cursor", "default")
|
||||
.on("mouseenter", function () {
|
||||
showTip(node, this as SVGGraphicsElement);
|
||||
})
|
||||
.on("mouseleave", () => hideTip());
|
||||
|
||||
// Border glow rect (slightly larger)
|
||||
ng.append("rect")
|
||||
.attr("x", -1)
|
||||
.attr("y", -1)
|
||||
.attr("width", NODE_W + 2)
|
||||
.attr("height", NODE_H + 2)
|
||||
.attr("rx", NODE_RX + 1)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", colors.stroke)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("opacity", 0.25);
|
||||
|
||||
// Main rect with gradient
|
||||
ng.append("rect")
|
||||
.attr("width", NODE_W)
|
||||
.attr("height", NODE_H)
|
||||
.attr("rx", NODE_RX)
|
||||
.attr("fill", `url(#flow-grad-${node.family})`)
|
||||
.attr("fill-opacity", 0.18)
|
||||
.attr("stroke", colors.stroke)
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Model name
|
||||
ng.append("text")
|
||||
.attr("x", 12)
|
||||
.attr("y", 22)
|
||||
.attr("fill", colors.text)
|
||||
.attr("font-size", 11)
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(formatModelName(node.label) ?? node.label);
|
||||
|
||||
// Agent count pill
|
||||
ng.append("rect")
|
||||
.attr("x", 10)
|
||||
.attr("y", 32)
|
||||
.attr("width", 64)
|
||||
.attr("height", 16)
|
||||
.attr("rx", 4)
|
||||
.attr("fill", colors.badge);
|
||||
|
||||
ng.append("text")
|
||||
.attr("x", 42)
|
||||
.attr("y", 43.5)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", colors.text)
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.agentsCount", { count: node.agentCount }));
|
||||
|
||||
// Token count
|
||||
if (node.totalTokens > 0) {
|
||||
ng.append("text")
|
||||
.attr("x", 12)
|
||||
.attr("y", 66)
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.tokensCount", { tokens: fmtTokens(node.totalTokens) }));
|
||||
}
|
||||
|
||||
// Session count (main nodes only)
|
||||
if (node.side === "main" && node.sessionCount > 0) {
|
||||
ng.append("text")
|
||||
.attr("x", NODE_W - 10)
|
||||
.attr("y", 66)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", 9.5)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
.text(t("modelDelegation.sessionsCount", { count: node.sessionCount }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ModelDelegationFlowProps {
|
||||
data: ModelDelegationData;
|
||||
}
|
||||
|
||||
export function ModelDelegationFlow({ data }: ModelDelegationFlowProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasData = data.mainModels.length > 0 || data.subagentModels.length > 0;
|
||||
const totalAgents = countTotalAgents(data);
|
||||
|
||||
const hideTip = useCallback(() => {
|
||||
const tip = tipRef.current;
|
||||
if (tip) tip.style.opacity = "0";
|
||||
}, []);
|
||||
|
||||
const showTip = useCallback(
|
||||
(node: NodeDatum, anchor: SVGGraphicsElement) => {
|
||||
const tip = tipRef.current;
|
||||
if (!tip) return;
|
||||
buildModelDelegationTooltip(tip, node, totalAgents, t);
|
||||
const r = anchor.getBoundingClientRect();
|
||||
tip.style.opacity = "0";
|
||||
tip.style.display = "block";
|
||||
const tipW = tip.offsetWidth || 280;
|
||||
const tipH = tip.offsetHeight || 160;
|
||||
|
||||
const margin = 8;
|
||||
let left = r.left + r.width / 2 - tipW / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
||||
let top = r.top - tipH - 10;
|
||||
if (top < margin) top = r.bottom + 10;
|
||||
|
||||
tip.style.left = `${left}px`;
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.opacity = "1";
|
||||
},
|
||||
[totalAgents, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !hasData) return;
|
||||
|
||||
const tokenMap = new Map<string, number>();
|
||||
data.tokensByModel.forEach(({ model, input_tokens, output_tokens }) => {
|
||||
tokenMap.set(model, (tokenMap.get(model) ?? 0) + input_tokens + output_tokens);
|
||||
});
|
||||
|
||||
const mainNodes: NodeDatum[] = data.mainModels.map((m, i) => ({
|
||||
id: `main-${m.model}`,
|
||||
label: formatModelName(m.model) ?? m.model,
|
||||
family: modelFamily(m.model),
|
||||
agentCount: m.agent_count,
|
||||
sessionCount: m.session_count,
|
||||
totalTokens: tokenMap.get(m.model) ?? 0,
|
||||
side: "main",
|
||||
x: PADDING.left,
|
||||
y: PADDING.top + i * ROW_GAP,
|
||||
}));
|
||||
|
||||
const subNodes: NodeDatum[] = data.subagentModels.map((m, i) => ({
|
||||
id: `sub-${m.model}`,
|
||||
label: formatModelName(m.model) ?? m.model,
|
||||
family: modelFamily(m.model),
|
||||
agentCount: m.agent_count,
|
||||
sessionCount: 0,
|
||||
totalTokens: tokenMap.get(m.model) ?? 0,
|
||||
side: "sub",
|
||||
x: PADDING.left + NODE_W + COL_GAP,
|
||||
y: PADDING.top + i * ROW_GAP,
|
||||
}));
|
||||
|
||||
// Connect all main models to all subagent models that share a family, or all if no match
|
||||
const edges: EdgeDatum[] = [];
|
||||
mainNodes.forEach((mn) => {
|
||||
subNodes.forEach((sn) => {
|
||||
edges.push({ sourceId: mn.id, targetId: sn.id });
|
||||
});
|
||||
});
|
||||
|
||||
renderFlow(svgRef.current, mainNodes, subNodes, edges, t, showTip, hideTip);
|
||||
hideTip();
|
||||
}, [data, hasData, t, showTip, hideTip]);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<span className="text-sm">{t("modelDelegation.noData")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full overflow-x-auto relative" onMouseLeave={hideTip}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="w-full"
|
||||
style={{ minHeight: 120 }}
|
||||
aria-label={t("modelDelegation.ariaLabel")}
|
||||
role="img"
|
||||
/>
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
background: "#12121f",
|
||||
border: "1px solid #2a2a4a",
|
||||
color: "#e2e8f0",
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tooltip helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function countTotalAgents(data: ModelDelegationData): number {
|
||||
const mainSum = data.mainModels.reduce((s, m) => s + m.agent_count, 0);
|
||||
const subSum = data.subagentModels.reduce((s, m) => s + m.agent_count, 0);
|
||||
return mainSum + subSum;
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function describeFamily(family: NodeDatum["family"], t: TFn): string {
|
||||
switch (family) {
|
||||
case "opus":
|
||||
return t("modelDelegation.tooltip.family.opus");
|
||||
case "sonnet":
|
||||
return t("modelDelegation.tooltip.family.sonnet");
|
||||
case "haiku":
|
||||
return t("modelDelegation.tooltip.family.haiku");
|
||||
case "other":
|
||||
return t("modelDelegation.tooltip.family.other");
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelDelegationTooltip(
|
||||
el: HTMLDivElement,
|
||||
node: NodeDatum,
|
||||
totalAgents: number,
|
||||
t: TFn
|
||||
) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
|
||||
const sharePct = totalAgents > 0 ? `${((node.agentCount / totalAgents) * 100).toFixed(1)}%` : "-";
|
||||
const sideLabel =
|
||||
node.side === "main"
|
||||
? t("modelDelegation.tooltip.mainModel")
|
||||
: t("modelDelegation.tooltip.subagentModel");
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
|
||||
title.textContent = node.label;
|
||||
el.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = `${sideLabel} · ${node.family}`;
|
||||
el.appendChild(subtitle);
|
||||
|
||||
const addRow = (label: string, value: string) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
el.appendChild(row);
|
||||
};
|
||||
|
||||
addRow(t("modelDelegation.tooltip.agentRuns"), node.agentCount.toLocaleString());
|
||||
addRow(t("modelDelegation.tooltip.shareOfAll"), sharePct);
|
||||
if (node.side === "main") {
|
||||
addRow(t("modelDelegation.tooltip.sessionsOnModel"), String(node.sessionCount));
|
||||
}
|
||||
addRow(t("modelDelegation.tooltip.totalTokens"), node.totalTokens.toLocaleString());
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:8px 0 0";
|
||||
desc.textContent = describeFamily(node.family, t);
|
||||
el.appendChild(desc);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.style.cssText = "font-size:11px;color:#64748b;line-height:1.45;margin:6px 0 0";
|
||||
hint.textContent =
|
||||
node.side === "main"
|
||||
? t("modelDelegation.tooltip.lines.main")
|
||||
: t("modelDelegation.tooltip.lines.sub");
|
||||
el.appendChild(hint);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* @file SessionComplexityScatter.tsx
|
||||
* @description A React component that renders a scatter plot visualization of session complexity using D3.js. Each session is represented as a bubble, where the x-axis represents the session duration, the y-axis represents the number of agents involved, and the size of the bubble corresponds to the total tokens used. The color of each bubble indicates the session status (e.g., completed, active, error, abandoned). The component also includes tooltips for detailed information on hover and a legend for status colors. It is designed to be responsive and provides an empty state when no data is available.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
* - `../../lib/format`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SessionComplexityScatterProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SessionComplexityScatter` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SessionComplexityScatterProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SessionComplexityScatter**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { SessionComplexityItem } from "../../lib/types";
|
||||
import { formatModelName } from "../../lib/format";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MARGIN = { top: 20, right: 24, bottom: 60, left: 52 };
|
||||
const MIN_BUBBLE_R = 4;
|
||||
const MAX_BUBBLE_R = 32;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
completed: "#22c55e",
|
||||
error: "#ef4444",
|
||||
active: "#6366f1",
|
||||
abandoned: "#eab308",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
return STATUS_COLOR[status] ?? "#6b7280";
|
||||
}
|
||||
|
||||
// ── Duration formatting ───────────────────────────────────────────────────────
|
||||
|
||||
function formatDurationSec(sec: number): string {
|
||||
if (sec < 60) return `${Math.round(sec)}s`;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
const s = Math.round(sec % 60);
|
||||
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
function fmtXTick(sec: number): string {
|
||||
if (sec === 0) return "0";
|
||||
if (sec < 60) return `${Math.round(sec)}s`;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m > 0 ? `${m}m` : ""}`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ── Tooltip ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
item: SessionComplexityItem;
|
||||
}
|
||||
|
||||
function Tooltip({ state }: { state: TooltipState }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const nearRight = state.x > window.innerWidth - 220;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-gray-200 pointer-events-none whitespace-nowrap"
|
||||
style={{
|
||||
left: nearRight ? state.x - 12 : state.x + 12,
|
||||
top: state.y - 10,
|
||||
transform: nearRight ? "translateX(-100%)" : undefined,
|
||||
}}
|
||||
>
|
||||
<p className="font-semibold text-gray-100 mb-1 truncate max-w-[180px]">
|
||||
{state.item.name ?? state.item.id.slice(0, 12)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 text-gray-400">
|
||||
<span>
|
||||
{t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)}
|
||||
</span>
|
||||
<span>
|
||||
{t("complexity.tooltip.agents")} {state.item.agentCount}
|
||||
</span>
|
||||
<span>
|
||||
{t("complexity.tooltip.subagents")} {state.item.subagentCount}
|
||||
</span>
|
||||
<span>
|
||||
{t("complexity.tooltip.tokens")} {fmtTokens(state.item.totalTokens)}
|
||||
</span>
|
||||
{state.item.model && (
|
||||
<span>
|
||||
{t("complexity.tooltip.model")} {formatModelName(state.item.model)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
|
||||
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
|
||||
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Legend ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const LEGEND_STATUSES = ["completed", "active", "error", "abandoned"] as const;
|
||||
|
||||
function Legend() {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 justify-center mt-2">
|
||||
{LEGEND_STATUSES.map((s) => (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: statusColor(s) }}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
{t(`common:status.${s}`, { defaultValue: s })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<circle cx="7" cy="12" r="3" />
|
||||
<circle cx="17" cy="8" r="2" />
|
||||
<circle cx="14" cy="17" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-400">{t("complexity.noData")}</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{t("complexity.noDataDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main chart ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SessionComplexityScatterProps {
|
||||
data: SessionComplexityItem[];
|
||||
onSessionClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SessionComplexityScatter({ data, onSessionClick }: SessionComplexityScatterProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [width, setWidth] = useState(600);
|
||||
|
||||
// Track container width for responsiveness
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const obs = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w) setWidth(w);
|
||||
});
|
||||
obs.observe(el);
|
||||
setWidth(el.clientWidth);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const height = Math.max(280, Math.min(420, width * 0.5));
|
||||
|
||||
const handleMouseLeave = useCallback(() => setTooltip(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || data.length === 0) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const innerW = width - MARGIN.left - MARGIN.right;
|
||||
const innerH = height - MARGIN.top - MARGIN.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
|
||||
|
||||
// Scales
|
||||
const xScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, d3.max(data, (d) => d.duration) ?? 1])
|
||||
.nice()
|
||||
.range([0, innerW]);
|
||||
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, (d3.max(data, (d) => d.agentCount) ?? 1) + 1])
|
||||
.nice()
|
||||
.range([innerH, 0]);
|
||||
|
||||
const rScale = d3
|
||||
.scaleSqrt()
|
||||
.domain([0, d3.max(data, (d) => d.totalTokens) ?? 1])
|
||||
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
|
||||
|
||||
// Grid lines
|
||||
const gridColor = "#2a2a3d";
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "grid-x")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(xScale)
|
||||
.ticks(5)
|
||||
.tickSize(-innerH)
|
||||
.tickFormat(() => "")
|
||||
)
|
||||
.attr("transform", `translate(0,${innerH})`)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").remove();
|
||||
sel.selectAll(".tick line").attr("stroke", gridColor).attr("stroke-dasharray", "3,3");
|
||||
});
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "grid-y")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.ticks(5)
|
||||
.tickSize(-innerW)
|
||||
.tickFormat(() => "")
|
||||
)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").remove();
|
||||
sel.selectAll(".tick line").attr("stroke", gridColor).attr("stroke-dasharray", "3,3");
|
||||
});
|
||||
|
||||
// X axis
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${innerH})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(xScale)
|
||||
.ticks(5)
|
||||
.tickFormat((d) => fmtXTick(d as number))
|
||||
)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// X axis label
|
||||
g.append("text")
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 44)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.duration"));
|
||||
|
||||
// Y axis
|
||||
g.append("g")
|
||||
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// Y axis label
|
||||
g.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -40)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.agentCount"));
|
||||
|
||||
// Bubbles - sort largest to back so small ones are clickable
|
||||
const sorted = [...data].sort((a, b) => b.totalTokens - a.totalTokens);
|
||||
|
||||
g.selectAll<SVGCircleElement, SessionComplexityItem>("circle")
|
||||
.data(sorted)
|
||||
.join("circle")
|
||||
.attr("cx", (d) => xScale(d.duration))
|
||||
.attr("cy", (d) => yScale(d.agentCount))
|
||||
.attr("r", (d) => rScale(d.totalTokens))
|
||||
.attr("fill", (d) => statusColor(d.status))
|
||||
.attr("fill-opacity", 0.75)
|
||||
.attr("stroke", (d) => statusColor(d.status))
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("stroke-opacity", 0.9)
|
||||
.style("cursor", onSessionClick ? "pointer" : "default")
|
||||
.on("mouseenter", (event: MouseEvent, d) => {
|
||||
d3.select(event.currentTarget as SVGCircleElement)
|
||||
.attr("fill-opacity", 1)
|
||||
.attr("stroke-width", 2.5);
|
||||
setTooltip({ x: event.clientX, y: event.clientY, item: d });
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) => {
|
||||
setTooltip((prev) => (prev ? { ...prev, x: event.clientX, y: event.clientY } : null));
|
||||
})
|
||||
.on("mouseleave", (event: MouseEvent) => {
|
||||
d3.select(event.currentTarget as SVGCircleElement)
|
||||
.attr("fill-opacity", 0.75)
|
||||
.attr("stroke-width", 1.5);
|
||||
setTooltip(null);
|
||||
})
|
||||
.on("click", (_event: MouseEvent, d) => {
|
||||
onSessionClick?.(d.id);
|
||||
});
|
||||
}, [data, width, height, onSessionClick, t]);
|
||||
|
||||
if (data.length === 0) return <EmptyState />;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full flex flex-col justify-center"
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
aria-label={t("complexity.ariaLabel")}
|
||||
role="img"
|
||||
/>
|
||||
<Legend />
|
||||
{tooltip && <Tooltip state={tooltip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
/**
|
||||
* @file SessionDrillIn.tsx
|
||||
* @description Defines the SessionDrillIn component, which provides a detailed view of a specific session in the agent dashboard application. It allows users to drill into the agent tree, tool timeline, and event sequence for a selected session. The component manages its own state for loading, error handling, and active tab selection, and it fetches the necessary data from the backend API when a session is selected. It also includes a session selector for searching and selecting different sessions to view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/api`
|
||||
* - `../../lib/format`
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SessionDrillInProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SessionDrillIn` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SessionDrillInProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SessionDrillIn**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime, formatMs, formatModelName } from "../../lib/format";
|
||||
import type {
|
||||
SessionDrillIn as SessionDrillInData,
|
||||
DashboardEvent,
|
||||
Session,
|
||||
} from "../../lib/types";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = "tree" | "timeline" | "events";
|
||||
|
||||
type AgentNode = SessionDrillInData["tree"][number];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||
case "working":
|
||||
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
|
||||
case "error":
|
||||
return "text-red-400 bg-red-500/10 border-red-500/20";
|
||||
case "active":
|
||||
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
|
||||
case "waiting":
|
||||
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||
default:
|
||||
return "text-gray-400 bg-gray-500/10 border-gray-500/20";
|
||||
}
|
||||
}
|
||||
|
||||
function safeTimestamp(raw: string): string {
|
||||
try {
|
||||
const normalized = /[Zz]$|[+-]\d{2}:\d{2}$/.test(raw) ? raw : raw.replace(" ", "T") + "Z";
|
||||
return formatDateTime(normalized);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab bar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TabBarProps {
|
||||
active: Tab;
|
||||
onChange: (t: Tab) => void;
|
||||
}
|
||||
|
||||
function TabBar({ active, onChange }: TabBarProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const tabs = [
|
||||
{
|
||||
id: "tree" as Tab,
|
||||
label: t("drillIn.tabs.agentTree"),
|
||||
icon: <GitFork className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "timeline" as Tab,
|
||||
label: t("drillIn.tabs.toolTimeline"),
|
||||
icon: <Wrench className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "events" as Tab,
|
||||
label: t("drillIn.tabs.eventSequence"),
|
||||
icon: <List className="w-3.5 h-3.5" />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex gap-1 p-1 bg-surface-3 rounded-lg">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={[
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150",
|
||||
active === tab.id
|
||||
? "bg-surface-5 text-gray-100 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agent Tree ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: AgentNode;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
const { t } = useTranslation(["workflows", "common"]);
|
||||
const indentPx = depth * 20;
|
||||
const isMain = node.type === "main";
|
||||
const dur = node.ended_at
|
||||
? formatMs(
|
||||
Math.max(
|
||||
0,
|
||||
new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime()
|
||||
)
|
||||
)
|
||||
: t("common:running");
|
||||
const sc = statusColor(node.status);
|
||||
const statusLabel = t(`common:status.${node.status}`, { defaultValue: node.status });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors"
|
||||
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
|
||||
>
|
||||
{/* Depth connector line */}
|
||||
{depth > 0 && <span className="w-px h-4 bg-border flex-shrink-0 -ml-3 mr-1" aria-hidden />}
|
||||
|
||||
{/* Status badge */}
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-gray-200"}`}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
|
||||
{/* Subagent type */}
|
||||
{node.subagent_type && (
|
||||
<span className="text-xs text-gray-500 truncate flex-shrink-0">
|
||||
[{node.subagent_type}]
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
<span className="ml-auto flex-shrink-0 text-xs text-gray-600 tabular-nums">{dur}</span>
|
||||
</div>
|
||||
|
||||
{node.children.length > 0 && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode key={child.id} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentTreeProps {
|
||||
tree: SessionDrillInData["tree"];
|
||||
}
|
||||
|
||||
function AgentTree({ tree }: AgentTreeProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
if (tree.length === 0) {
|
||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noAgentTree")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto max-h-[420px] pr-1">
|
||||
{tree.map((node) => (
|
||||
<TreeNode key={node.id} node={node} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tool Timeline ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ToolEvent = SessionDrillInData["toolTimeline"][number];
|
||||
|
||||
interface ToolTimelineProps {
|
||||
events: ToolEvent[];
|
||||
}
|
||||
|
||||
function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
if (events.length === 0) {
|
||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noToolEvents")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto max-h-[420px] overflow-y-auto">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
{events.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{/* Tool pill */}
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
{ev.tool_name ?? ev.event_type}
|
||||
</span>
|
||||
|
||||
{/* Summary */}
|
||||
{ev.summary && (
|
||||
<span className="text-xs text-gray-400 truncate flex-1 min-w-0">{ev.summary}</span>
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums ml-auto">
|
||||
{safeTimestamp(ev.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event Sequence ────────────────────────────────────────────────────────────
|
||||
|
||||
interface EventSequenceProps {
|
||||
events: DashboardEvent[];
|
||||
}
|
||||
|
||||
const EVENT_TYPE_COLOR: Record<string, string> = {
|
||||
tool_use: "text-blue-400",
|
||||
tool_result: "text-emerald-400",
|
||||
agent_start: "text-indigo-400",
|
||||
agent_stop: "text-violet-400",
|
||||
compaction: "text-amber-400",
|
||||
error: "text-red-400",
|
||||
};
|
||||
|
||||
function eventTypeColor(type: string): string {
|
||||
return EVENT_TYPE_COLOR[type] ?? "text-gray-400";
|
||||
}
|
||||
|
||||
function EventSequence({ events }: EventSequenceProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
if (events.length === 0) {
|
||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noEvents")}</p>;
|
||||
}
|
||||
|
||||
const recent = events.slice(0, 100);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto max-h-[420px]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{recent.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
{/* Event type badge */}
|
||||
<span
|
||||
className={`flex-shrink-0 text-[10px] font-semibold uppercase tracking-wide mt-0.5 w-[90px] truncate ${eventTypeColor(ev.event_type)}`}
|
||||
title={ev.event_type}
|
||||
>
|
||||
{ev.event_type}
|
||||
</span>
|
||||
|
||||
{/* Summary */}
|
||||
<span className="text-xs text-gray-400 flex-1 min-w-0 truncate">
|
||||
{ev.summary ?? ev.tool_name ?? "-"}
|
||||
</span>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{safeTimestamp(ev.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{events.length > 100 && (
|
||||
<p className="text-xs text-gray-600 text-center py-2">
|
||||
{t("drillIn.showingOf", { total: events.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Loading / Error states ────────────────────────────────────────────────────
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 py-8 px-4 animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-4 bg-surface-4 rounded" style={{ width: `${80 - i * 10}%` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorStateProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
function ErrorState({ message }: ErrorStateProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center px-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/10 border border-red-500/20 flex items-center justify-center mb-3">
|
||||
<X className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-400">{t("drillIn.failedLoad")}</p>
|
||||
<p className="text-xs text-gray-600 mt-1 max-w-xs">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty / no-selection state ────────────────────────────────────────────────
|
||||
|
||||
interface NoSessionStateProps {
|
||||
onSelectSession: (id: string) => void;
|
||||
}
|
||||
|
||||
function NoSessionState({ onSelectSession }: NoSessionStateProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const tabs = [
|
||||
{
|
||||
id: "tree" as Tab,
|
||||
label: t("drillIn.tabs.agentTree"),
|
||||
icon: <GitFork className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "timeline" as Tab,
|
||||
label: t("drillIn.tabs.toolTimeline"),
|
||||
icon: <Wrench className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "events" as Tab,
|
||||
label: t("drillIn.tabs.eventSequence"),
|
||||
icon: <List className="w-3.5 h-3.5" />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col py-6 px-4 border-2 border-dashed border-border rounded-xl">
|
||||
<SessionSelector onSelectSession={onSelectSession} />
|
||||
|
||||
<div className="flex flex-col items-center text-center mt-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-4">
|
||||
<GitFork className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-400 mb-1">{t("drillIn.noSessionSelected")}</p>
|
||||
<p className="text-xs text-gray-600 max-w-xs">{t("drillIn.noSessionDesc")}</p>
|
||||
|
||||
{/* Preview tab pills */}
|
||||
<div className="flex gap-2 mt-5">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-gray-600 bg-surface-3 border border-border"
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session header ────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionHeaderProps {
|
||||
drillIn: SessionDrillInData;
|
||||
onClose: () => void;
|
||||
activeTab: Tab;
|
||||
onTabChange: (t: Tab) => void;
|
||||
}
|
||||
|
||||
function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHeaderProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const { session } = drillIn;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-100 truncate">
|
||||
{session.name ?? session.id}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{formatModelName(session.model) ?? t("drillIn.unknownModel")} ·{" "}
|
||||
{t(`common:status.${session.status}`, { defaultValue: session.status })}
|
||||
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-gray-500 hover:text-gray-200 hover:bg-white/10 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<TabBar active={activeTab} onChange={onTabChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session Selector ──────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface SessionSelectorProps {
|
||||
onSelectSession: (id: string) => void;
|
||||
}
|
||||
|
||||
function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [allLoaded, setAllLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchPage = useCallback((pageOffset: number, replace: boolean) => {
|
||||
setLoading(true);
|
||||
api.sessions
|
||||
.list({ limit: PAGE_SIZE, offset: pageOffset })
|
||||
.then(({ sessions: page }) => {
|
||||
setSessions((prev) => (replace ? page : [...prev, ...page]));
|
||||
setHasMore(page.length === PAGE_SIZE);
|
||||
setOffset(pageOffset + page.length);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Load ALL sessions for search (once, lazily)
|
||||
const loadAllSessions = useCallback(() => {
|
||||
if (allLoaded) return;
|
||||
setAllLoaded(true);
|
||||
// Fetch large batch for search
|
||||
api.sessions
|
||||
.list({ limit: 5000, offset: 0 })
|
||||
.then(({ sessions: all }) => setAllSessions(all))
|
||||
.catch(() => {});
|
||||
}, [allLoaded]);
|
||||
|
||||
// Load first page when dropdown opens
|
||||
useEffect(() => {
|
||||
if (open && sessions.length === 0) {
|
||||
fetchPage(0, true);
|
||||
}
|
||||
}, [open, sessions.length, fetchPage]);
|
||||
|
||||
// When user starts typing, load all sessions for search
|
||||
useEffect(() => {
|
||||
if (search.trim().length > 0) {
|
||||
loadAllSessions();
|
||||
}
|
||||
}, [search, loadAllSessions]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
// When searching, filter across ALL sessions; otherwise show paginated
|
||||
const filtered = search.trim()
|
||||
? (allSessions.length > 0 ? allSessions : sessions).filter((s) => {
|
||||
const q = search.toLowerCase();
|
||||
return (s.name ?? "").toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
|
||||
})
|
||||
: sessions;
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
onSelectSession(id);
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
fetchPage(offset, false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative mb-4">
|
||||
{/* Trigger row */}
|
||||
<div
|
||||
className={[
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg border bg-surface-3 transition-colors cursor-text",
|
||||
open ? "border-indigo-500/40 ring-1 ring-indigo-500/20" : "border-border",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
placeholder={t("drillIn.searchPlaceholder")}
|
||||
className="flex-1 bg-transparent text-xs text-gray-200 placeholder-gray-600 outline-none min-w-0"
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={[
|
||||
"w-3.5 h-3.5 text-gray-600 flex-shrink-0 transition-transform duration-150",
|
||||
open ? "rotate-180" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
{open && (
|
||||
<div className="absolute z-50 left-0 right-0 top-full mt-1 bg-surface-2 border border-border rounded-lg shadow-xl overflow-hidden">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<div className="flex flex-col gap-2 p-3 animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-3 bg-surface-4 rounded"
|
||||
style={{ width: `${75 - i * 8}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-xs text-gray-600 text-center py-6 px-3">
|
||||
{search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filtered.map((s) => {
|
||||
const sc = statusColor(s.status);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(s.id)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0"
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
|
||||
>
|
||||
{s.status}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-medium text-gray-200 truncate">
|
||||
{s.name ?? s.id}
|
||||
</span>
|
||||
{s.name && (
|
||||
<span className="block text-[10px] text-gray-600 font-mono truncate">
|
||||
{s.id}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{s.model && (
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-500 truncate max-w-[80px]">
|
||||
{formatModelName(s.model)}
|
||||
</span>
|
||||
)}
|
||||
{s.started_at && (
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums">
|
||||
{safeTimestamp(s.started_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more - only show when not filtering client-side */}
|
||||
{!search.trim() && hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 text-xs text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public component ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface SessionDrillInProps {
|
||||
sessionId: string | null;
|
||||
onClose: () => void;
|
||||
onSelectSession: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionDrillInProps) {
|
||||
const { t } = useTranslation(["workflows", "common"]);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("tree");
|
||||
const [drillIn, setDrillIn] = useState<SessionDrillInData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setDrillIn(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDrillIn(null);
|
||||
|
||||
api.workflows
|
||||
.session(sessionId)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setDrillIn(data);
|
||||
setActiveTab("tree");
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
const msg = err instanceof Error ? err.message : t("common:unexpectedError");
|
||||
setError(msg);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, t]);
|
||||
|
||||
// No session selected
|
||||
if (!sessionId) {
|
||||
return <NoSessionState onSelectSession={onSelectSession} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-surface-2 border border-border rounded-xl p-4">
|
||||
<SessionSelector onSelectSession={onSelectSession} />
|
||||
<LoadingState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-surface-2 border border-border rounded-xl p-4">
|
||||
<SessionSelector onSelectSession={onSelectSession} />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-gray-600 font-mono truncate">{sessionId}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-600 hover:text-gray-300 hover:bg-white/10 transition-colors"
|
||||
aria-label={t("drillIn.close")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ErrorState message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!drillIn) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-surface-2 border border-border rounded-xl p-4 animate-fade-in">
|
||||
<SessionSelector onSelectSession={onSelectSession} />
|
||||
<SessionHeader
|
||||
drillIn={drillIn}
|
||||
onClose={onClose}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "tree" && <AgentTree tree={drillIn.tree} />}
|
||||
{activeTab === "timeline" && <ToolTimeline events={drillIn.toolTimeline} />}
|
||||
{activeTab === "events" && <EventSequence events={drillIn.events} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* @file SubagentEffectiveness.tsx
|
||||
* @description Defines the SubagentEffectiveness React component that visualizes the effectiveness of subagents in a workflow. It displays a success rate as a circular progress ring, key metrics such as total sessions and average duration, and a sparkline showing weekly activity trends. The component is designed to handle cases with no data gracefully and uses a consistent color scheme for clarity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SubagentEffectivenessProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SubagentEffectiveness` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SubagentEffectivenessProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SubagentEffectiveness**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SubagentEffectivenessItem } from "../../lib/types";
|
||||
|
||||
const COLORS = [
|
||||
"#10b981",
|
||||
"#3b82f6",
|
||||
"#a855f7",
|
||||
"#f59e0b",
|
||||
"#f43f5e",
|
||||
"#06b6d4",
|
||||
"#f97316",
|
||||
"#6366f1",
|
||||
] as const;
|
||||
|
||||
const RING_RADIUS = 28;
|
||||
const RING_STROKE = 5;
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
function formatDurationSec(seconds: number | null): string {
|
||||
if (seconds === null || seconds < 0) return "-";
|
||||
const totalSec = Math.floor(seconds);
|
||||
const hours = Math.floor(totalSec / 3600);
|
||||
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||
const secs = totalSec % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
interface SuccessRingProps {
|
||||
rate: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function SuccessRing({ rate, color }: SuccessRingProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const clampedRate = Math.max(0, Math.min(100, rate));
|
||||
const filled = (clampedRate / 100) * RING_CIRCUMFERENCE;
|
||||
const gap = RING_CIRCUMFERENCE - filled;
|
||||
const viewSize = (RING_RADIUS + RING_STROKE) * 2 + 4;
|
||||
const center = viewSize / 2;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
width={viewSize}
|
||||
height={viewSize}
|
||||
viewBox={`0 0 ${viewSize} ${viewSize}`}
|
||||
aria-label={t("effectiveness.successRateAria", { rate: clampedRate.toFixed(1) })}
|
||||
role="img"
|
||||
>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="#2a2a3d"
|
||||
strokeWidth={RING_STROKE}
|
||||
/>
|
||||
{/* Arc */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeDasharray={`${filled} ${gap}`}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
style={{ transition: "stroke-dasharray 0.6s ease" }}
|
||||
/>
|
||||
{/* Percentage label */}
|
||||
<text
|
||||
x={center}
|
||||
y={center}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="#e4e4ed"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
fontFamily="Inter, sans-serif"
|
||||
>
|
||||
{clampedRate.toFixed(0)}%
|
||||
</text>
|
||||
</svg>
|
||||
<span className="text-[10px] font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("effectiveness.success")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface SparklineTooltipState {
|
||||
index: number;
|
||||
/** Bounding rect of the hovered bar (in viewport coordinates). */
|
||||
rect: DOMRect;
|
||||
}
|
||||
|
||||
function Sparkline({ data, color }: SparklineProps) {
|
||||
const { t, i18n } = useTranslation(["workflows", "common"]);
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language;
|
||||
const dayLabels = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 7 }, (_, day) =>
|
||||
new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
|
||||
new Date(Date.UTC(2026, 0, 5 + day))
|
||||
)
|
||||
),
|
||||
[locale]
|
||||
);
|
||||
const [tip, setTip] = useState<SparklineTooltipState | null>(null);
|
||||
const bars = data.length > 0 ? data : Array.from({ length: 7 }, () => 0);
|
||||
const max = Math.max(...bars, 1);
|
||||
|
||||
return (
|
||||
<div aria-label={t("effectiveness.weeklyActivityAria")}>
|
||||
{/* Bars */}
|
||||
<div className="flex items-end gap-1 h-8 relative" onMouseLeave={() => setTip(null)}>
|
||||
{bars.map((value, i) => {
|
||||
const heightPct = Math.max((value / max) * 100, value > 0 ? 8 : 4);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 relative"
|
||||
style={{ height: "100%" }}
|
||||
onMouseEnter={(e) =>
|
||||
setTip({ index: i, rect: e.currentTarget.getBoundingClientRect() })
|
||||
}
|
||||
>
|
||||
{/* Bar (anchored to bottom) */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 rounded-sm transition-all duration-300"
|
||||
style={{
|
||||
height: `${heightPct}%`,
|
||||
backgroundColor: value > 0 ? color : "#2a2a3d",
|
||||
opacity: tip?.index === i ? 1 : value > 0 ? 0.85 : 0.4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Day labels */}
|
||||
<div className="flex gap-1 mt-1">
|
||||
{bars.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 text-center text-[8px] text-gray-600 leading-none select-none"
|
||||
>
|
||||
{dayLabels[i % dayLabels.length] ?? ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{tip && (
|
||||
<SparklineTooltip
|
||||
rect={tip.rect}
|
||||
label={dayLabels[tip.index % dayLabels.length] ?? ""}
|
||||
value={bars[tip.index] ?? 0}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip is rendered into `document.body` via a portal so the parent
|
||||
* ScoreCard's `overflow-hidden` (and hover-transform that would otherwise
|
||||
* become its containing block) cannot clip it. Coordinates are computed
|
||||
* from the hovered bar's bounding rect and clamped to the viewport with an
|
||||
* 8 px margin, so the tooltip can never be cut off on any day of the week.
|
||||
*/
|
||||
function SparklineTooltip({
|
||||
rect,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
rect: DOMRect;
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
const margin = 8;
|
||||
|
||||
// Center horizontally over the bar, then clamp to viewport.
|
||||
let left = rect.left + rect.width / 2 - w / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
||||
|
||||
// Default above the bar; flip below if there isn't room.
|
||||
let top = rect.top - h - 8;
|
||||
if (top < margin) top = rect.bottom + 8;
|
||||
|
||||
setPos({ left, top });
|
||||
}, [rect]);
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="tooltip"
|
||||
className="fixed z-[60] px-2 py-1 bg-[#12121f] border border-[#2a2a4a] rounded-md shadow-xl text-[10px] text-gray-200 whitespace-nowrap pointer-events-none"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-gray-400 mx-1">·</span>
|
||||
<span className="tabular-nums" style={{ color }}>
|
||||
{t("effectiveness.sessionCount", { count: value })}
|
||||
</span>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricBoxProps {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function MetricBox({ label, value }: MetricBoxProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5 bg-surface-3 rounded-lg px-2 py-2 flex-1 min-w-0 overflow-hidden">
|
||||
<span className="text-xs font-semibold text-gray-200 tabular-nums truncate w-full text-center">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 uppercase tracking-wider truncate w-full text-center">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScoreCardProps {
|
||||
item: SubagentEffectivenessItem;
|
||||
colorIndex: number;
|
||||
}
|
||||
|
||||
function ScoreCard({ item, colorIndex }: ScoreCardProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const color = COLORS[colorIndex % COLORS.length] ?? COLORS[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
bg-surface-2 border border-border rounded-xl p-4
|
||||
flex flex-col gap-4 min-w-0 overflow-hidden
|
||||
transition-all duration-200
|
||||
hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30 hover:border-border-light
|
||||
"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-200 truncate" title={item.subagent_type}>
|
||||
{item.subagent_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Success ring */}
|
||||
<div className="flex justify-center">
|
||||
<SuccessRing rate={item.successRate} color={color} />
|
||||
</div>
|
||||
|
||||
{/* Metric boxes */}
|
||||
<div className="flex gap-2">
|
||||
<MetricBox label={t("effectiveness.sessions")} value={String(item.sessions)} />
|
||||
<MetricBox
|
||||
label={t("effectiveness.avgDuration")}
|
||||
value={formatDurationSec(item.avgDuration)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sparkline */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-wider">
|
||||
{t("effectiveness.weeklyActivity")}
|
||||
</span>
|
||||
<Sparkline data={item.trend} color={color} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SubagentEffectivenessProps {
|
||||
data: SubagentEffectivenessItem[];
|
||||
}
|
||||
|
||||
export function SubagentEffectiveness({ data }: SubagentEffectivenessProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-gray-500 text-sm">
|
||||
{t("effectiveness.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.map((item, i) => (
|
||||
<ScoreCard key={item.subagent_type} item={item} colorIndex={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* @file ToolExecutionFlow.tsx
|
||||
* @description Defines the ToolExecutionFlow component that visualizes the flow of tool usage in agent workflows using a Sankey diagram. It processes the provided tool flow data, constructs a Sankey graph, and renders it using D3.js. The component also includes interactive tooltips for links and a legend for tool types. It handles responsiveness and edge cases such as empty data gracefully.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolExecutionFlow` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolExecutionFlow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { sankey, sankeyLinkHorizontal } from "d3-sankey";
|
||||
import type { SankeyGraph, SankeyNode, SankeyLink } from "d3-sankey";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToolFlowData } from "../../lib/types";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MARGIN = { top: 24, right: 140, bottom: 24, left: 140 };
|
||||
const NODE_WIDTH = 14;
|
||||
const NODE_PADDING = 18;
|
||||
const MIN_NODE_HEIGHT = 6;
|
||||
const LINK_OPACITY_DEFAULT = 0.15;
|
||||
const LINK_OPACITY_HOVER = 0.45;
|
||||
|
||||
const TOOL_COLORS: Record<string, string> = {
|
||||
Read: "#3b82f6",
|
||||
Write: "#22c55e",
|
||||
Edit: "#eab308",
|
||||
Bash: "#ef4444",
|
||||
Grep: "#a855f7",
|
||||
Glob: "#ec4899",
|
||||
Agent: "#6366f1",
|
||||
};
|
||||
const COLOR_DEFAULT = "#64748b";
|
||||
|
||||
function toolColor(name: string): string {
|
||||
// Strip the _source / _target suffix we add internally
|
||||
const base = name.replace(/_(source|target)$/, "");
|
||||
return TOOL_COLORS[base] ?? COLOR_DEFAULT;
|
||||
}
|
||||
|
||||
function toolLabel(name: string): string {
|
||||
return name.replace(/_(source|target)$/, "");
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NodeExtra {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface LinkExtra {
|
||||
uid: string;
|
||||
}
|
||||
|
||||
type SNode = SankeyNode<NodeExtra, LinkExtra>;
|
||||
type SLink = SankeyLink<NodeExtra, LinkExtra>;
|
||||
type SGraph = SankeyGraph<NodeExtra, LinkExtra>;
|
||||
|
||||
interface NodeTipPayload {
|
||||
kind: "node";
|
||||
rawName: string;
|
||||
count: number;
|
||||
shareOfTotal: number;
|
||||
}
|
||||
|
||||
interface LinkTipPayload {
|
||||
kind: "link";
|
||||
source: string;
|
||||
target: string;
|
||||
count: number;
|
||||
shareOfSource: number;
|
||||
shareOfTarget: number;
|
||||
}
|
||||
|
||||
type TipPayload = NodeTipPayload | LinkTipPayload;
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ToolExecutionFlowProps {
|
||||
data: ToolFlowData;
|
||||
filterAgentType?: string | null;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* d3-sankey collapses self-loops and duplicate node references. To represent a
|
||||
* tool appearing as both source and target we suffix the node id with
|
||||
* `_source` or `_target` and deduplicate at the label layer.
|
||||
*
|
||||
* Strategy:
|
||||
* - A node that ONLY appears as a source keeps its plain name.
|
||||
* - A node that ONLY appears as a target keeps its plain name.
|
||||
* - A node that appears on BOTH sides gets `_source` / `_target` copies.
|
||||
*/
|
||||
function buildSankeyInput(data: ToolFlowData): {
|
||||
nodes: NodeExtra[];
|
||||
links: Array<{ source: string; target: string; value: number; uid: string }>;
|
||||
} {
|
||||
const { transitions } = data;
|
||||
if (transitions.length === 0) return { nodes: [], links: [] };
|
||||
|
||||
const sourcesSet = new Set(transitions.map((t) => t.source));
|
||||
const targetsSet = new Set(transitions.map((t) => t.target));
|
||||
|
||||
// Nodes that appear on both sides need splitting
|
||||
const bothSides = new Set<string>();
|
||||
for (const s of sourcesSet) {
|
||||
if (targetsSet.has(s)) bothSides.add(s);
|
||||
}
|
||||
|
||||
const nodeIdSet = new Set<string>();
|
||||
|
||||
function sourceId(name: string): string {
|
||||
return bothSides.has(name) ? `${name}_source` : name;
|
||||
}
|
||||
|
||||
function targetId(name: string): string {
|
||||
return bothSides.has(name) ? `${name}_target` : name;
|
||||
}
|
||||
|
||||
const links = transitions.map((t, i) => ({
|
||||
source: sourceId(t.source),
|
||||
target: targetId(t.target),
|
||||
value: Math.max(1, t.value),
|
||||
uid: `link-${i}`,
|
||||
}));
|
||||
|
||||
for (const l of links) {
|
||||
nodeIdSet.add(l.source);
|
||||
nodeIdSet.add(l.target);
|
||||
}
|
||||
|
||||
const nodes: NodeExtra[] = Array.from(nodeIdSet).map((id) => ({ id }));
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ToolExecutionFlow({
|
||||
data,
|
||||
filterAgentType: _filterAgentType,
|
||||
}: ToolExecutionFlowProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 700, height: 420 });
|
||||
|
||||
// Track container width for responsiveness
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const w = Math.floor(entry.contentRect.width);
|
||||
if (w > 0) {
|
||||
setDimensions((prev) => ({
|
||||
...prev,
|
||||
width: w,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const isEmpty = data.transitions.length === 0 || data.transitions.every((t) => t.value === 0);
|
||||
|
||||
const totalUsage = data.toolCounts.reduce((s, c) => s + c.count, 0);
|
||||
|
||||
const hideTip = useCallback(() => {
|
||||
const tip = tipRef.current;
|
||||
if (tip) tip.style.opacity = "0";
|
||||
}, []);
|
||||
const localizeToolLabel = useCallback(
|
||||
(name: string) => {
|
||||
const lower = name.toLowerCase();
|
||||
switch (lower) {
|
||||
case "read":
|
||||
case "write":
|
||||
case "edit":
|
||||
case "bash":
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "agent":
|
||||
case "other":
|
||||
return t(`errors:toolLegend.${lower}`);
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const showTip = useCallback(
|
||||
(payload: TipPayload, anchorEl: SVGGraphicsElement) => {
|
||||
const tip = tipRef.current;
|
||||
if (!tip) return;
|
||||
buildToolFlowTooltip(tip, payload, localizeToolLabel, t);
|
||||
|
||||
// Position
|
||||
const r = anchorEl.getBoundingClientRect();
|
||||
tip.style.opacity = "0";
|
||||
tip.style.display = "block";
|
||||
const tipW = tip.offsetWidth || 280;
|
||||
const tipH = tip.offsetHeight || 160;
|
||||
|
||||
const margin = 8;
|
||||
let left = r.left + r.width / 2 - tipW / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
||||
|
||||
let top = r.top - tipH - 10;
|
||||
if (top < margin) top = r.bottom + 10;
|
||||
|
||||
tip.style.left = `${left}px`;
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.opacity = "1";
|
||||
},
|
||||
[localizeToolLabel, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const svgEl = svgRef.current;
|
||||
if (!svgEl || isEmpty) return;
|
||||
|
||||
const { width, height } = dimensions;
|
||||
const innerW = width - MARGIN.left - MARGIN.right;
|
||||
const innerH = height - MARGIN.top - MARGIN.bottom;
|
||||
|
||||
if (innerW <= 0 || innerH <= 0) return;
|
||||
|
||||
// Clear previous render
|
||||
d3.select(svgEl).selectAll("*").remove();
|
||||
|
||||
const { nodes: rawNodes, links: rawLinks } = buildSankeyInput(data);
|
||||
if (rawNodes.length === 0) return;
|
||||
|
||||
// Build sankey layout
|
||||
const sankeyGen = sankey<NodeExtra, LinkExtra>()
|
||||
.nodeId((d) => d.id)
|
||||
.nodeWidth(NODE_WIDTH)
|
||||
.nodePadding(NODE_PADDING)
|
||||
.nodeSort(null) // preserve insertion order
|
||||
.extent([
|
||||
[0, 0],
|
||||
[innerW, innerH],
|
||||
]);
|
||||
|
||||
let graph: SGraph;
|
||||
try {
|
||||
graph = sankeyGen({
|
||||
nodes: rawNodes.map((n) => ({ ...n })),
|
||||
links: rawLinks.map((l) => ({ ...l })),
|
||||
});
|
||||
} catch {
|
||||
// If layout fails (e.g., cycles), bail gracefully
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce minimum node height by adjusting y0/y1
|
||||
for (const node of graph.nodes) {
|
||||
const n = node as SNode;
|
||||
if (n.y0 !== undefined && n.y1 !== undefined) {
|
||||
const h = n.y1 - n.y0;
|
||||
if (h < MIN_NODE_HEIGHT) {
|
||||
const mid = (n.y0 + n.y1) / 2;
|
||||
n.y0 = mid - MIN_NODE_HEIGHT / 2;
|
||||
n.y1 = mid + MIN_NODE_HEIGHT / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run update step so links follow the adjusted node positions
|
||||
sankeyGen.update(graph);
|
||||
|
||||
const svg = d3
|
||||
.select(svgEl)
|
||||
.attr("viewBox", `0 0 ${width} ${height}`)
|
||||
.attr("preserveAspectRatio", "xMidYMid meet");
|
||||
|
||||
const root = svg.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
|
||||
|
||||
// ── Gradient defs ──────────────────────────────────────────────────────
|
||||
const defs = svg.append("defs");
|
||||
|
||||
(graph.links as SLink[]).forEach((link, i) => {
|
||||
const sourceNode = link.source as SNode;
|
||||
const targetNode = link.target as SNode;
|
||||
const gradId = `link-grad-${i}`;
|
||||
|
||||
const grad = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", gradId)
|
||||
.attr("gradientUnits", "userSpaceOnUse")
|
||||
.attr("x1", sourceNode.x1 ?? 0)
|
||||
.attr("x2", targetNode.x0 ?? 0);
|
||||
|
||||
const srcColor = toolColor(sourceNode.id);
|
||||
const tgtColor = toolColor(targetNode.id);
|
||||
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", srcColor);
|
||||
grad.append("stop").attr("offset", "100%").attr("stop-color", tgtColor);
|
||||
|
||||
(link as SLink & { _gradId: string })._gradId = gradId;
|
||||
});
|
||||
|
||||
// ── Links ──────────────────────────────────────────────────────────────
|
||||
const linkPath = sankeyLinkHorizontal();
|
||||
|
||||
const linkGroup = root.append("g").attr("class", "links");
|
||||
|
||||
// Pre-compute outgoing/incoming totals per node so we can show share-of-source
|
||||
// and share-of-target percentages in the tooltip.
|
||||
const outgoingByNode = new Map<string, number>();
|
||||
const incomingByNode = new Map<string, number>();
|
||||
for (const link of graph.links as SLink[]) {
|
||||
const src = (link.source as SNode).id;
|
||||
const tgt = (link.target as SNode).id;
|
||||
outgoingByNode.set(src, (outgoingByNode.get(src) ?? 0) + (link.value ?? 0));
|
||||
incomingByNode.set(tgt, (incomingByNode.get(tgt) ?? 0) + (link.value ?? 0));
|
||||
}
|
||||
|
||||
linkGroup
|
||||
.selectAll<SVGPathElement, SLink>("path")
|
||||
.data(graph.links as SLink[])
|
||||
.join("path")
|
||||
.attr("d", (d) => linkPath(d) ?? "")
|
||||
.attr("stroke", (d, i) => {
|
||||
const gradId = (graph.links[i] as SLink & { _gradId?: string })._gradId;
|
||||
return gradId ? `url(#${gradId})` : toolColor((d.source as SNode).id);
|
||||
})
|
||||
.attr("stroke-width", (d) => Math.max(1, d.width ?? 1))
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-opacity", LINK_OPACITY_DEFAULT)
|
||||
.style("cursor", "default")
|
||||
.on("mouseenter", function (_event: MouseEvent, d: SLink) {
|
||||
d3.select(this).attr("stroke-opacity", LINK_OPACITY_HOVER);
|
||||
const src = (d.source as SNode).id;
|
||||
const tgt = (d.target as SNode).id;
|
||||
const srcOut = outgoingByNode.get(src) ?? 0;
|
||||
const tgtIn = incomingByNode.get(tgt) ?? 0;
|
||||
showTip(
|
||||
{
|
||||
kind: "link",
|
||||
source: toolLabel(src),
|
||||
target: toolLabel(tgt),
|
||||
count: d.value ?? 0,
|
||||
shareOfSource: srcOut > 0 ? (d.value ?? 0) / srcOut : 0,
|
||||
shareOfTarget: tgtIn > 0 ? (d.value ?? 0) / tgtIn : 0,
|
||||
},
|
||||
this as SVGGraphicsElement
|
||||
);
|
||||
})
|
||||
.on("mouseleave", function () {
|
||||
d3.select(this).attr("stroke-opacity", LINK_OPACITY_DEFAULT);
|
||||
hideTip();
|
||||
});
|
||||
|
||||
// ── Nodes ──────────────────────────────────────────────────────────────
|
||||
const nodeGroup = root.append("g").attr("class", "nodes");
|
||||
|
||||
const nodeGs = nodeGroup
|
||||
.selectAll<SVGGElement, SNode>("g")
|
||||
.data(graph.nodes as SNode[])
|
||||
.join("g");
|
||||
|
||||
nodeGs
|
||||
.append("rect")
|
||||
.attr("x", (d) => d.x0 ?? 0)
|
||||
.attr("y", (d) => d.y0 ?? 0)
|
||||
.attr("width", (d) => (d.x1 ?? 0) - (d.x0 ?? 0))
|
||||
.attr("height", (d) => Math.max(MIN_NODE_HEIGHT, (d.y1 ?? 0) - (d.y0 ?? 0)))
|
||||
.attr("rx", 2)
|
||||
.attr("ry", 2)
|
||||
.attr("fill", (d) => toolColor(d.id))
|
||||
.attr("stroke-width", 0)
|
||||
.attr("fill-opacity", 0.9)
|
||||
.style("cursor", "default")
|
||||
.on("mouseenter", function (_event: MouseEvent, d: SNode) {
|
||||
const rawName = toolLabel(d.id);
|
||||
const countEntry = data.toolCounts.find((c) => c.tool_name === rawName);
|
||||
const count = countEntry?.count ?? 0;
|
||||
showTip(
|
||||
{
|
||||
kind: "node",
|
||||
rawName,
|
||||
count,
|
||||
shareOfTotal: totalUsage > 0 ? count / totalUsage : 0,
|
||||
},
|
||||
this as SVGGraphicsElement
|
||||
);
|
||||
})
|
||||
.on("mouseleave", function () {
|
||||
hideTip();
|
||||
});
|
||||
|
||||
// ── Node labels ────────────────────────────────────────────────────────
|
||||
nodeGs.each(function (d: SNode) {
|
||||
const g = d3.select(this);
|
||||
const nodeX0 = d.x0 ?? 0;
|
||||
const nodeX1 = d.x1 ?? 0;
|
||||
const nodeY0 = d.y0 ?? 0;
|
||||
const nodeY1 = d.y1 ?? 0;
|
||||
const nodeH = nodeY1 - nodeY0;
|
||||
const midY = nodeY0 + nodeH / 2;
|
||||
|
||||
const rawLabel = toolLabel(d.id);
|
||||
const label = localizeToolLabel(rawLabel);
|
||||
const isRightSide = (nodeX0 + nodeX1) / 2 > innerW / 2;
|
||||
|
||||
// Percentage of total
|
||||
const countEntry = data.toolCounts.find((c) => c.tool_name === rawLabel);
|
||||
const pct =
|
||||
countEntry && totalUsage > 0
|
||||
? ` ${((countEntry.count / totalUsage) * 100).toFixed(1)}%`
|
||||
: "";
|
||||
|
||||
const textX = isRightSide ? nodeX1 + 8 : nodeX0 - 8;
|
||||
const anchor = isRightSide ? "start" : "end";
|
||||
|
||||
const text = g
|
||||
.append("text")
|
||||
.attr("x", textX)
|
||||
.attr("y", midY)
|
||||
.attr("dy", "0.35em")
|
||||
.attr("text-anchor", anchor)
|
||||
.style("font-size", "12px")
|
||||
.style("font-family", "Inter, -apple-system, sans-serif")
|
||||
.style("fill", "#e2e8f0")
|
||||
.style("pointer-events", "none")
|
||||
.style("user-select", "none");
|
||||
|
||||
text.append("tspan").text(label).style("font-weight", "500");
|
||||
|
||||
if (pct) {
|
||||
text.append("tspan").text(pct).style("fill", "#64748b").style("font-size", "11px");
|
||||
}
|
||||
});
|
||||
|
||||
// Hide any stale tooltip when the chart re-renders so it cannot get stuck.
|
||||
hideTip();
|
||||
}, [data, dimensions, isEmpty, localizeToolLabel, t, totalUsage, showTip, hideTip]);
|
||||
|
||||
// Adapt SVG height based on node count so tall graphs don't crush
|
||||
useEffect(() => {
|
||||
const nodeCount = new Set(data.transitions.flatMap((t) => [t.source, t.target])).size;
|
||||
const estimatedH = Math.max(
|
||||
320,
|
||||
Math.min(600, nodeCount * (NODE_PADDING + 20) + MARGIN.top + MARGIN.bottom)
|
||||
);
|
||||
setDimensions((prev) => ({ ...prev, height: estimatedH }));
|
||||
}, [data.transitions]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef} onMouseLeave={hideTip}>
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center justify-center" style={{ height: dimensions.height }}>
|
||||
<span className="text-sm text-gray-500">{t("toolFlow.noData")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{ display: "block", width: "100%", height: dimensions.height }}
|
||||
onMouseLeave={hideTip}
|
||||
/>
|
||||
)}
|
||||
<Legend />
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
background: "#12121f",
|
||||
border: "1px solid #2a2a4a",
|
||||
color: "#e2e8f0",
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tooltip DOM builder ───────────────────────────────────────────────────────
|
||||
|
||||
function fmtPct(v: number): string {
|
||||
if (v <= 0) return "-";
|
||||
if (v < 0.01) return "<1%";
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function appendTipRow(parent: HTMLElement, label: string, value: string) {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
parent.appendChild(row);
|
||||
}
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function buildToolFlowTooltip(
|
||||
el: HTMLDivElement,
|
||||
payload: TipPayload,
|
||||
localizeToolLabel: (name: string) => string,
|
||||
t: TFn
|
||||
) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
|
||||
if (payload.kind === "node") {
|
||||
const name = localizeToolLabel(payload.rawName);
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
|
||||
title.textContent = name;
|
||||
el.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = t("toolFlow.tooltip.node");
|
||||
el.appendChild(subtitle);
|
||||
|
||||
appendTipRow(el, t("toolFlow.tooltip.totalCalls"), payload.count.toLocaleString());
|
||||
appendTipRow(el, t("toolFlow.tooltip.shareOfAll"), fmtPct(payload.shareOfTotal));
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:8px 0 0";
|
||||
desc.textContent = t("toolFlow.tooltip.nodeDescFmt", { name });
|
||||
el.appendChild(desc);
|
||||
return;
|
||||
}
|
||||
|
||||
// Link tooltip
|
||||
const src = localizeToolLabel(payload.source);
|
||||
const tgt = localizeToolLabel(payload.target);
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0";
|
||||
const tspanArrow = document.createElement("span");
|
||||
tspanArrow.style.color = "#64748b";
|
||||
tspanArrow.textContent = " → ";
|
||||
title.appendChild(document.createTextNode(src));
|
||||
title.appendChild(tspanArrow);
|
||||
title.appendChild(document.createTextNode(tgt));
|
||||
el.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.style.cssText =
|
||||
"font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
||||
subtitle.textContent = t("toolFlow.tooltip.link");
|
||||
el.appendChild(subtitle);
|
||||
|
||||
appendTipRow(el, t("toolFlow.tooltip.transitionsObserved"), payload.count.toLocaleString());
|
||||
appendTipRow(
|
||||
el,
|
||||
t("toolFlow.tooltip.shareOfSourceFmt", { source: src }),
|
||||
fmtPct(payload.shareOfSource)
|
||||
);
|
||||
appendTipRow(
|
||||
el,
|
||||
t("toolFlow.tooltip.shareOfTargetFmt", { target: tgt }),
|
||||
fmtPct(payload.shareOfTarget)
|
||||
);
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:8px 0 0";
|
||||
desc.textContent = t("toolFlow.tooltip.linkDescFmt", { source: src, target: tgt });
|
||||
el.appendChild(desc);
|
||||
}
|
||||
|
||||
// ── Legend ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const LEGEND_ITEMS: Array<{ key: string; color: string }> = [
|
||||
{ key: "read", color: "#3b82f6" },
|
||||
{ key: "write", color: "#22c55e" },
|
||||
{ key: "edit", color: "#eab308" },
|
||||
{ key: "bash", color: "#ef4444" },
|
||||
{ key: "grep", color: "#a855f7" },
|
||||
{ key: "glob", color: "#ec4899" },
|
||||
{ key: "agent", color: "#6366f1" },
|
||||
{ key: "other", color: "#64748b" },
|
||||
];
|
||||
|
||||
function Legend() {
|
||||
const { t } = useTranslation("errors");
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-3 px-1">
|
||||
{LEGEND_ITEMS.map(({ key, color }) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
style={{ background: color, opacity: 0.9 }}
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">{t(`toolLegend.${key}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* @file WorkflowPatterns.tsx
|
||||
* @description Defines the WorkflowPatterns React component that visualizes common workflow patterns detected from session data. It displays a ranked list of patterns based on their frequency, showing the sequence of agent steps in each pattern along with an icon representing the type of workflow. The component also handles cases where no patterns are detected and includes a special item for solo sessions without subagents. Users can click on a pattern to trigger a callback with the pattern's steps for further analysis or filtering.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `WorkflowPatterns` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **WorkflowPatterns**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, Zap, Code2, Shield, Bug, FileText, Lightbulb, Info } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { WorkflowPattern, WorkflowPatternsData } from "../../lib/types";
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_VISIBLE_STEPS = 4;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function patternIcon(steps: string[]): LucideIcon {
|
||||
const joined = steps.join(" ").toLowerCase();
|
||||
if (joined.includes("debug")) return Bug;
|
||||
if (joined.includes("security") || joined.includes("audit")) return Shield;
|
||||
if (joined.includes("code-review") || joined.includes("review")) return Code2;
|
||||
if (joined.includes("doc") || joined.includes("text")) return FileText;
|
||||
return Zap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first agent that appears more than once in the sequence (loop indicator).
|
||||
* Returns null if every step is unique.
|
||||
*/
|
||||
function findRepeatedStep(steps: string[]): string | null {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
if (s !== undefined && steps.indexOf(s) !== i) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic, value-dependent narrative for a workflow pattern.
|
||||
* Pure rule-based mapping - same input always yields the same output, so the
|
||||
* UI never produces hallucinated descriptions for ambiguous patterns.
|
||||
*/
|
||||
function describePattern(pattern: WorkflowPattern, t: TFn): string {
|
||||
const { steps, percentage } = pattern;
|
||||
if (steps.length === 0) return "";
|
||||
|
||||
const first = steps[0] ?? "";
|
||||
const last = steps[steps.length - 1] ?? first;
|
||||
const repeated = findRepeatedStep(steps);
|
||||
|
||||
// Shape of the chain
|
||||
let core: string;
|
||||
if (steps.length === 1) {
|
||||
core = t("patterns.detail.narrative.soloFmt", { first });
|
||||
} else if (steps.length === 2) {
|
||||
core = t("patterns.detail.narrative.twoStepFmt", { first, last });
|
||||
} else if (steps.length <= 5) {
|
||||
core = t("patterns.detail.narrative.shortFmt", { count: steps.length, first, last });
|
||||
} else {
|
||||
core = t("patterns.detail.narrative.longFmt", { count: steps.length, first, last });
|
||||
}
|
||||
|
||||
if (repeated) {
|
||||
core += t("patterns.detail.narrative.loopHintFmt", { agent: repeated });
|
||||
}
|
||||
|
||||
// Frequency bucket
|
||||
let freq: string;
|
||||
if (percentage > 50) freq = t("patterns.detail.narrative.dominant");
|
||||
else if (percentage > 25) freq = t("patterns.detail.narrative.common");
|
||||
else if (percentage > 10) freq = t("patterns.detail.narrative.regular");
|
||||
else freq = t("patterns.detail.narrative.niche");
|
||||
|
||||
return core + freq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a suggestion bucket based on chain length and whether a loop exists.
|
||||
* Loop wins over length so the user is reminded to confirm intentional loops.
|
||||
*/
|
||||
function suggestionForPattern(pattern: WorkflowPattern, t: TFn): string {
|
||||
const { steps } = pattern;
|
||||
if (findRepeatedStep(steps)) return t("patterns.detail.suggestion.loop");
|
||||
if (steps.length <= 1) return t("patterns.detail.suggestion.solo");
|
||||
if (steps.length <= 3) return t("patterns.detail.suggestion.shortChain");
|
||||
if (steps.length <= 6) return t("patterns.detail.suggestion.mediumChain");
|
||||
return t("patterns.detail.suggestion.longChain");
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function StepPill({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StepFlow({ steps }: { steps: string[] }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const visible = steps.slice(0, MAX_VISIBLE_STEPS);
|
||||
const overflow = steps.length - MAX_VISIBLE_STEPS;
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap gap-1 min-w-0">
|
||||
{visible.map((step, idx) => (
|
||||
<span key={idx} className="flex items-center gap-1">
|
||||
<StepPill label={step} />
|
||||
{(idx < visible.length - 1 || overflow > 0) && (
|
||||
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-gray-600" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium bg-gray-700/50 text-gray-400 border border-gray-600/20 whitespace-nowrap">
|
||||
{t("common:plusMore", { count: overflow })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternFrequency({ count, percentage }: { count: number; percentage: number }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-sm font-semibold text-gray-100">{count.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PatternItemProps {
|
||||
pattern: WorkflowPattern;
|
||||
rank: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function PatternItem({ pattern, rank, isSelected, onClick }: PatternItemProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const Icon = patternIcon(pattern.steps);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"rounded-lg border transition-colors duration-150 overflow-hidden",
|
||||
isSelected
|
||||
? "bg-indigo-500/10 border-indigo-500/30"
|
||||
: "bg-surface-2 border-transparent hover:bg-white/5 hover:border-white/10",
|
||||
].join(" ")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-expanded={isSelected}
|
||||
title={t("patterns.detail.clickHint")}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
{/* Rank / icon */}
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
|
||||
{rank <= 3 ? (
|
||||
<span className="text-xs font-bold text-indigo-400">{rank}</span>
|
||||
) : (
|
||||
<Icon className="w-3.5 h-3.5 text-indigo-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step flow */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<StepFlow steps={pattern.steps} />
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<PatternFrequency count={pattern.count} percentage={pattern.percentage} />
|
||||
|
||||
{/* Click affordance - visible only when not yet expanded so users know the row is interactive. */}
|
||||
{!isSelected && (
|
||||
<Info className="hidden sm:block w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSelected && <PatternDetail pattern={pattern} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternDetail({ pattern }: { pattern: WorkflowPattern }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const uniqueAgents = new Set(pattern.steps).size;
|
||||
const narrative = describePattern(pattern, t);
|
||||
const suggestion = suggestionForPattern(pattern, t);
|
||||
|
||||
return (
|
||||
<div className="border-t border-indigo-500/20 bg-surface-1/40 px-4 py-3.5 space-y-3.5">
|
||||
{/* Full step sequence (no truncation) */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
{t("patterns.detail.stepsHeading")}
|
||||
</p>
|
||||
<div className="flex items-center flex-wrap gap-1.5">
|
||||
{pattern.steps.map((step, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-indigo-500/15 text-indigo-200 border border-indigo-500/25">
|
||||
<span className="text-indigo-400/70 mr-1.5 text-[10px] font-bold">{i + 1}</span>
|
||||
{step}
|
||||
</span>
|
||||
{i < pattern.steps.length - 1 && (
|
||||
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-gray-600" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<DetailStat
|
||||
label={t("patterns.detail.stepsCount", { count: pattern.steps.length })}
|
||||
value={String(pattern.steps.length)}
|
||||
/>
|
||||
<DetailStat label={t("patterns.detail.uniqueAgents")} value={String(uniqueAgents)} />
|
||||
<DetailStat
|
||||
label={t("patterns.detail.occurrences")}
|
||||
value={pattern.count.toLocaleString()}
|
||||
/>
|
||||
<DetailStat
|
||||
label={t("patterns.detail.shareOfSessions")}
|
||||
value={`${pattern.percentage.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Narrative - what this means */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Info className="w-3 h-3 text-indigo-400" />
|
||||
{t("patterns.detail.narrativeHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-300 leading-relaxed">{narrative}</p>
|
||||
</div>
|
||||
|
||||
{/* Suggestion */}
|
||||
<div className="bg-indigo-500/5 border border-indigo-500/15 rounded-md px-3 py-2.5">
|
||||
<p className="text-[10px] font-semibold text-indigo-300 uppercase tracking-wider mb-1 flex items-center gap-1.5">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
{t("patterns.detail.suggestionHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-300 leading-relaxed">{suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-2.5 py-2">
|
||||
<p className="text-sm font-semibold text-gray-100 tabular-nums">{value}</p>
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider mt-0.5 truncate">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SoloSessionItem({ count, percentage }: { count: number; percentage: number }) {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-lg border bg-yellow-500/5 border-yellow-500/20">
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-yellow-500/10 border border-yellow-500/20 flex items-center justify-center">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-yellow-500/15 text-yellow-300 border border-yellow-500/20">
|
||||
{t("patterns.solo")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-sm font-semibold text-gray-100">{count.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPatterns() {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
||||
<Zap className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-400">{t("patterns.noData")}</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{t("patterns.noDataDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public component ──────────────────────────────────────────────────────────
|
||||
|
||||
interface WorkflowPatternsProps {
|
||||
data: WorkflowPatternsData;
|
||||
onPatternClick?: (steps: string[]) => void;
|
||||
}
|
||||
|
||||
export function WorkflowPatterns({ data, onPatternClick }: WorkflowPatternsProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const handlePatternClick = (index: number, steps: string[]) => {
|
||||
const next = selectedIndex === index ? null : index;
|
||||
setSelectedIndex(next);
|
||||
if (next !== null) {
|
||||
onPatternClick?.(steps);
|
||||
}
|
||||
};
|
||||
|
||||
const hasContent = data.patterns.length > 0 || data.soloSessionCount > 0;
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">
|
||||
{t("patterns.label")}
|
||||
</h2>
|
||||
|
||||
{!hasContent ? (
|
||||
<EmptyPatterns />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.patterns.map((pattern, idx) => (
|
||||
<PatternItem
|
||||
key={idx}
|
||||
pattern={pattern}
|
||||
rank={idx + 1}
|
||||
isSelected={selectedIndex === idx}
|
||||
onClick={() => handlePatternClick(idx, pattern.steps)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{data.soloSessionCount > 0 && (
|
||||
<SoloSessionItem count={data.soloSessionCount} percentage={data.soloPercentage} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* @file WorkflowRunsPanel.tsx
|
||||
* @description Surfaces dynamic Workflow-tool runs (issue #167) - fleets of
|
||||
* inner sub-agents spawned by the Claude Code "Workflow" tool, ingested from
|
||||
* on-disk run journals. Works in two modes: controlled (pass `runs`, e.g. from
|
||||
* SessionDetail) or self-fetching (pass a `statusFilter`, e.g. the Workflows
|
||||
* page) with live `workflow_upserted` updates. Each run expands to colored,
|
||||
* clickable phase filters, a per-agent metrics table, and an expandable list of
|
||||
* per-agent results. The collapsed row shows a short teaser from the run
|
||||
* journal; expanding an agent lazily fetches its full transcript (the journal
|
||||
* only carries server-truncated previews) and renders the complete prompt and
|
||||
* result, falling back to the teaser when the transcript is pruned/unavailable.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/api`
|
||||
* - `../../lib/eventBus`
|
||||
* - `../../lib/types`
|
||||
* - `../../lib/format`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `friendlyPreview` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `fullPreview` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `extractPromptResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `WorkflowRunsPanel` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **friendlyPreview**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **fullPreview**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **extractPromptResult**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **WorkflowRunsPanel**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Workflow, ChevronRight, ChevronDown, Layers, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import type {
|
||||
WorkflowRun,
|
||||
WorkflowProgressEntry,
|
||||
WSMessage,
|
||||
TranscriptMessage,
|
||||
} from "../../lib/types";
|
||||
import { fmt, formatMs, timeAgo, truncate } from "../../lib/format";
|
||||
|
||||
type StatusFilter = "all" | "active" | "completed";
|
||||
|
||||
interface Props {
|
||||
/** Controlled mode: render exactly these runs (no fetch, no live updates). */
|
||||
runs?: WorkflowRun[];
|
||||
/** Self-fetch mode: page-level status filter (active → running). */
|
||||
statusFilter?: StatusFilter;
|
||||
/** Self-fetch mode: scope to one session. */
|
||||
sessionId?: string;
|
||||
/** Hide the parent-session link (e.g. when already on that session). */
|
||||
hideSessionLink?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
running: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
working: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
queued: "bg-gray-500/15 text-gray-400 border-gray-500/30",
|
||||
completed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
done: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
error: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
failed: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
function statusClass(status: string): string {
|
||||
return STATUS_STYLES[status] || "bg-gray-500/15 text-gray-400 border-gray-500/30";
|
||||
}
|
||||
|
||||
// Distinct per-phase chip colors, cycled by phase index so every phase
|
||||
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
|
||||
// as its own color in both the filter row and the result label chips.
|
||||
const PHASE_PALETTE = [
|
||||
"bg-violet-500/15 text-violet-300 border-violet-500/40",
|
||||
"bg-sky-500/15 text-sky-300 border-sky-500/40",
|
||||
"bg-amber-500/15 text-amber-300 border-amber-500/40",
|
||||
"bg-emerald-500/15 text-emerald-300 border-emerald-500/40",
|
||||
"bg-rose-500/15 text-rose-300 border-rose-500/40",
|
||||
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
|
||||
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
|
||||
];
|
||||
|
||||
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
|
||||
if (!title) return "bg-gray-500/15 text-gray-300 border-gray-500/40";
|
||||
const i = phaseTitles.indexOf(title);
|
||||
const idx = i >= 0 ? i : Math.abs(hashStr(title)) % PHASE_PALETTE.length;
|
||||
return PHASE_PALETTE[idx % PHASE_PALETTE.length] as string;
|
||||
}
|
||||
|
||||
function hashStr(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a human-readable excerpt from an agent's result preview, which is
|
||||
* often a (frequently truncated) JSON blob. Prefer a known content field, then
|
||||
* the first substantial quoted string, then a de-JSON'd snippet - so the panel
|
||||
* shows a sentence instead of raw `{"angle":"…","findings":[{"claim":"…`.
|
||||
*/
|
||||
export function friendlyPreview(raw: unknown): string {
|
||||
if (!raw) return "";
|
||||
const s = String(raw).trim();
|
||||
const keyed = s.match(
|
||||
/"(?:claim|pitch|note|text|summary|result|answer|brief|description|title|content)"\s*:\s*"([^"\\]{8,})/i
|
||||
);
|
||||
if (keyed && keyed[1]) return keyed[1].trim();
|
||||
const firstLong = s.match(/"([^"\\]{24,})"/);
|
||||
if (firstLong && firstLong[1]) return firstLong[1].trim();
|
||||
if (/^[[{]/.test(s)) {
|
||||
return s
|
||||
.replace(/[{}[\]"]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Full, un-truncated content for the expanded view - pretty-printed if JSON. */
|
||||
export function fullPreview(raw: unknown): string {
|
||||
if (raw == null) return "";
|
||||
const s = String(raw);
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
/** Join the text blocks of one transcript message into a single string. */
|
||||
function messageText(m: TranscriptMessage): string {
|
||||
return (m.content || [])
|
||||
.filter((b) => b.type === "text" && b.text)
|
||||
.map((b) => b.text as string)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an agent's full prompt and result from its fetched transcript: the
|
||||
* first user message carries the task prompt; the last assistant message that
|
||||
* has text carries the returned result. Either is "" when absent (e.g. a
|
||||
* schema-mode agent whose final turn is a tool call rather than text) - callers
|
||||
* fall back to the journal teaser in that case.
|
||||
*/
|
||||
export function extractPromptResult(messages: TranscriptMessage[]): {
|
||||
prompt: string;
|
||||
result: string;
|
||||
} {
|
||||
let prompt = "";
|
||||
let result = "";
|
||||
for (const m of messages || []) {
|
||||
if (m.type === "user" && !prompt) {
|
||||
const t = messageText(m);
|
||||
if (t) prompt = t;
|
||||
} else if (m.type === "assistant") {
|
||||
const t = messageText(m);
|
||||
if (t) result = t; // keep the last non-empty assistant text
|
||||
}
|
||||
}
|
||||
return { prompt, result };
|
||||
}
|
||||
|
||||
/** Per-agent transcript fetch state, keyed `${run_id}::${agentId}`. */
|
||||
interface AgentTranscriptState {
|
||||
loading: boolean;
|
||||
prompt?: string;
|
||||
result?: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export function WorkflowRunsPanel({
|
||||
runs: controlledRuns,
|
||||
statusFilter,
|
||||
sessionId,
|
||||
hideSessionLink,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const controlled = controlledRuns != null;
|
||||
|
||||
const [fetchedRuns, setFetchedRuns] = useState<WorkflowRun[]>([]);
|
||||
const [loading, setLoading] = useState(!controlled);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [phaseFilter, setPhaseFilter] = useState<Record<string, string | null>>({});
|
||||
const [openResults, setOpenResults] = useState<Set<string>>(() => new Set());
|
||||
// Full agent transcripts fetched on demand when a result row is expanded,
|
||||
// keyed `${run_id}::${agentId}`. The run journal only carries truncated
|
||||
// previews; the complete text lives in the per-agent transcript file.
|
||||
const [transcripts, setTranscripts] = useState<Record<string, AgentTranscriptState>>({});
|
||||
const inflightRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const loadTranscript = useCallback(
|
||||
async (sessionId: string, runId: string, agentId: string, key: string) => {
|
||||
if (inflightRef.current.has(key)) return;
|
||||
inflightRef.current.add(key);
|
||||
setTranscripts((prev) => ({ ...prev, [key]: { loading: true } }));
|
||||
try {
|
||||
const res = await api.sessions.transcript(sessionId, {
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
limit: 200,
|
||||
});
|
||||
const { prompt, result } = extractPromptResult(res.messages || []);
|
||||
setTranscripts((prev) => ({ ...prev, [key]: { loading: false, prompt, result } }));
|
||||
} catch {
|
||||
setTranscripts((prev) => ({ ...prev, [key]: { loading: false, error: true } }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
if (controlled) return;
|
||||
try {
|
||||
const status =
|
||||
statusFilter === "active"
|
||||
? "running"
|
||||
: statusFilter === "completed"
|
||||
? "completed"
|
||||
: undefined;
|
||||
const res = await api.workflows.runs({ status, session_id: sessionId, limit: 200 });
|
||||
setFetchedRuns(res.runs);
|
||||
} catch {
|
||||
/* leave previous runs in place */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [controlled, statusFilter, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlled) return;
|
||||
fetchRuns();
|
||||
}, [controlled, fetchRuns]);
|
||||
|
||||
// Live updates: debounce a refetch when a workflow row changes.
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (controlled) return;
|
||||
const handler = (msg: WSMessage) => {
|
||||
if (msg.type !== "workflow_upserted") return;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(fetchRuns, 1500);
|
||||
};
|
||||
const unsub = eventBus.subscribe(handler);
|
||||
return () => {
|
||||
unsub();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [controlled, fetchRuns]);
|
||||
|
||||
const runs = controlled ? controlledRuns : fetchedRuns;
|
||||
|
||||
const toggle = (runId: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(runId)) next.delete(runId);
|
||||
else next.add(runId);
|
||||
return next;
|
||||
});
|
||||
const setPhase = (runId: string, phase: string) =>
|
||||
setPhaseFilter((prev) => ({ ...prev, [runId]: prev[runId] === phase ? null : phase }));
|
||||
const toggleResult = (key: string) =>
|
||||
setOpenResults((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!controlled && loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
|
||||
<span className="animate-pulse">{t("runs.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<Workflow className="w-4 h-4 text-gray-600" />
|
||||
{t("runs.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => {
|
||||
const isOpen = expanded.has(run.run_id);
|
||||
const running = run.status === "running" || run.status === "working";
|
||||
// progress[] mixes phase markers and agents; only `workflow_agent`
|
||||
// entries are real agents.
|
||||
const agentRows = (run.progress || []).filter((p) => p.type === "workflow_agent");
|
||||
const phaseTitles = (run.phases || []).map((p) => p.title || "").filter(Boolean);
|
||||
const sel = phaseFilter[run.run_id] || null;
|
||||
const shown = sel ? agentRows.filter((a) => a.phaseTitle === sel) : agentRows;
|
||||
const resultRows = shown.filter((a) => a.resultPreview);
|
||||
return (
|
||||
<div
|
||||
key={run.run_id}
|
||||
className="rounded-lg border border-gray-800 bg-card/40 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggle(run.run_id)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-gray-800/30 transition-colors"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
{running ? (
|
||||
<Loader2 className="w-4 h-4 text-amber-400 flex-shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-200 truncate">
|
||||
{run.name || run.run_id}
|
||||
</span>
|
||||
<span className={`badge text-[10px] border ${statusClass(run.status)}`}>
|
||||
{t(`runs.status.${run.status}`, run.status)}
|
||||
</span>
|
||||
{run.default_model && (
|
||||
<span className="text-[10px] font-mono text-gray-500">{run.default_model}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-[11px] text-gray-500 flex-wrap">
|
||||
<span>{t("runs.agents", { count: run.agent_count })}</span>
|
||||
<span>{t("runs.tools", { count: run.total_tool_calls })}</span>
|
||||
<span>
|
||||
{fmt(run.total_tokens)} {t("runs.tokens")}
|
||||
</span>
|
||||
{run.duration_ms != null && <span>{formatMs(run.duration_ms)}</span>}
|
||||
{run.started_at && <span>{timeAgo(run.started_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!hideSessionLink && (
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(run.session_id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-gray-500 hover:text-violet-400 transition-colors flex-shrink-0"
|
||||
title={t("runs.openSession")}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-3 pb-3 pt-3 border-t border-gray-800/60 space-y-3">
|
||||
{/* Clickable, colored phase filters */}
|
||||
{phaseTitles.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Layers className="w-3.5 h-3.5 text-gray-500" />
|
||||
{phaseTitles.map((title, i) => {
|
||||
const active = sel === title;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPhase(run.run_id, title)}
|
||||
className={`badge text-[10px] border transition ${phaseColor(phaseTitles, title)} ${
|
||||
active
|
||||
? "ring-1 ring-white/50"
|
||||
: sel
|
||||
? "opacity-40 hover:opacity-100"
|
||||
: "hover:ring-1 hover:ring-white/20"
|
||||
}`}
|
||||
title={t("runs.filterPhase")}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{sel && (
|
||||
<button
|
||||
onClick={() => setPhase(run.run_id, sel)}
|
||||
className="text-[10px] text-gray-500 hover:text-gray-300 underline"
|
||||
>
|
||||
{t("runs.clearFilter")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shown.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-gray-500 text-left border-b border-gray-800">
|
||||
<th className="py-1 pr-3 font-medium">{t("runs.col.agent")}</th>
|
||||
<th className="py-1 pr-3 font-medium">{t("runs.col.phase")}</th>
|
||||
<th className="py-1 pr-3 font-medium">{t("runs.col.state")}</th>
|
||||
<th className="py-1 pr-3 font-medium text-right">
|
||||
{t("runs.col.tokens")}
|
||||
</th>
|
||||
<th className="py-1 pr-3 font-medium text-right">
|
||||
{t("runs.col.tools")}
|
||||
</th>
|
||||
<th className="py-1 pr-3 font-medium text-right">
|
||||
{t("runs.col.duration")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((a: WorkflowProgressEntry, i) => (
|
||||
<tr key={a.agentId || i} className="border-b border-gray-800/40">
|
||||
<td className="py-1 pr-3 text-gray-300">
|
||||
{a.label || a.agentType || a.agentId}
|
||||
{a.lastToolName && (
|
||||
<span className="text-gray-600 font-mono ml-1">
|
||||
· {a.lastToolName}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 pr-3">
|
||||
<span
|
||||
className={`badge text-[10px] border ${phaseColor(phaseTitles, a.phaseTitle)}`}
|
||||
>
|
||||
{a.phaseTitle || "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1 pr-3">
|
||||
<span
|
||||
className={`badge text-[10px] border ${statusClass(String(a.state || ""))}`}
|
||||
>
|
||||
{t(`runs.status.${a.state}`, String(a.state || "-"))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1 pr-3 text-right text-gray-400">
|
||||
{fmt(a.tokens || 0)}
|
||||
</td>
|
||||
<td className="py-1 pr-3 text-right text-gray-400">
|
||||
{a.toolCalls || 0}
|
||||
</td>
|
||||
<td className="py-1 pr-3 text-right text-gray-400">
|
||||
{a.durationMs != null ? formatMs(a.durationMs) : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-gray-600">{t("runs.noAgents")}</p>
|
||||
)}
|
||||
|
||||
{/* Clickable, colored, expandable results - full content on click */}
|
||||
{resultRows.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-gray-600">
|
||||
{t("runs.resultsLabel")}
|
||||
<span className="ml-1 text-gray-700">· {resultRows.length}</span>
|
||||
</div>
|
||||
{resultRows.map((a, i) => {
|
||||
const key = `${run.run_id}::${a.agentId || i}`;
|
||||
const open = openResults.has(key);
|
||||
const ts = transcripts[key];
|
||||
const hasFull = !!ts && !ts.loading && !ts.error;
|
||||
const fullPrompt =
|
||||
hasFull && ts.prompt
|
||||
? ts.prompt
|
||||
: a.promptPreview
|
||||
? String(a.promptPreview)
|
||||
: "";
|
||||
const fullResult =
|
||||
hasFull && ts.result ? ts.result : fullPreview(a.resultPreview);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded border border-gray-800/70 bg-gray-900/30 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!open && a.agentId) {
|
||||
loadTranscript(run.session_id, run.run_id, a.agentId, key);
|
||||
}
|
||||
toggleResult(key);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-left hover:bg-gray-800/40 transition-colors"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`badge text-[10px] border flex-shrink-0 ${phaseColor(phaseTitles, a.phaseTitle)}`}
|
||||
>
|
||||
{a.label || a.agentType || a.agentId}
|
||||
</span>
|
||||
{!open && (
|
||||
<span className="text-[11px] text-gray-500 leading-snug min-w-0">
|
||||
{truncate(friendlyPreview(a.resultPreview), 160)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-2.5 pb-2.5 pt-0.5 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[10px] text-gray-500">
|
||||
{a.model && <span className="font-mono">{a.model}</span>}
|
||||
<span
|
||||
className={`badge border ${statusClass(String(a.state || ""))}`}
|
||||
>
|
||||
{t(`runs.status.${a.state}`, String(a.state || "-"))}
|
||||
</span>
|
||||
<span>
|
||||
{fmt(a.tokens || 0)} {t("runs.tokens")}
|
||||
</span>
|
||||
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
|
||||
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
|
||||
{ts?.loading && (
|
||||
<span className="flex items-center gap-1 text-violet-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{t("runs.loadingFull")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fullPrompt && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-600 mb-0.5">
|
||||
{t("runs.promptLabel")}
|
||||
</div>
|
||||
<pre className="text-[11px] text-gray-400 whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-48 overflow-auto">
|
||||
{fullPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-600 mb-0.5">
|
||||
{t("runs.resultLabel")}
|
||||
</div>
|
||||
<pre className="text-[11px] text-gray-300 whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-96 overflow-auto">
|
||||
{fullResult}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* @file WorkflowStats.tsx
|
||||
* @description Six headline statistics rendered as cards. Each card has the accent icon top-right and an info popover (i icon) bottom-right that explains how the metric is calculated and gives a deterministic, value-dependent interpretation. The popover is fixed-positioned and clamped to the viewport so it never gets clipped by the sidebar or screen edges. All copy is i18n-driven (workflows.stats.tooltip.*).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `WorkflowStatsProps` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `WorkflowStats` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **WorkflowStatsProps**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **WorkflowStats**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitFork, Users, CheckCircle, ArrowRightLeft, Layers, Clock, Info } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { WorkflowStats } from "../../lib/types";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDurationSec(sec: number): string {
|
||||
if (sec <= 0) return "0s";
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = Math.round(sec % 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function successRateColor(rate: number): string {
|
||||
if (rate > 90) return "text-emerald-400";
|
||||
if (rate > 70) return "text-yellow-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
// ── Deterministic interpreters - return an i18n key + params ─────────────────
|
||||
// Pure rule-based mapping so the same input always yields the same explanation.
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
type Interp = { key: string; params?: Record<string, unknown> };
|
||||
|
||||
function interpAvgDepth(v: number): Interp {
|
||||
if (v <= 0) return { key: "stats.tooltip.depth.zero" };
|
||||
if (v < 0.5) return { key: "stats.tooltip.depth.rare" };
|
||||
if (v < 1.5) return { key: "stats.tooltip.depth.single" };
|
||||
if (v < 2.5) return { key: "stats.tooltip.depth.multi" };
|
||||
return { key: "stats.tooltip.depth.deep" };
|
||||
}
|
||||
|
||||
function interpAvgSubagents(v: number): Interp {
|
||||
if (v <= 0) return { key: "stats.tooltip.subagents.zero" };
|
||||
if (v < 1) {
|
||||
const oneIn = v > 0 ? Math.round(1 / v) : 0;
|
||||
return { key: "stats.tooltip.subagents.lowFreq", params: { count: Math.max(2, oneIn) } };
|
||||
}
|
||||
if (v < 3) return { key: "stats.tooltip.subagents.moderate" };
|
||||
if (v < 6) return { key: "stats.tooltip.subagents.heavy" };
|
||||
return { key: "stats.tooltip.subagents.veryHeavy" };
|
||||
}
|
||||
|
||||
function interpSuccessRate(v: number): Interp {
|
||||
if (v >= 99) return { key: "stats.tooltip.success.perfect" };
|
||||
if (v >= 95) return { key: "stats.tooltip.success.healthy" };
|
||||
if (v >= 80) return { key: "stats.tooltip.success.acceptable" };
|
||||
if (v >= 50) return { key: "stats.tooltip.success.concerning" };
|
||||
return { key: "stats.tooltip.success.critical" };
|
||||
}
|
||||
|
||||
function interpTopFlow(source: string | null, target: string | null): Interp {
|
||||
if (!source || !target) return { key: "stats.tooltip.topFlow.none" };
|
||||
if (source === target) {
|
||||
return { key: "stats.tooltip.topFlow.selfLoop", params: { tool: source } };
|
||||
}
|
||||
return {
|
||||
key: "stats.tooltip.topFlow.pair",
|
||||
params: {
|
||||
source,
|
||||
target,
|
||||
sourceLower: source.toLowerCase(),
|
||||
targetLower: target.toLowerCase(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function interpAvgCompactions(v: number): Interp {
|
||||
if (v <= 0) return { key: "stats.tooltip.compactions.zero" };
|
||||
if (v < 0.5) {
|
||||
const oneIn = v > 0 ? Math.round(1 / v) : 0;
|
||||
return { key: "stats.tooltip.compactions.lowFreq", params: { count: Math.max(2, oneIn) } };
|
||||
}
|
||||
if (v < 2) return { key: "stats.tooltip.compactions.moderate" };
|
||||
return { key: "stats.tooltip.compactions.high" };
|
||||
}
|
||||
|
||||
function interpAvgDuration(sec: number): Interp {
|
||||
if (sec <= 0) return { key: "stats.tooltip.duration.zero" };
|
||||
if (sec < 60) return { key: "stats.tooltip.duration.veryShort" };
|
||||
if (sec < 5 * 60) return { key: "stats.tooltip.duration.short" };
|
||||
if (sec < 30 * 60) return { key: "stats.tooltip.duration.medium" };
|
||||
if (sec < 60 * 60) return { key: "stats.tooltip.duration.long" };
|
||||
if (sec < 3 * 60 * 60) return { key: "stats.tooltip.duration.veryLong" };
|
||||
return { key: "stats.tooltip.duration.marathon" };
|
||||
}
|
||||
|
||||
// ── Info popover ──────────────────────────────────────────────────────────────
|
||||
|
||||
const POPOVER_W = 300;
|
||||
const POPOVER_MARGIN = 12;
|
||||
|
||||
interface InfoPopoverProps {
|
||||
calculationKey: string;
|
||||
interp: Interp;
|
||||
valueDisplay: string;
|
||||
metricPhraseKey: string;
|
||||
}
|
||||
|
||||
function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }: InfoPopoverProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [coords, setCoords] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const update = () => {
|
||||
const btn = buttonRef.current;
|
||||
const pop = popoverRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const popH = pop?.offsetHeight ?? 240;
|
||||
|
||||
let left = r.right - POPOVER_W;
|
||||
if (left < POPOVER_MARGIN) left = POPOVER_MARGIN;
|
||||
if (left + POPOVER_W > window.innerWidth - POPOVER_MARGIN) {
|
||||
left = window.innerWidth - POPOVER_W - POPOVER_MARGIN;
|
||||
}
|
||||
const spaceBelow = window.innerHeight - r.bottom;
|
||||
const placeAbove = spaceBelow < popH + POPOVER_MARGIN && r.top > popH + POPOVER_MARGIN;
|
||||
const top = placeAbove ? Math.max(POPOVER_MARGIN, r.top - popH - 8) : r.bottom + 8;
|
||||
|
||||
setCoords({ left, top });
|
||||
};
|
||||
update();
|
||||
const raf = requestAnimationFrame(update);
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
const metricPhrase = t(metricPhraseKey);
|
||||
const interpretation = t(interp.key, interp.params);
|
||||
const valueMeans = t("stats.tooltip.valueMeansFmt", {
|
||||
value: valueDisplay,
|
||||
phrase: metricPhrase,
|
||||
interpretation,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label={t("stats.tooltip.moreInfo")}
|
||||
aria-expanded={open}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
className="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-gray-600 hover:text-gray-300 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-gray-300 pointer-events-none"
|
||||
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
|
||||
<span className="text-base font-semibold text-gray-100 tabular-nums">
|
||||
{valueDisplay}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||
{metricPhrase}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("stats.tooltip.howCalc")}
|
||||
</p>
|
||||
<p className="text-gray-400 leading-snug mb-2.5">{t(calculationKey)}</p>
|
||||
|
||||
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("stats.tooltip.whatItMeans")}
|
||||
</p>
|
||||
<p className="text-gray-400 leading-snug">{valueMeans}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stat card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: LucideIcon;
|
||||
accentClass?: string;
|
||||
calculationKey: string;
|
||||
interp: Interp;
|
||||
metricPhraseKey: string;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
accentClass = "text-accent",
|
||||
calculationKey,
|
||||
interp,
|
||||
metricPhraseKey,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-surface-2 border border-border rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider leading-none">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${accentClass}`} />
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<span
|
||||
className={`text-2xl font-semibold leading-none truncate ${accentClass}`}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<InfoPopover
|
||||
calculationKey={calculationKey}
|
||||
interp={interp}
|
||||
valueDisplay={value}
|
||||
metricPhraseKey={metricPhraseKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public component ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkflowStatsProps {
|
||||
stats: WorkflowStats;
|
||||
}
|
||||
|
||||
export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
// t is referenced for translation prefix consistency.
|
||||
void (t as TFn);
|
||||
const topFlow = stats.topFlow;
|
||||
const topFlowLabel = topFlow ? `${topFlow.source} → ${topFlow.target}` : "-";
|
||||
|
||||
const srColor = successRateColor(stats.successRate);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
label={t("stats.avgAgentDepth")}
|
||||
value={stats.avgDepth.toFixed(1)}
|
||||
icon={GitFork}
|
||||
accentClass="text-indigo-400"
|
||||
calculationKey="stats.tooltip.calc.depth"
|
||||
interp={interpAvgDepth(stats.avgDepth)}
|
||||
metricPhraseKey="stats.tooltip.phrase.depth"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("stats.avgSubagentsPerSession")}
|
||||
value={stats.avgSubagents.toFixed(1)}
|
||||
icon={Users}
|
||||
accentClass="text-blue-400"
|
||||
calculationKey="stats.tooltip.calc.subagents"
|
||||
interp={interpAvgSubagents(stats.avgSubagents)}
|
||||
metricPhraseKey="stats.tooltip.phrase.subagents"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("stats.agentSuccessRate")}
|
||||
value={`${stats.successRate.toFixed(1)}%`}
|
||||
icon={CheckCircle}
|
||||
accentClass={srColor}
|
||||
calculationKey="stats.tooltip.calc.success"
|
||||
interp={interpSuccessRate(stats.successRate)}
|
||||
metricPhraseKey="stats.tooltip.phrase.success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("stats.mostCommonFlow")}
|
||||
value={topFlowLabel}
|
||||
icon={ArrowRightLeft}
|
||||
accentClass="text-violet-400"
|
||||
calculationKey="stats.tooltip.calc.topFlow"
|
||||
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
|
||||
metricPhraseKey="stats.tooltip.phrase.topFlow"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("stats.avgCompactions")}
|
||||
value={stats.avgCompactions.toFixed(1)}
|
||||
icon={Layers}
|
||||
accentClass="text-cyan-400"
|
||||
calculationKey="stats.tooltip.calc.compactions"
|
||||
interp={interpAvgCompactions(stats.avgCompactions)}
|
||||
metricPhraseKey="stats.tooltip.phrase.compactions"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("stats.avgDuration")}
|
||||
value={formatDurationSec(stats.avgDurationSec)}
|
||||
icon={Clock}
|
||||
accentClass="text-amber-400"
|
||||
calculationKey="stats.tooltip.calc.duration"
|
||||
interp={interpAvgDuration(stats.avgDurationSec)}
|
||||
metricPhraseKey="stats.tooltip.phrase.duration"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file Render + interaction tests for WorkflowRunsPanel (controlled mode):
|
||||
* empty state, collapsed header, expand → phase chips + per-agent table +
|
||||
* results (phase markers excluded), phase-filter toggling, per-result expand to
|
||||
* full content, and the running-run indicator.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { WorkflowRunsPanel } from "../WorkflowRunsPanel";
|
||||
import type { WorkflowRun } from "../../../lib/types";
|
||||
|
||||
function makeRun(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
|
||||
return {
|
||||
run_id: "wf_x",
|
||||
session_id: "s1",
|
||||
task_id: null,
|
||||
name: "review-changes",
|
||||
status: "completed",
|
||||
default_model: "claude-opus-4-8",
|
||||
started_at: "2026-06-14T00:00:00.000Z",
|
||||
ended_at: "2026-06-14T00:00:05.000Z",
|
||||
duration_ms: 5000,
|
||||
agent_count: 3,
|
||||
total_tokens: 48000,
|
||||
total_tool_calls: 9,
|
||||
phases: [{ title: "Scout" }, { title: "Verify" }],
|
||||
progress: [
|
||||
{ type: "workflow_phase", index: 1, title: "Scout" },
|
||||
{
|
||||
type: "workflow_agent",
|
||||
agentId: "a1",
|
||||
label: "scout:starship",
|
||||
phaseTitle: "Scout",
|
||||
state: "done",
|
||||
tokens: 20000,
|
||||
toolCalls: 5,
|
||||
durationMs: 1000,
|
||||
lastToolName: "Read",
|
||||
resultPreview: '{"claim":"SCOUTCLAIM about starship"}',
|
||||
},
|
||||
{
|
||||
type: "workflow_agent",
|
||||
agentId: "a2",
|
||||
label: "verify:starship",
|
||||
phaseTitle: "Verify",
|
||||
state: "done",
|
||||
tokens: 18000,
|
||||
toolCalls: 3,
|
||||
durationMs: 900,
|
||||
resultPreview: '{"verdict":"confirmed","note":"VERIFYNOTE"}',
|
||||
},
|
||||
{
|
||||
type: "workflow_agent",
|
||||
agentId: "a3",
|
||||
label: "scout:starlink",
|
||||
phaseTitle: "Scout",
|
||||
state: "running",
|
||||
tokens: 1000,
|
||||
toolCalls: 1,
|
||||
},
|
||||
],
|
||||
script_path: null,
|
||||
journal_path: null,
|
||||
source: "journal",
|
||||
created_at: "2026-06-14T00:00:00.000Z",
|
||||
updated_at: "2026-06-14T00:00:05.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const renderPanel = (runs: WorkflowRun[]) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WorkflowRunsPanel runs={runs} hideSessionLink />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const runHeader = () => screen.getByRole("button", { name: /review-changes/ });
|
||||
const buttonByText = (text: string) =>
|
||||
screen.getAllByRole("button").find((b) => (b.textContent || "").trim() === text);
|
||||
|
||||
describe("WorkflowRunsPanel", () => {
|
||||
it("shows the empty state with no runs", () => {
|
||||
renderPanel([]);
|
||||
expect(screen.getByText(/No dynamic workflows yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a collapsed run header; the agent table is hidden until expanded", () => {
|
||||
renderPanel([makeRun()]);
|
||||
expect(screen.getByText("review-changes")).toBeInTheDocument();
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-opus-4-8")).toBeInTheDocument();
|
||||
// collapsed → inner agents not rendered yet
|
||||
expect(screen.queryByText("scout:starship")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands to phase chips + per-agent rows, excluding phase markers", () => {
|
||||
renderPanel([makeRun()]);
|
||||
fireEvent.click(runHeader());
|
||||
// 3 workflow_agent entries → present; the workflow_phase marker is NOT a row
|
||||
expect(screen.getAllByText("scout:starship").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("verify:starship").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("scout:starlink")).toBeInTheDocument();
|
||||
// phase filter chips exist as buttons
|
||||
expect(buttonByText("Scout")).toBeTruthy();
|
||||
expect(buttonByText("Verify")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("filters rows by phase when a phase chip is clicked, then clears", () => {
|
||||
renderPanel([makeRun()]);
|
||||
fireEvent.click(runHeader());
|
||||
fireEvent.click(buttonByText("Scout")!);
|
||||
// Verify-phase agent is filtered out everywhere; Scout agents remain
|
||||
expect(screen.queryByText("verify:starship")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("scout:starlink")).toBeInTheDocument();
|
||||
// clicking the same chip again clears the filter → Verify agent returns
|
||||
fireEvent.click(buttonByText("Scout")!);
|
||||
expect(screen.getAllByText("verify:starship").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("expands a result row to its full, un-truncated content", () => {
|
||||
renderPanel([makeRun()]);
|
||||
fireEvent.click(runHeader());
|
||||
// collapsed result shows the humanized excerpt, not the raw JSON keys
|
||||
expect(screen.queryByText(/confirmed/)).not.toBeInTheDocument();
|
||||
const resultBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => /verify:starship/.test(b.textContent || "") && b.getAttribute("aria-expanded"));
|
||||
fireEvent.click(resultBtn!);
|
||||
// full content (pretty-printed JSON) now visible → the "verdict" key shows
|
||||
expect(screen.getByText(/confirmed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a running workflow as running", () => {
|
||||
renderPanel([makeRun({ status: "running", name: "live-run" })]);
|
||||
const header = screen.getByRole("button", { name: /live-run/ });
|
||||
expect(within(header).getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file Tests for the lazy full-transcript fetch in WorkflowRunsPanel: expanding
|
||||
* a result row fetches the agent's complete prompt/result (the run journal only
|
||||
* carries truncated "…" previews), with a graceful fallback to the journal
|
||||
* teaser when the fetch fails. Also unit-tests extractPromptResult.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const { transcriptMock } = vi.hoisted(() => ({ transcriptMock: vi.fn() }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { sessions: { transcript: transcriptMock } },
|
||||
}));
|
||||
|
||||
import { WorkflowRunsPanel, extractPromptResult } from "../WorkflowRunsPanel";
|
||||
import type { WorkflowRun } from "../../../lib/types";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function makeRun(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
|
||||
return {
|
||||
run_id: "wf_x",
|
||||
session_id: "s1",
|
||||
task_id: null,
|
||||
name: "review-changes",
|
||||
status: "completed",
|
||||
default_model: "claude-opus-4-8",
|
||||
started_at: "2026-06-14T00:00:00.000Z",
|
||||
ended_at: "2026-06-14T00:00:05.000Z",
|
||||
duration_ms: 5000,
|
||||
agent_count: 1,
|
||||
total_tokens: 20000,
|
||||
total_tool_calls: 5,
|
||||
phases: [{ title: "Verify" }],
|
||||
progress: [
|
||||
{ type: "workflow_phase", index: 1, title: "Verify" },
|
||||
{
|
||||
type: "workflow_agent",
|
||||
agentId: "a1",
|
||||
label: "verify:starship",
|
||||
phaseTitle: "Verify",
|
||||
state: "done",
|
||||
tokens: 18000,
|
||||
toolCalls: 3,
|
||||
durationMs: 900,
|
||||
promptPreview: "Verify the claim…",
|
||||
resultPreview: '{"verdict":"confirmed","note":"VERIFYNOTE"}',
|
||||
},
|
||||
],
|
||||
script_path: null,
|
||||
journal_path: null,
|
||||
source: "journal",
|
||||
created_at: "2026-06-14T00:00:00.000Z",
|
||||
updated_at: "2026-06-14T00:00:05.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const renderPanel = (runs: WorkflowRun[]) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WorkflowRunsPanel runs={runs} hideSessionLink />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const runHeader = () => screen.getByRole("button", { name: /review-changes/ });
|
||||
const resultButton = () =>
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => /verify:starship/.test(b.textContent || "") && b.getAttribute("aria-expanded"))!;
|
||||
|
||||
beforeEach(() => {
|
||||
transcriptMock.mockReset();
|
||||
});
|
||||
|
||||
const msg = (type: "user" | "assistant", text: string): TranscriptMessage =>
|
||||
({ type, content: [{ type: "text", text }] }) as TranscriptMessage;
|
||||
|
||||
describe("extractPromptResult", () => {
|
||||
it("takes the first user text as prompt and the last assistant text as result", () => {
|
||||
const out = extractPromptResult([
|
||||
msg("user", "do the task"),
|
||||
msg("assistant", "thinking…"),
|
||||
msg("assistant", "FINAL ANSWER"),
|
||||
]);
|
||||
expect(out).toEqual({ prompt: "do the task", result: "FINAL ANSWER" });
|
||||
});
|
||||
|
||||
it("returns empty strings for missing turns (e.g. schema-mode tool-only final)", () => {
|
||||
expect(extractPromptResult([])).toEqual({ prompt: "", result: "" });
|
||||
expect(extractPromptResult([msg("user", "only a prompt")])).toEqual({
|
||||
prompt: "only a prompt",
|
||||
result: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowRunsPanel lazy transcript fetch", () => {
|
||||
it("fetches the full transcript on expand and renders it instead of the teaser", async () => {
|
||||
const LONG_RESULT =
|
||||
"CONFIRMED across every primary source. " +
|
||||
"The full reasoning runs well past the truncated journal preview. ".repeat(6).trim();
|
||||
transcriptMock.mockResolvedValue({
|
||||
messages: [msg("user", "Verify the starship claim in full."), msg("assistant", LONG_RESULT)],
|
||||
});
|
||||
|
||||
renderPanel([makeRun()]);
|
||||
fireEvent.click(runHeader());
|
||||
fireEvent.click(resultButton());
|
||||
|
||||
// Called with the run_id so the route can resolve the nested transcript.
|
||||
expect(transcriptMock).toHaveBeenCalledWith("s1", {
|
||||
agent_id: "a1",
|
||||
run_id: "wf_x",
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
// Full fetched text appears (async) in a <pre>, and the prompt is the
|
||||
// fetched one - not the short journal teaser.
|
||||
const resultPre = await screen.findByText(
|
||||
(_, el) => el?.tagName === "PRE" && (el.textContent || "").includes("runs well past"),
|
||||
{ selector: "pre" }
|
||||
);
|
||||
expect(resultPre).toBeInTheDocument();
|
||||
expect(screen.getByText("Verify the starship claim in full.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the journal preview when the fetch fails", async () => {
|
||||
transcriptMock.mockRejectedValue(new Error("network"));
|
||||
|
||||
renderPanel([makeRun()]);
|
||||
fireEvent.click(runHeader());
|
||||
fireEvent.click(resultButton());
|
||||
|
||||
// The pretty-printed journal preview (with the "confirmed" key) is shown.
|
||||
expect(await screen.findByText(/confirmed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file Unit tests for friendlyPreview - turns an agent's raw (often truncated)
|
||||
* JSON result preview into a human-readable excerpt for the Workflow Runs panel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { friendlyPreview, fullPreview } from "../WorkflowRunsPanel";
|
||||
|
||||
describe("friendlyPreview", () => {
|
||||
it("returns empty for nullish/empty input", () => {
|
||||
expect(friendlyPreview(null)).toBe("");
|
||||
expect(friendlyPreview(undefined)).toBe("");
|
||||
expect(friendlyPreview("")).toBe("");
|
||||
});
|
||||
|
||||
it("prefers a known content field (pitch) over earlier short fields", () => {
|
||||
const raw =
|
||||
'{"language":"Python","pitch":"If you learn one language first in 2026, make it Python."}';
|
||||
expect(friendlyPreview(raw)).toBe("If you learn one language first in 2026, make it Python.");
|
||||
});
|
||||
|
||||
it("extracts the first claim from a findings array", () => {
|
||||
const raw =
|
||||
'{"angle":"starship","findings":[{"claim":"SpaceX flew Starship Flight 12 on May 22, 2026.","source":"x","confidence":"high"}]}';
|
||||
expect(friendlyPreview(raw)).toBe("SpaceX flew Starship Flight 12 on May 22, 2026.");
|
||||
});
|
||||
|
||||
it("handles a TRUNCATED json blob (no closing quote/brace) via the keyed field", () => {
|
||||
const raw =
|
||||
'{"angle":"starlink","findings":[{"claim":"As of June 2026, Starlink has roughly 10,500 active satellites in orbit and a';
|
||||
// keyed match stops at end-of-string (no closing quote), so it returns the partial claim
|
||||
expect(friendlyPreview(raw)).toContain("As of June 2026, Starlink has roughly 10,500");
|
||||
expect(friendlyPreview(raw)).not.toContain('{"angle"');
|
||||
});
|
||||
|
||||
it("falls back to the first substantial quoted string when no known key matches", () => {
|
||||
const raw = '{"x":"ab","y":"this is a sufficiently long quoted value to surface"}';
|
||||
expect(friendlyPreview(raw)).toBe("this is a sufficiently long quoted value to surface");
|
||||
});
|
||||
|
||||
it("de-JSONs a blob with no good string, stripping structural punctuation", () => {
|
||||
const raw = '{"a":1,"b":[2,3]}';
|
||||
const out = friendlyPreview(raw);
|
||||
expect(out).not.toContain("{");
|
||||
expect(out).not.toContain('"');
|
||||
expect(out).not.toContain("[");
|
||||
});
|
||||
|
||||
it("passes plain prose through unchanged", () => {
|
||||
const raw = "All four claims confirmed against primary sources.";
|
||||
expect(friendlyPreview(raw)).toBe("All four claims confirmed against primary sources.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fullPreview", () => {
|
||||
it("pretty-prints valid JSON", () => {
|
||||
const out = fullPreview('{"a":1,"b":{"c":2}}');
|
||||
expect(out).toBe('{\n "a": 1,\n "b": {\n "c": 2\n }\n}');
|
||||
});
|
||||
|
||||
it("returns truncated/invalid JSON verbatim", () => {
|
||||
const raw = '{"claim":"partial value with no close';
|
||||
expect(fullPreview(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("returns empty for nullish input", () => {
|
||||
expect(fullPreview(null)).toBe("");
|
||||
expect(fullPreview(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user