apteva 0.4.15 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,326 @@
1
+ import React, { useState, useMemo, useEffect, useRef } from "react";
2
+ import { useAgentActivity, useAuth, useProjects, useTelemetryContext } from "../../context";
3
+ import type { TelemetryEvent } from "../../context";
4
+ import type { Agent } from "../../types";
5
+ import { CloseIcon } from "../common/Icons";
6
+
7
+ interface ActivityPageProps {
8
+ agents: Agent[];
9
+ loading: boolean;
10
+ }
11
+
12
+ export function ActivityPage({ agents, loading }: ActivityPageProps) {
13
+ const { currentProjectId } = useProjects();
14
+ const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
15
+
16
+ const filteredAgents = useMemo(() => {
17
+ if (currentProjectId === null) return agents;
18
+ if (currentProjectId === "unassigned") return agents.filter(a => !a.projectId);
19
+ return agents.filter(a => a.projectId === currentProjectId);
20
+ }, [agents, currentProjectId]);
21
+
22
+ const selectedAgent = filteredAgents.find(a => a.id === selectedAgentId) || null;
23
+
24
+ return (
25
+ <div className="flex-1 flex flex-col overflow-hidden">
26
+ {/* Top: Agent Visualization */}
27
+ <div className="flex-[3] min-h-0 p-6 overflow-auto">
28
+ <ActivityVisualization
29
+ agents={filteredAgents}
30
+ loading={loading}
31
+ selectedAgentId={selectedAgentId}
32
+ onSelectAgent={setSelectedAgentId}
33
+ />
34
+ </div>
35
+
36
+ {/* Bottom: Command + Stream */}
37
+ <div className="flex-[2] min-h-0 border-t border-[#1a1a1a] flex">
38
+ <QuickCommandPanel
39
+ agent={selectedAgent}
40
+ onClose={() => setSelectedAgentId(null)}
41
+ />
42
+ <LiveActivityStream agents={filteredAgents} />
43
+ </div>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ // --- Visualization Grid ---
49
+
50
+ function ActivityVisualization({ agents, loading, selectedAgentId, onSelectAgent }: {
51
+ agents: Agent[];
52
+ loading: boolean;
53
+ selectedAgentId: string | null;
54
+ onSelectAgent: (id: string | null) => void;
55
+ }) {
56
+ if (loading) {
57
+ return <div className="flex items-center justify-center h-full text-[#666]">Loading agents...</div>;
58
+ }
59
+
60
+ if (agents.length === 0) {
61
+ return (
62
+ <div className="flex items-center justify-center h-full text-[#666]">
63
+ <div className="text-center">
64
+ <p className="text-lg">No agents found</p>
65
+ <p className="text-sm text-[#444] mt-1">Create and start agents to see them here</p>
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ const runningCount = agents.filter(a => a.status === "running").length;
72
+
73
+ return (
74
+ <div className="h-full flex flex-col">
75
+ <div className="mb-4 flex items-center justify-between">
76
+ <h2 className="text-lg font-semibold">Activity</h2>
77
+ <span className="text-sm text-[#666]">
78
+ {runningCount} of {agents.length} running
79
+ </span>
80
+ </div>
81
+ <div className="flex-1 flex items-center justify-center">
82
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-8">
83
+ {agents.map(agent => (
84
+ <AgentNode
85
+ key={agent.id}
86
+ agent={agent}
87
+ selected={selectedAgentId === agent.id}
88
+ onClick={() => onSelectAgent(selectedAgentId === agent.id ? null : agent.id)}
89
+ />
90
+ ))}
91
+ </div>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ // --- Agent Node ---
98
+
99
+ function AgentNode({ agent, selected, onClick }: {
100
+ agent: Agent;
101
+ selected: boolean;
102
+ onClick: () => void;
103
+ }) {
104
+ const { isActive, type } = useAgentActivity(agent.id);
105
+ const isRunning = agent.status === "running";
106
+
107
+ const ringStyle = selected
108
+ ? "ring-2 ring-[#f97316] shadow-[0_0_12px_rgba(249,115,22,0.3)]"
109
+ : isRunning && isActive
110
+ ? "ring-2 ring-green-400"
111
+ : isRunning
112
+ ? "ring-1 ring-[#3b82f6]/60"
113
+ : "ring-1 ring-[#333]";
114
+
115
+ const bgClass = isRunning
116
+ ? isActive ? "bg-green-500/10" : "bg-[#1a1a1a]"
117
+ : "bg-[#111]";
118
+
119
+ const textClass = isRunning ? "text-[#e0e0e0]" : "text-[#555]";
120
+
121
+ return (
122
+ <button
123
+ onClick={onClick}
124
+ className="flex flex-col items-center gap-2 group"
125
+ >
126
+ <div className={`w-16 h-16 rounded-full ${bgClass} ${ringStyle} flex items-center justify-center transition-all duration-300 group-hover:scale-110 relative`}>
127
+ <span className={`text-xl font-semibold ${textClass}`}>
128
+ {agent.name.charAt(0).toUpperCase()}
129
+ </span>
130
+ {isActive && isRunning && (
131
+ <div className="absolute inset-0 rounded-full bg-green-400/20 animate-ping" style={{ animationDuration: "1.5s" }} />
132
+ )}
133
+ {isRunning && isActive && (
134
+ <div className="absolute inset-0 rounded-full animate-pulse" style={{ boxShadow: "0 0 12px 3px rgba(74, 222, 128, 0.4)" }} />
135
+ )}
136
+ </div>
137
+ <div className="text-center max-w-[100px]">
138
+ <p className={`text-xs font-medium truncate ${textClass}`}>{agent.name}</p>
139
+ {isActive && type ? (
140
+ <p className="text-[10px] text-green-400 truncate">{type}</p>
141
+ ) : (
142
+ <p className={`text-[10px] ${isRunning ? "text-[#3b82f6]" : "text-[#444]"}`}>
143
+ {isRunning ? "idle" : "stopped"}
144
+ </p>
145
+ )}
146
+ </div>
147
+ </button>
148
+ );
149
+ }
150
+
151
+ // --- Live Activity Stream ---
152
+
153
+ const categoryColors: Record<string, string> = {
154
+ LLM: "bg-purple-500/20 text-purple-400",
155
+ TOOL: "bg-blue-500/20 text-blue-400",
156
+ CHAT: "bg-green-500/20 text-green-400",
157
+ ERROR: "bg-red-500/20 text-red-400",
158
+ SYSTEM: "bg-gray-500/20 text-gray-400",
159
+ TASK: "bg-yellow-500/20 text-yellow-400",
160
+ MEMORY: "bg-cyan-500/20 text-cyan-400",
161
+ MCP: "bg-orange-500/20 text-orange-400",
162
+ };
163
+
164
+ function LiveActivityStream({ agents }: { agents: Agent[] }) {
165
+ const { events } = useTelemetryContext();
166
+ const scrollRef = useRef<HTMLDivElement>(null);
167
+
168
+ const agentIds = useMemo(() => new Set(agents.map(a => a.id)), [agents]);
169
+ const agentNameMap = useMemo(() => {
170
+ const map = new Map<string, string>();
171
+ agents.forEach(a => map.set(a.id, a.name));
172
+ return map;
173
+ }, [agents]);
174
+
175
+ const filteredEvents = useMemo(() => {
176
+ return events
177
+ .filter(e => agentIds.has(e.agent_id))
178
+ .slice(0, 50);
179
+ }, [events, agentIds]);
180
+
181
+ return (
182
+ <div className="flex-1 flex flex-col overflow-hidden border-l border-[#1a1a1a]">
183
+ <div className="px-4 py-3 border-b border-[#1a1a1a] flex items-center justify-between shrink-0">
184
+ <h3 className="font-semibold text-sm">Live Activity</h3>
185
+ <span className="text-xs text-[#666]">{filteredEvents.length} events</span>
186
+ </div>
187
+ <div ref={scrollRef} className="flex-1 overflow-auto">
188
+ {filteredEvents.length === 0 ? (
189
+ <div className="p-4 text-center text-[#666] text-sm">
190
+ No activity yet. Events appear in real-time.
191
+ </div>
192
+ ) : (
193
+ <div className="divide-y divide-[#1a1a1a]">
194
+ {filteredEvents.map(event => (
195
+ <div key={event.id} className="px-4 py-2 hover:bg-[#111] transition" style={{ animation: "slideIn 0.3s ease-out" }}>
196
+ <div className="flex items-center gap-2">
197
+ <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${categoryColors[event.category] || "bg-[#222] text-[#888]"}`}>
198
+ {event.category}
199
+ </span>
200
+ <span className="text-xs font-medium truncate flex-1">{event.type}</span>
201
+ <span className="text-[10px] text-[#555] shrink-0">{timeAgo(event.timestamp)}</span>
202
+ </div>
203
+ <div className="text-[10px] text-[#555] mt-0.5">
204
+ {agentNameMap.get(event.agent_id) || event.agent_id}
205
+ {event.duration_ms ? ` \u00b7 ${event.duration_ms}ms` : ""}
206
+ </div>
207
+ </div>
208
+ ))}
209
+ </div>
210
+ )}
211
+ </div>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ // --- Quick Command Panel ---
217
+
218
+ function QuickCommandPanel({ agent, onClose }: { agent: Agent | null; onClose: () => void }) {
219
+ const { authFetch } = useAuth();
220
+ const [command, setCommand] = useState("");
221
+ const [sending, setSending] = useState(false);
222
+ const [toast, setToast] = useState<string | null>(null);
223
+
224
+ useEffect(() => {
225
+ setCommand("");
226
+ setToast(null);
227
+ }, [agent?.id]);
228
+
229
+ if (!agent) {
230
+ return (
231
+ <div className="w-80 shrink-0 flex items-center justify-center text-[#555] text-sm p-4 text-center">
232
+ Click an agent to send a quick command
233
+ </div>
234
+ );
235
+ }
236
+
237
+ const handleSend = async () => {
238
+ if (!command.trim() || sending) return;
239
+ if (agent.status !== "running") {
240
+ setToast("Agent is not running");
241
+ setTimeout(() => setToast(null), 3000);
242
+ return;
243
+ }
244
+ setSending(true);
245
+ try {
246
+ const res = await authFetch(`/api/agents/${agent.id}/chat`, {
247
+ method: "POST",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({ message: command, agent_id: agent.id }),
250
+ });
251
+ if (res.ok) {
252
+ setToast("Command sent");
253
+ setCommand("");
254
+ } else {
255
+ const data = await res.json().catch(() => ({}));
256
+ setToast(data.error || "Failed to send");
257
+ }
258
+ } catch {
259
+ setToast("Failed to send command");
260
+ } finally {
261
+ setSending(false);
262
+ setTimeout(() => setToast(null), 3000);
263
+ }
264
+ };
265
+
266
+ const isRunning = agent.status === "running";
267
+
268
+ return (
269
+ <div className="w-80 shrink-0 flex flex-col">
270
+ <div className="px-4 py-3 border-b border-[#1a1a1a] flex items-center justify-between shrink-0">
271
+ <div className="min-w-0">
272
+ <h3 className="font-semibold text-sm truncate">{agent.name}</h3>
273
+ <p className={`text-[10px] ${isRunning ? "text-green-400" : "text-[#666]"}`}>
274
+ {isRunning ? "Running" : "Stopped"}
275
+ </p>
276
+ </div>
277
+ <button onClick={onClose} className="text-[#666] hover:text-[#e0e0e0] transition shrink-0 ml-2">
278
+ <CloseIcon className="w-4 h-4" />
279
+ </button>
280
+ </div>
281
+ <div className="flex-1 p-4 flex flex-col justify-end">
282
+ {toast && (
283
+ <div className={`mb-3 px-3 py-2 rounded text-xs ${
284
+ toast === "Command sent"
285
+ ? "bg-green-500/10 border border-green-500/20 text-green-400"
286
+ : "bg-red-500/10 border border-red-500/20 text-red-400"
287
+ }`}>
288
+ {toast}
289
+ </div>
290
+ )}
291
+ <div className="flex gap-2">
292
+ <input
293
+ type="text"
294
+ value={command}
295
+ onChange={e => setCommand(e.target.value)}
296
+ onKeyDown={e => e.key === "Enter" && handleSend()}
297
+ placeholder={isRunning ? "Quick command..." : "Agent not running"}
298
+ disabled={sending || !isRunning}
299
+ className="flex-1 bg-[#111] border border-[#1a1a1a] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#f97316] placeholder-[#444] disabled:opacity-50"
300
+ />
301
+ <button
302
+ onClick={handleSend}
303
+ disabled={sending || !command.trim() || !isRunning}
304
+ className="px-3 py-2 bg-[#f97316]/20 text-[#f97316] rounded text-sm font-medium hover:bg-[#f97316]/30 transition disabled:opacity-30"
305
+ >
306
+ {sending ? "..." : "Send"}
307
+ </button>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ );
312
+ }
313
+
314
+ // --- Helpers ---
315
+
316
+ function timeAgo(timestamp: string): string {
317
+ const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000);
318
+ if (seconds < 5) return "just now";
319
+ if (seconds < 60) return `${seconds}s ago`;
320
+ const minutes = Math.floor(seconds / 60);
321
+ if (minutes < 60) return `${minutes}m ago`;
322
+ const hours = Math.floor(minutes / 60);
323
+ if (hours < 24) return `${hours}h ago`;
324
+ const days = Math.floor(hours / 24);
325
+ return `${days}d ago`;
326
+ }
@@ -0,0 +1 @@
1
+ export { ActivityPage } from "./ActivityPage";
@@ -182,6 +182,14 @@ export function TestsIcon({ className = "w-4 h-4" }: IconProps) {
182
182
  );
183
183
  }
184
184
 
185
+ export function ActivityIcon({ className = "w-5 h-5" }: IconProps) {
186
+ return (
187
+ <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
188
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
189
+ </svg>
190
+ );
191
+ }
192
+
185
193
  export function PlusIcon({ className = "w-4 h-4" }: IconProps) {
186
194
  return (
187
195
  <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -189,3 +197,30 @@ export function PlusIcon({ className = "w-4 h-4" }: IconProps) {
189
197
  </svg>
190
198
  );
191
199
  }
200
+
201
+ export function RecurringIcon({ className = "w-4 h-4" }: IconProps) {
202
+ return (
203
+ <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
204
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h5M20 20v-5h-5" />
205
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.49 9A9 9 0 005.64 5.64L4 4m16 16l-1.64-1.64A9 9 0 013.51 15" />
206
+ </svg>
207
+ );
208
+ }
209
+
210
+ export function ScheduledIcon({ className = "w-4 h-4" }: IconProps) {
211
+ return (
212
+ <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
213
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3" />
214
+ <circle cx="12" cy="12" r="9" strokeWidth={2} />
215
+ </svg>
216
+ );
217
+ }
218
+
219
+ export function TaskOnceIcon({ className = "w-4 h-4" }: IconProps) {
220
+ return (
221
+ <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
222
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
223
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 14l2 2 4-4" />
224
+ </svg>
225
+ );
226
+ }
@@ -15,4 +15,5 @@ export {
15
15
  SkillsIcon,
16
16
  RealtimeIcon,
17
17
  TelemetryIcon,
18
+ ActivityIcon,
18
19
  } from "./Icons";
@@ -198,7 +198,18 @@ export function Dashboard({
198
198
  >
199
199
  <div className="flex-1 min-w-0">
200
200
  <p className="font-medium truncate">{task.title}</p>
201
- <p className="text-sm text-[#666]">{task.agentName}</p>
201
+ <p className="text-sm text-[#666]">
202
+ {task.agentName}
203
+ {task.recurrence && (
204
+ <span className="ml-1 text-[#555]">· {formatCronShort(task.recurrence)}</span>
205
+ )}
206
+ {task.next_run && (
207
+ <span className="ml-1 text-[#f97316]">· {formatRelativeShort(task.next_run)}</span>
208
+ )}
209
+ {!task.next_run && task.execute_at && (
210
+ <span className="ml-1 text-[#f97316]">· {formatRelativeShort(task.execute_at)}</span>
211
+ )}
212
+ </p>
202
213
  </div>
203
214
  <TaskStatusBadge status={task.status} />
204
215
  </div>
@@ -330,3 +341,72 @@ function TaskStatusBadge({ status }: { status: Task["status"] }) {
330
341
  </span>
331
342
  );
332
343
  }
344
+
345
+ // --- Schedule formatting helpers (compact versions for dashboard) ---
346
+
347
+ const DASH_DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
348
+
349
+ function formatCronShort(cron: string): string {
350
+ try {
351
+ const parts = cron.trim().split(/\s+/);
352
+ if (parts.length !== 5) return cron;
353
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
354
+
355
+ if (minute.startsWith("*/") && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
356
+ const n = parseInt(minute.slice(2));
357
+ return n === 1 ? "Every min" : `Every ${n}min`;
358
+ }
359
+ if (minute !== "*" && !minute.includes("/") && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
360
+ return "Hourly";
361
+ }
362
+ if (hour.startsWith("*/") && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
363
+ const n = parseInt(hour.slice(2));
364
+ return n === 1 ? "Hourly" : `Every ${n}h`;
365
+ }
366
+
367
+ const formatTime = (h: string, m: string): string => {
368
+ const hr = parseInt(h);
369
+ const mn = parseInt(m);
370
+ if (isNaN(hr)) return "";
371
+ const ampm = hr >= 12 ? "PM" : "AM";
372
+ const h12 = hr === 0 ? 12 : hr > 12 ? hr - 12 : hr;
373
+ return `${h12}:${mn.toString().padStart(2, "0")} ${ampm}`;
374
+ };
375
+
376
+ if (hour !== "*" && !hour.includes("/") && dayOfMonth === "*" && month === "*") {
377
+ const timeStr = formatTime(hour, minute);
378
+ if (dayOfWeek === "*") return `Daily ${timeStr}`;
379
+ const days = dayOfWeek.split(",").map(d => DASH_DAY_NAMES[parseInt(d.trim())] || d);
380
+ if (days.length === 1) return `${days[0]} ${timeStr}`;
381
+ return `${days.join(" & ")} ${timeStr}`;
382
+ }
383
+ return cron;
384
+ } catch {
385
+ return cron;
386
+ }
387
+ }
388
+
389
+ function formatRelativeShort(dateStr: string): string {
390
+ const date = new Date(dateStr);
391
+ const now = new Date();
392
+ const diffMs = date.getTime() - now.getTime();
393
+ const isFuture = diffMs > 0;
394
+ const absDiffMs = Math.abs(diffMs);
395
+ const minutes = Math.floor(absDiffMs / 60000);
396
+ const hours = Math.floor(absDiffMs / 3600000);
397
+
398
+ const timeStr = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
399
+
400
+ const isToday = date.toDateString() === now.toDateString();
401
+ const tomorrow = new Date(now);
402
+ tomorrow.setDate(tomorrow.getDate() + 1);
403
+ const isTomorrow = date.toDateString() === tomorrow.toDateString();
404
+
405
+ if (isToday) {
406
+ if (minutes < 1) return "now";
407
+ if (minutes < 60) return isFuture ? `in ${minutes}m` : `${minutes}m ago`;
408
+ return isFuture ? `in ${hours}h` : `${hours}h ago`;
409
+ }
410
+ if (isTomorrow) return `Tomorrow ${timeStr}`;
411
+ return `${DASH_DAY_NAMES[date.getDay()]} ${timeStr}`;
412
+ }
@@ -12,6 +12,7 @@ export { OnboardingWizard } from "./onboarding";
12
12
  export { SettingsPage } from "./settings";
13
13
  export { AgentCard, CreateAgentModal, AgentPanel, AgentsView } from "./agents";
14
14
  export { Dashboard } from "./dashboard";
15
+ export { ActivityPage } from "./activity";
15
16
  export { TasksPage } from "./tasks";
16
17
  export { McpPage } from "./mcp";
17
18
  export { SkillsPage } from "./skills/SkillsPage";
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { DashboardIcon, AgentsIcon, TasksIcon, McpIcon, SkillsIcon, TestsIcon, TelemetryIcon, ApiIcon, SettingsIcon, CloseIcon } from "../common/Icons";
2
+ import { DashboardIcon, ActivityIcon, AgentsIcon, TasksIcon, McpIcon, SkillsIcon, TestsIcon, TelemetryIcon, ApiIcon, SettingsIcon, CloseIcon } from "../common/Icons";
3
3
  import type { Route } from "../../types";
4
4
 
5
5
  interface SidebarProps {
@@ -63,6 +63,12 @@ export function Sidebar({ route, agentCount, taskCount, onNavigate, isOpen, onCl
63
63
  onClick={() => handleNavigate("agents")}
64
64
  badge={agentCount > 0 ? String(agentCount) : undefined}
65
65
  />
66
+ <NavButton
67
+ icon={<ActivityIcon />}
68
+ label="Activity"
69
+ active={route === "activity"}
70
+ onClick={() => handleNavigate("activity")}
71
+ />
66
72
  <NavButton
67
73
  icon={<TasksIcon />}
68
74
  label="Tasks"