@swarmclawai/swarmclaw 0.6.7 → 0.6.8

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 (73) hide show
  1. package/README.md +24 -6
  2. package/package.json +1 -1
  3. package/src/app/api/agents/route.ts +1 -0
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -0
  5. package/src/app/api/eval/run/route.ts +37 -0
  6. package/src/app/api/eval/scenarios/route.ts +24 -0
  7. package/src/app/api/eval/suite/route.ts +29 -0
  8. package/src/app/api/memory/graph/route.ts +46 -0
  9. package/src/app/api/sessions/[id]/checkpoints/route.ts +31 -0
  10. package/src/app/api/sessions/[id]/restore/route.ts +36 -0
  11. package/src/app/api/souls/[id]/route.ts +65 -0
  12. package/src/app/api/souls/route.ts +70 -0
  13. package/src/app/api/tasks/[id]/route.ts +5 -0
  14. package/src/app/api/tasks/route.ts +2 -0
  15. package/src/app/api/usage/route.ts +9 -2
  16. package/src/cli/index.js +24 -0
  17. package/src/components/agents/agent-sheet.tsx +27 -6
  18. package/src/components/agents/soul-library-picker.tsx +84 -13
  19. package/src/components/chat/activity-moment.tsx +2 -0
  20. package/src/components/chat/checkpoint-timeline.tsx +112 -0
  21. package/src/components/chat/message-list.tsx +19 -3
  22. package/src/components/chat/session-debug-panel.tsx +106 -84
  23. package/src/components/chat/task-approval-card.tsx +78 -0
  24. package/src/components/chat/tool-call-bubble.tsx +3 -0
  25. package/src/components/connectors/connector-sheet.tsx +8 -1
  26. package/src/components/home/home-view.tsx +39 -15
  27. package/src/components/layout/app-layout.tsx +18 -2
  28. package/src/components/memory/memory-browser.tsx +73 -45
  29. package/src/components/memory/memory-graph-view.tsx +203 -0
  30. package/src/components/plugins/plugin-list.tsx +1 -1
  31. package/src/components/schedules/schedule-sheet.tsx +9 -2
  32. package/src/components/shared/hint-tip.tsx +31 -0
  33. package/src/components/shared/settings/section-runtime-loop.tsx +5 -4
  34. package/src/components/tasks/approvals-panel.tsx +120 -0
  35. package/src/components/usage/metrics-dashboard.tsx +25 -3
  36. package/src/lib/server/chat-execution.ts +96 -12
  37. package/src/lib/server/chatroom-helpers.ts +63 -5
  38. package/src/lib/server/chatroom-orchestration.ts +74 -0
  39. package/src/lib/server/context-manager.ts +132 -50
  40. package/src/lib/server/daemon-state.ts +70 -1
  41. package/src/lib/server/eval/runner.ts +126 -0
  42. package/src/lib/server/eval/scenarios.ts +218 -0
  43. package/src/lib/server/eval/scorer.ts +96 -0
  44. package/src/lib/server/eval/store.ts +37 -0
  45. package/src/lib/server/eval/types.ts +48 -0
  46. package/src/lib/server/execution-log.ts +12 -8
  47. package/src/lib/server/guardian.ts +34 -0
  48. package/src/lib/server/heartbeat-service.ts +53 -1
  49. package/src/lib/server/langgraph-checkpoint.ts +10 -0
  50. package/src/lib/server/link-understanding.ts +55 -0
  51. package/src/lib/server/main-agent-loop.ts +114 -15
  52. package/src/lib/server/memory-db.ts +18 -7
  53. package/src/lib/server/mmr.ts +73 -0
  54. package/src/lib/server/orchestrator-lg.ts +3 -0
  55. package/src/lib/server/plugins.ts +44 -22
  56. package/src/lib/server/query-expansion.ts +57 -0
  57. package/src/lib/server/queue.ts +27 -0
  58. package/src/lib/server/session-run-manager.ts +21 -1
  59. package/src/lib/server/session-tools/http.ts +19 -9
  60. package/src/lib/server/session-tools/index.ts +34 -0
  61. package/src/lib/server/session-tools/memory.ts +39 -11
  62. package/src/lib/server/session-tools/schedule.ts +43 -0
  63. package/src/lib/server/session-tools/web.ts +35 -11
  64. package/src/lib/server/storage.ts +12 -0
  65. package/src/lib/server/stream-agent-chat.ts +57 -8
  66. package/src/lib/server/tool-capability-policy.ts +1 -0
  67. package/src/lib/server/tool-retry.ts +62 -0
  68. package/src/lib/server/transcript-repair.ts +72 -0
  69. package/src/lib/setup-defaults.ts +1 -0
  70. package/src/lib/tool-definitions.ts +1 -0
  71. package/src/lib/validation/schemas.ts +1 -0
  72. package/src/lib/view-routes.ts +1 -0
  73. package/src/types/index.ts +34 -3
@@ -5,6 +5,7 @@ import { searchMemory } from '@/lib/memory'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { MemoryCard } from './memory-card'
7
7
  import { MemoryDetail } from './memory-detail'
8
+ import { MemoryGraphView } from './memory-graph-view'
8
9
  import type { MemoryEntry } from '@/types'
9
10
 
10
11
  export function MemoryBrowser() {
@@ -19,6 +20,7 @@ export function MemoryBrowser() {
19
20
  const [loaded, setLoaded] = useState(false)
20
21
  const [error, setError] = useState<string | null>(null)
21
22
  const [categoryFilter, setCategoryFilter] = useState<string>('')
23
+ const [viewMode, setViewMode] = useState<'list' | 'graph'>('list')
22
24
  const searchRef = useRef(search)
23
25
 
24
26
  // Derive the API agentId from the filter
@@ -102,12 +104,26 @@ export function MemoryBrowser() {
102
104
 
103
105
  return (
104
106
  <div className="flex-1 flex h-full min-w-0">
105
- {/* Left: Memory card list */}
107
+ {/* Left: Memory card list or Graph Toggle */}
106
108
  <div className="w-[360px] shrink-0 border-r border-white/[0.06] flex flex-col overflow-hidden">
107
109
  {/* Header + search */}
108
110
  <div className="px-3 pt-3 pb-1 shrink-0">
109
111
  <div className="flex items-center gap-2 mb-2">
110
112
  <h3 className="font-display text-[13px] font-600 text-text-2 tracking-[-0.01em] flex-1 truncate">{filterLabel}</h3>
113
+ <div className="flex bg-white/[0.04] p-0.5 rounded-[8px]">
114
+ <button
115
+ onClick={() => setViewMode('list')}
116
+ className={`p-1 rounded-[6px] transition-colors ${viewMode === 'list' ? 'bg-white/[0.08] text-text' : 'text-text-3 hover:text-text-2'}`}
117
+ >
118
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
119
+ </button>
120
+ <button
121
+ onClick={() => setViewMode('graph')}
122
+ className={`p-1 rounded-[6px] transition-colors ${viewMode === 'graph' ? 'bg-white/[0.08] text-text' : 'text-text-3 hover:text-text-2'}`}
123
+ >
124
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
125
+ </button>
126
+ </div>
111
127
  <span className="text-[10px] font-mono tabular-nums text-text-3/50">{filtered.length}</span>
112
128
  </div>
113
129
  <input
@@ -150,56 +166,68 @@ export function MemoryBrowser() {
150
166
 
151
167
  {/* Cards */}
152
168
  <div className="flex-1 overflow-y-auto">
153
- {filtered.length > 0 ? (
154
- <div className="flex flex-col gap-0.5 px-2 pb-4">
155
- {filtered.map((e) => {
156
- // Show agent info on cards when in "All Memories" view
157
- const showAgent = !memoryAgentFilter
158
- const agent = showAgent && e.agentId ? agents[e.agentId] : null
159
- return (
160
- <MemoryCard
161
- key={e.id}
162
- entry={e}
163
- active={e.id === selectedMemoryId}
164
- agentName={showAgent ? (agent?.name || null) : undefined}
165
- agentAvatarSeed={showAgent ? (agent?.avatarSeed || null) : undefined}
166
- agentAvatarUrl={showAgent ? (agent?.avatarUrl || null) : undefined}
167
- onClick={() => setSelectedMemoryId(e.id)}
168
- />
169
- )
170
- })}
171
- </div>
172
- ) : error ? (
173
- <div className="flex-1 flex flex-col items-center justify-center gap-3 text-text-3 p-8 text-center">
174
- <p className="font-display text-[14px] font-600 text-text-2">Couldn&apos;t load memories</p>
175
- <p className="text-[12px] text-text-3/60">{error}</p>
176
- <button
177
- onClick={() => { void load(search) }}
178
- className="px-3 py-1.5 rounded-[8px] bg-accent-soft text-accent-bright text-[12px] font-600 cursor-pointer border-none"
179
- style={{ fontFamily: 'inherit' }}
180
- >
181
- Retry
182
- </button>
183
- </div>
184
- ) : loaded ? (
185
- <div className="flex flex-col items-center justify-center gap-3 text-text-3 p-8 text-center">
186
- <div className="w-10 h-10 rounded-[12px] bg-accent-soft flex items-center justify-center">
187
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-accent-bright">
188
- <ellipse cx="12" cy="5" rx="9" ry="3" />
189
- <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
190
- <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
191
- </svg>
169
+ {viewMode === 'list' ? (
170
+ filtered.length > 0 ? (
171
+ <div className="flex flex-col gap-0.5 px-2 pb-4">
172
+ {filtered.map((e) => {
173
+ // Show agent info on cards when in "All Memories" view
174
+ const showAgent = !memoryAgentFilter
175
+ const agent = showAgent && e.agentId ? agents[e.agentId] : null
176
+ return (
177
+ <MemoryCard
178
+ key={e.id}
179
+ entry={e}
180
+ active={e.id === selectedMemoryId}
181
+ agentName={showAgent ? (agent?.name || null) : undefined}
182
+ agentAvatarSeed={showAgent ? (agent?.avatarSeed || null) : undefined}
183
+ agentAvatarUrl={showAgent ? (agent?.avatarUrl || null) : undefined}
184
+ onClick={() => setSelectedMemoryId(e.id)}
185
+ />
186
+ )
187
+ })}
192
188
  </div>
193
- <p className="font-display text-[14px] font-600 text-text-2">No memories yet</p>
194
- <p className="text-[12px] text-text-3/50">Agents store knowledge here as they learn</p>
189
+ ) : error ? (
190
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 text-text-3 p-8 text-center">
191
+ <p className="font-display text-[14px] font-600 text-text-2">Couldn&apos;t load memories</p>
192
+ <p className="text-[12px] text-text-3/60">{error}</p>
193
+ <button
194
+ onClick={() => { void load(search) }}
195
+ className="px-3 py-1.5 rounded-[8px] bg-accent-soft text-accent-bright text-[12px] font-600 cursor-pointer border-none"
196
+ style={{ fontFamily: 'inherit' }}
197
+ >
198
+ Retry
199
+ </button>
200
+ </div>
201
+ ) : loaded ? (
202
+ <div className="flex flex-col items-center justify-center gap-3 text-text-3 p-8 text-center">
203
+ <div className="w-10 h-10 rounded-[12px] bg-accent-soft flex items-center justify-center">
204
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-accent-bright">
205
+ <ellipse cx="12" cy="5" rx="9" ry="3" />
206
+ <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
207
+ <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
208
+ </svg>
209
+ </div>
210
+ <p className="font-display text-[14px] font-600 text-text-2">No memories yet</p>
211
+ <p className="text-[12px] text-text-3/50">Agents store knowledge here as they learn</p>
212
+ </div>
213
+ ) : null
214
+ ) : (
215
+ <div className="p-4 text-[12px] text-text-3 italic">
216
+ Graph view enabled in main area.
195
217
  </div>
196
- ) : null}
218
+ )}
197
219
  </div>
198
220
  </div>
199
221
 
200
- {/* Right: Detail */}
222
+ {/* Right: Detail or Graph */}
201
223
  <div className="flex-1 flex flex-col min-w-0">
202
- <MemoryDetail />
224
+ {viewMode === 'graph' ? (
225
+ <div className="flex-1 p-4 flex flex-col">
226
+ <MemoryGraphView />
227
+ </div>
228
+ ) : (
229
+ <MemoryDetail />
230
+ )}
203
231
  </div>
204
232
  </div>
205
233
  )
@@ -0,0 +1,203 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { api } from '@/lib/api-client'
5
+ import { useAppStore } from '@/stores/use-app-store'
6
+
7
+ interface Node {
8
+ id: string
9
+ title: string
10
+ category: string
11
+ agentId?: string | null
12
+ x: number
13
+ y: number
14
+ vx: number
15
+ vy: number
16
+ }
17
+
18
+ interface Link {
19
+ source: string
20
+ target: string
21
+ type: string
22
+ }
23
+
24
+ export function MemoryGraphView() {
25
+ const [data, setData] = useState<{ nodes: Node[]; links: Link[] }>({ nodes: [], links: [] })
26
+ const [loading, setLoading] = useState(true)
27
+ const [hoveredNode, setHoveredNode] = useState<string | null>(null)
28
+ const containerRef = useRef<HTMLDivElement>(null)
29
+ const requestRef = useRef<number>(null)
30
+
31
+ const selectedMemoryId = useAppStore((s) => s.selectedMemoryId)
32
+ const setSelectedMemoryId = useAppStore((s) => s.setSelectedMemoryId)
33
+ const memoryAgentFilter = useAppStore((s) => s.memoryAgentFilter)
34
+
35
+ useEffect(() => {
36
+ async function load() {
37
+ setLoading(true)
38
+ try {
39
+ const url = `/memory/graph${memoryAgentFilter ? `?agentId=${memoryAgentFilter}` : ''}`
40
+ const res = await api<{ nodes: Node[]; links: Link[] }>('GET', url)
41
+
42
+ // Initialize positions
43
+ const nodes = res.nodes.map(n => ({
44
+ ...n,
45
+ x: Math.random() * 800,
46
+ y: Math.random() * 600,
47
+ vx: 0,
48
+ vy: 0
49
+ }))
50
+
51
+ setData({ nodes, links: res.links })
52
+ } catch (err) {
53
+ console.error('Failed to load memory graph', err)
54
+ } finally {
55
+ setLoading(false)
56
+ }
57
+ }
58
+ load()
59
+ }, [memoryAgentFilter])
60
+
61
+ // Simple Force-Directed Simulation
62
+ useEffect(() => {
63
+ if (data.nodes.length === 0) return
64
+
65
+ const animate = () => {
66
+ setData(prev => {
67
+ const nodes = [...prev.nodes]
68
+ const links = prev.links
69
+
70
+ // 1. Repulsion between all nodes
71
+ for (let i = 0; i < nodes.length; i++) {
72
+ for (let j = i + 1; j < nodes.length; j++) {
73
+ const dx = nodes[i].x - nodes[j].x
74
+ const dy = nodes[i].y - nodes[j].y
75
+ const distSq = dx * dx + dy * dy + 0.1
76
+ const force = 400 / distSq
77
+ const fx = dx * force
78
+ const fy = dy * force
79
+ nodes[i].vx += fx
80
+ nodes[i].vy += fy
81
+ nodes[j].vx -= fx
82
+ nodes[j].vy -= fy
83
+ }
84
+ }
85
+
86
+ // 2. Attraction along links
87
+ for (const link of links) {
88
+ const source = nodes.find(n => n.id === link.source)
89
+ const target = nodes.find(n => n.id === link.target)
90
+ if (source && target) {
91
+ const dx = target.x - source.x
92
+ const dy = target.y - source.y
93
+ const dist = Math.sqrt(dx * dx + dy * dy) + 0.1
94
+ const force = (dist - 100) * 0.02
95
+ const fx = (dx / dist) * force
96
+ const fy = (dy / dist) * force
97
+ source.vx += fx
98
+ source.vy += fy
99
+ target.vx -= fx
100
+ target.vy -= fy
101
+ }
102
+ }
103
+
104
+ // 3. Centering force
105
+ const cx = 400
106
+ const cy = 300
107
+ for (const node of nodes) {
108
+ node.vx += (cx - node.x) * 0.01
109
+ node.vy += (cy - node.y) * 0.01
110
+ }
111
+
112
+ // 4. Update positions with damping
113
+ for (const node of nodes) {
114
+ node.x += node.vx
115
+ node.y += node.vy
116
+ node.vx *= 0.8
117
+ node.vy *= 0.8
118
+ }
119
+
120
+ return { nodes, links }
121
+ })
122
+ requestRef.current = requestAnimationFrame(animate)
123
+ }
124
+
125
+ requestRef.current = requestAnimationFrame(animate)
126
+ return () => {
127
+ if (requestRef.current) cancelAnimationFrame(requestRef.current)
128
+ }
129
+ }, [data.nodes.length])
130
+
131
+ if (loading) {
132
+ return (
133
+ <div className="flex-1 flex items-center justify-center">
134
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-bright"></div>
135
+ </div>
136
+ )
137
+ }
138
+
139
+ return (
140
+ <div ref={containerRef} className="flex-1 relative overflow-hidden bg-black/20 rounded-[16px] border border-white/[0.06]">
141
+ <svg width="100%" height="100%" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet">
142
+ {/* Links */}
143
+ {data.links.map((link, i) => {
144
+ const s = data.nodes.find(n => n.id === link.source)
145
+ const t = data.nodes.find(n => n.id === link.target)
146
+ if (!s || !t) return null
147
+ return (
148
+ <line
149
+ key={i}
150
+ x1={s.x} y1={s.y}
151
+ x2={t.x} y2={t.y}
152
+ stroke="white"
153
+ strokeOpacity="0.1"
154
+ strokeWidth="1"
155
+ />
156
+ )
157
+ })}
158
+
159
+ {/* Nodes */}
160
+ {data.nodes.map(node => (
161
+ <g
162
+ key={node.id}
163
+ transform={`translate(${node.x},${node.y})`}
164
+ onMouseEnter={() => setHoveredNode(node.id)}
165
+ onMouseLeave={() => setHoveredNode(null)}
166
+ onClick={() => setSelectedMemoryId(node.id)}
167
+ className="cursor-pointer"
168
+ >
169
+ <circle
170
+ r={selectedMemoryId === node.id ? 8 : 5}
171
+ fill={node.category === 'knowledge' ? '#10B981' : '#6366F1'}
172
+ stroke="white"
173
+ strokeWidth={selectedMemoryId === node.id ? 2 : 0}
174
+ className="transition-all"
175
+ />
176
+ {(hoveredNode === node.id || selectedMemoryId === node.id) && (
177
+ <text
178
+ y="-12"
179
+ textAnchor="middle"
180
+ className="text-[10px] fill-text font-600 pointer-events-none"
181
+ style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }}
182
+ >
183
+ {node.title}
184
+ </text>
185
+ )}
186
+ </g>
187
+ ))}
188
+ </svg>
189
+
190
+ {/* Legend */}
191
+ <div className="absolute bottom-4 left-4 p-3 bg-surface/80 backdrop-blur rounded-[12px] border border-white/[0.06] flex flex-col gap-2">
192
+ <div className="flex items-center gap-2">
193
+ <div className="w-3 h-3 rounded-full bg-[#10B981]" />
194
+ <span className="text-[11px] text-text-3">Knowledge</span>
195
+ </div>
196
+ <div className="flex items-center gap-2">
197
+ <div className="w-3 h-3 rounded-full bg-[#6366F1]" />
198
+ <span className="text-[11px] text-text-3">Note / Working</span>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ )
203
+ }
@@ -191,7 +191,7 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
191
191
  Installed
192
192
  </button>
193
193
  <button onClick={() => setTab('marketplace')} className={tabClass('marketplace')} style={{ fontFamily: 'inherit' }}>
194
- Marketplace
194
+ SwarmForge
195
195
  </button>
196
196
  </div>
197
197
  )}
@@ -10,6 +10,7 @@ import type { ScheduleType, ScheduleStatus } from '@/types'
10
10
  import cronstrue from 'cronstrue'
11
11
  import { SectionLabel } from '@/components/shared/section-label'
12
12
  import { SCHEDULE_TEMPLATES, type ScheduleTemplate } from '@/lib/schedule-templates'
13
+ import { HintTip } from '@/components/shared/hint-tip'
13
14
  import {
14
15
  Newspaper, BarChart3, HeartPulse, PenLine, Trash2,
15
16
  Activity, ShieldCheck, DatabaseBackup, FileText,
@@ -326,7 +327,10 @@ export function ScheduleSheet() {
326
327
  {step === whenStep && (
327
328
  <div>
328
329
  <div className="mb-8">
329
- <SectionLabel>Schedule Type</SectionLabel>
330
+ <div className="flex items-center gap-2 mb-3">
331
+ <SectionLabel className="mb-0">Schedule Type</SectionLabel>
332
+ <HintTip text="Once: runs a single time. Interval: repeats every N minutes. Cron: advanced scheduling with cron syntax" />
333
+ </div>
330
334
  <div className="grid grid-cols-3 gap-3">
331
335
  {(['cron', 'interval', 'once'] as ScheduleType[]).map((t) => (
332
336
  <button
@@ -347,7 +351,10 @@ export function ScheduleSheet() {
347
351
 
348
352
  {scheduleType === 'cron' && (
349
353
  <div className="mb-8">
350
- <SectionLabel>Schedule</SectionLabel>
354
+ <div className="flex items-center gap-2 mb-3">
355
+ <SectionLabel className="mb-0">Schedule</SectionLabel>
356
+ <HintTip text="Standard cron format: minute hour day month weekday (e.g. 0 9 * * 1-5 = weekdays at 9am)" />
357
+ </div>
351
358
 
352
359
  {/* Preset buttons */}
353
360
  <div className="flex flex-wrap gap-2 mb-4">
@@ -0,0 +1,31 @@
1
+ 'use client'
2
+
3
+ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
4
+
5
+ interface Props {
6
+ text: string
7
+ side?: 'top' | 'bottom' | 'left' | 'right'
8
+ }
9
+
10
+ export function HintTip({ text, side = 'top' }: Props) {
11
+ return (
12
+ <Tooltip>
13
+ <TooltipTrigger asChild>
14
+ <span
15
+ className="inline-flex items-center justify-center w-[14px] h-[14px] rounded-full border border-white/[0.12]
16
+ text-[9px] font-600 text-text-3/50 hover:text-text-3 hover:border-white/[0.2] transition-colors cursor-help select-none shrink-0"
17
+ aria-label="More info"
18
+ >
19
+ ?
20
+ </span>
21
+ </TooltipTrigger>
22
+ <TooltipContent
23
+ side={side}
24
+ sideOffset={6}
25
+ className="bg-raised border border-white/[0.08] text-text shadow-[0_8px_32px_rgba(0,0,0,0.5)] rounded-[8px] px-2.5 py-1.5 text-[11px] max-w-[240px]"
26
+ >
27
+ {text}
28
+ </TooltipContent>
29
+ </Tooltip>
30
+ )
31
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from '@/lib/runtime-loop'
13
13
  import type { LoopMode } from '@/types'
14
14
  import type { SettingsSectionProps } from './types'
15
+ import { HintTip } from '@/components/shared/hint-tip'
15
16
 
16
17
  export function RuntimeLoopSection({ appSettings, patchSettings, inputClass }: SettingsSectionProps) {
17
18
  const loopMode: LoopMode = appSettings.loopMode === 'ongoing' ? 'ongoing' : 'bounded'
@@ -25,7 +26,7 @@ export function RuntimeLoopSection({ appSettings, patchSettings, inputClass }: S
25
26
  Choose bounded or ongoing agent loops and set safety guards for task execution.
26
27
  </p>
27
28
  <div className="p-6 rounded-[18px] bg-surface border border-white/[0.06]">
28
- <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-3">Loop Mode</label>
29
+ <label className="flex items-center gap-1.5 font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-3">Loop Mode <HintTip text="Bounded = fixed max steps. Ongoing = runs until the task completes (with a safety cap)" /></label>
29
30
  <div className="grid grid-cols-2 gap-2 mb-5">
30
31
  {([
31
32
  { id: 'bounded' as const, name: 'Bounded' },
@@ -48,7 +49,7 @@ export function RuntimeLoopSection({ appSettings, patchSettings, inputClass }: S
48
49
  {loopMode === 'bounded' ? (
49
50
  <div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
50
51
  <div>
51
- <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Agent Steps</label>
52
+ <label className="flex items-center gap-1.5 font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Agent Steps <HintTip text="Maximum actions an agent can take before stopping — prevents infinite loops" /></label>
52
53
  <input
53
54
  type="number"
54
55
  min={1}
@@ -63,7 +64,7 @@ export function RuntimeLoopSection({ appSettings, patchSettings, inputClass }: S
63
64
  />
64
65
  </div>
65
66
  <div>
66
- <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Orchestrator Steps</label>
67
+ <label className="flex items-center gap-1.5 font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Orchestrator Steps <HintTip text="Maximum tool calls the orchestrator can make when coordinating multiple agents" /></label>
67
68
  <input
68
69
  type="number"
69
70
  min={1}
@@ -78,7 +79,7 @@ export function RuntimeLoopSection({ appSettings, patchSettings, inputClass }: S
78
79
  />
79
80
  </div>
80
81
  <div>
81
- <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Legacy Turns</label>
82
+ <label className="flex items-center gap-1.5 font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Legacy Turns <HintTip text="Max conversation turns for older orchestration mode — increase if agents get cut off mid-task" /></label>
82
83
  <input
83
84
  type="number"
84
85
  min={1}
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { api } from '@/lib/api-client'
6
+ import { toast } from 'sonner'
7
+
8
+
9
+ export function ApprovalsPanel() {
10
+ const tasks = useAppStore((s) => s.tasks)
11
+ const agents = useAppStore((s) => s.agents)
12
+ const loadTasks = useAppStore((s) => s.loadTasks)
13
+
14
+ const pendingApprovals = useMemo(() => {
15
+ return Object.values(tasks)
16
+ .filter((t) => t.pendingApproval)
17
+ .sort((a, b) => b.updatedAt - a.updatedAt)
18
+ }, [tasks])
19
+
20
+ const handleDecision = async (taskId: string, approved: boolean) => {
21
+ try {
22
+ await api('POST', `/tasks/${taskId}/approve`, { approved })
23
+ toast.success(approved ? 'Tool execution approved' : 'Tool execution rejected')
24
+ loadTasks()
25
+ } catch (err: unknown) {
26
+ toast.error(err instanceof Error ? err.message : 'Failed to submit decision')
27
+ }
28
+ }
29
+
30
+ if (pendingApprovals.length === 0) {
31
+ return (
32
+ <div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
33
+ <div className="w-16 h-16 rounded-[24px] bg-white/[0.02] border border-white/[0.04] flex items-center justify-center mb-6">
34
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/40">
35
+ <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/>
36
+ <path d="m9 12 2 2 4-4"/>
37
+ </svg>
38
+ </div>
39
+ <h2 className="font-display text-[18px] font-600 text-text-2 mb-2">No pending approvals</h2>
40
+ <p className="text-[13px] text-text-3/60 max-w-[280px]">
41
+ Your swarm is operating autonomously. Any actions requiring human oversight will appear here.
42
+ </p>
43
+ </div>
44
+ )
45
+ }
46
+
47
+ return (
48
+ <div className="flex-1 overflow-y-auto px-6 py-8">
49
+ <div className="max-w-4xl mx-auto">
50
+ <div className="flex items-center justify-between mb-8">
51
+ <div>
52
+ <h1 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-1">Approvals</h1>
53
+ <p className="text-[13px] text-text-3">Governance queue for manual tool interventions</p>
54
+ </div>
55
+ <div className="px-3 py-1.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-[11px] font-600">
56
+ {pendingApprovals.length} Pending
57
+ </div>
58
+ </div>
59
+
60
+ <div className="grid grid-cols-1 gap-4">
61
+ {pendingApprovals.map((task) => {
62
+ const agent = agents[task.agentId]
63
+ const argsString = JSON.stringify(task.pendingApproval!.args, null, 2)
64
+
65
+ return (
66
+ <div key={task.id} className="bg-surface rounded-[16px] border border-white/[0.06] overflow-hidden">
67
+ {/* Header */}
68
+ <div className="px-5 py-3 border-b border-white/[0.04] flex items-center justify-between bg-surface-2/50">
69
+ <div className="flex items-center gap-3">
70
+ <div className="w-8 h-8 rounded-[8px] bg-white/[0.04] flex items-center justify-center">
71
+ <span className="text-[14px]">{agent?.avatarSeed ? '🤖' : '🦞'}</span>
72
+ </div>
73
+ <div>
74
+ <h3 className="text-[13px] font-600 text-text">{agent?.name || 'Unknown Agent'}</h3>
75
+ <p className="text-[11px] text-text-3">Task: {task.title}</p>
76
+ </div>
77
+ </div>
78
+ <span className="text-[10px] text-text-3/50 font-mono">
79
+ {new Date(task.updatedAt).toLocaleString()}
80
+ </span>
81
+ </div>
82
+
83
+ {/* Body */}
84
+ <div className="p-5">
85
+ <div className="flex items-center gap-2 mb-3">
86
+ <span className="px-2 py-0.5 rounded-[6px] bg-accent-soft text-accent-bright text-[10px] font-mono font-600">
87
+ {task.pendingApproval!.toolName}
88
+ </span>
89
+ <span className="text-[12px] text-text-3">requested permission to execute.</span>
90
+ </div>
91
+
92
+ <div className="bg-black/30 rounded-[10px] border border-white/[0.04] p-4 mb-5 overflow-x-auto">
93
+ <pre className="text-[12px] font-mono text-text-2/80">
94
+ {argsString}
95
+ </pre>
96
+ </div>
97
+
98
+ <div className="flex items-center justify-end gap-3 pt-4 border-t border-white/[0.04]">
99
+ <button
100
+ onClick={() => handleDecision(task.id, false)}
101
+ className="px-5 py-2 rounded-[10px] bg-transparent border border-red-500/30 text-red-400 text-[12px] font-600 hover:bg-red-500/10 transition-colors"
102
+ >
103
+ Reject
104
+ </button>
105
+ <button
106
+ onClick={() => handleDecision(task.id, true)}
107
+ className="px-5 py-2 rounded-[10px] bg-emerald-500 border border-emerald-400 text-[#000] text-[12px] font-700 hover:brightness-110 transition-all shadow-[0_0_15px_rgba(16,185,129,0.3)]"
108
+ >
109
+ Approve Execution
110
+ </button>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ )
115
+ })}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ )
120
+ }
@@ -9,6 +9,7 @@ import { useAppStore } from '@/stores/use-app-store'
9
9
  import { useWs } from '@/hooks/use-ws'
10
10
  import { api } from '@/lib/api-client'
11
11
  import type { BoardTask } from '@/types'
12
+ import { HintTip } from '@/components/shared/hint-tip'
12
13
 
13
14
  type Range = '24h' | '7d' | '30d'
14
15
 
@@ -348,10 +349,31 @@ export function MetricsDashboard() {
348
349
  </>
349
350
  )}
350
351
 
352
+ {/* Latency by Provider */}
353
+ <ChartCard title="Average Latency by Provider (ms)">
354
+ {providerData.some(p => (data?.providerHealth?.[p.name]?.avgLatencyMs ?? 0) > 0) ? (
355
+ <ResponsiveContainer width="100%" height={280}>
356
+ <BarChart data={providerData.map(p => ({ ...p, latency: Math.round(data?.providerHealth?.[p.name]?.avgLatencyMs || 0) }))} layout="vertical" margin={{ top: 5, right: 30, bottom: 5, left: 40 }}>
357
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} />
358
+ <XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
359
+ <YAxis dataKey="name" type="category" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={80} />
360
+ <Tooltip {...tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
361
+ <Bar dataKey="latency" radius={[0, 4, 4, 0]}>
362
+ {providerData.map((_entry, index) => (
363
+ <Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
364
+ ))}
365
+ </Bar>
366
+ </BarChart>
367
+ </ResponsiveContainer>
368
+ ) : (
369
+ <EmptyChart />
370
+ )}
371
+ </ChartCard>
372
+
351
373
  {/* Provider Health */}
352
374
  {data?.providerHealth && Object.keys(data.providerHealth).length > 0 && (
353
375
  <div>
354
- <h3 className="font-display text-[14px] font-600 text-text-2 mb-3">Provider Health</h3>
376
+ <h3 className="font-display text-[14px] font-600 text-text-2 mb-3 flex items-center gap-2">Provider Health <HintTip text="API reliability and performance across your configured providers" /></h3>
355
377
  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
356
378
  {Object.entries(data.providerHealth)
357
379
  .sort(([, a], [, b]) => b.totalRequests - a.totalRequests)
@@ -367,13 +389,13 @@ export function MetricsDashboard() {
367
389
  <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[12px]">
368
390
  <span className="text-text-3">Requests</span>
369
391
  <span className="text-text font-500 text-right">{h.totalRequests}</span>
370
- <span className="text-text-3">Error Rate</span>
392
+ <span className="text-text-3 flex items-center gap-1">Error Rate <HintTip text="Percentage of API calls that failed" /></span>
371
393
  <span className={`font-500 text-right ${errorRateColor(h.errorRate)}`}>
372
394
  {(h.errorRate * 100).toFixed(1)}%
373
395
  </span>
374
396
  {h.avgLatencyMs > 0 && (
375
397
  <>
376
- <span className="text-text-3">Avg Latency</span>
398
+ <span className="text-text-3 flex items-center gap-1">Avg Latency <HintTip text="Average response time from the provider" /></span>
377
399
  <span className="text-text font-500 text-right">{Math.round(h.avgLatencyMs)}ms</span>
378
400
  </>
379
401
  )}