@swarmclawai/swarmclaw 0.6.6 → 0.6.7

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 (80) hide show
  1. package/README.md +57 -27
  2. package/package.json +6 -1
  3. package/src/app/api/agents/[id]/clone/route.ts +40 -0
  4. package/src/app/api/agents/route.ts +39 -14
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +17 -1
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +19 -1
  8. package/src/app/api/chatrooms/route.ts +12 -2
  9. package/src/app/api/connectors/[id]/health/route.ts +64 -0
  10. package/src/app/api/connectors/route.ts +17 -2
  11. package/src/app/api/knowledge/route.ts +6 -1
  12. package/src/app/api/openclaw/doctor/route.ts +17 -0
  13. package/src/app/api/sessions/[id]/chat/route.ts +5 -1
  14. package/src/app/api/sessions/route.ts +11 -2
  15. package/src/app/api/tasks/[id]/route.ts +18 -13
  16. package/src/app/api/tasks/route.ts +20 -1
  17. package/src/app/api/usage/route.ts +16 -7
  18. package/src/cli/index.js +5 -0
  19. package/src/cli/index.ts +223 -39
  20. package/src/components/agents/agent-card.tsx +37 -6
  21. package/src/components/agents/agent-chat-list.tsx +78 -2
  22. package/src/components/agents/agent-sheet.tsx +79 -0
  23. package/src/components/auth/setup-wizard.tsx +268 -353
  24. package/src/components/chat/chat-area.tsx +22 -7
  25. package/src/components/chat/message-bubble.tsx +14 -14
  26. package/src/components/chat/message-list.tsx +1 -1
  27. package/src/components/chatrooms/chatroom-message.tsx +164 -22
  28. package/src/components/chatrooms/chatroom-sheet.tsx +288 -3
  29. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  30. package/src/components/connectors/connector-health.tsx +120 -0
  31. package/src/components/connectors/connector-sheet.tsx +9 -0
  32. package/src/components/home/home-view.tsx +23 -2
  33. package/src/components/input/chat-input.tsx +8 -1
  34. package/src/components/layout/app-layout.tsx +17 -1
  35. package/src/components/schedules/schedule-list.tsx +55 -9
  36. package/src/components/schedules/schedule-sheet.tsx +134 -23
  37. package/src/components/shared/command-palette.tsx +237 -0
  38. package/src/components/shared/connector-platform-icon.tsx +1 -0
  39. package/src/components/tasks/task-card.tsx +22 -2
  40. package/src/components/tasks/task-sheet.tsx +91 -16
  41. package/src/components/usage/metrics-dashboard.tsx +13 -25
  42. package/src/hooks/use-swipe.ts +49 -0
  43. package/src/lib/providers/anthropic.ts +16 -2
  44. package/src/lib/providers/claude-cli.ts +7 -1
  45. package/src/lib/providers/index.ts +7 -0
  46. package/src/lib/providers/ollama.ts +16 -2
  47. package/src/lib/providers/openai.ts +7 -2
  48. package/src/lib/providers/openclaw.ts +6 -1
  49. package/src/lib/providers/provider-defaults.ts +7 -0
  50. package/src/lib/schedule-templates.ts +115 -0
  51. package/src/lib/server/alert-dispatch.ts +64 -0
  52. package/src/lib/server/chat-execution.ts +41 -1
  53. package/src/lib/server/chatroom-helpers.ts +22 -1
  54. package/src/lib/server/chatroom-routing.ts +65 -0
  55. package/src/lib/server/connectors/discord.ts +3 -0
  56. package/src/lib/server/connectors/email.ts +267 -0
  57. package/src/lib/server/connectors/manager.ts +159 -3
  58. package/src/lib/server/connectors/openclaw.ts +3 -0
  59. package/src/lib/server/connectors/slack.ts +6 -0
  60. package/src/lib/server/connectors/telegram.ts +18 -0
  61. package/src/lib/server/connectors/types.ts +2 -0
  62. package/src/lib/server/connectors/whatsapp.ts +9 -0
  63. package/src/lib/server/cost.ts +70 -0
  64. package/src/lib/server/create-notification.ts +2 -0
  65. package/src/lib/server/daemon-state.ts +124 -0
  66. package/src/lib/server/dag-validation.ts +115 -0
  67. package/src/lib/server/memory-db.ts +12 -7
  68. package/src/lib/server/openclaw-doctor.ts +48 -0
  69. package/src/lib/server/queue.ts +12 -0
  70. package/src/lib/server/session-run-manager.ts +22 -1
  71. package/src/lib/server/session-tools/index.ts +2 -0
  72. package/src/lib/server/session-tools/memory.ts +22 -3
  73. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  74. package/src/lib/server/storage.ts +120 -6
  75. package/src/lib/setup-defaults.ts +277 -0
  76. package/src/lib/validation/schemas.ts +69 -0
  77. package/src/stores/use-app-store.ts +7 -3
  78. package/src/stores/use-chatroom-store.ts +52 -2
  79. package/src/types/index.ts +38 -1
  80. package/tsconfig.json +2 -1
@@ -6,9 +6,145 @@ import { useAppStore } from '@/stores/use-app-store'
6
6
  import { BottomSheet } from '@/components/shared/bottom-sheet'
7
7
  import { toast } from 'sonner'
8
8
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
- import type { Agent } from '@/types'
9
+ import type { Agent, ChatroomRoutingRule } from '@/types'
10
10
  import { CheckIcon } from '@/components/shared/check-icon'
11
11
 
12
+ function genRuleId(): string {
13
+ return `rule-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
14
+ }
15
+
16
+ interface RuleFormState {
17
+ type: 'keyword' | 'capability'
18
+ pattern: string
19
+ keywords: string
20
+ agentId: string
21
+ priority: number
22
+ }
23
+
24
+ const emptyRuleForm: RuleFormState = {
25
+ type: 'keyword',
26
+ pattern: '',
27
+ keywords: '',
28
+ agentId: '',
29
+ priority: 10,
30
+ }
31
+
32
+ function RoutingRuleForm({
33
+ rule,
34
+ memberAgents,
35
+ onSave,
36
+ onCancel,
37
+ }: {
38
+ rule: RuleFormState
39
+ memberAgents: Agent[]
40
+ onSave: (form: RuleFormState) => void
41
+ onCancel: () => void
42
+ }) {
43
+ const [form, setForm] = useState<RuleFormState>(rule)
44
+
45
+ return (
46
+ <div className="p-3 rounded-[8px] bg-white/[0.04] border border-white/[0.08] space-y-3">
47
+ <div className="flex gap-2">
48
+ {(['keyword', 'capability'] as const).map((t) => (
49
+ <button
50
+ key={t}
51
+ type="button"
52
+ onClick={() => setForm((f) => ({ ...f, type: t }))}
53
+ className={`flex-1 py-1.5 text-[11px] font-600 capitalize rounded-[6px] cursor-pointer transition-all ${
54
+ form.type === t
55
+ ? 'bg-accent-soft text-accent-bright'
56
+ : 'bg-white/[0.04] text-text-3 hover:text-text-2'
57
+ }`}
58
+ >
59
+ {t}
60
+ </button>
61
+ ))}
62
+ </div>
63
+
64
+ {form.type === 'keyword' && (
65
+ <>
66
+ <div>
67
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Keywords (comma-separated)</label>
68
+ <input
69
+ type="text"
70
+ value={form.keywords}
71
+ onChange={(e) => setForm((f) => ({ ...f, keywords: e.target.value }))}
72
+ placeholder="e.g. deploy, devops, infrastructure"
73
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
74
+ />
75
+ </div>
76
+ <div>
77
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Regex pattern (optional)</label>
78
+ <input
79
+ type="text"
80
+ value={form.pattern}
81
+ onChange={(e) => setForm((f) => ({ ...f, pattern: e.target.value }))}
82
+ placeholder="e.g. deploy|release|ship"
83
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
84
+ />
85
+ </div>
86
+ </>
87
+ )}
88
+
89
+ {form.type === 'capability' && (
90
+ <div>
91
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Capability pattern</label>
92
+ <input
93
+ type="text"
94
+ value={form.pattern}
95
+ onChange={(e) => setForm((f) => ({ ...f, pattern: e.target.value }))}
96
+ placeholder="e.g. frontend, research, devops"
97
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
98
+ />
99
+ </div>
100
+ )}
101
+
102
+ <div className="flex gap-2">
103
+ <div className="flex-1">
104
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Route to agent</label>
105
+ <select
106
+ value={form.agentId}
107
+ onChange={(e) => setForm((f) => ({ ...f, agentId: e.target.value }))}
108
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text focus:outline-none focus:border-accent-bright/40"
109
+ >
110
+ <option value="">Select agent...</option>
111
+ {memberAgents.map((a) => (
112
+ <option key={a.id} value={a.id}>{a.name}</option>
113
+ ))}
114
+ </select>
115
+ </div>
116
+ <div className="w-20">
117
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Priority</label>
118
+ <input
119
+ type="number"
120
+ value={form.priority}
121
+ onChange={(e) => setForm((f) => ({ ...f, priority: parseInt(e.target.value, 10) || 0 }))}
122
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text focus:outline-none focus:border-accent-bright/40"
123
+ />
124
+ </div>
125
+ </div>
126
+
127
+ <div className="flex gap-2 justify-end">
128
+ <button
129
+ type="button"
130
+ onClick={onCancel}
131
+ className="px-3 py-1.5 text-[11px] font-600 text-text-3 hover:text-text-2 cursor-pointer"
132
+ >
133
+ Cancel
134
+ </button>
135
+ <button
136
+ type="button"
137
+ onClick={() => onSave(form)}
138
+ disabled={!form.agentId || (form.type === 'keyword' && !form.keywords.trim() && !form.pattern.trim()) || (form.type === 'capability' && !form.pattern.trim())}
139
+ className="px-3 py-1.5 text-[11px] font-600 bg-accent-bright text-white rounded-[6px] hover:bg-accent-bright/90 disabled:opacity-50 cursor-pointer"
140
+ >
141
+ Save Rule
142
+ </button>
143
+ </div>
144
+ </div>
145
+ )
146
+ }
147
+
12
148
  export function ChatroomSheet() {
13
149
  const open = useChatroomStore((s) => s.chatroomSheetOpen)
14
150
  const editingId = useChatroomStore((s) => s.editingChatroomId)
@@ -25,7 +161,10 @@ export function ChatroomSheet() {
25
161
  const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([])
26
162
  const [chatMode, setChatMode] = useState<'sequential' | 'parallel'>('sequential')
27
163
  const [autoAddress, setAutoAddress] = useState(false)
164
+ const [routingRules, setRoutingRules] = useState<ChatroomRoutingRule[]>([])
28
165
  const [saving, setSaving] = useState(false)
166
+ const [addingRule, setAddingRule] = useState(false)
167
+ const [editingRuleId, setEditingRuleId] = useState<string | null>(null)
29
168
 
30
169
  const editing = editingId ? chatrooms[editingId] : null
31
170
 
@@ -36,24 +175,36 @@ export function ChatroomSheet() {
36
175
  setSelectedAgentIds([...editing.agentIds])
37
176
  setChatMode(editing.chatMode || 'sequential')
38
177
  setAutoAddress(editing.autoAddress || false)
178
+ setRoutingRules([...(editing.routingRules || [])])
39
179
  } else {
40
180
  setName('')
41
181
  setDescription('')
42
182
  setSelectedAgentIds([])
43
183
  setChatMode('sequential')
44
184
  setAutoAddress(false)
185
+ setRoutingRules([])
45
186
  }
187
+ setAddingRule(false)
188
+ setEditingRuleId(null)
46
189
  }, [editing, open])
47
190
 
48
191
  const handleSave = async () => {
49
192
  if (!name.trim() || saving) return
50
193
  setSaving(true)
51
194
  try {
195
+ const payload = {
196
+ name,
197
+ description,
198
+ agentIds: selectedAgentIds,
199
+ chatMode,
200
+ autoAddress,
201
+ routingRules: routingRules.length > 0 ? routingRules : undefined,
202
+ }
52
203
  if (editing) {
53
- await updateChatroom(editing.id, { name, description, agentIds: selectedAgentIds, chatMode, autoAddress })
204
+ await updateChatroom(editing.id, payload)
54
205
  toast.success('Chatroom saved')
55
206
  } else {
56
- const chatroom = await createChatroom({ name, description, agentIds: selectedAgentIds, chatMode, autoAddress })
207
+ const chatroom = await createChatroom(payload)
57
208
  setCurrentChatroom(chatroom.id)
58
209
  toast.success('Chatroom created')
59
210
  }
@@ -81,10 +232,53 @@ export function ChatroomSheet() {
81
232
  )
82
233
  }
83
234
 
235
+ const handleAddRule = (form: RuleFormState) => {
236
+ const rule: ChatroomRoutingRule = {
237
+ id: genRuleId(),
238
+ type: form.type,
239
+ agentId: form.agentId,
240
+ priority: form.priority,
241
+ ...(form.pattern.trim() ? { pattern: form.pattern.trim() } : {}),
242
+ ...(form.type === 'keyword' && form.keywords.trim()
243
+ ? { keywords: form.keywords.split(',').map((k) => k.trim()).filter(Boolean) }
244
+ : {}),
245
+ }
246
+ setRoutingRules((prev) => [...prev, rule].sort((a, b) => a.priority - b.priority))
247
+ setAddingRule(false)
248
+ }
249
+
250
+ const handleEditRule = (form: RuleFormState) => {
251
+ setRoutingRules((prev) =>
252
+ prev.map((r) =>
253
+ r.id === editingRuleId
254
+ ? {
255
+ ...r,
256
+ type: form.type,
257
+ agentId: form.agentId,
258
+ priority: form.priority,
259
+ pattern: form.pattern.trim() || undefined,
260
+ keywords:
261
+ form.type === 'keyword' && form.keywords.trim()
262
+ ? form.keywords.split(',').map((k) => k.trim()).filter(Boolean)
263
+ : undefined,
264
+ }
265
+ : r,
266
+ ).sort((a, b) => a.priority - b.priority),
267
+ )
268
+ setEditingRuleId(null)
269
+ }
270
+
271
+ const removeRule = (ruleId: string) => {
272
+ setRoutingRules((prev) => prev.filter((r) => r.id !== ruleId))
273
+ }
274
+
84
275
  const agentList = Object.values(agents).filter(
85
276
  (a: Agent) => !a.trashedAt
86
277
  ) as Agent[]
87
278
 
279
+ const memberAgents = agentList.filter((a) => selectedAgentIds.includes(a.id))
280
+ const sortedRules = [...routingRules].sort((a, b) => a.priority - b.priority)
281
+
88
282
  return (
89
283
  <BottomSheet open={open} onClose={() => setChatroomSheetOpen(false)}>
90
284
  <div className="p-6 max-w-[560px] mx-auto">
@@ -189,6 +383,97 @@ export function ChatroomSheet() {
189
383
  )}
190
384
  </div>
191
385
  </div>
386
+
387
+ {/* Routing Rules */}
388
+ <div>
389
+ <label className="block text-[12px] font-600 text-text-2 mb-1.5">
390
+ Routing Rules ({sortedRules.length})
391
+ </label>
392
+ <p className="text-[11px] text-text-3 mb-2">
393
+ Route messages to specific agents based on keywords or capabilities. Evaluated before auto-address.
394
+ </p>
395
+
396
+ {sortedRules.length > 0 && (
397
+ <div className="space-y-2 mb-2">
398
+ {sortedRules.map((rule) => {
399
+ const agent = agents[rule.agentId]
400
+ if (editingRuleId === rule.id) {
401
+ return (
402
+ <RoutingRuleForm
403
+ key={rule.id}
404
+ rule={{
405
+ type: rule.type,
406
+ pattern: rule.pattern || '',
407
+ keywords: rule.keywords?.join(', ') || '',
408
+ agentId: rule.agentId,
409
+ priority: rule.priority,
410
+ }}
411
+ memberAgents={memberAgents}
412
+ onSave={handleEditRule}
413
+ onCancel={() => setEditingRuleId(null)}
414
+ />
415
+ )
416
+ }
417
+ return (
418
+ <div
419
+ key={rule.id}
420
+ className="flex items-center gap-2 px-3 py-2 rounded-[8px] bg-white/[0.04] border border-white/[0.08]"
421
+ >
422
+ <span className="text-[10px] font-700 text-text-3 bg-white/[0.06] px-1.5 py-0.5 rounded">
423
+ P{rule.priority}
424
+ </span>
425
+ <span className="text-[10px] font-600 text-accent-bright/70 uppercase">
426
+ {rule.type}
427
+ </span>
428
+ <span className="text-[12px] text-text-2 flex-1 truncate">
429
+ {rule.type === 'keyword'
430
+ ? (rule.keywords?.join(', ') || rule.pattern || '(no match)')
431
+ : (rule.pattern || '(no pattern)')}
432
+ </span>
433
+ <span className="text-[11px] text-text-3 truncate max-w-[100px]">
434
+ {agent?.name || 'Unknown'}
435
+ </span>
436
+ <button
437
+ type="button"
438
+ onClick={() => setEditingRuleId(rule.id)}
439
+ className="text-[11px] text-text-3 hover:text-text-2 cursor-pointer px-1"
440
+ >
441
+ Edit
442
+ </button>
443
+ <button
444
+ type="button"
445
+ onClick={() => removeRule(rule.id)}
446
+ className="text-[11px] text-red-400 hover:text-red-300 cursor-pointer px-1"
447
+ >
448
+ Remove
449
+ </button>
450
+ </div>
451
+ )
452
+ })}
453
+ </div>
454
+ )}
455
+
456
+ {addingRule ? (
457
+ <RoutingRuleForm
458
+ rule={emptyRuleForm}
459
+ memberAgents={memberAgents}
460
+ onSave={handleAddRule}
461
+ onCancel={() => setAddingRule(false)}
462
+ />
463
+ ) : (
464
+ <button
465
+ type="button"
466
+ onClick={() => setAddingRule(true)}
467
+ disabled={memberAgents.length === 0}
468
+ className="w-full py-2 rounded-[8px] border border-dashed border-white/[0.12] text-[12px] font-600 text-text-3 hover:text-text-2 hover:border-white/[0.2] cursor-pointer transition-all disabled:opacity-40 disabled:cursor-not-allowed"
469
+ >
470
+ + Add Rule
471
+ </button>
472
+ )}
473
+ {memberAgents.length === 0 && (
474
+ <p className="text-[11px] text-text-3 mt-1">Add members first to create routing rules.</p>
475
+ )}
476
+ </div>
192
477
  </div>
193
478
 
194
479
  <div className="flex items-center gap-3 mt-6">
@@ -10,13 +10,35 @@ import { ChatroomTypingBar } from './chatroom-typing-bar'
10
10
  import { AgentAvatar } from '@/components/agents/agent-avatar'
11
11
  import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
12
12
  import { HeartbeatMoment, ActivityMoment, isNotableTool } from '@/components/chat/activity-moment'
13
- import type { Chatroom, ChatroomMessage, Agent } from '@/types'
13
+ import type { Chatroom, ChatroomMessage, ChatroomMember, Agent } from '@/types'
14
14
 
15
15
  function navigateToAgent(agentId: string) {
16
16
  useAppStore.getState().setActiveView('agents')
17
17
  useAppStore.getState().setCurrentAgent(agentId)
18
18
  }
19
19
 
20
+ function getRoleBadge(role: string) {
21
+ if (role === 'admin') return { label: 'Admin', className: 'bg-purple-500/20 text-purple-400 border-purple-500/30' }
22
+ if (role === 'moderator') return { label: 'Mod', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' }
23
+ return null
24
+ }
25
+
26
+ function getMemberFromChatroom(chatroom: Chatroom, agentId: string): ChatroomMember | undefined {
27
+ if (chatroom.members?.length) return chatroom.members.find((m) => m.agentId === agentId)
28
+ return undefined
29
+ }
30
+
31
+ function getMemberRole(chatroom: Chatroom, agentId: string): string {
32
+ const member = getMemberFromChatroom(chatroom, agentId)
33
+ return member?.role || 'member'
34
+ }
35
+
36
+ function isAgentMuted(chatroom: Chatroom, agentId: string): boolean {
37
+ const member = getMemberFromChatroom(chatroom, agentId)
38
+ if (!member?.mutedUntil) return false
39
+ return new Date(member.mutedUntil).getTime() > Date.now()
40
+ }
41
+
20
42
  type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
21
43
 
22
44
  /** Subscribe to a single agent heartbeat topic — one hook call per agent */
@@ -65,6 +87,10 @@ export function ChatroomView() {
65
87
  const loadChatrooms = useChatroomStore((s) => s.loadChatrooms)
66
88
  const setChatroomSheetOpen = useChatroomStore((s) => s.setChatroomSheetOpen)
67
89
  const setEditingChatroomId = useChatroomStore((s) => s.setEditingChatroomId)
90
+ const deleteMessage = useChatroomStore((s) => s.deleteMessage)
91
+ const muteAgent = useChatroomStore((s) => s.muteAgent)
92
+ const unmuteAgent = useChatroomStore((s) => s.unmuteAgent)
93
+ const setMemberRole = useChatroomStore((s) => s.setMemberRole)
68
94
  const agents = useAppStore((s) => s.agents) as Record<string, Agent>
69
95
  const scrollRef = useRef<HTMLDivElement>(null)
70
96
  const [pinsExpanded, setPinsExpanded] = useState(false)
@@ -183,23 +209,37 @@ export function ChatroomView() {
183
209
  {chatroom.description ? ` · ${chatroom.description}` : ''}
184
210
  </p>
185
211
  </div>
186
- {/* Member avatars */}
212
+ {/* Member avatars with role badges */}
187
213
  <div className="flex -space-x-1.5 shrink-0">
188
- {memberAgents.slice(0, 5).map((agent) => (
189
- <Tooltip key={agent.id}>
190
- <TooltipTrigger asChild>
191
- <button
192
- onClick={() => navigateToAgent(agent.id)}
193
- className="relative transition-all duration-200 hover:scale-110 hover:z-10 hover:-translate-y-0.5 cursor-pointer bg-transparent border-none p-0"
194
- >
195
- <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={22} status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
196
- </button>
197
- </TooltipTrigger>
198
- <TooltipContent side="bottom" sideOffset={6}>
199
- {agent.name}
200
- </TooltipContent>
201
- </Tooltip>
202
- ))}
214
+ {memberAgents.slice(0, 5).map((agent) => {
215
+ const role = getMemberRole(chatroom, agent.id)
216
+ const badge = getRoleBadge(role)
217
+ const muted = isAgentMuted(chatroom, agent.id)
218
+ return (
219
+ <Tooltip key={agent.id}>
220
+ <TooltipTrigger asChild>
221
+ <button
222
+ onClick={() => navigateToAgent(agent.id)}
223
+ className={`relative transition-all duration-200 hover:scale-110 hover:z-10 hover:-translate-y-0.5 cursor-pointer bg-transparent border-none p-0 ${muted ? 'opacity-40' : ''}`}
224
+ >
225
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={22} status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
226
+ {badge && (
227
+ <span className={`absolute -bottom-1 -right-1 text-[7px] font-700 px-0.5 rounded border ${badge.className}`}>
228
+ {badge.label[0]}
229
+ </span>
230
+ )}
231
+ </button>
232
+ </TooltipTrigger>
233
+ <TooltipContent side="bottom" sideOffset={6}>
234
+ <div className="flex items-center gap-1.5">
235
+ <span>{agent.name}</span>
236
+ {badge && <span className={`text-[9px] font-600 px-1 py-0.5 rounded border ${badge.className}`}>{badge.label}</span>}
237
+ {muted && <span className="text-[9px] text-red-400">Muted</span>}
238
+ </div>
239
+ </TooltipContent>
240
+ </Tooltip>
241
+ )
242
+ })}
203
243
  {memberAgents.length > 5 && (
204
244
  <div className="w-[22px] h-[22px] rounded-full bg-white/[0.08] flex items-center justify-center text-[9px] text-text-3">
205
245
  +{memberAgents.length - 5}
@@ -320,6 +360,11 @@ export function ChatroomView() {
320
360
  onReply={(m: ChatroomMessage) => setReplyingTo(m)}
321
361
  onTogglePin={togglePin}
322
362
  onTransfer={handleTransfer}
363
+ onDeleteMessage={(messageId, targetAgentId) => deleteMessage(messageId, targetAgentId)}
364
+ onMuteAgent={(agentId) => muteAgent(agentId)}
365
+ onUnmuteAgent={(agentId) => unmuteAgent(agentId)}
366
+ onSetRole={(agentId, role) => setMemberRole(agentId, role)}
367
+ chatroom={chatroom}
323
368
  pinnedMessageIds={pinnedIds}
324
369
  streamingAgentIds={streamingAgentIds}
325
370
  messages={chatroom.messages}
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { api } from '@/lib/api-client'
5
+ import type { ConnectorHealthEvent, ConnectorHealthEventType } from '@/types'
6
+
7
+ interface HealthResponse {
8
+ events: ConnectorHealthEvent[]
9
+ uptimePercent: number
10
+ }
11
+
12
+ const EVENT_CONFIG: Record<ConnectorHealthEventType, { color: string; label: string }> = {
13
+ started: { color: 'bg-green-400', label: 'Started' },
14
+ reconnected: { color: 'bg-green-400', label: 'Reconnected' },
15
+ stopped: { color: 'bg-white/30', label: 'Stopped' },
16
+ error: { color: 'bg-red-400', label: 'Error' },
17
+ disconnected: { color: 'bg-amber-400', label: 'Disconnected' },
18
+ }
19
+
20
+ function formatTimestamp(ts: string): string {
21
+ const d = new Date(ts)
22
+ const now = new Date()
23
+ const diffMs = now.getTime() - d.getTime()
24
+ const diffMin = Math.floor(diffMs / 60_000)
25
+ const diffHr = Math.floor(diffMs / 3_600_000)
26
+ const diffDay = Math.floor(diffMs / 86_400_000)
27
+
28
+ if (diffMin < 1) return 'just now'
29
+ if (diffMin < 60) return `${diffMin}m ago`
30
+ if (diffHr < 24) return `${diffHr}h ago`
31
+ if (diffDay < 7) return `${diffDay}d ago`
32
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
33
+ }
34
+
35
+ function uptimeBadgeColor(pct: number): string {
36
+ if (pct >= 99) return 'bg-green-500/15 text-green-400 border-green-500/20'
37
+ if (pct >= 95) return 'bg-amber-500/15 text-amber-400 border-amber-500/20'
38
+ return 'bg-red-500/15 text-red-400 border-red-500/20'
39
+ }
40
+
41
+ export function ConnectorHealth({ connectorId }: { connectorId: string }) {
42
+ const [data, setData] = useState<HealthResponse | null>(null)
43
+ const [loading, setLoading] = useState(true)
44
+
45
+ useEffect(() => {
46
+ let cancelled = false
47
+ async function load() {
48
+ setLoading(true)
49
+ try {
50
+ const resp = await api<HealthResponse>('GET', `/connectors/${connectorId}/health`)
51
+ if (!cancelled) setData(resp)
52
+ } catch {
53
+ // ignore fetch errors
54
+ } finally {
55
+ if (!cancelled) setLoading(false)
56
+ }
57
+ }
58
+ load()
59
+ return () => { cancelled = true }
60
+ }, [connectorId])
61
+
62
+ if (loading) {
63
+ return (
64
+ <div className="p-4 rounded-[14px] border border-white/[0.06] bg-white/[0.01]">
65
+ <div className="text-[13px] text-text-3 animate-pulse">Loading health data...</div>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ if (!data || data.events.length === 0) {
71
+ return (
72
+ <div className="p-4 rounded-[14px] border border-white/[0.06] bg-white/[0.01]">
73
+ <div className="text-[13px] text-text-3">No health events recorded yet.</div>
74
+ </div>
75
+ )
76
+ }
77
+
78
+ // Show most recent events first (up to 50)
79
+ const recentEvents = [...data.events].reverse().slice(0, 50)
80
+
81
+ return (
82
+ <div className="p-4 rounded-[14px] border border-white/[0.06] bg-white/[0.01] space-y-4">
83
+ {/* Uptime badge */}
84
+ <div className="flex items-center justify-between">
85
+ <div className="text-[13px] font-600 text-text-2">Health Timeline</div>
86
+ <span className={`px-3 py-1 rounded-[8px] text-[12px] font-600 border ${uptimeBadgeColor(data.uptimePercent)}`}>
87
+ {data.uptimePercent}% uptime
88
+ </span>
89
+ </div>
90
+
91
+ {/* Timeline */}
92
+ <div className="relative pl-5">
93
+ {/* Vertical line */}
94
+ <div className="absolute left-[7px] top-2 bottom-2 w-px bg-white/[0.08]" />
95
+
96
+ <div className="space-y-3">
97
+ {recentEvents.map((ev) => {
98
+ const cfg = EVENT_CONFIG[ev.event] ?? { color: 'bg-white/30', label: ev.event }
99
+ return (
100
+ <div key={ev.id} className="relative flex items-start gap-3">
101
+ {/* Dot */}
102
+ <div className={`absolute left-[-13px] top-[6px] w-[10px] h-[10px] rounded-full ${cfg.color} ring-2 ring-surface shrink-0`} />
103
+ {/* Content */}
104
+ <div className="min-w-0 flex-1">
105
+ <div className="flex items-center gap-2">
106
+ <span className="text-[13px] font-600 text-text-2">{cfg.label}</span>
107
+ <span className="text-[11px] text-text-3">{formatTimestamp(ev.timestamp)}</span>
108
+ </div>
109
+ {ev.message && (
110
+ <p className="text-[12px] text-text-3/70 mt-0.5 leading-[1.4] break-words">{ev.message}</p>
111
+ )}
112
+ </div>
113
+ </div>
114
+ )
115
+ })}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ )
120
+ }
@@ -13,6 +13,7 @@ import { ChatroomPickerList } from '@/components/shared/chatroom-picker-list'
13
13
  import { SheetFooter } from '@/components/shared/sheet-footer'
14
14
  import { SectionLabel } from '@/components/shared/section-label'
15
15
  import { useChatroomStore } from '@/stores/use-chatroom-store'
16
+ import { ConnectorHealth } from '@/components/connectors/connector-health'
16
17
 
17
18
  /** Auto-detect URLs in text and make them clickable links that open in a new tab */
18
19
  function linkify(text: string) {
@@ -655,6 +656,7 @@ export function ConnectorSheet() {
655
656
  <span key={i} className="flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] bg-accent-soft/50 border border-accent-bright/20 text-[12px] font-mono text-accent-bright">
656
657
  {tag}
657
658
  <button
659
+ aria-label={`Remove ${tag}`}
658
660
  onClick={() => {
659
661
  const next = tags.filter((_, j) => j !== i).join(',')
660
662
  setConfig({ ...config, [field.key]: next })
@@ -860,6 +862,13 @@ export function ConnectorSheet() {
860
862
  </div>
861
863
  )}
862
864
 
865
+ {/* Health timeline (existing connectors only) */}
866
+ {editing && (
867
+ <div className="mb-6">
868
+ <ConnectorHealth connectorId={editing.id} />
869
+ </div>
870
+ )}
871
+
863
872
  {/* Actions */}
864
873
  <SheetFooter
865
874
  onCancel={() => { setOpen(false); setEditingId(null) }}
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useEffect, useMemo, useState } from 'react'
4
+ import { AreaChart, Area, ResponsiveContainer } from 'recharts'
4
5
  import { useAppStore } from '@/stores/use-app-store'
5
6
  import { useChatStore } from '@/stores/use-chat-store'
6
7
  import { AgentAvatar } from '@/components/agents/agent-avatar'
@@ -85,6 +86,7 @@ export function HomeView() {
85
86
  const setTaskSheetOpen = useAppStore((s) => s.setTaskSheetOpen)
86
87
  const setMessages = useChatStore((s) => s.setMessages)
87
88
  const [todayCost, setTodayCost] = useState(0)
89
+ const [costTrend, setCostTrend] = useState<{ cost: number }[]>([])
88
90
 
89
91
  const allAgents = Object.values(agents).filter((a) => !a.trashedAt)
90
92
  const pinnedAgents = allAgents.filter((a) => a.pinned)
@@ -144,10 +146,11 @@ export function HomeView() {
144
146
  void loadSchedules()
145
147
  void loadNotifications()
146
148
  void loadConnectors()
147
- api<{ records: Array<{ estimatedCost: number }> }>('GET', '/usage?range=24h')
149
+ api<{ records: Array<{ estimatedCost: number }>; timeSeries: Array<{ cost: number }> }>('GET', '/usage?range=7d')
148
150
  .then((data) => {
149
151
  const total = (data.records || []).reduce((s, r) => s + (r.estimatedCost || 0), 0)
150
152
  setTodayCost(total)
153
+ setCostTrend((data.timeSeries || []).map((pt) => ({ cost: pt.cost })))
151
154
  })
152
155
  .catch(() => {})
153
156
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -196,13 +199,31 @@ export function HomeView() {
196
199
  </div>
197
200
 
198
201
  {/* Quick Stats */}
199
- <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-10">
202
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
200
203
  <StatCard label="Agents" value={String(agentCount)} />
201
204
  <StatCard label="Active Tasks" value={String(activeTaskCount)} accent={activeTaskCount > 0} />
202
205
  <StatCard label="Today's Cost" value={`$${todayCost.toFixed(2)}`} />
203
206
  <StatCard label="Connectors" value={`${activeConnectorCount}/${allConnectors.length}`} accent={activeConnectorCount > 0} />
204
207
  </div>
205
208
 
209
+ {/* Cost trend sparkline */}
210
+ {costTrend.length > 1 && (
211
+ <div className="mb-10 px-1">
212
+ <p className="text-[10px] text-text-3/50 uppercase tracking-wider mb-1">7-day cost trend</p>
213
+ <ResponsiveContainer width="100%" height={60}>
214
+ <AreaChart data={costTrend} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
215
+ <defs>
216
+ <linearGradient id="costGrad" x1="0" y1="0" x2="0" y2="1">
217
+ <stop offset="0%" stopColor="#818CF8" stopOpacity={0.3} />
218
+ <stop offset="100%" stopColor="#818CF8" stopOpacity={0} />
219
+ </linearGradient>
220
+ </defs>
221
+ <Area type="monotone" dataKey="cost" stroke="#818CF8" strokeWidth={1.5} fill="url(#costGrad)" dot={false} />
222
+ </AreaChart>
223
+ </ResponsiveContainer>
224
+ </div>
225
+ )}
226
+
206
227
  {/* Notifications banner */}
207
228
  {unreadNotifications.length > 0 && (
208
229
  <section className="mb-8">