@swarmclawai/swarmclaw 0.6.4 → 0.6.6

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 (92) hide show
  1. package/README.md +5 -3
  2. package/package.json +5 -1
  3. package/src/app/api/chatrooms/[id]/chat/route.ts +41 -2
  4. package/src/app/api/chatrooms/[id]/route.ts +15 -1
  5. package/src/app/api/chatrooms/route.ts +15 -2
  6. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  7. package/src/app/api/tasks/route.ts +24 -0
  8. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  9. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  10. package/src/app/api/wallets/[id]/route.ts +118 -0
  11. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  12. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  13. package/src/app/api/wallets/route.ts +74 -0
  14. package/src/app/globals.css +8 -0
  15. package/src/cli/index.js +15 -0
  16. package/src/cli/spec.js +14 -0
  17. package/src/components/agents/agent-avatar.tsx +15 -1
  18. package/src/components/agents/agent-card.tsx +1 -0
  19. package/src/components/agents/agent-chat-list.tsx +1 -1
  20. package/src/components/agents/agent-sheet.tsx +112 -26
  21. package/src/components/chat/chat-area.tsx +2 -2
  22. package/src/components/chat/chat-header.tsx +48 -19
  23. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  24. package/src/components/chat/delegation-banner.test.ts +27 -0
  25. package/src/components/chat/delegation-banner.tsx +109 -23
  26. package/src/components/chat/message-bubble.tsx +3 -2
  27. package/src/components/chat/message-list.tsx +5 -4
  28. package/src/components/chat/streaming-bubble.tsx +3 -2
  29. package/src/components/chat/thinking-indicator.tsx +3 -2
  30. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  31. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  32. package/src/components/chatrooms/chatroom-input.tsx +1 -1
  33. package/src/components/chatrooms/chatroom-message.tsx +1 -1
  34. package/src/components/chatrooms/chatroom-sheet.tsx +1 -1
  35. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  36. package/src/components/chatrooms/chatroom-view.tsx +1 -1
  37. package/src/components/connectors/connector-list.tsx +1 -1
  38. package/src/components/home/home-view.tsx +2 -1
  39. package/src/components/knowledge/knowledge-list.tsx +1 -1
  40. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  41. package/src/components/layout/app-layout.tsx +18 -3
  42. package/src/components/memory/memory-agent-list.tsx +1 -1
  43. package/src/components/memory/memory-browser.tsx +1 -0
  44. package/src/components/memory/memory-card.tsx +3 -2
  45. package/src/components/memory/memory-detail.tsx +3 -3
  46. package/src/components/memory/memory-sheet.tsx +2 -2
  47. package/src/components/projects/project-detail.tsx +4 -4
  48. package/src/components/secrets/secret-sheet.tsx +1 -1
  49. package/src/components/secrets/secrets-list.tsx +1 -1
  50. package/src/components/sessions/session-card.tsx +1 -1
  51. package/src/components/shared/agent-picker-list.tsx +1 -1
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  54. package/src/components/skills/skill-list.tsx +1 -1
  55. package/src/components/skills/skill-sheet.tsx +1 -1
  56. package/src/components/tasks/task-board.tsx +3 -3
  57. package/src/components/tasks/task-sheet.tsx +21 -1
  58. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  59. package/src/components/wallets/wallet-panel.tsx +616 -0
  60. package/src/components/wallets/wallet-section.tsx +100 -0
  61. package/src/lib/server/agent-registry.ts +2 -2
  62. package/src/lib/server/chat-execution.ts +35 -3
  63. package/src/lib/server/chatroom-health.ts +60 -0
  64. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  65. package/src/lib/server/chatroom-helpers.ts +64 -11
  66. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  67. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  68. package/src/lib/server/connectors/manager.ts +80 -2
  69. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  70. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  71. package/src/lib/server/connectors/whatsapp.ts +8 -5
  72. package/src/lib/server/orchestrator-lg.ts +12 -2
  73. package/src/lib/server/orchestrator.ts +6 -1
  74. package/src/lib/server/queue-followups.test.ts +224 -0
  75. package/src/lib/server/queue.ts +226 -24
  76. package/src/lib/server/scheduler.ts +3 -0
  77. package/src/lib/server/session-tools/chatroom.ts +11 -2
  78. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  79. package/src/lib/server/session-tools/index.ts +6 -2
  80. package/src/lib/server/session-tools/memory.ts +1 -1
  81. package/src/lib/server/session-tools/shell.ts +1 -1
  82. package/src/lib/server/session-tools/wallet.ts +124 -0
  83. package/src/lib/server/session-tools/web.ts +2 -2
  84. package/src/lib/server/solana.ts +122 -0
  85. package/src/lib/server/storage.ts +38 -0
  86. package/src/lib/server/stream-agent-chat.ts +126 -63
  87. package/src/lib/server/task-mention.test.ts +41 -0
  88. package/src/lib/server/task-mention.ts +3 -2
  89. package/src/lib/tool-definitions.ts +1 -0
  90. package/src/lib/view-routes.ts +1 -0
  91. package/src/stores/use-app-store.ts +8 -0
  92. package/src/types/index.ts +60 -1
@@ -0,0 +1,74 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { genId } from '@/lib/id'
3
+ import { loadWallets, upsertWallet, loadAgents, saveAgents } from '@/lib/server/storage'
4
+ import { generateSolanaKeypair } from '@/lib/server/solana'
5
+ import { notify } from '@/lib/server/ws-hub'
6
+ import type { AgentWallet, WalletChain } from '@/types'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ /** Strip encryptedPrivateKey from wallet for safe API responses */
10
+ function stripPrivateKey(wallet: Record<string, unknown>): Record<string, unknown> {
11
+ return Object.fromEntries(Object.entries(wallet).filter(([k]) => k !== 'encryptedPrivateKey'))
12
+ }
13
+
14
+ export async function GET() {
15
+ const wallets = loadWallets() as Record<string, AgentWallet>
16
+ const safe = Object.fromEntries(
17
+ Object.entries(wallets).map(([id, w]) => [id, stripPrivateKey(w as unknown as Record<string, unknown>)]),
18
+ )
19
+ return NextResponse.json(safe)
20
+ }
21
+
22
+ export async function POST(req: Request) {
23
+ const body = await req.json()
24
+ const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : ''
25
+ if (!agentId) {
26
+ return NextResponse.json({ error: 'agentId is required' }, { status: 400 })
27
+ }
28
+
29
+ const agents = loadAgents()
30
+ if (!agents[agentId]) {
31
+ return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
32
+ }
33
+
34
+ // Check agent doesn't already have a wallet
35
+ const existing = loadWallets() as Record<string, AgentWallet>
36
+ const hasWallet = Object.values(existing).some((w) => w.agentId === agentId)
37
+ if (hasWallet) {
38
+ return NextResponse.json({ error: 'Agent already has a wallet' }, { status: 409 })
39
+ }
40
+
41
+ const chain: WalletChain = body.chain === 'solana' ? 'solana' : 'solana' // extensible later
42
+ const { publicKey, encryptedPrivateKey } = generateSolanaKeypair()
43
+
44
+ const id = genId()
45
+ const now = Date.now()
46
+
47
+ const wallet: AgentWallet = {
48
+ id,
49
+ agentId,
50
+ chain,
51
+ publicKey,
52
+ encryptedPrivateKey,
53
+ label: typeof body.label === 'string' ? body.label : undefined,
54
+ spendingLimitLamports: typeof body.spendingLimitLamports === 'number' ? body.spendingLimitLamports : 100_000_000,
55
+ dailyLimitLamports: typeof body.dailyLimitLamports === 'number' ? body.dailyLimitLamports : 1_000_000_000,
56
+ requireApproval: body.requireApproval !== false,
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ }
60
+
61
+ upsertWallet(id, wallet)
62
+
63
+ // Link wallet to agent
64
+ const agent = agents[agentId]
65
+ agent.walletId = id
66
+ agent.updatedAt = now
67
+ agents[agentId] = agent
68
+ saveAgents(agents)
69
+
70
+ notify('wallets')
71
+ notify('agents')
72
+
73
+ return NextResponse.json(stripPrivateKey(wallet as unknown as Record<string, unknown>))
74
+ }
@@ -215,6 +215,14 @@ textarea:hover::-webkit-scrollbar { width: 6px; }
215
215
  to { opacity: 1; transform: translateY(0); }
216
216
  }
217
217
  @keyframes spin { to { transform: rotate(360deg); } }
218
+ @keyframes pulse-glow {
219
+ 0%, 100% { transform: scale(1); opacity: 0.45; }
220
+ 50% { transform: scale(1.2); opacity: 0.85; }
221
+ }
222
+ @keyframes avatar-pulse {
223
+ 0%, 100% { transform: scale(1); }
224
+ 50% { transform: scale(1.1); }
225
+ }
218
226
  @keyframes slide-in-left {
219
227
  from { transform: translateX(-100%); }
220
228
  to { transform: translateX(0); }
package/src/cli/index.js CHANGED
@@ -485,6 +485,21 @@ const COMMAND_GROUPS = [
485
485
  }),
486
486
  ],
487
487
  },
488
+ {
489
+ name: 'wallets',
490
+ description: 'Manage agent wallets and wallet transactions',
491
+ commands: [
492
+ cmd('list', 'GET', '/wallets', 'List wallets'),
493
+ cmd('get', 'GET', '/wallets/:id', 'Get wallet by id'),
494
+ cmd('create', 'POST', '/wallets', 'Create wallet', { expectsJsonBody: true }),
495
+ cmd('update', 'PATCH', '/wallets/:id', 'Update wallet settings', { expectsJsonBody: true }),
496
+ cmd('delete', 'DELETE', '/wallets/:id', 'Delete wallet'),
497
+ cmd('send', 'POST', '/wallets/:id/send', 'Send funds from wallet', { expectsJsonBody: true }),
498
+ cmd('approve', 'POST', '/wallets/:id/approve', 'Approve or deny a pending wallet transaction', { expectsJsonBody: true }),
499
+ cmd('transactions', 'GET', '/wallets/:id/transactions', 'List wallet transactions'),
500
+ cmd('balance-history', 'GET', '/wallets/:id/balance-history', 'Get wallet balance history'),
501
+ ],
502
+ },
488
503
  {
489
504
  name: 'upload',
490
505
  description: 'Upload raw file/blob',
package/src/cli/spec.js CHANGED
@@ -351,6 +351,20 @@ const COMMAND_GROUPS = {
351
351
  metrics: { description: 'Get task board metrics (supports --query range=24h|7d|30d)', method: 'GET', path: '/tasks/metrics' },
352
352
  },
353
353
  },
354
+ wallets: {
355
+ description: 'Agent wallet operations',
356
+ commands: {
357
+ list: { description: 'List wallets', method: 'GET', path: '/wallets' },
358
+ get: { description: 'Get wallet by id', method: 'GET', path: '/wallets/:id', params: ['id'] },
359
+ create: { description: 'Create wallet', method: 'POST', path: '/wallets' },
360
+ update: { description: 'Update wallet settings', method: 'PATCH', path: '/wallets/:id', params: ['id'] },
361
+ delete: { description: 'Delete wallet', method: 'DELETE', path: '/wallets/:id', params: ['id'] },
362
+ send: { description: 'Send funds from wallet', method: 'POST', path: '/wallets/:id/send', params: ['id'] },
363
+ approve: { description: 'Approve or deny pending wallet transaction', method: 'POST', path: '/wallets/:id/approve', params: ['id'] },
364
+ transactions: { description: 'List wallet transactions', method: 'GET', path: '/wallets/:id/transactions', params: ['id'] },
365
+ 'balance-history': { description: 'Get wallet balance history', method: 'GET', path: '/wallets/:id/balance-history', params: ['id'] },
366
+ },
367
+ },
354
368
  webhooks: {
355
369
  description: 'Inbound webhook triggers',
356
370
  commands: {
@@ -13,6 +13,7 @@ function sanitizeSvg(svg: string): string {
13
13
 
14
14
  interface Props {
15
15
  seed?: string | null
16
+ avatarUrl?: string | null
16
17
  name: string
17
18
  size?: number
18
19
  className?: string
@@ -27,7 +28,7 @@ const STATUS_COLORS: Record<string, string> = {
27
28
 
28
29
  const HEART_PATH = 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
29
30
 
30
- export function AgentAvatar({ seed, name, size = 32, className = '', status, heartbeatPulse }: Props) {
31
+ export function AgentAvatar({ seed, avatarUrl, name, size = 32, className = '', status, heartbeatPulse }: Props) {
31
32
  const svgHtml = useMemo(() => {
32
33
  if (!seed) return null
33
34
  return sanitizeSvg(multiavatar(seed))
@@ -53,6 +54,19 @@ export function AgentAvatar({ seed, name, size = 32, className = '', status, hea
53
54
  </svg>
54
55
  ) : null
55
56
 
57
+ if (avatarUrl) {
58
+ return (
59
+ <div className={`relative shrink-0 ${className}`} style={{ width: size, height: size }}>
60
+ <div className="rounded-full overflow-hidden w-full h-full">
61
+ {/* eslint-disable-next-line @next/next/no-img-element */}
62
+ <img src={avatarUrl} alt={name} className="w-full h-full object-cover" draggable={false} />
63
+ </div>
64
+ {heartEl}
65
+ {dot}
66
+ </div>
67
+ )
68
+ }
69
+
56
70
  if (svgHtml) {
57
71
  return (
58
72
  <div className={`relative shrink-0 ${className}`} style={{ width: size, height: size }}>
@@ -157,6 +157,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
157
157
  <div className="flex items-center gap-2.5">
158
158
  <AgentAvatar
159
159
  seed={agent.avatarSeed}
160
+ avatarUrl={agent.avatarUrl}
160
161
  name={agent.name}
161
162
  size={28}
162
163
  status={isRunning ? 'busy' : isOnline ? 'online' : undefined}
@@ -223,7 +223,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
223
223
  >
224
224
  <div className="flex items-center gap-2.5">
225
225
  <div className="relative shrink-0">
226
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={36} />
226
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={36} />
227
227
  <div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-bg ${
228
228
  isWorking ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)]' : 'bg-text-3/30'
229
229
  }`} />
@@ -7,7 +7,8 @@ import { api } from '@/lib/api-client'
7
7
  import { BottomSheet } from '@/components/shared/bottom-sheet'
8
8
  import { toast } from 'sonner'
9
9
  import { ModelCombobox } from '@/components/shared/model-combobox'
10
- import type { ProviderType, ClaudeSkill } from '@/types'
10
+ import type { ProviderType, ClaudeSkill, AgentWallet } from '@/types'
11
+ import { WalletSection } from '@/components/wallets/wallet-section'
11
12
  import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
12
13
  import { NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
13
14
  import { AgentAvatar } from './agent-avatar'
@@ -105,12 +106,15 @@ export function AgentSheet() {
105
106
  const [openclawEnabled, setOpenclawEnabled] = useState(false)
106
107
  const [projectId, setProjectId] = useState<string | undefined>(undefined)
107
108
  const [avatarSeed, setAvatarSeed] = useState('')
109
+ const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
110
+ const [uploading, setUploading] = useState(false)
108
111
  const [thinkingLevel, setThinkingLevel] = useState<'' | 'minimal' | 'low' | 'medium' | 'high'>('')
109
112
  const [voiceId, setVoiceId] = useState('')
110
113
  const [heartbeatEnabled, setHeartbeatEnabled] = useState(false)
111
114
  const [heartbeatIntervalSec, setHeartbeatIntervalSec] = useState('') // '' = default (30m)
112
115
  const [heartbeatModel, setHeartbeatModel] = useState('')
113
116
  const [heartbeatPrompt, setHeartbeatPrompt] = useState('')
117
+ const [agentWallet, setAgentWallet] = useState<(Omit<AgentWallet, 'encryptedPrivateKey'> & { balanceLamports?: number; balanceSol?: number }) | null>(null)
114
118
  const [addingKey, setAddingKey] = useState(false)
115
119
  const [newKeyName, setNewKeyName] = useState('')
116
120
  const [newKeyValue, setNewKeyValue] = useState('')
@@ -178,18 +182,27 @@ export function AgentSheet() {
178
182
  setMcpDisabledTools(editing.mcpDisabledTools || [])
179
183
  setFallbackCredentialIds(editing.fallbackCredentialIds || [])
180
184
  // platformAssignScope derived from isOrchestrator — no separate state
181
- setCapabilities(editing.capabilities || [])
185
+ setCapabilities(Array.isArray(editing.capabilities) ? editing.capabilities : [])
182
186
  setCapInput('')
183
187
  setOllamaMode(editing.credentialId && editing.provider === 'ollama' ? 'cloud' : 'local')
184
188
  setOpenclawEnabled(editing.provider === 'openclaw')
185
189
  setProjectId(editing.projectId)
186
190
  setAvatarSeed(editing.avatarSeed || crypto.randomUUID().slice(0, 8))
191
+ setAvatarUrl(editing.avatarUrl || null)
187
192
  setThinkingLevel(editing.thinkingLevel || '')
188
193
  setVoiceId(editing.elevenLabsVoiceId || '')
189
194
  setHeartbeatEnabled(editing.heartbeatEnabled || false)
190
195
  setHeartbeatIntervalSec(parseDurationToSec(editing.heartbeatInterval, editing.heartbeatIntervalSec))
191
196
  setHeartbeatModel(editing.heartbeatModel || '')
192
197
  setHeartbeatPrompt(editing.heartbeatPrompt || '')
198
+ // Load wallet if agent has one
199
+ if (editing.walletId) {
200
+ api<Omit<AgentWallet, 'encryptedPrivateKey'> & { balanceLamports?: number; balanceSol?: number }>('GET', `/wallets/${editing.walletId}`)
201
+ .then(setAgentWallet)
202
+ .catch(() => setAgentWallet(null))
203
+ } else {
204
+ setAgentWallet(null)
205
+ }
193
206
  } else {
194
207
  setName('')
195
208
  setDescription('')
@@ -310,6 +323,7 @@ export function AgentSheet() {
310
323
  capabilities,
311
324
  projectId: projectId || undefined,
312
325
  avatarSeed: avatarSeed.trim() || undefined,
326
+ avatarUrl: avatarUrl || null,
313
327
  thinkingLevel: thinkingLevel || undefined,
314
328
  elevenLabsVoiceId: voiceId.trim() || null,
315
329
  heartbeatEnabled,
@@ -480,30 +494,82 @@ export function AgentSheet() {
480
494
 
481
495
  <div className="mb-8">
482
496
  <SectionLabel>Avatar</SectionLabel>
483
- <div className="flex items-center gap-3">
484
- <AgentAvatar seed={avatarSeed || null} name={name || 'A'} size={40} />
485
- <input
486
- type="text"
487
- value={avatarSeed}
488
- onChange={(e) => setAvatarSeed(e.target.value)}
489
- placeholder="Avatar seed (any text)"
490
- className={inputClass}
491
- style={{ fontFamily: 'inherit', flex: 1 }}
492
- />
493
- <button
494
- type="button"
495
- onClick={() => setAvatarSeed(crypto.randomUUID().slice(0, 8))}
496
- className="inline-flex items-center gap-1.5 px-3 py-2 rounded-[10px] border border-white/[0.08] bg-transparent text-text-3 text-[12px] font-600 cursor-pointer transition-all hover:bg-white/[0.04] hover:text-text-2 active:scale-95 shrink-0"
497
- style={{ fontFamily: 'inherit' }}
498
- title="Shuffle avatar"
499
- >
500
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
501
- <rect x="4" y="4" width="16" height="16" rx="2" />
502
- <circle cx="9" cy="9" r="1" fill="currentColor" />
503
- <circle cx="15" cy="15" r="1" fill="currentColor" />
504
- </svg>
505
- Shuffle
506
- </button>
497
+ <div className="flex flex-col gap-3">
498
+ <div className="flex items-center gap-4">
499
+ <div className="relative group shrink-0">
500
+ <AgentAvatar seed={avatarUrl ? null : (avatarSeed || null)} avatarUrl={avatarUrl} name={name || 'A'} size={64} />
501
+ <label className="absolute inset-0 rounded-full flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
502
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
503
+ <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
504
+ <circle cx="12" cy="13" r="4" />
505
+ </svg>
506
+ <input
507
+ type="file"
508
+ accept="image/*"
509
+ className="hidden"
510
+ onChange={async (e) => {
511
+ const file = e.target.files?.[0]
512
+ if (!file) return
513
+ setUploading(true)
514
+ try {
515
+ const res = await fetch('/api/upload', {
516
+ method: 'POST',
517
+ headers: { 'x-filename': file.name },
518
+ body: await file.arrayBuffer(),
519
+ })
520
+ const data = await res.json()
521
+ if (data.url) {
522
+ setAvatarUrl(data.url)
523
+ setAvatarSeed('')
524
+ }
525
+ } finally {
526
+ setUploading(false)
527
+ e.target.value = ''
528
+ }
529
+ }}
530
+ />
531
+ </label>
532
+ </div>
533
+ <div className="flex flex-col gap-1.5 flex-1 min-w-0">
534
+ {avatarUrl && (
535
+ <button
536
+ type="button"
537
+ onClick={() => {
538
+ setAvatarUrl(null)
539
+ if (!avatarSeed) setAvatarSeed(crypto.randomUUID().slice(0, 8))
540
+ }}
541
+ className="text-[11px] text-text-3 hover:text-red-400 transition-colors self-start cursor-pointer"
542
+ >
543
+ Remove custom image
544
+ </button>
545
+ )}
546
+ {uploading && <span className="text-[11px] text-text-3">Uploading...</span>}
547
+ </div>
548
+ </div>
549
+ <div className="flex items-center gap-3">
550
+ <input
551
+ type="text"
552
+ value={avatarSeed}
553
+ onChange={(e) => { setAvatarSeed(e.target.value); setAvatarUrl(null) }}
554
+ placeholder="Avatar seed (any text)"
555
+ className={inputClass}
556
+ style={{ fontFamily: 'inherit', flex: 1 }}
557
+ />
558
+ <button
559
+ type="button"
560
+ onClick={() => { setAvatarSeed(crypto.randomUUID().slice(0, 8)); setAvatarUrl(null) }}
561
+ className="inline-flex items-center gap-1.5 px-3 py-2 rounded-[10px] border border-white/[0.08] bg-transparent text-text-3 text-[12px] font-600 cursor-pointer transition-all hover:bg-white/[0.04] hover:text-text-2 active:scale-95 shrink-0"
562
+ style={{ fontFamily: 'inherit' }}
563
+ title="Shuffle avatar"
564
+ >
565
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
566
+ <rect x="4" y="4" width="16" height="16" rx="2" />
567
+ <circle cx="9" cy="9" r="1" fill="currentColor" />
568
+ <circle cx="15" cy="15" r="1" fill="currentColor" />
569
+ </svg>
570
+ Shuffle
571
+ </button>
572
+ </div>
507
573
  </div>
508
574
  </div>
509
575
 
@@ -666,6 +732,26 @@ export function AgentSheet() {
666
732
  <p className="text-[11px] text-text-3/70 mt-1.5">Periodic check-in runs on idle sessions using this agent. Processes pending events and monitors status.</p>
667
733
  </div>
668
734
 
735
+ {/* Wallet Section */}
736
+ {editingId && (
737
+ <WalletSection
738
+ agentId={editingId}
739
+ wallet={agentWallet}
740
+ onWalletCreated={async () => {
741
+ await loadAgents()
742
+ // Fetch the wallet for this agent
743
+ try {
744
+ const wallets = await api<Record<string, Omit<AgentWallet, 'encryptedPrivateKey'>>>('GET', '/wallets')
745
+ const match = Object.values(wallets).find((w) => w.agentId === editingId)
746
+ if (match) {
747
+ const detail = await api<Omit<AgentWallet, 'encryptedPrivateKey'> & { balanceLamports?: number; balanceSol?: number }>('GET', `/wallets/${match.id}`)
748
+ setAgentWallet(detail)
749
+ }
750
+ } catch { /* ignore */ }
751
+ }}
752
+ />
753
+ )}
754
+
669
755
  {provider !== 'openclaw' && (
670
756
  <div className="mb-8">
671
757
  <label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
@@ -146,8 +146,8 @@ export function ChatArea() {
146
146
  if (!sessionId) return
147
147
  try {
148
148
  const msgs = await fetchMessages(sessionId)
149
- if (msgs.length > messagesLenRef.current) {
150
- const newMsgs = msgs.slice(messagesLenRef.current)
149
+ if (msgs.length !== messagesLenRef.current) {
150
+ const newMsgs = msgs.length > messagesLenRef.current ? msgs.slice(messagesLenRef.current) : []
151
151
  setMessages(msgs)
152
152
  if (ttsEnabledRef.current && typeof document !== 'undefined' && document.visibilityState === 'visible') {
153
153
  const latestAssistant = [...newMsgs].reverse().find((m) => {
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState, useMemo, useRef } from 'react'
3
+ import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
4
4
  import type { Session } from '@/types'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { useChatStore } from '@/stores/use-chat-store'
@@ -17,6 +17,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
17
17
  import { ModelCombobox } from '@/components/shared/model-combobox'
18
18
  import { toast } from 'sonner'
19
19
  import type { ProviderType } from '@/types'
20
+ import { useWs } from '@/hooks/use-ws'
20
21
 
21
22
  function shortPath(p: string): string {
22
23
  return (p || '').replace(/^\/Users\/\w+/, '~')
@@ -106,6 +107,19 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
106
107
  const [renameError, setRenameError] = useState('')
107
108
  const renameInputRef = useRef<HTMLInputElement>(null)
108
109
  const renameContainerRef = useRef<HTMLSpanElement>(null)
110
+ const setWalletPanelAgentId = useAppStore((s) => s.setWalletPanelAgentId)
111
+ const [walletBalance, setWalletBalance] = useState<number | null>(null)
112
+
113
+ const fetchWalletBalance = useCallback(async () => {
114
+ if (!agent?.walletId) { setWalletBalance(null); return }
115
+ try {
116
+ const data = await api<{ balanceSol?: number }>('GET', `/wallets/${agent.walletId}`)
117
+ setWalletBalance(data.balanceSol ?? null)
118
+ } catch { setWalletBalance(null) }
119
+ }, [agent?.walletId])
120
+
121
+ useEffect(() => { fetchWalletBalance() }, [fetchWalletBalance])
122
+ useWs('wallets', fetchWalletBalance)
109
123
 
110
124
  // Find linked task for this session
111
125
  const linkedTask = useMemo(() => {
@@ -458,7 +472,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
458
472
  const hasToolToggles = ((agent?.tools?.length ?? 0) > 0) || ((session.tools?.length ?? 0) > 0)
459
473
  const hasMemoryLink = !!(agent && session.tools?.includes('memory'))
460
474
  const hasSourceFilter = !!hasMultipleSources
461
- const hasContextBar = !!(hasToolToggles || isMainSession || hasMemoryLink || hasSourceFilter || linkedTask || resumeHandle || (isOpenClawAgent && openclawSessionKey) || browserActive)
475
+ const hasContextBar = !!(isMainSession || hasMemoryLink || hasSourceFilter || linkedTask || resumeHandle || (isOpenClawAgent && openclawSessionKey) || browserActive)
462
476
 
463
477
  return (
464
478
  <header
@@ -486,26 +500,26 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
486
500
  <div className="relative shrink-0">
487
501
  {streaming && (
488
502
  <div
489
- className="absolute -inset-[3px] rounded-full opacity-40"
503
+ className="absolute -inset-[4px] rounded-full"
490
504
  style={{
491
- background: 'conic-gradient(from 0deg, var(--color-accent-bright), transparent 120deg, transparent 240deg, var(--color-accent-bright))',
492
- animation: 'spin 2.5s linear infinite',
493
- filter: 'blur(3px)',
505
+ background: 'radial-gradient(circle, var(--color-accent-bright), transparent 70%)',
506
+ animation: 'pulse-glow 2s ease-in-out infinite',
507
+ filter: 'blur(5px)',
494
508
  }}
495
509
  />
496
510
  )}
497
511
  <div
498
- className="relative rounded-full"
512
+ className="relative rounded-full transition-transform duration-500"
499
513
  style={{
500
514
  padding: 2,
501
515
  background: streaming
502
- ? 'conic-gradient(from 0deg, var(--color-accent-bright), transparent 120deg, transparent 240deg, var(--color-accent-bright))'
516
+ ? 'linear-gradient(135deg, var(--color-accent-bright), var(--color-accent))'
503
517
  : 'linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.03))',
504
- animation: streaming ? 'spin 2.5s linear infinite' : undefined,
518
+ animation: streaming ? 'avatar-pulse 2s ease-in-out infinite' : undefined,
505
519
  }}
506
520
  >
507
521
  <div className="rounded-full bg-bg">
508
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={hasContextBar ? 44 : 34} />
522
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={(hasContextBar || hasToolToggles) ? 44 : 34} />
509
523
  </div>
510
524
  </div>
511
525
  </div>
@@ -513,8 +527,9 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
513
527
 
514
528
  {/* Identity + metadata — fills center */}
515
529
  <div className="flex-1 min-w-0 flex items-center gap-3">
516
- {/* Name + inline badges */}
517
- <div className="flex items-center gap-2 min-w-0 shrink">
530
+ {/* Name (row 1) + tools (row 2) */}
531
+ <div className="flex flex-col gap-0.5 min-w-0 shrink">
532
+ <div className="flex items-center gap-2 min-w-0">
518
533
  {renaming && agent ? (
519
534
  <span ref={renameContainerRef} className="inline-flex items-center gap-2">
520
535
  <input
@@ -585,10 +600,28 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
585
600
  <span className="shrink-0 w-2 h-2 rounded-full bg-accent-bright" style={{ animation: 'pulse 1.5s ease infinite' }} />
586
601
  )}
587
602
  </div>
603
+ {hasToolToggles && <ChatToolToggles session={session} />}
604
+ </div>
588
605
 
589
- {/* Metadata tray: model · usage · path · status */}
606
+ {/* Metadata tray: wallet · model · path · status */}
590
607
  <div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
591
608
  <span className="text-text-3/10 text-[10px] select-none shrink-0">/</span>
609
+ {walletBalance !== null && (
610
+ <>
611
+ <button
612
+ type="button"
613
+ onClick={() => { setWalletPanelAgentId(agent!.id); setActiveView('wallets') }}
614
+ className="inline-flex items-center gap-1 shrink-0 bg-transparent border-none p-0.5 rounded-[4px] cursor-pointer text-[11px] text-text-3/45 font-mono hover:text-text-3/70 hover:bg-white/[0.04] transition-colors"
615
+ title="View wallet"
616
+ >
617
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
618
+ <rect x="2" y="6" width="20" height="14" rx="2" /><path d="M22 10H18a2 2 0 0 0 0 4h4" />
619
+ </svg>
620
+ {walletBalance.toFixed(3)} SOL
621
+ </button>
622
+ <span className="text-text-3/10 text-[10px] select-none shrink-0">·</span>
623
+ </>
624
+ )}
592
625
  {modelName && (
593
626
  <div className="relative shrink-0" ref={modelSwitcherRef}>
594
627
  <button
@@ -727,7 +760,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
727
760
  </button>
728
761
  {hbDropdownOpen && (
729
762
  <div className="absolute top-full right-0 mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[80px]">
730
- {[1800, 3600, 7200, 21600, 43200].map((sec) => (
763
+ {[...(typeof window !== 'undefined' && window.location.hostname === 'localhost' ? [10, 15, 30, 60] : []), 1800, 3600, 7200, 21600, 43200].map((sec) => (
731
764
  <button
732
765
  key={sec}
733
766
  onClick={() => handleSelectHeartbeatInterval(sec)}
@@ -809,11 +842,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
809
842
 
810
843
  {/* Context bar: tools, mission controls, links */}
811
844
  {hasContextBar && (
812
- <div className="flex items-center gap-1.5 px-3.5 pb-1.5 overflow-x-auto scrollbar-none">
813
- {hasToolToggles && <ChatToolToggles session={session} />}
814
- {hasToolToggles && (hasMemoryLink || isMainSession || linkedTask || resumeHandle || isOpenClawAgent || browserActive) && (
815
- <div className="w-px h-4 bg-white/[0.05] shrink-0" />
816
- )}
845
+ <div className="flex items-center gap-1.5 px-3.5 pb-1.5 flex-wrap">
817
846
  {isMainSession && (
818
847
  <>
819
848
  <button
@@ -76,7 +76,7 @@ export function ChatToolToggles({ session }: Props) {
76
76
  {group.tools.map((tool) => {
77
77
  const enabled = sessionTools.includes(tool.id)
78
78
  return (
79
- <label key={tool.id} className="flex items-center gap-2.5 py-1.5 cursor-pointer">
79
+ <label key={tool.id} className="flex items-center gap-2.5 py-1.5 cursor-pointer" title={tool.description}>
80
80
  <div
81
81
  onClick={() => toggleTool(tool.id)}
82
82
  className={`w-8 h-[18px] rounded-full transition-all duration-200 relative cursor-pointer shrink-0
@@ -0,0 +1,27 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { parseTaskCompletion } from './delegation-banner'
4
+
5
+ describe('parseTaskCompletion', () => {
6
+ it('extracts output files and report path from completion payload', () => {
7
+ const text = [
8
+ 'Task completed: **[Build docs](#task:abc12345)**',
9
+ '',
10
+ 'Working directory: `/tmp/work`',
11
+ '',
12
+ 'Output files:',
13
+ '- `docs/guide.md`',
14
+ '- `docs/faq.md`',
15
+ '',
16
+ 'Task report: `data/task-reports/abc12345.md`',
17
+ '',
18
+ 'Done.',
19
+ ].join('\n')
20
+ const parsed = parseTaskCompletion(text)
21
+ assert.ok(parsed)
22
+ assert.deepEqual(parsed?.outputFiles, ['docs/guide.md', 'docs/faq.md'])
23
+ assert.equal(parsed?.reportPath, 'data/task-reports/abc12345.md')
24
+ assert.equal(parsed?.workingDir, '/tmp/work')
25
+ })
26
+ })
27
+