@swarmclawai/swarmclaw 0.6.3 → 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 (106) 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/app/page.tsx +7 -3
  16. package/src/cli/index.js +15 -0
  17. package/src/cli/spec.js +14 -0
  18. package/src/components/agents/agent-avatar.tsx +15 -1
  19. package/src/components/agents/agent-card.tsx +1 -0
  20. package/src/components/agents/agent-chat-list.tsx +1 -1
  21. package/src/components/agents/agent-sheet.tsx +112 -26
  22. package/src/components/auth/access-key-gate.tsx +22 -11
  23. package/src/components/chat/chat-area.tsx +2 -2
  24. package/src/components/chat/chat-header.tsx +48 -19
  25. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  26. package/src/components/chat/delegation-banner.test.ts +27 -0
  27. package/src/components/chat/delegation-banner.tsx +109 -23
  28. package/src/components/chat/message-bubble.tsx +14 -3
  29. package/src/components/chat/message-list.tsx +5 -4
  30. package/src/components/chat/streaming-bubble.tsx +3 -2
  31. package/src/components/chat/thinking-indicator.tsx +3 -2
  32. package/src/components/chat/tool-call-bubble.test.ts +28 -0
  33. package/src/components/chat/tool-call-bubble.tsx +13 -1
  34. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  35. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  36. package/src/components/chatrooms/chatroom-input.tsx +7 -6
  37. package/src/components/chatrooms/chatroom-message.tsx +1 -1
  38. package/src/components/chatrooms/chatroom-sheet.tsx +1 -1
  39. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  40. package/src/components/chatrooms/chatroom-view.tsx +1 -1
  41. package/src/components/connectors/connector-list.tsx +1 -1
  42. package/src/components/home/home-view.tsx +2 -1
  43. package/src/components/input/chat-input.tsx +5 -4
  44. package/src/components/knowledge/knowledge-list.tsx +1 -1
  45. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  46. package/src/components/layout/app-layout.tsx +23 -9
  47. package/src/components/logs/log-list.tsx +7 -7
  48. package/src/components/memory/memory-agent-list.tsx +1 -1
  49. package/src/components/memory/memory-browser.tsx +1 -0
  50. package/src/components/memory/memory-card.tsx +3 -2
  51. package/src/components/memory/memory-detail.tsx +3 -3
  52. package/src/components/memory/memory-sheet.tsx +2 -2
  53. package/src/components/projects/project-detail.tsx +4 -4
  54. package/src/components/secrets/secret-sheet.tsx +1 -1
  55. package/src/components/secrets/secrets-list.tsx +1 -1
  56. package/src/components/sessions/new-session-sheet.tsx +4 -3
  57. package/src/components/sessions/session-card.tsx +1 -1
  58. package/src/components/shared/agent-picker-list.tsx +1 -1
  59. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  60. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  61. package/src/components/skills/skill-list.tsx +1 -1
  62. package/src/components/skills/skill-sheet.tsx +1 -1
  63. package/src/components/tasks/task-board.tsx +3 -3
  64. package/src/components/tasks/task-sheet.tsx +21 -1
  65. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  66. package/src/components/wallets/wallet-panel.tsx +616 -0
  67. package/src/components/wallets/wallet-section.tsx +100 -0
  68. package/src/hooks/use-media-query.ts +30 -4
  69. package/src/lib/api-client.ts +6 -18
  70. package/src/lib/fetch-timeout.ts +17 -0
  71. package/src/lib/notification-sounds.ts +4 -4
  72. package/src/lib/safe-storage.ts +42 -0
  73. package/src/lib/server/agent-registry.ts +2 -2
  74. package/src/lib/server/chat-execution.ts +35 -3
  75. package/src/lib/server/chatroom-health.ts +60 -0
  76. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  77. package/src/lib/server/chatroom-helpers.ts +64 -11
  78. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  79. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  80. package/src/lib/server/connectors/manager.ts +80 -2
  81. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  82. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  83. package/src/lib/server/connectors/whatsapp.ts +8 -5
  84. package/src/lib/server/orchestrator-lg.ts +12 -2
  85. package/src/lib/server/orchestrator.ts +6 -1
  86. package/src/lib/server/queue-followups.test.ts +224 -0
  87. package/src/lib/server/queue.ts +226 -24
  88. package/src/lib/server/scheduler.ts +3 -0
  89. package/src/lib/server/session-tools/chatroom.ts +11 -2
  90. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  91. package/src/lib/server/session-tools/index.ts +6 -2
  92. package/src/lib/server/session-tools/memory.ts +1 -1
  93. package/src/lib/server/session-tools/shell.ts +1 -1
  94. package/src/lib/server/session-tools/wallet.ts +124 -0
  95. package/src/lib/server/session-tools/web-output.test.ts +29 -0
  96. package/src/lib/server/session-tools/web-output.ts +16 -0
  97. package/src/lib/server/session-tools/web.ts +7 -3
  98. package/src/lib/server/solana.ts +122 -0
  99. package/src/lib/server/storage.ts +38 -0
  100. package/src/lib/server/stream-agent-chat.ts +126 -63
  101. package/src/lib/server/task-mention.test.ts +41 -0
  102. package/src/lib/server/task-mention.ts +3 -2
  103. package/src/lib/tool-definitions.ts +1 -0
  104. package/src/lib/view-routes.ts +6 -1
  105. package/src/stores/use-app-store.ts +17 -11
  106. package/src/types/index.ts +60 -1
@@ -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
+
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import ReactMarkdown from 'react-markdown'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { AgentAvatar } from '@/components/agents/agent-avatar'
6
6
  import { api } from '@/lib/api-client'
@@ -10,6 +10,7 @@ type DelegationStatus = 'delegating' | 'checking' | 'completed' | 'failed'
10
10
  interface DelegationBannerProps {
11
11
  agentName: string
12
12
  agentAvatarSeed: string | null
13
+ agentAvatarUrl?: string | null
13
14
  taskPreview: string
14
15
  taskId: string | null
15
16
  status: DelegationStatus
@@ -60,7 +61,7 @@ function statusText(status: DelegationStatus, name: string): string {
60
61
  }
61
62
  }
62
63
 
63
- export function DelegationBanner({ agentName, agentAvatarSeed, taskPreview, taskId, status }: DelegationBannerProps) {
64
+ export function DelegationBanner({ agentName, agentAvatarSeed, agentAvatarUrl, taskPreview, taskId, status }: DelegationBannerProps) {
64
65
  const cfg = STATUS_CONFIG[status]
65
66
 
66
67
  const handleTaskClick = () => {
@@ -83,7 +84,7 @@ export function DelegationBanner({ agentName, agentAvatarSeed, taskPreview, task
83
84
  }}
84
85
  >
85
86
  <div className="shrink-0" style={{ animation: 'delegation-handoff-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both' }}>
86
- <AgentAvatar seed={agentAvatarSeed} name={agentName} size={24} />
87
+ <AgentAvatar seed={agentAvatarSeed} avatarUrl={agentAvatarUrl} name={agentName} size={24} />
87
88
  </div>
88
89
  <StatusIcon status={status} color={cfg.color} />
89
90
  <div className="flex flex-col gap-0.5 min-w-0 flex-1">
@@ -120,6 +121,8 @@ export interface TaskCompletionInfo {
120
121
  /** The agent that executed the task (present on delegated results) */
121
122
  executorName: string | null
122
123
  workingDir: string | null
124
+ reportPath: string | null
125
+ outputFiles: string[]
123
126
  resumeInfo: string | null
124
127
  resultBody: string
125
128
  imageUrl?: string
@@ -140,12 +143,32 @@ export function parseTaskCompletion(text: string): TaskCompletionInfo | null {
140
143
  const sections = bodyStart === -1 ? [] : text.slice(bodyStart + 2).split('\n\n')
141
144
 
142
145
  let workingDir: string | null = null
146
+ let reportPath: string | null = null
147
+ const outputFiles: string[] = []
143
148
  let resumeInfo: string | null = null
144
149
  const resultParts: string[] = []
145
150
 
146
151
  for (const section of sections) {
147
152
  if (section.startsWith('Working directory: ')) {
148
153
  workingDir = section.replace('Working directory: ', '').replace(/^`|`$/g, '')
154
+ } else if (section.startsWith('Output files:')) {
155
+ const fromBackticks = [...section.matchAll(/`([^`\n]+)`/g)].map((m) => (m[1] || '').trim()).filter(Boolean)
156
+ if (fromBackticks.length > 0) {
157
+ for (const fileRef of fromBackticks) {
158
+ if (!outputFiles.includes(fileRef)) outputFiles.push(fileRef)
159
+ }
160
+ } else {
161
+ const fromBullets = section
162
+ .split('\n')
163
+ .slice(1)
164
+ .map((line) => line.replace(/^\s*-\s*/, '').trim())
165
+ .filter(Boolean)
166
+ for (const fileRef of fromBullets) {
167
+ if (!outputFiles.includes(fileRef)) outputFiles.push(fileRef)
168
+ }
169
+ }
170
+ } else if (section.startsWith('Task report: ')) {
171
+ reportPath = section.replace('Task report: ', '').replace(/^`|`$/g, '')
149
172
  } else if (/^(Claude session|Codex thread|OpenCode session|CLI session):/.test(section)) {
150
173
  resumeInfo = section
151
174
  } else if (section.trim()) {
@@ -153,12 +176,21 @@ export function parseTaskCompletion(text: string): TaskCompletionInfo | null {
153
176
  }
154
177
  }
155
178
 
156
- return { status, taskTitle, taskId, executorName, workingDir, resumeInfo, resultBody: resultParts.join('\n\n') }
179
+ return {
180
+ status,
181
+ taskTitle,
182
+ taskId,
183
+ executorName,
184
+ workingDir,
185
+ reportPath,
186
+ outputFiles,
187
+ resumeInfo,
188
+ resultBody: resultParts.join('\n\n'),
189
+ }
157
190
  }
158
191
 
159
192
  export function TaskCompletionCard({ info }: { info: TaskCompletionInfo }) {
160
193
  const isSuccess = info.status === 'completed'
161
- const [expanded, setExpanded] = useState(false)
162
194
 
163
195
  const handleTaskClick = () => {
164
196
  if (!info.taskId) return
@@ -170,10 +202,6 @@ export function TaskCompletionCard({ info }: { info: TaskCompletionInfo }) {
170
202
  })
171
203
  }
172
204
 
173
- // Truncate result for preview
174
- const resultPreview = info.resultBody.length > 200 ? info.resultBody.slice(0, 200) + '...' : info.resultBody
175
- const hasLongResult = info.resultBody.length > 200
176
-
177
205
  return (
178
206
  <div
179
207
  className="rounded-[14px] overflow-hidden"
@@ -278,6 +306,47 @@ export function TaskCompletionCard({ info }: { info: TaskCompletionInfo }) {
278
306
  </div>
279
307
  )}
280
308
 
309
+ {info.outputFiles.length > 0 && (
310
+ <div className="flex flex-col gap-1">
311
+ <span className="text-[11px] text-text-3/55">Output files</span>
312
+ <div className="flex flex-wrap gap-1.5">
313
+ {info.outputFiles.map((fileRef) => {
314
+ const openPath = info.workingDir && !fileRef.startsWith('/') && !fileRef.startsWith('~/')
315
+ ? `${info.workingDir.replace(/\/$/, '')}/${fileRef}`
316
+ : fileRef
317
+ return (
318
+ <button
319
+ key={fileRef}
320
+ type="button"
321
+ onClick={() => { api('POST', '/files/open', { path: openPath }).catch(() => {}) }}
322
+ className="px-2 py-1 rounded-[7px] text-[10px] font-mono bg-white/[0.03] border border-white/[0.08] text-text-3/70 hover:text-text-3 cursor-pointer max-w-full truncate"
323
+ title={`Open ${openPath}`}
324
+ >
325
+ {fileRef}
326
+ </button>
327
+ )
328
+ })}
329
+ </div>
330
+ </div>
331
+ )}
332
+
333
+ {info.reportPath && (
334
+ <div className="flex items-center gap-2">
335
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0 text-text-3/40">
336
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
337
+ <polyline points="14 2 14 8 20 8" />
338
+ </svg>
339
+ <button
340
+ type="button"
341
+ onClick={() => { api('POST', '/files/open', { path: info.reportPath }).catch(() => {}) }}
342
+ className="text-[11px] text-text-3/60 hover:text-text-3 font-mono truncate bg-transparent border-none p-0 cursor-pointer transition-colors"
343
+ title="Open task report"
344
+ >
345
+ {info.reportPath}
346
+ </button>
347
+ </div>
348
+ )}
349
+
281
350
  {/* Image artifact */}
282
351
  {info.imageUrl && (
283
352
  <div className="mt-0.5">
@@ -295,19 +364,35 @@ export function TaskCompletionCard({ info }: { info: TaskCompletionInfo }) {
295
364
  {/* Result body */}
296
365
  {info.resultBody && (
297
366
  <div className="mt-0.5">
298
- <div className="rounded-[10px] bg-white/[0.02] border border-white/[0.04] px-3 py-2.5">
299
- <pre className="text-[12px] leading-[1.6] text-text-3/80 whitespace-pre-wrap break-words m-0 font-mono">
300
- {expanded ? info.resultBody : resultPreview}
301
- </pre>
302
- {hasLongResult && (
303
- <button
304
- type="button"
305
- onClick={() => setExpanded((v) => !v)}
306
- className="mt-2 text-[11px] font-600 text-text-3/50 hover:text-text-3 bg-transparent border-none cursor-pointer p-0 transition-colors"
367
+ <div className="rounded-[10px] bg-white/[0.02] border border-white/[0.04] px-3 py-2.5 max-h-[260px] overflow-y-auto">
368
+ <div className="text-[12px] leading-[1.6] text-text-3/80 break-words">
369
+ <ReactMarkdown
370
+ components={{
371
+ a: ({ href, children }) => (
372
+ <a
373
+ href={href}
374
+ target="_blank"
375
+ rel="noreferrer"
376
+ className="text-emerald-300 hover:text-emerald-200 underline decoration-emerald-300/40"
377
+ >
378
+ {children}
379
+ </a>
380
+ ),
381
+ img: ({ src, alt }) => (
382
+ // eslint-disable-next-line @next/next/no-img-element
383
+ <img
384
+ src={src || ''}
385
+ alt={alt || 'Task artifact'}
386
+ loading="lazy"
387
+ className="max-w-full rounded-[8px] border border-white/[0.08] my-2"
388
+ />
389
+ ),
390
+ p: ({ children }) => <p className="m-0 mb-2">{children}</p>,
391
+ }}
307
392
  >
308
- {expanded ? 'Show less' : 'Show full result'}
309
- </button>
310
- )}
393
+ {info.resultBody}
394
+ </ReactMarkdown>
395
+ </div>
311
396
  </div>
312
397
  </div>
313
398
  )}
@@ -321,12 +406,13 @@ export function TaskCompletionCard({ info }: { info: TaskCompletionInfo }) {
321
406
  interface DelegationSourceBannerProps {
322
407
  delegatorName: string
323
408
  delegatorAvatarSeed: string | null
409
+ delegatorAvatarUrl?: string | null
324
410
  taskTitle: string
325
411
  taskId: string | null
326
412
  description: string
327
413
  }
328
414
 
329
- export function DelegationSourceBanner({ delegatorName, delegatorAvatarSeed, taskTitle, taskId, description }: DelegationSourceBannerProps) {
415
+ export function DelegationSourceBanner({ delegatorName, delegatorAvatarSeed, delegatorAvatarUrl, taskTitle, taskId, description }: DelegationSourceBannerProps) {
330
416
  const handleTaskClick = () => {
331
417
  if (!taskId) return
332
418
  const store = useAppStore.getState()
@@ -343,7 +429,7 @@ export function DelegationSourceBanner({ delegatorName, delegatorAvatarSeed, tas
343
429
  style={{ animation: 'delegation-handoff-in 0.45s cubic-bezier(0.16, 1, 0.3, 1)' }}
344
430
  >
345
431
  <div className="shrink-0 mt-0.5" style={{ animation: 'delegation-handoff-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both' }}>
346
- <AgentAvatar seed={delegatorAvatarSeed} name={delegatorName} size={24} />
432
+ <AgentAvatar seed={delegatorAvatarSeed} avatarUrl={delegatorAvatarUrl} name={delegatorName} size={24} />
347
433
  </div>
348
434
  <div className="flex flex-col gap-1 min-w-0 flex-1">
349
435
  <span className="text-[12px] font-600 text-indigo-400">
@@ -90,6 +90,12 @@ const STATUS_COLORS: Record<string, string> = {
90
90
  blocked: '#EF4444',
91
91
  }
92
92
 
93
+ function isGeneratedBrowserScreenshot(url: string): boolean {
94
+ const match = url.match(/\/api\/uploads\/([^/?#]+)/)
95
+ if (!match?.[1]) return false
96
+ return /^(browser|screenshot)-\d+\./i.test(match[1])
97
+ }
98
+
93
99
  // AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS
94
100
  // are now imported from @/components/shared/attachment-chip
95
101
 
@@ -130,6 +136,7 @@ interface Props {
130
136
  message: Message
131
137
  assistantName?: string
132
138
  agentAvatarSeed?: string
139
+ agentAvatarUrl?: string | null
133
140
  agentName?: string
134
141
  isLast?: boolean
135
142
  onRetry?: () => void
@@ -141,7 +148,7 @@ interface Props {
141
148
  momentOverlay?: React.ReactNode
142
149
  }
143
150
 
144
- export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork, onTransferToAgent, momentOverlay }: Props) {
151
+ export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentAvatarUrl, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork, onTransferToAgent, momentOverlay }: Props) {
145
152
  const isUser = message.role === 'user'
146
153
  const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
147
154
  const currentUser = useAppStore((s) => s.currentUser)
@@ -162,6 +169,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
162
169
  // Collect URLs from the visible (last) tool event to avoid showing duplicates
163
170
  const lastOutput = toolEvents[toolEvents.length - 1]?.output || ''
164
171
  const visibleMedia = extractMedia(lastOutput)
172
+ const hasNamedVisibleImage = visibleMedia.images.some((url) => !isGeneratedBrowserScreenshot(url))
165
173
  const seen = new Set<string>([
166
174
  ...visibleMedia.images,
167
175
  ...visibleMedia.videos,
@@ -175,7 +183,10 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
175
183
  for (const ev of toolEvents.slice(0, -1)) {
176
184
  if (!ev.output) continue
177
185
  const m = extractMedia(ev.output)
178
- for (const url of m.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
186
+ for (const url of m.images) {
187
+ if (hasNamedVisibleImage && isGeneratedBrowserScreenshot(url)) continue
188
+ if (!seen.has(url)) { seen.add(url); images.push(url) }
189
+ }
179
190
  for (const url of m.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
180
191
  for (const p of m.pdfs) { if (!seen.has(p.url)) { seen.add(p.url); pdfs.push(p) } }
181
192
  for (const f of m.files) { if (!seen.has(f.url)) { seen.add(f.url); files.push(f) } }
@@ -221,7 +232,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
221
232
  {!isUser && (
222
233
  <div className="absolute left-[4px] top-0">
223
234
  <div style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}>
224
- {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" />}
235
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={28} /> : <AiAvatar size="sm" />}
225
236
  </div>
226
237
  {momentOverlay}
227
238
  </div>
@@ -439,7 +439,7 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
439
439
  )}
440
440
  {filteredMessages.length === 0 && !streaming && (
441
441
  <div className="flex flex-col items-center justify-center gap-3 py-20 text-center" style={{ animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) both' }}>
442
- <AgentAvatar seed={agent?.avatarSeed || null} name={agent?.name || 'Agent'} size={48} />
442
+ <AgentAvatar seed={agent?.avatarSeed || null} avatarUrl={agent?.avatarUrl} name={agent?.name || 'Agent'} size={48} />
443
443
  <span className="font-display text-[16px] font-600 text-text-2">{agent?.name || 'Assistant'}</span>
444
444
  <span className="text-[14px] text-text-3/60">
445
445
  {INTRO_GREETINGS[stableHash(agent?.id || session?.id || '') % INTRO_GREETINGS.length]}
@@ -526,6 +526,7 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
526
526
  message={msg}
527
527
  assistantName={assistantName}
528
528
  agentAvatarSeed={agent?.avatarSeed}
529
+ agentAvatarUrl={agent?.avatarUrl}
529
530
  agentName={agent?.name}
530
531
  isLast={isLastAssistant}
531
532
  onRetry={isLastAssistant ? retryLastMessage : undefined}
@@ -540,9 +541,9 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
540
541
  )
541
542
  })}
542
543
  <ApprovalCards agentId={agent?.id} />
543
- {streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
544
- {streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
545
- {appSettings.suggestionsEnabled !== false && !streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
544
+ {streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentAvatarUrl={agent?.avatarUrl} agentName={agent?.name} />}
545
+ {streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentAvatarUrl={agent?.avatarUrl} agentName={agent?.name} />}
546
+ {appSettings.suggestionsEnabled === true && !streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
546
547
  <SuggestionsBar lastMessage={filteredMessages[filteredMessages.length - 1]} onSend={sendMessage} />
547
548
  )}
548
549
  </div>
@@ -104,10 +104,11 @@ interface Props {
104
104
  text: string
105
105
  assistantName?: string
106
106
  agentAvatarSeed?: string
107
+ agentAvatarUrl?: string | null
107
108
  agentName?: string
108
109
  }
109
110
 
110
- export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentName }: Props) {
111
+ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentAvatarUrl, agentName }: Props) {
111
112
  const toolEvents = useChatStore((s) => s.toolEvents)
112
113
  const streamPhase = useChatStore((s) => s.streamPhase)
113
114
  const streamToolName = useChatStore((s) => s.streamToolName)
@@ -137,7 +138,7 @@ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentNam
137
138
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}
138
139
  >
139
140
  <div className="absolute left-[4px] top-0 relative">
140
- {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
141
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
141
142
  {currentMoment && (
142
143
  <ActivityMoment
143
144
  key={currentMoment.id}
@@ -10,6 +10,7 @@ import { useChatStore } from '@/stores/use-chat-store'
10
10
  interface Props {
11
11
  assistantName?: string
12
12
  agentAvatarSeed?: string
13
+ agentAvatarUrl?: string | null
13
14
  agentName?: string
14
15
  }
15
16
 
@@ -34,7 +35,7 @@ function ElapsedTimer({ startTime }: { startTime: number }) {
34
35
  )
35
36
  }
36
37
 
37
- export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentName }: Props) {
38
+ export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentAvatarUrl, agentName }: Props) {
38
39
  const streamPhase = useChatStore((s) => s.streamPhase)
39
40
  const streamToolName = useChatStore((s) => s.streamToolName)
40
41
  const thinkingText = useChatStore((s) => s.thinkingText)
@@ -50,7 +51,7 @@ export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentName }:
50
51
  <div className="flex flex-col items-start relative pl-[44px]"
51
52
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}>
52
53
  <div className="absolute left-[4px] top-0">
53
- {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
54
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
54
55
  </div>
55
56
  <div className="flex items-center gap-2.5 mb-2 px-1">
56
57
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
@@ -0,0 +1,28 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { extractMedia } from './tool-call-bubble'
4
+
5
+ describe('extractMedia', () => {
6
+ it('dedupes browser-* screenshot variants when screenshot-* exists', () => {
7
+ const output = [
8
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
9
+ '![Screenshot](/api/uploads/screenshot-1772498741526.png)',
10
+ 'Saved to: example_screenshot.png',
11
+ ].join('\n')
12
+
13
+ const media = extractMedia(output)
14
+ assert.deepEqual(media.images, ['/api/uploads/screenshot-1772498741526.png'])
15
+ assert.equal(media.cleanText, 'Saved to: example_screenshot.png')
16
+ })
17
+
18
+ it('keeps browser-* screenshot when it is the only image artifact', () => {
19
+ const output = [
20
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
21
+ 'Saved to: example_screenshot.png',
22
+ ].join('\n')
23
+
24
+ const media = extractMedia(output)
25
+ assert.deepEqual(media.images, ['/api/uploads/browser-1772498741525.png'])
26
+ assert.equal(media.cleanText, 'Saved to: example_screenshot.png')
27
+ })
28
+ })
@@ -265,6 +265,7 @@ export function extractMedia(output: string): { images: string[]; videos: string
265
265
  const videos: string[] = []
266
266
  const pdfs: { name: string; url: string }[] = []
267
267
  const files: { name: string; url: string }[] = []
268
+ const imageEntries: Array<{ filename: string; url: string }> = []
268
269
 
269
270
  // Extract ![alt](/api/uploads/filename) — detect videos vs images by extension
270
271
  let cleanText = output.replace(/!\[([^\]]*)\]\(\/api\/uploads\/([^)]+)\)/g, (_match, _alt, filename) => {
@@ -272,7 +273,7 @@ export function extractMedia(output: string): { images: string[]; videos: string
272
273
  if (/\.(mp4|webm|mov|avi)$/i.test(filename)) {
273
274
  videos.push(url)
274
275
  } else {
275
- images.push(url)
276
+ imageEntries.push({ filename, url })
276
277
  }
277
278
  return ''
278
279
  })
@@ -291,6 +292,17 @@ export function extractMedia(output: string): { images: string[]; videos: string
291
292
  // Clean up leftover whitespace
292
293
  cleanText = cleanText.replace(/\n{3,}/g, '\n\n').trim()
293
294
 
295
+ // Playwright screenshot calls can surface both browser-*.png and screenshot-*.png
296
+ // for the same capture; prefer screenshot-* to avoid duplicate UI images.
297
+ const hasScreenshotVariant = imageEntries.some((entry) => /^screenshot-\d+\./i.test(entry.filename))
298
+ const seenImages = new Set<string>()
299
+ for (const entry of imageEntries) {
300
+ if (hasScreenshotVariant && /^browser-\d+\./i.test(entry.filename)) continue
301
+ if (seenImages.has(entry.url)) continue
302
+ seenImages.add(entry.url)
303
+ images.push(entry.url)
304
+ }
305
+
294
306
  return { images, videos, pdfs, files, cleanText }
295
307
  }
296
308
 
@@ -52,7 +52,7 @@ export function TransferAgentPicker({ excludeIds, filterIds, onSelect, onClose }
52
52
  className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none"
53
53
  style={{ fontFamily: 'inherit' }}
54
54
  >
55
- <AgentAvatar seed={a.avatarSeed} name={a.name} size={20} />
55
+ <AgentAvatar seed={a.avatarSeed} avatarUrl={a.avatarUrl} name={a.name} size={20} />
56
56
  <span className="text-[12px] text-text truncate">{a.name}</span>
57
57
  </button>
58
58
  ))}
@@ -46,7 +46,7 @@ export function AgentHoverCard({ agent, children, status }: Props) {
46
46
  <HoverCardContent align="start" className="w-[280px]">
47
47
  {/* Header: avatar + name + model */}
48
48
  <div className="flex items-center gap-2">
49
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={28} status={status} />
49
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={28} status={status} />
50
50
  <div className="min-w-0 flex-1">
51
51
  <div className="text-[13px] font-600 text-text truncate">{agent.name}</div>
52
52
  <div className="label-mono truncate">{agent.model}</div>