@swarmclawai/swarmclaw 0.5.0 → 0.5.2

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -1
  4. package/public/screenshots/agents.png +0 -0
  5. package/public/screenshots/dashboard.png +0 -0
  6. package/public/screenshots/providers.png +0 -0
  7. package/public/screenshots/tasks.png +0 -0
  8. package/src/app/api/activity/route.ts +30 -0
  9. package/src/app/api/agents/[id]/route.ts +3 -1
  10. package/src/app/api/agents/route.ts +2 -1
  11. package/src/app/api/connectors/[id]/route.ts +4 -1
  12. package/src/app/api/openclaw/approvals/route.ts +20 -0
  13. package/src/app/api/tasks/[id]/route.ts +37 -1
  14. package/src/app/api/tasks/route.ts +7 -1
  15. package/src/app/api/usage/route.ts +74 -22
  16. package/src/app/api/webhooks/[id]/route.ts +62 -22
  17. package/src/app/globals.css +78 -3
  18. package/src/cli/index.js +7 -0
  19. package/src/cli/spec.js +6 -0
  20. package/src/components/activity/activity-feed.tsx +91 -0
  21. package/src/components/agents/agent-card.tsx +7 -3
  22. package/src/components/agents/agent-list.tsx +33 -3
  23. package/src/components/agents/inspector-panel.tsx +1 -1
  24. package/src/components/chat/code-block.tsx +1 -1
  25. package/src/components/chat/exec-approval-card.tsx +6 -3
  26. package/src/components/chat/message-bubble.tsx +11 -1
  27. package/src/components/chat/message-list.tsx +28 -1
  28. package/src/components/chat/trace-block.tsx +15 -25
  29. package/src/components/layout/app-layout.tsx +21 -7
  30. package/src/components/tasks/task-board.tsx +40 -2
  31. package/src/components/tasks/task-card.tsx +40 -2
  32. package/src/components/tasks/task-sheet.tsx +147 -1
  33. package/src/components/usage/metrics-dashboard.tsx +278 -0
  34. package/src/hooks/use-page-active.ts +21 -0
  35. package/src/hooks/use-ws.ts +13 -1
  36. package/src/lib/fetch-dedup.ts +20 -0
  37. package/src/lib/optimistic.ts +25 -0
  38. package/src/lib/server/connectors/manager.ts +18 -0
  39. package/src/lib/server/daemon-state.ts +205 -20
  40. package/src/lib/server/queue.ts +16 -0
  41. package/src/lib/server/storage.ts +34 -0
  42. package/src/lib/view-routes.ts +1 -0
  43. package/src/lib/ws-client.ts +2 -1
  44. package/src/stores/use-app-store.ts +48 -1
  45. package/src/stores/use-approval-store.ts +21 -7
  46. package/src/types/index.ts +40 -1
@@ -0,0 +1,91 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { useWs } from '@/hooks/use-ws'
6
+ import type { ActivityEntry } from '@/types'
7
+
8
+ const ENTITY_ICONS: Record<string, string> = {
9
+ agent: 'A', task: 'T', connector: 'C', session: 'S', webhook: 'W', schedule: 'R',
10
+ }
11
+
12
+ const ACTION_COLORS: Record<string, string> = {
13
+ created: 'bg-emerald-500/15 text-emerald-400',
14
+ updated: 'bg-blue-500/15 text-blue-400',
15
+ deleted: 'bg-red-500/15 text-red-400',
16
+ started: 'bg-green-500/15 text-green-400',
17
+ stopped: 'bg-gray-500/15 text-gray-400',
18
+ queued: 'bg-amber-500/15 text-amber-400',
19
+ completed: 'bg-emerald-500/15 text-emerald-400',
20
+ failed: 'bg-red-500/15 text-red-400',
21
+ approved: 'bg-green-500/15 text-green-400',
22
+ rejected: 'bg-red-500/15 text-red-400',
23
+ }
24
+
25
+ function timeAgo(ts: number) {
26
+ const diff = Date.now() - ts
27
+ if (diff < 60_000) return 'just now'
28
+ if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`
29
+ if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`
30
+ return `${Math.floor(diff / 86400_000)}d ago`
31
+ }
32
+
33
+ const ENTITY_TYPES = ['', 'agent', 'task', 'connector', 'session', 'webhook', 'schedule'] as const
34
+
35
+ export function ActivityFeed() {
36
+ const entries = useAppStore((s) => s.activityEntries)
37
+ const loadActivity = useAppStore((s) => s.loadActivity)
38
+ const [filterType, setFilterType] = useState('')
39
+
40
+ useEffect(() => { loadActivity({ entityType: filterType || undefined, limit: 100 }) }, [filterType])
41
+ useWs('activity', () => loadActivity({ entityType: filterType || undefined, limit: 100 }), 10_000)
42
+
43
+ return (
44
+ <div className="flex-1 flex flex-col h-full overflow-hidden">
45
+ <div className="flex items-center justify-between px-8 pt-6 pb-4 shrink-0">
46
+ <div>
47
+ <h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Activity</h1>
48
+ <p className="text-[13px] text-text-3 mt-1">Audit trail of all entity mutations</p>
49
+ </div>
50
+ <select
51
+ value={filterType}
52
+ onChange={(e) => setFilterType(e.target.value)}
53
+ className="px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03] appearance-none"
54
+ style={{ fontFamily: 'inherit', minWidth: 130 }}
55
+ >
56
+ <option value="">All Types</option>
57
+ {ENTITY_TYPES.filter(Boolean).map((t) => (
58
+ <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}s</option>
59
+ ))}
60
+ </select>
61
+ </div>
62
+
63
+ <div className="flex-1 overflow-y-auto px-8 pb-6">
64
+ {entries.length === 0 ? (
65
+ <div className="text-center text-text-3 text-[14px] mt-16">No activity yet</div>
66
+ ) : (
67
+ <div className="space-y-1">
68
+ {entries.map((entry: ActivityEntry) => (
69
+ <div key={entry.id} className="flex items-start gap-3 py-3 border-b border-white/[0.04]">
70
+ <div className="w-8 h-8 rounded-[8px] bg-surface-2 flex items-center justify-center text-[12px] font-700 text-text-3 shrink-0">
71
+ {ENTITY_ICONS[entry.entityType] || '?'}
72
+ </div>
73
+ <div className="flex-1 min-w-0">
74
+ <div className="flex items-center gap-2 mb-1">
75
+ <span className={`px-1.5 py-0.5 rounded-[5px] text-[10px] font-600 ${ACTION_COLORS[entry.action] || 'bg-white/[0.06] text-text-3'}`}>
76
+ {entry.action}
77
+ </span>
78
+ <span className="text-[10px] text-text-3/50 font-mono">{entry.entityType}</span>
79
+ <span className="text-[10px] text-text-3/40">{entry.actor}</span>
80
+ </div>
81
+ <p className="text-[13px] text-text-2 leading-[1.4] truncate">{entry.summary}</p>
82
+ </div>
83
+ <span className="text-[11px] text-text-3/50 shrink-0 pt-1">{timeAgo(entry.timestamp)}</span>
84
+ </div>
85
+ ))}
86
+ </div>
87
+ )}
88
+ </div>
89
+ </div>
90
+ )
91
+ }
@@ -22,10 +22,11 @@ interface Props {
22
22
  agent: Agent
23
23
  isDefault?: boolean
24
24
  isRunning?: boolean
25
+ isSelected?: boolean
25
26
  onSetDefault?: (id: string) => void
26
27
  }
27
28
 
28
- export function AgentCard({ agent, isDefault, isRunning, onSetDefault }: Props) {
29
+ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefault }: Props) {
29
30
  const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
30
31
  const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
31
32
  const loadSessions = useAppStore((s) => s.loadSessions)
@@ -85,10 +86,13 @@ export function AgentCard({ agent, isDefault, isRunning, onSetDefault }: Props)
85
86
  <>
86
87
  <div
87
88
  onClick={handleClick}
88
- className="group relative py-3.5 px-4 cursor-pointer rounded-[14px]
89
+ className={`group relative py-3.5 px-4 cursor-pointer rounded-[14px]
89
90
  transition-all duration-200 active:scale-[0.98]
90
- bg-transparent border border-transparent hover:bg-white/[0.05] hover:border-white/[0.08]"
91
+ ${isSelected
92
+ ? 'bg-white/[0.04] border border-white/[0.08]'
93
+ : 'bg-transparent border border-transparent hover:bg-white/[0.05] hover:border-white/[0.08]'}`}
91
94
  >
95
+ {isSelected && <div className="card-select-indicator" />}
92
96
  {/* Three-dot dropdown */}
93
97
  <DropdownMenu>
94
98
  <DropdownMenuTrigger asChild>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useMemo, useState, useCallback } from 'react'
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
6
  import { AgentCard } from './agent-card'
@@ -23,10 +23,18 @@ export function AgentList({ inSidebar }: Props) {
23
23
  const setShowTrash = useAppStore((s) => s.setShowTrash)
24
24
  const fleetFilter = useAppStore((s) => s.fleetFilter)
25
25
  const setFleetFilter = useAppStore((s) => s.setFleetFilter)
26
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
26
27
  const approvals = useApprovalStore((s) => s.approvals)
27
28
  const [search, setSearch] = useState('')
28
29
  const [filter, setFilter] = useState<'all' | 'orchestrator' | 'agent'>('all')
29
30
 
31
+ // FLIP animation refs
32
+ const flipPositions = useRef<Map<string, number>>(new Map())
33
+ const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
34
+
35
+ const currentSession = currentSessionId ? sessions[currentSessionId] : null
36
+ const selectedAgentId = currentSession?.agentId
37
+
30
38
  const mainSession = useMemo(() =>
31
39
  Object.values(sessions).find((s: any) => s.name === '__main__' && s.user === currentUser),
32
40
  [sessions, currentUser]
@@ -76,6 +84,26 @@ export function AgentList({ inSidebar }: Props) {
76
84
  .sort((a, b) => b.updatedAt - a.updatedAt)
77
85
  }, [agents, search, filter, activeProjectFilter, fleetFilter, runningAgentIds, approvalsByAgent])
78
86
 
87
+ // FLIP animation: animate agent cards when order changes
88
+ useLayoutEffect(() => {
89
+ const newPositions = new Map<string, number>()
90
+ for (const [id, el] of cardRefs.current) {
91
+ const newTop = el.getBoundingClientRect().top
92
+ newPositions.set(id, newTop)
93
+ const prevTop = flipPositions.current.get(id)
94
+ if (prevTop != null) {
95
+ const delta = prevTop - newTop
96
+ if (Math.abs(delta) > 1) {
97
+ el.animate(
98
+ [{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }],
99
+ { duration: 300, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }
100
+ )
101
+ }
102
+ }
103
+ }
104
+ flipPositions.current = newPositions
105
+ }, [filtered])
106
+
79
107
  if (showTrash) {
80
108
  return (
81
109
  <div className="flex-1 flex flex-col overflow-hidden">
@@ -124,7 +152,7 @@ export function AgentList({ inSidebar }: Props) {
124
152
  }
125
153
 
126
154
  return (
127
- <div className="flex-1 overflow-y-auto">
155
+ <div className="flex-1 overflow-y-auto fade-up">
128
156
  {(filtered.length > 3 || search) && (
129
157
  <div className="px-4 py-2.5">
130
158
  <input
@@ -183,7 +211,9 @@ export function AgentList({ inSidebar }: Props) {
183
211
  </div>
184
212
  <div className="flex flex-col gap-1 px-2 pb-4">
185
213
  {filtered.map((p) => (
186
- <AgentCard key={p.id} agent={p} isDefault={p.id === defaultAgentId} isRunning={runningAgentIds.has(p.id)} onSetDefault={handleSetDefault} />
214
+ <div key={p.id} ref={(el) => { if (el) cardRefs.current.set(p.id, el); else cardRefs.current.delete(p.id) }}>
215
+ <AgentCard agent={p} isDefault={p.id === defaultAgentId} isRunning={runningAgentIds.has(p.id)} isSelected={p.id === selectedAgentId} onSetDefault={handleSetDefault} />
216
+ </div>
187
217
  ))}
188
218
  </div>
189
219
  </div>
@@ -52,7 +52,7 @@ export function InspectorPanel({ agent }: Props) {
52
52
  const agentSchedules = Object.values(schedules).filter((s) => s.agentId === agent.id)
53
53
 
54
54
  return (
55
- <div className="w-[400px] shrink-0 border-l border-white/[0.06] bg-[#0d0f1a] flex flex-col h-full overflow-hidden">
55
+ <div className="w-[400px] shrink-0 border-l border-white/[0.06] bg-[#0d0f1a] flex flex-col h-full overflow-hidden fade-up-delay">
56
56
  {/* Header */}
57
57
  <div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06] shrink-0">
58
58
  <h3 className="font-display text-[14px] font-600 text-text truncate">{agent.name}</h3>
@@ -56,7 +56,7 @@ export function CodeBlock({ children, className }: Props) {
56
56
  }, [getText, language])
57
57
 
58
58
  return (
59
- <div className="relative group/code">
59
+ <div className="relative group/code command-surface">
60
60
  <div className="flex items-center justify-between px-4 py-2 bg-black/30 border-b border-white/[0.03]">
61
61
  <span className="text-[10px] font-600 uppercase tracking-[0.08em] text-text-3 font-mono">{language}</span>
62
62
  <div className="flex items-center gap-1">
@@ -14,8 +14,9 @@ export function ExecApprovalCard({ approval }: Props) {
14
14
  resolveApproval(approval.id, decision)
15
15
  }
16
16
 
17
+ const alreadyResolved = approval.error?.includes('Already resolved') ?? false
17
18
  const expired = approval.expiresAtMs < Date.now()
18
- const disabled = !!approval.resolving || expired
19
+ const disabled = !!approval.resolving || expired || alreadyResolved
19
20
 
20
21
  return (
21
22
  <div className="my-2 rounded-[12px] border border-amber-500/20 bg-amber-500/[0.04] p-3.5">
@@ -47,11 +48,13 @@ export function ExecApprovalCard({ approval }: Props) {
47
48
  )}
48
49
  </div>
49
50
 
50
- {approval.error && (
51
+ {approval.error && !alreadyResolved && (
51
52
  <p className="text-[12px] text-red-400 mb-2">{approval.error}</p>
52
53
  )}
53
54
 
54
- {expired ? (
55
+ {alreadyResolved ? (
56
+ <p className="text-[12px] text-text-3/50 italic">Already resolved by another session</p>
57
+ ) : expired ? (
55
58
  <p className="text-[12px] text-text-3/50 italic">Approval expired</p>
56
59
  ) : (
57
60
  <div className="flex items-center gap-2">
@@ -376,6 +376,15 @@ interface Props {
376
376
  onFork?: (index: number) => void
377
377
  }
378
378
 
379
+ function isStructuredMarkdown(text: string): boolean {
380
+ if (!text) return false
381
+ return /```/.test(text)
382
+ || /^#{1,4}\s/m.test(text)
383
+ || /^[-*]\s/m.test(text)
384
+ || /^\d+\.\s/m.test(text)
385
+ || /\|.*\|.*\|/m.test(text)
386
+ }
387
+
379
388
  export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork }: Props) {
380
389
  const isUser = message.role === 'user'
381
390
  const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
@@ -388,6 +397,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
388
397
  const toolEvents = message.toolEvents || []
389
398
  const hasToolEvents = !isUser && toolEvents.length > 0
390
399
  const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
400
+ const isStructured = !isUser && !isHeartbeat && isStructuredMarkdown(message.text)
391
401
 
392
402
  const handleCopy = useCallback(() => {
393
403
  navigator.clipboard.writeText(message.text).then(() => {
@@ -442,7 +452,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
442
452
  )}
443
453
 
444
454
  {/* Message bubble */}
445
- <div className={`max-w-[85%] md:max-w-[72%] ${isUser ? 'bubble-user px-5 py-3.5' : isHeartbeat ? 'bubble-ai px-4 py-3' : 'bubble-ai px-5 py-3.5'}`}>
455
+ <div className={`${isStructured ? 'max-w-[92%] md:max-w-[85%]' : 'max-w-[85%] md:max-w-[72%]'} ${isUser ? 'bubble-user px-5 py-3.5' : isHeartbeat ? 'bubble-ai px-4 py-3' : 'bubble-ai px-5 py-3.5'}`}>
446
456
  {renderAttachments(message)}
447
457
 
448
458
  {isHeartbeat ? (
@@ -5,6 +5,7 @@ import type { Message } from '@/types'
5
5
  import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { api } from '@/lib/api-client'
8
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
9
  import { MessageBubble } from './message-bubble'
9
10
  import { StreamingBubble } from './streaming-bubble'
10
11
  import { ThinkingIndicator } from './thinking-indicator'
@@ -12,6 +13,23 @@ import { SuggestionsBar } from './suggestions-bar'
12
13
  import { ExecApprovalCard } from './exec-approval-card'
13
14
  import { useApprovalStore } from '@/stores/use-approval-store'
14
15
 
16
+ const INTRO_GREETINGS = [
17
+ 'What can I help you with?',
18
+ 'Ready when you are.',
19
+ "Let's get started.",
20
+ 'How can I assist you today?',
21
+ 'What are we working on?',
22
+ ]
23
+
24
+ function stableHash(str: string): number {
25
+ let hash = 0
26
+ for (let i = 0; i < str.length; i++) {
27
+ hash = ((hash << 5) - hash) + str.charCodeAt(i)
28
+ hash |= 0
29
+ }
30
+ return Math.abs(hash)
31
+ }
32
+
15
33
  function dateSeparator(ts: number): string {
16
34
  const d = new Date(ts)
17
35
  const today = new Date()
@@ -316,9 +334,18 @@ export function MessageList({ messages, streaming }: Props) {
316
334
  <div
317
335
  ref={scrollRef}
318
336
  onScroll={updateScrollState}
319
- className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6"
337
+ className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6 fade-up"
320
338
  >
321
339
  <div className="flex flex-col gap-6">
340
+ {filteredMessages.length === 0 && !streaming && (
341
+ <div className="flex flex-col items-center justify-center gap-3 py-20 text-center" style={{ animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) both' }}>
342
+ <AgentAvatar seed={agent?.avatarSeed || null} name={agent?.name || 'Agent'} size={48} />
343
+ <span className="font-display text-[16px] font-600 text-text-2">{agent?.name || 'Assistant'}</span>
344
+ <span className="text-[14px] text-text-3/60">
345
+ {INTRO_GREETINGS[stableHash(agent?.id || session?.id || '') % INTRO_GREETINGS.length]}
346
+ </span>
347
+ </div>
348
+ )}
322
349
  {filteredMessages.map((msg, i) => {
323
350
  // Find original index in the full messages array for API calls
324
351
  const originalIndex = messages.indexOf(msg)
@@ -1,6 +1,5 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
4
3
  import type { ChatTraceBlock } from '@/types'
5
4
 
6
5
  interface Props {
@@ -8,8 +7,6 @@ interface Props {
8
7
  }
9
8
 
10
9
  export function TraceBlock({ trace }: Props) {
11
- const [collapsed, setCollapsed] = useState(trace.collapsed !== false)
12
-
13
10
  const bgColor = trace.type === 'thinking'
14
11
  ? 'bg-purple-500/[0.04] border-purple-500/10'
15
12
  : trace.type === 'tool-call'
@@ -29,32 +26,25 @@ export function TraceBlock({ trace }: Props) {
29
26
  : '<'
30
27
 
31
28
  return (
32
- <div className={`my-1 rounded-[8px] border ${bgColor} overflow-hidden`}>
33
- <button
34
- onClick={() => setCollapsed(!collapsed)}
35
- className={`w-full flex items-center gap-2 px-3 py-1.5 text-left cursor-pointer border-none bg-transparent transition-colors hover:bg-white/[0.02] ${labelColor}`}
36
- style={{ fontFamily: 'inherit' }}
37
- >
38
- <span className="font-mono text-[10px] w-4 shrink-0">{collapsed ? '+' : '-'}</span>
29
+ <details className={`my-1 rounded-[8px] border ${bgColor} overflow-hidden`} open={trace.collapsed === false || undefined}>
30
+ <summary className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer select-none transition-colors hover:bg-white/[0.02] ${labelColor} [&::-webkit-details-marker]:hidden list-none`}>
39
31
  <span className="font-mono text-[10px] shrink-0">{icon}</span>
40
32
  <span className="text-[11px] font-600 truncate">
41
33
  {trace.label || trace.type.replace('-', ' ')}
42
34
  </span>
43
- </button>
44
- {!collapsed && (
45
- <div className="px-3 pb-2">
46
- <pre className={`text-[11px] leading-relaxed whitespace-pre-wrap break-words m-0 ${
47
- trace.type === 'thinking'
48
- ? 'text-text-3/60 italic'
49
- : 'text-text-3/70 font-mono'
50
- }`}>
51
- {trace.content.length > 2000
52
- ? trace.content.slice(0, 2000) + '\n... (truncated)'
53
- : trace.content}
54
- </pre>
55
- </div>
56
- )}
57
- </div>
35
+ </summary>
36
+ <div className="px-3 pb-2">
37
+ <pre className={`text-[11px] leading-relaxed whitespace-pre-wrap break-words m-0 ${
38
+ trace.type === 'thinking'
39
+ ? 'text-text-3/60 italic'
40
+ : 'text-text-3/70 font-mono'
41
+ }`}>
42
+ {trace.content.length > 2000
43
+ ? trace.content.slice(0, 2000) + '\n... (truncated)'
44
+ : trace.content}
45
+ </pre>
46
+ </div>
47
+ </details>
58
48
  )
59
49
  }
60
50
 
@@ -35,8 +35,9 @@ import { KnowledgeList } from '@/components/knowledge/knowledge-list'
35
35
  import { KnowledgeSheet } from '@/components/knowledge/knowledge-sheet'
36
36
  import { PluginList } from '@/components/plugins/plugin-list'
37
37
  import { PluginSheet } from '@/components/plugins/plugin-sheet'
38
- import { UsageList } from '@/components/usage/usage-list'
39
38
  import { RunList } from '@/components/runs/run-list'
39
+ import { ActivityFeed } from '@/components/activity/activity-feed'
40
+ import { MetricsDashboard } from '@/components/usage/metrics-dashboard'
40
41
  import { ProjectList } from '@/components/projects/project-list'
41
42
  import { ProjectSheet } from '@/components/projects/project-sheet'
42
43
  import { NetworkBanner } from './network-banner'
@@ -314,6 +315,11 @@ export function AppLayout() {
314
315
  <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
315
316
  </svg>
316
317
  </NavItem>
318
+ <NavItem view="activity" label="Activity" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('activity')}>
319
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
320
+ <path d="M12 8v4l3 3" /><circle cx="12" cy="12" r="10" />
321
+ </svg>
322
+ </NavItem>
317
323
  <NavItem view="logs" label="Logs" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('logs')}>
318
324
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
319
325
  <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
@@ -463,7 +469,6 @@ export function AppLayout() {
463
469
  {activeView === 'webhooks' && <WebhookList inSidebar />}
464
470
  {activeView === 'mcp_servers' && <McpServerList />}
465
471
  {activeView === 'knowledge' && <KnowledgeList />}
466
- {activeView === 'usage' && <UsageList />}
467
472
  {activeView === 'runs' && <RunList />}
468
473
  {activeView === 'logs' && <LogList />}
469
474
  </div>
@@ -562,7 +567,6 @@ export function AppLayout() {
562
567
  {activeView === 'knowledge' && <KnowledgeList />}
563
568
  {activeView === 'plugins' && <PluginList inSidebar />}
564
569
  {activeView === 'projects' && <ProjectList />}
565
- {activeView === 'usage' && <UsageList />}
566
570
  {activeView === 'runs' && <RunList />}
567
571
  {activeView === 'logs' && <LogList />}
568
572
  </div>
@@ -596,6 +600,10 @@ export function AppLayout() {
596
600
  <TaskBoard />
597
601
  ) : activeView === 'memory' ? (
598
602
  <MemoryDetail />
603
+ ) : activeView === 'activity' ? (
604
+ <ActivityFeed />
605
+ ) : activeView === 'usage' ? (
606
+ <MetricsDashboard />
599
607
  ) : activeView === 'settings' ? (
600
608
  <SettingsPage />
601
609
  ) : !sidebarOpen && FULL_WIDTH_VIEWS.has(activeView) ? (
@@ -604,7 +612,7 @@ export function AppLayout() {
604
612
  <h2 className="font-display text-[14px] font-600 text-text-2 tracking-[-0.01em] capitalize flex-1">
605
613
  {activeView === 'mcp_servers' ? 'MCP Servers' : activeView.replace('_', ' ')}
606
614
  </h2>
607
- {activeView !== 'usage' && activeView !== 'runs' && activeView !== 'logs' && (
615
+ {activeView !== 'runs' && activeView !== 'logs' && (
608
616
  <button
609
617
  onClick={openNewSheet}
610
618
  className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] text-[11px] font-600 text-accent-bright bg-accent-soft hover:bg-[#6366F1]/15 transition-all cursor-pointer"
@@ -627,7 +635,6 @@ export function AppLayout() {
627
635
  {activeView === 'knowledge' && <KnowledgeList />}
628
636
  {activeView === 'plugins' && <PluginList />}
629
637
  {activeView === 'projects' && <ProjectList />}
630
- {activeView === 'usage' && <UsageList />}
631
638
  {activeView === 'runs' && <RunList />}
632
639
  {activeView === 'logs' && <LogList />}
633
640
  </div>
@@ -742,16 +749,17 @@ const VIEW_DESCRIPTIONS: Record<AppView, string> = {
742
749
  knowledge: 'Shared knowledge base accessible by all agents',
743
750
  logs: 'Application logs & error tracking',
744
751
  plugins: 'Extend agent capabilities with custom plugins',
745
- usage: 'Token usage analytics & cost tracking',
752
+ usage: 'Usage metrics, cost tracking & agent performance',
746
753
  runs: 'Live run monitoring & history',
747
754
  settings: 'Manage providers, API keys & orchestrator engine',
748
755
  projects: 'Group agents, tasks & schedules into projects',
756
+ activity: 'Audit trail of all entity mutations',
749
757
  }
750
758
 
751
759
  const FULL_WIDTH_VIEWS = new Set<AppView>([
752
760
  'schedules', 'secrets', 'providers', 'skills',
753
761
  'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'plugins',
754
- 'usage', 'runs', 'logs', 'settings', 'projects',
762
+ 'usage', 'runs', 'logs', 'settings', 'projects', 'activity',
755
763
  ])
756
764
 
757
765
  const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; title: string; description: string; features: string[] }> = {
@@ -851,6 +859,12 @@ const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; titl
851
859
  description: 'Organize your work into projects. Group agents, tasks, and schedules under a common scope.',
852
860
  features: ['Create named projects with color badges', 'Assign agents and tasks to projects', 'Filter sidebar views by project', 'Global view when no filter is active'],
853
861
  },
862
+ activity: {
863
+ icon: 'clock',
864
+ title: 'Activity',
865
+ description: 'Audit trail of all entity mutations across the system.',
866
+ features: ['Track agent, task, and connector changes', 'Filter by entity type and action', 'Real-time updates via WebSocket', 'Relative timestamps'],
867
+ },
854
868
  }
855
869
 
856
870
  function ViewEmptyState({ view }: { view: AppView }) {
@@ -18,16 +18,40 @@ export function TaskBoard() {
18
18
  const agents = useAppStore((s) => s.agents)
19
19
  const showArchived = useAppStore((s) => s.showArchivedTasks)
20
20
  const setShowArchived = useAppStore((s) => s.setShowArchivedTasks)
21
- const [filterAgentId, setFilterAgentId] = useState<string>('')
21
+ // URL-based filter state
22
+ const [filterAgentId, setFilterAgentId] = useState<string>(() => {
23
+ if (typeof window === 'undefined') return ''
24
+ return new URLSearchParams(window.location.search).get('agent') || ''
25
+ })
26
+ const [filterTag, setFilterTag] = useState<string>(() => {
27
+ if (typeof window === 'undefined') return ''
28
+ return new URLSearchParams(window.location.search).get('tag') || ''
29
+ })
30
+
31
+ // Sync filters to URL
32
+ useEffect(() => {
33
+ if (typeof window === 'undefined') return
34
+ const params = new URLSearchParams()
35
+ if (filterAgentId) params.set('agent', filterAgentId)
36
+ if (filterTag) params.set('tag', filterTag)
37
+ const qs = params.toString()
38
+ const newUrl = `${window.location.pathname}${qs ? `?${qs}` : ''}`
39
+ window.history.replaceState(null, '', newUrl)
40
+ }, [filterAgentId, filterTag])
22
41
 
23
42
  useEffect(() => { loadTasks(); loadAgents() }, [])
24
43
  useWs('tasks', loadTasks, 5000)
25
44
 
45
+ // Collect all unique tags across tasks
46
+ const allTags = Array.from(new Set(Object.values(tasks).flatMap((t) => t.tags || []))).sort()
47
+
26
48
  const columns: BoardTaskStatus[] = showArchived ? [...ACTIVE_COLUMNS, 'archived'] : ACTIVE_COLUMNS
27
49
 
28
50
  const tasksByStatus = (status: BoardTaskStatus) =>
29
51
  Object.values(tasks)
30
- .filter((t) => t.status === status && (!filterAgentId || t.agentId === filterAgentId))
52
+ .filter((t) => t.status === status
53
+ && (!filterAgentId || t.agentId === filterAgentId)
54
+ && (!filterTag || (t.tags && t.tags.includes(filterTag))))
31
55
  .sort((a, b) => b.updatedAt - a.updatedAt)
32
56
 
33
57
  const handleDrop = useCallback(async (taskId: string, newStatus: BoardTaskStatus) => {
@@ -59,6 +83,20 @@ export function TaskBoard() {
59
83
  <option key={a.id} value={a.id}>{a.name}</option>
60
84
  ))}
61
85
  </select>
86
+ {allTags.length > 0 && (
87
+ <select
88
+ value={filterTag}
89
+ onChange={(e) => setFilterTag(e.target.value)}
90
+ className="px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
91
+ bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03] appearance-none"
92
+ style={{ fontFamily: 'inherit', minWidth: 110 }}
93
+ >
94
+ <option value="">All Tags</option>
95
+ {allTags.map((tag) => (
96
+ <option key={tag} value={tag}>{tag}</option>
97
+ ))}
98
+ </select>
99
+ )}
62
100
  <button
63
101
  onClick={() => setShowArchived(!showArchived)}
64
102
  className={`px-4 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
6
  import { updateTask, archiveTask } from '@/lib/tasks'
7
- import type { BoardTask, BoardTaskStatus } from '@/types'
7
+ import type { BoardTask } from '@/types'
8
8
 
9
9
  function timeAgo(ts: number) {
10
10
  const diff = Date.now() - ts
@@ -25,6 +25,14 @@ export function TaskCard({ task }: { task: BoardTask }) {
25
25
 
26
26
  const agent = agents[task.agentId]
27
27
 
28
+ const isBlocked = Array.isArray(task.blockedBy) && task.blockedBy.length > 0
29
+ const isOverdue = task.dueAt && task.dueAt < Date.now() && task.status !== 'completed' && task.status !== 'archived'
30
+ const borderColor = isBlocked ? 'border-l-rose-500'
31
+ : task.pendingApproval ? 'border-l-amber-500'
32
+ : task.status === 'running' ? 'border-l-emerald-500'
33
+ : task.status === 'failed' ? 'border-l-red-500'
34
+ : 'border-l-transparent'
35
+
28
36
  const handleQueue = async (e: React.MouseEvent) => {
29
37
  e.stopPropagation()
30
38
  await updateTask(task.id, { status: 'queued' })
@@ -64,17 +72,47 @@ export function TaskCard({ task }: { task: BoardTask }) {
64
72
  setEditingTaskId(task.id)
65
73
  setTaskSheetOpen(true)
66
74
  }}
67
- className={`p-4 rounded-[14px] border border-white/[0.06] bg-surface hover:bg-surface-2 cursor-grab active:cursor-grabbing
75
+ className={`p-4 rounded-[14px] border border-white/[0.06] border-l-[3px] ${borderColor} bg-surface hover:bg-surface-2 cursor-grab active:cursor-grabbing
68
76
  transition-all group ${dragging ? 'opacity-40 scale-[0.97]' : ''}`}
69
77
  >
70
78
  <div className="flex items-start gap-3 mb-3">
79
+ {isBlocked && (
80
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-rose-400 shrink-0 mt-0.5">
81
+ <title>{`Blocked by ${task.blockedBy?.length} task(s)`}</title>
82
+ <rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
83
+ </svg>
84
+ )}
71
85
  <h4 className="flex-1 text-[14px] font-600 text-text leading-[1.4] line-clamp-2">{task.title}</h4>
86
+ {isBlocked && (
87
+ <span className="px-1.5 py-0.5 rounded-[5px] bg-rose-500/10 text-rose-400 text-[10px] font-600 shrink-0">
88
+ {task.blockedBy?.length}
89
+ </span>
90
+ )}
72
91
  </div>
73
92
 
74
93
  {task.description && (
75
94
  <p className="text-[12px] text-text-3 line-clamp-2 mb-3">{task.description}</p>
76
95
  )}
77
96
 
97
+ {/* Tags */}
98
+ {task.tags && task.tags.length > 0 && (
99
+ <div className="flex flex-wrap gap-1 mb-3">
100
+ {task.tags.map((tag) => (
101
+ <span key={tag} className="px-1.5 py-0.5 rounded-[5px] bg-indigo-500/10 text-indigo-400 text-[10px] font-600">
102
+ {tag}
103
+ </span>
104
+ ))}
105
+ </div>
106
+ )}
107
+
108
+ {/* Due date */}
109
+ {task.dueAt && (
110
+ <p className={`text-[11px] mb-3 font-600 ${isOverdue ? 'text-red-400' : 'text-text-3/60'}`}>
111
+ Due {new Date(task.dueAt).toLocaleDateString([], { month: 'short', day: 'numeric' })}
112
+ {isOverdue && ' (overdue)'}
113
+ </p>
114
+ )}
115
+
78
116
  {task.images && task.images.length > 0 && (
79
117
  <div className="flex gap-1.5 mb-3 overflow-x-auto">
80
118
  {task.images.slice(0, 3).map((url, i) => (