@swarmclawai/swarmclaw 0.6.0 → 0.6.3

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 (118) hide show
  1. package/README.md +56 -42
  2. package/bin/server-cmd.js +1 -0
  3. package/package.json +2 -1
  4. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
  6. package/src/app/api/connectors/[id]/route.ts +1 -0
  7. package/src/app/api/connectors/route.ts +2 -1
  8. package/src/app/api/files/open/route.ts +43 -0
  9. package/src/app/api/search/route.ts +9 -7
  10. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  11. package/src/app/api/sessions/[id]/route.ts +4 -0
  12. package/src/app/api/tasks/metrics/route.ts +101 -0
  13. package/src/app/api/tasks/route.ts +17 -2
  14. package/src/app/api/tts/route.ts +16 -35
  15. package/src/app/api/tts/stream/route.ts +14 -42
  16. package/src/app/api/uploads/[filename]/route.ts +19 -34
  17. package/src/app/api/uploads/route.ts +94 -0
  18. package/src/app/globals.css +5 -0
  19. package/src/cli/index.js +16 -1
  20. package/src/cli/spec.js +26 -0
  21. package/src/components/agents/agent-card.tsx +3 -3
  22. package/src/components/agents/agent-chat-list.tsx +29 -6
  23. package/src/components/agents/agent-sheet.tsx +66 -4
  24. package/src/components/agents/inspector-panel.tsx +81 -6
  25. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  26. package/src/components/agents/personality-builder.tsx +42 -14
  27. package/src/components/agents/soul-library-picker.tsx +89 -0
  28. package/src/components/canvas/canvas-panel.tsx +96 -0
  29. package/src/components/chat/activity-moment.tsx +8 -4
  30. package/src/components/chat/chat-area.tsx +76 -24
  31. package/src/components/chat/chat-header.tsx +522 -286
  32. package/src/components/chat/chat-preview-panel.tsx +1 -2
  33. package/src/components/chat/delegation-banner.tsx +371 -0
  34. package/src/components/chat/file-path-chip.tsx +23 -2
  35. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  36. package/src/components/chat/message-bubble.tsx +315 -25
  37. package/src/components/chat/message-list.tsx +113 -8
  38. package/src/components/chat/streaming-bubble.tsx +68 -1
  39. package/src/components/chat/tool-call-bubble.tsx +45 -3
  40. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  41. package/src/components/chatrooms/chatroom-list.tsx +8 -1
  42. package/src/components/chatrooms/chatroom-message.tsx +8 -3
  43. package/src/components/chatrooms/chatroom-view.tsx +3 -3
  44. package/src/components/connectors/connector-list.tsx +168 -90
  45. package/src/components/connectors/connector-sheet.tsx +84 -17
  46. package/src/components/home/home-view.tsx +1 -1
  47. package/src/components/input/chat-input.tsx +28 -2
  48. package/src/components/layout/app-layout.tsx +19 -2
  49. package/src/components/projects/project-detail.tsx +1 -1
  50. package/src/components/schedules/schedule-sheet.tsx +260 -127
  51. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  54. package/src/components/shared/connector-platform-icon.tsx +51 -4
  55. package/src/components/shared/icon-button.tsx +16 -2
  56. package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
  57. package/src/components/shared/search-dialog.tsx +17 -10
  58. package/src/components/shared/settings/section-embedding.tsx +48 -13
  59. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  60. package/src/components/shared/settings/section-storage.tsx +206 -0
  61. package/src/components/shared/settings/section-user-preferences.tsx +18 -0
  62. package/src/components/shared/settings/section-voice.tsx +42 -21
  63. package/src/components/shared/settings/section-web-search.tsx +30 -6
  64. package/src/components/shared/settings/settings-page.tsx +3 -1
  65. package/src/components/shared/settings/storage-browser.tsx +259 -0
  66. package/src/components/tasks/task-card.tsx +14 -1
  67. package/src/components/tasks/task-sheet.tsx +328 -3
  68. package/src/components/usage/metrics-dashboard.tsx +90 -6
  69. package/src/hooks/use-continuous-speech.ts +10 -4
  70. package/src/hooks/use-voice-conversation.ts +53 -10
  71. package/src/hooks/use-ws.ts +4 -2
  72. package/src/lib/providers/anthropic.ts +13 -7
  73. package/src/lib/providers/index.ts +1 -0
  74. package/src/lib/providers/openai.ts +13 -7
  75. package/src/lib/server/chat-execution.ts +125 -14
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/connector-routing.test.ts +118 -1
  78. package/src/lib/server/connectors/discord.ts +31 -8
  79. package/src/lib/server/connectors/manager.ts +594 -16
  80. package/src/lib/server/connectors/media.ts +5 -0
  81. package/src/lib/server/connectors/telegram.ts +12 -2
  82. package/src/lib/server/connectors/types.ts +2 -0
  83. package/src/lib/server/connectors/whatsapp.ts +28 -2
  84. package/src/lib/server/elevenlabs.test.ts +60 -0
  85. package/src/lib/server/elevenlabs.ts +103 -0
  86. package/src/lib/server/heartbeat-service.ts +8 -1
  87. package/src/lib/server/main-agent-loop.ts +1 -1
  88. package/src/lib/server/memory-consolidation.ts +15 -2
  89. package/src/lib/server/memory-db.ts +134 -6
  90. package/src/lib/server/mime.ts +51 -0
  91. package/src/lib/server/openclaw-gateway.ts +2 -2
  92. package/src/lib/server/orchestrator-lg.ts +2 -0
  93. package/src/lib/server/orchestrator.ts +5 -2
  94. package/src/lib/server/playwright-proxy.mjs +2 -3
  95. package/src/lib/server/prompt-runtime-context.ts +53 -0
  96. package/src/lib/server/queue.ts +182 -8
  97. package/src/lib/server/session-tools/canvas.ts +67 -0
  98. package/src/lib/server/session-tools/connector.ts +583 -63
  99. package/src/lib/server/session-tools/crud.ts +21 -0
  100. package/src/lib/server/session-tools/delegate.ts +68 -4
  101. package/src/lib/server/session-tools/file.ts +26 -7
  102. package/src/lib/server/session-tools/git.ts +71 -0
  103. package/src/lib/server/session-tools/http.ts +57 -0
  104. package/src/lib/server/session-tools/index.ts +8 -0
  105. package/src/lib/server/session-tools/memory.ts +1 -0
  106. package/src/lib/server/session-tools/search-providers.ts +16 -8
  107. package/src/lib/server/session-tools/subagent.ts +106 -0
  108. package/src/lib/server/session-tools/web.ts +118 -8
  109. package/src/lib/server/stream-agent-chat.ts +39 -10
  110. package/src/lib/server/task-mention.ts +41 -0
  111. package/src/lib/sessions.ts +10 -0
  112. package/src/lib/soul-library.ts +103 -0
  113. package/src/lib/task-dedupe.ts +26 -0
  114. package/src/lib/tool-definitions.ts +2 -0
  115. package/src/lib/tts.ts +2 -2
  116. package/src/stores/use-app-store.ts +5 -1
  117. package/src/stores/use-chat-store.ts +65 -2
  118. package/src/types/index.ts +32 -2
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { memo, useState, useCallback } from 'react'
3
+ import { memo, useState, useCallback, useMemo } from 'react'
4
4
  import ReactMarkdown from 'react-markdown'
5
5
  import remarkGfm from 'remark-gfm'
6
6
  import rehypeHighlight from 'rehype-highlight'
@@ -9,12 +9,27 @@ import { useAppStore } from '@/stores/use-app-store'
9
9
  import { AiAvatar } from '@/components/shared/avatar'
10
10
  import { AgentAvatar } from '@/components/agents/agent-avatar'
11
11
  import { CodeBlock } from './code-block'
12
- import { ToolCallBubble } from './tool-call-bubble'
12
+ import { ToolCallBubble, extractMedia } from './tool-call-bubble'
13
13
  import { ToolRequestBanner } from './tool-request-banner'
14
14
  import { AttachmentChip, parseAttachmentUrl } from '@/components/shared/attachment-chip'
15
15
  import { isStructuredMarkdown } from './markdown-utils'
16
16
  import { FilePathChip, FILE_PATH_RE, DIR_PATH_RE } from './file-path-chip'
17
17
  import { TransferAgentPicker } from './transfer-agent-picker'
18
+ import { DelegationBanner, DelegationSourceBanner, TaskCompletionCard, parseTaskCompletion } from './delegation-banner'
19
+ import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
20
+
21
+ /** Parse delegation-source metadata prefix from system messages */
22
+ const DELEGATION_SOURCE_RE = /^\[delegation-source:([^:]*):([^:]*):([^\]]*)\]/
23
+ function parseDelegationSource(text: string): { delegatorId: string; delegatorName: string; delegatorAvatarSeed: string; rest: string } | null {
24
+ const m = text.match(DELEGATION_SOURCE_RE)
25
+ if (!m) return null
26
+ return { delegatorId: m[1], delegatorName: m[2], delegatorAvatarSeed: m[3], rest: text.slice(m[0].length).replace(/^\n/, '') }
27
+ }
28
+
29
+ /** Try to parse JSON safely, returning null on failure */
30
+ function tryParseJson(s: string): Record<string, unknown> | null {
31
+ try { return JSON.parse(s) } catch { return null }
32
+ }
18
33
 
19
34
  function fmtTime(ts: number): string {
20
35
  return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -33,9 +48,26 @@ function relativeTime(ts: number): string {
33
48
  return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
34
49
  }
35
50
 
51
+ interface HeartbeatMeta {
52
+ goal?: string
53
+ status?: string
54
+ next_action?: string
55
+ }
56
+
57
+ function parseHeartbeatMeta(text: string): HeartbeatMeta | null {
58
+ const match = text.match(/\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i)
59
+ if (!match?.[1]) return null
60
+ try {
61
+ const parsed = JSON.parse(match[1])
62
+ if (typeof parsed === 'object' && parsed !== null) return parsed as HeartbeatMeta
63
+ } catch { /* ignore */ }
64
+ return null
65
+ }
66
+
36
67
  function heartbeatSummary(text: string): string {
37
68
  const clean = (text || '')
38
69
  .replace(/\bHEARTBEAT_OK\b/gi, '')
70
+ .replace(/\[AGENT_HEARTBEAT_META\]\s*\{[^\n]*\}/gi, '')
39
71
  .replace(/\*\*(.*?)\*\*/g, '$1')
40
72
  .replace(/\*(.*?)\*/g, '$1')
41
73
  .replace(/`([^`]+)`/g, '$1')
@@ -51,6 +83,13 @@ function heartbeatSummary(text: string): string {
51
83
  return clean.length > 180 ? `${clean.slice(0, 180)}...` : clean
52
84
  }
53
85
 
86
+ const STATUS_COLORS: Record<string, string> = {
87
+ progress: '#F59E0B',
88
+ ok: '#22C55E',
89
+ idle: '#6B7280',
90
+ blocked: '#EF4444',
91
+ }
92
+
54
93
  // AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS
55
94
  // are now imported from @/components/shared/attachment-chip
56
95
 
@@ -117,6 +156,55 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
117
156
  const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
118
157
  const isStructured = !isUser && !isHeartbeat && isStructuredMarkdown(message.text)
119
158
 
159
+ // When collapsed, collect media from hidden tool events so files are always visible
160
+ const hiddenMedia = useMemo(() => {
161
+ if (toolEventsExpanded || toolEvents.length <= 1) return null
162
+ // Collect URLs from the visible (last) tool event to avoid showing duplicates
163
+ const lastOutput = toolEvents[toolEvents.length - 1]?.output || ''
164
+ const visibleMedia = extractMedia(lastOutput)
165
+ const seen = new Set<string>([
166
+ ...visibleMedia.images,
167
+ ...visibleMedia.videos,
168
+ ...visibleMedia.pdfs.map((p) => p.url),
169
+ ...visibleMedia.files.map((f) => f.url),
170
+ ])
171
+ const images: string[] = []
172
+ const videos: string[] = []
173
+ const pdfs: { name: string; url: string }[] = []
174
+ const files: { name: string; url: string }[] = []
175
+ for (const ev of toolEvents.slice(0, -1)) {
176
+ if (!ev.output) continue
177
+ const m = extractMedia(ev.output)
178
+ for (const url of m.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
179
+ for (const url of m.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
180
+ for (const p of m.pdfs) { if (!seen.has(p.url)) { seen.add(p.url); pdfs.push(p) } }
181
+ for (const f of m.files) { if (!seen.has(f.url)) { seen.add(f.url); files.push(f) } }
182
+ }
183
+ if (!images.length && !videos.length && !pdfs.length && !files.length) return null
184
+ return { images, videos, pdfs, files }
185
+ // eslint-disable-next-line react-hooks/exhaustive-deps
186
+ }, [message.toolEvents, toolEventsExpanded])
187
+
188
+ // Collect all media URLs already rendered via tool events to avoid duplicates in markdown
189
+ const toolEventMediaUrls = useMemo(() => {
190
+ if (!toolEvents.length) return null
191
+ const urls = new Set<string>()
192
+ for (const ev of toolEvents) {
193
+ if (!ev.output) continue
194
+ const m = extractMedia(ev.output)
195
+ for (const url of m.images) urls.add(url)
196
+ for (const url of m.videos) urls.add(url)
197
+ }
198
+ return urls.size > 0 ? urls : null
199
+ // eslint-disable-next-line react-hooks/exhaustive-deps
200
+ }, [message.toolEvents])
201
+
202
+ // Detect delegation-source system messages
203
+ const delegationSource = !isUser && message.kind === 'system' ? parseDelegationSource(message.text || '') : null
204
+ // Detect task completion system messages (delegated or direct)
205
+ const taskCompletion = !isUser && message.kind === 'system' ? parseTaskCompletion(message.text || '') : null
206
+ const displayText = delegationSource ? delegationSource.rest : message.text
207
+
120
208
  const handleCopy = useCallback(() => {
121
209
  navigator.clipboard.writeText(message.text).then(() => {
122
210
  setCopied(true)
@@ -140,8 +228,15 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
140
228
  )}
141
229
  {/* Sender label + timestamp */}
142
230
  <div className={`flex items-center gap-2.5 mb-2 px-1 ${isUser ? 'flex-row-reverse' : ''}`}>
143
- <span className={`text-[12px] font-600 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
144
- {isUser ? (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You') : (assistantName || 'Claude')}
231
+ <span className={`text-[12px] font-600 flex items-center gap-1.5 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
232
+ {message.source && (
233
+ <ConnectorPlatformIcon platform={message.source.platform} size={12} />
234
+ )}
235
+ {isUser
236
+ ? (message.source?.senderName
237
+ ? `${message.source.senderName} via ${CONNECTOR_PLATFORM_META[message.source.platform]?.label || message.source.platform}`
238
+ : (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You'))
239
+ : (assistantName || 'Claude')}
145
240
  </span>
146
241
  <span className="text-[11px] text-text-3/70 font-mono" title={message.time ? new Date(message.time).toLocaleString() : ''}>
147
242
  {message.time ? relativeTime(message.time) : ''}
@@ -161,23 +256,183 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
161
256
  </button>
162
257
  )}
163
258
  <div className={`${toolEventsExpanded ? 'max-h-[320px] overflow-y-auto pr-1 flex flex-col gap-2' : 'flex flex-col gap-2'}`}>
164
- {visibleToolEvents.map((event, i) => (
165
- <ToolCallBubble
166
- key={`${message.time}-tool-${toolEventsExpanded ? `all-${i}` : `latest-${toolEvents.length - 1}`}`}
167
- event={{
168
- id: `${message.time}-${toolEventsExpanded ? i : toolEvents.length - 1}`,
169
- name: event.name,
170
- input: event.input,
171
- output: event.output,
172
- status: event.error ? 'error' : 'done',
259
+ {visibleToolEvents.map((event, i) => {
260
+ const key = `${message.time}-tool-${toolEventsExpanded ? `all-${i}` : `latest-${toolEvents.length - 1}`}`
261
+
262
+ if (event.name === 'delegate_to_agent') {
263
+ const inp = tryParseJson(event.input || '{}')
264
+ const out = tryParseJson(event.output || '{}')
265
+ return (
266
+ <DelegationBanner
267
+ key={key}
268
+ agentName={out?.agentName as string || inp?.agentName as string || inp?.agentId as string || 'Agent'}
269
+ agentAvatarSeed={(out?.agentAvatarSeed as string) || null}
270
+ taskPreview={(inp?.task as string || '').slice(0, 100)}
271
+ taskId={(out?.taskId as string) || null}
272
+ status="delegating"
273
+ />
274
+ )
275
+ }
276
+
277
+ if (event.name === 'check_delegation_status') {
278
+ const out = tryParseJson(event.output || '{}')
279
+ const rawStatus = out?.status as string || ''
280
+ const mapped = rawStatus === 'completed' ? 'completed' as const
281
+ : rawStatus === 'failed' ? 'failed' as const
282
+ : 'checking' as const
283
+ return (
284
+ <DelegationBanner
285
+ key={key}
286
+ agentName={out?.agentName as string || 'Agent'}
287
+ agentAvatarSeed={(out?.agentAvatarSeed as string) || null}
288
+ taskPreview={(out?.title as string || '').slice(0, 100)}
289
+ taskId={(out?.taskId as string) || null}
290
+ status={mapped}
291
+ />
292
+ )
293
+ }
294
+
295
+ return (
296
+ <ToolCallBubble
297
+ key={key}
298
+ event={{
299
+ id: `${message.time}-${toolEventsExpanded ? i : toolEvents.length - 1}`,
300
+ name: event.name,
301
+ input: event.input,
302
+ output: event.output,
303
+ status: event.error ? 'error' : 'done',
304
+ }}
305
+ />
306
+ )
307
+ })}
308
+ </div>
309
+ </div>
310
+ )}
311
+
312
+ {/* Media from hidden tool calls (shown when collapsed so files are never buried) */}
313
+ {hiddenMedia && (
314
+ <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
315
+ {hiddenMedia.images.map((src, i) => (
316
+ <div key={`himg-${i}`} className="relative group/img">
317
+ {/* eslint-disable-next-line @next/next/no-img-element */}
318
+ <img
319
+ src={src}
320
+ alt={`Screenshot ${i + 1}`}
321
+ loading="lazy"
322
+ className="max-w-[400px] rounded-[10px] border border-white/10 cursor-pointer hover:border-white/25 transition-all"
323
+ onClick={() => {
324
+ import('@/stores/use-chat-store').then(({ useChatStore }) =>
325
+ useChatStore.getState().setPreviewContent({ type: 'image', url: src, title: `Screenshot ${i + 1}` })
326
+ )
173
327
  }}
328
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
174
329
  />
175
- ))}
176
- </div>
330
+ <a
331
+ href={src}
332
+ download
333
+ onClick={(e) => e.stopPropagation()}
334
+ className="absolute top-2 right-2 bg-black/60 backdrop-blur-sm rounded-[8px] p-1.5 hover:bg-black/80 opacity-0 group-hover/img:opacity-100 transition-opacity"
335
+ title="Download"
336
+ >
337
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
338
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
339
+ <polyline points="7 10 12 15 17 10" />
340
+ <line x1="12" y1="15" x2="12" y2="3" />
341
+ </svg>
342
+ </a>
343
+ </div>
344
+ ))}
345
+ {hiddenMedia.videos.map((src, i) => (
346
+ <video key={`hvid-${i}`} src={src} controls playsInline preload="none" className="max-w-full rounded-[10px] border border-white/10" />
347
+ ))}
348
+ {hiddenMedia.pdfs.map((file, i) => (
349
+ <div key={`hpdf-${i}`} className="rounded-[10px] border border-white/10 overflow-hidden">
350
+ <iframe src={file.url} loading="lazy" className="w-full h-[400px] bg-white" title={file.name} />
351
+ <a
352
+ href={file.url}
353
+ download
354
+ onClick={(e) => e.stopPropagation()}
355
+ className="flex items-center gap-2 px-3 py-2 bg-surface/80 border-t border-white/10 text-[12px] text-text-2 hover:text-text no-underline transition-colors"
356
+ >
357
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
358
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
359
+ <polyline points="7 10 12 15 17 10" />
360
+ <line x1="12" y1="15" x2="12" y2="3" />
361
+ </svg>
362
+ {file.name}
363
+ </a>
364
+ </div>
365
+ ))}
366
+ {hiddenMedia.files.map((file, i) => (
367
+ <a
368
+ key={`hfile-${i}`}
369
+ href={file.url}
370
+ download
371
+ onClick={(e) => e.stopPropagation()}
372
+ className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/10 bg-surface/60 hover:bg-surface-2 transition-colors text-[13px] text-text-2 hover:text-text no-underline"
373
+ >
374
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
375
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
376
+ <polyline points="14 2 14 8 20 8" />
377
+ </svg>
378
+ {file.name}
379
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="ml-auto opacity-50">
380
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
381
+ <polyline points="7 10 12 15 17 10" />
382
+ <line x1="12" y1="15" x2="12" y2="3" />
383
+ </svg>
384
+ </a>
385
+ ))}
386
+ </div>
387
+ )}
388
+
389
+ {/* Thinking block (collapsible, shown for assistant messages with persisted thinking) */}
390
+ {!isUser && message.thinking && (
391
+ <div className="max-w-[85%] md:max-w-[72%] mb-2">
392
+ <details className="group rounded-[12px] border border-purple-500/15 bg-purple-500/[0.04]">
393
+ <summary className="flex items-center gap-2 px-3.5 py-2.5 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
394
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-purple-400/60 shrink-0 transition-transform group-open:rotate-90">
395
+ <polyline points="9 18 15 12 9 6" />
396
+ </svg>
397
+ <span className="text-[11px] font-600 text-purple-400/70 uppercase tracking-[0.05em]">Thinking</span>
398
+ <span className="text-[10px] text-text-3/40 font-mono">{Math.ceil(message.thinking.length / 4)} tokens</span>
399
+ </summary>
400
+ <div className="px-3.5 pb-3 pt-1 max-h-[300px] overflow-y-auto">
401
+ <div className="text-[13px] leading-[1.6] text-text-3/70 whitespace-pre-wrap break-words">
402
+ {message.thinking}
403
+ </div>
404
+ </div>
405
+ </details>
177
406
  </div>
178
407
  )}
179
408
 
180
- {/* Message bubble */}
409
+ {/* Delegation source banner (receiving agent's chat) */}
410
+ {delegationSource && (() => {
411
+ const taskLinkMatch = delegationSource.rest.match(/\[([^\]]+)\]\(#task:([^)]+)\)/)
412
+ const dsTaskTitle = taskLinkMatch?.[1] || ''
413
+ const dsTaskId = taskLinkMatch?.[2] || null
414
+ const descLines = delegationSource.rest.split('\n\n').slice(1).filter((l) => !l.startsWith('Working directory:') && !l.startsWith("I'll begin"))
415
+ const dsDescription = descLines.join(' ').trim().slice(0, 200)
416
+ return (
417
+ <div className="max-w-[85%] md:max-w-[72%] mb-2">
418
+ <DelegationSourceBanner
419
+ delegatorName={delegationSource.delegatorName}
420
+ delegatorAvatarSeed={delegationSource.delegatorAvatarSeed || null}
421
+ taskTitle={dsTaskTitle}
422
+ taskId={dsTaskId}
423
+ description={dsDescription}
424
+ />
425
+ </div>
426
+ )
427
+ })()}
428
+
429
+ {/* Task completion card (replaces bubble for task result system messages) */}
430
+ {taskCompletion ? (
431
+ <div className="max-w-[85%] md:max-w-[72%]">
432
+ <TaskCompletionCard info={{ ...taskCompletion, imageUrl: message.imageUrl }} />
433
+ </div>
434
+ ) : (
435
+ /* Message bubble */
181
436
  <div className={`${isStructured ? 'max-w-[92%] md:max-w-[85%]' : 'max-w-[85%] md:max-w-[72%]'} ${isUser ? 'bubble-user px-5 py-3.5' : isHeartbeat ? 'bubble-ai px-4 py-3' : 'bubble-ai px-5 py-3.5'}`}>
182
437
  {renderAttachments(message)}
183
438
 
@@ -190,12 +445,43 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
190
445
  >
191
446
  <div className="flex items-center justify-between gap-3">
192
447
  <div className="flex items-center gap-2">
193
- <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
448
+ {(() => {
449
+ const meta = parseHeartbeatMeta(message.text)
450
+ const statusColor = meta?.status ? (STATUS_COLORS[meta.status] || '#6B7280') : '#22C55E'
451
+ return <span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: statusColor }} />
452
+ })()}
194
453
  <span className="text-[11px] uppercase tracking-[0.08em] text-text-2 font-600">Heartbeat</span>
454
+ {(() => {
455
+ const meta = parseHeartbeatMeta(message.text)
456
+ if (!meta?.status) return null
457
+ const color = STATUS_COLORS[meta.status] || '#6B7280'
458
+ return <span className="text-[10px] font-500 px-1.5 py-0.5 rounded-[4px]" style={{ color, background: `${color}18` }}>{meta.status}</span>
459
+ })()}
195
460
  </div>
196
461
  <span className="text-[11px] text-text-3">{heartbeatExpanded ? 'Collapse' : 'Expand'}</span>
197
462
  </div>
198
- <p className="text-[13px] text-text-2/90 leading-[1.5] mt-1.5">{heartbeatSummary(message.text)}</p>
463
+ {(() => {
464
+ const meta = parseHeartbeatMeta(message.text)
465
+ if (meta && (meta.goal || meta.next_action)) {
466
+ return (
467
+ <div className="mt-2 flex flex-col gap-1">
468
+ {meta.goal && (
469
+ <div className="flex items-baseline gap-1.5">
470
+ <span className="text-[10px] uppercase tracking-[0.06em] text-text-3 font-600 shrink-0">Goal</span>
471
+ <span className="text-[12px] text-text-2/90 truncate">{meta.goal}</span>
472
+ </div>
473
+ )}
474
+ {meta.next_action && (
475
+ <div className="flex items-baseline gap-1.5">
476
+ <span className="text-[10px] uppercase tracking-[0.06em] text-text-3 font-600 shrink-0">Next</span>
477
+ <span className="text-[12px] text-text-2/90 truncate">{meta.next_action}</span>
478
+ </div>
479
+ )}
480
+ </div>
481
+ )
482
+ }
483
+ return <p className="text-[13px] text-text-2/90 leading-[1.5] mt-1.5">{heartbeatSummary(message.text)}</p>
484
+ })()}
199
485
  </button>
200
486
  {heartbeatExpanded && (
201
487
  <div className="msg-content text-[14px] leading-[1.7] text-text break-words px-3 py-2 rounded-[10px] border border-white/[0.08] bg-black/20">
@@ -213,7 +499,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
213
499
  },
214
500
  }}
215
501
  >
216
- {message.text}
502
+ {message.text.replace(/\[AGENT_HEARTBEAT_META\]\s*\{[^\n]*\}/gi, '').trim()}
217
503
  </ReactMarkdown>
218
504
  </div>
219
505
  )}
@@ -241,6 +527,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
241
527
  },
242
528
  img({ src, alt }) {
243
529
  if (!src || typeof src !== 'string') return null
530
+ // Skip images already rendered via tool events
531
+ if (toolEventMediaUrls?.has(src)) return null
244
532
  const isVideo = /\.(mp4|webm|mov|avi)$/i.test(src)
245
533
  if (isVideo) {
246
534
  return (
@@ -264,6 +552,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
264
552
  onClick={async () => {
265
553
  const store = useAppStore.getState()
266
554
  await store.loadTasks(true)
555
+ store.setTaskSheetViewOnly(true)
267
556
  store.setEditingTaskId(taskMatch[1])
268
557
  store.setTaskSheetOpen(true)
269
558
  }}
@@ -336,11 +625,12 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
336
625
  },
337
626
  }}
338
627
  >
339
- {message.text}
628
+ {displayText}
340
629
  </ReactMarkdown>
341
630
  </div>
342
631
  )}
343
632
  </div>
633
+ )}
344
634
 
345
635
  {/* Tool access request banners */}
346
636
  {!isUser && <ToolRequestBanner
@@ -351,10 +641,10 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
351
641
  {/* Bookmark indicator */}
352
642
  {message.bookmarked && (
353
643
  <div className={`flex items-center gap-1 mt-1 px-1 ${isUser ? 'justify-end' : ''}`}>
354
- <svg width="10" height="10" viewBox="0 0 24 24" fill="#F59E0B" stroke="#F59E0B" strokeWidth="2" className="shrink-0">
644
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" className="shrink-0 text-amber-400">
355
645
  <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
356
646
  </svg>
357
- <span className="text-[10px] text-[#F59E0B]/70 font-600">Bookmarked</span>
647
+ <span className="text-[10px] text-amber-400/70 font-600">Bookmarked</span>
358
648
  </div>
359
649
  )}
360
650
 
@@ -377,9 +667,9 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
377
667
  <button
378
668
  onClick={() => onToggleBookmark(messageIndex)}
379
669
  aria-label={message.bookmarked ? 'Remove bookmark' : 'Bookmark message'}
380
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
381
- text-[11px] font-500 cursor-pointer hover:bg-white/[0.04] transition-all"
382
- style={{ fontFamily: 'inherit', color: message.bookmarked ? '#F59E0B' : undefined }}
670
+ className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
671
+ text-[11px] font-500 cursor-pointer hover:bg-white/[0.04] transition-all ${message.bookmarked ? 'text-amber-400' : ''}`}
672
+ style={{ fontFamily: 'inherit' }}
383
673
  >
384
674
  <svg width="12" height="12" viewBox="0 0 24 24" fill={message.bookmarked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
385
675
  <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />