@swarmclawai/swarmclaw 0.5.3 → 0.6.2

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 (224) hide show
  1. package/README.md +53 -9
  2. package/bin/server-cmd.js +1 -0
  3. package/bin/swarmclaw.js +76 -16
  4. package/next.config.ts +11 -1
  5. package/package.json +5 -2
  6. package/scripts/postinstall.mjs +18 -0
  7. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  8. package/src/app/api/chatrooms/[id]/chat/route.ts +284 -0
  9. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  10. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  11. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  12. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  13. package/src/app/api/chatrooms/route.ts +50 -0
  14. package/src/app/api/connectors/[id]/route.ts +1 -0
  15. package/src/app/api/connectors/route.ts +2 -1
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/files/open/route.ts +43 -0
  18. package/src/app/api/knowledge/[id]/route.ts +13 -2
  19. package/src/app/api/knowledge/route.ts +8 -1
  20. package/src/app/api/memory/route.ts +8 -0
  21. package/src/app/api/notifications/route.ts +4 -0
  22. package/src/app/api/orchestrator/run/route.ts +1 -1
  23. package/src/app/api/plugins/install/route.ts +2 -2
  24. package/src/app/api/search/route.ts +53 -1
  25. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  26. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  27. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  28. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  29. package/src/app/api/sessions/[id]/route.ts +4 -0
  30. package/src/app/api/sessions/route.ts +3 -3
  31. package/src/app/api/settings/route.ts +9 -0
  32. package/src/app/api/setup/check-provider/route.ts +3 -16
  33. package/src/app/api/skills/[id]/route.ts +6 -0
  34. package/src/app/api/skills/route.ts +6 -0
  35. package/src/app/api/tasks/[id]/route.ts +12 -0
  36. package/src/app/api/tasks/bulk/route.ts +100 -0
  37. package/src/app/api/tasks/metrics/route.ts +101 -0
  38. package/src/app/api/tasks/route.ts +18 -2
  39. package/src/app/api/tts/route.ts +3 -2
  40. package/src/app/api/tts/stream/route.ts +3 -2
  41. package/src/app/api/uploads/[filename]/route.ts +19 -34
  42. package/src/app/api/uploads/route.ts +94 -0
  43. package/src/app/api/webhooks/[id]/route.ts +15 -1
  44. package/src/app/globals.css +63 -15
  45. package/src/app/page.tsx +142 -13
  46. package/src/cli/index.js +40 -1
  47. package/src/cli/index.test.js +30 -0
  48. package/src/cli/spec.js +42 -0
  49. package/src/components/agents/agent-avatar.tsx +57 -10
  50. package/src/components/agents/agent-card.tsx +50 -17
  51. package/src/components/agents/agent-chat-list.tsx +148 -12
  52. package/src/components/agents/agent-list.tsx +50 -19
  53. package/src/components/agents/agent-sheet.tsx +120 -65
  54. package/src/components/agents/inspector-panel.tsx +81 -6
  55. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  56. package/src/components/agents/personality-builder.tsx +42 -14
  57. package/src/components/agents/soul-library-picker.tsx +89 -0
  58. package/src/components/auth/access-key-gate.tsx +10 -3
  59. package/src/components/auth/setup-wizard.tsx +2 -2
  60. package/src/components/auth/user-picker.tsx +31 -3
  61. package/src/components/canvas/canvas-panel.tsx +96 -0
  62. package/src/components/chat/activity-moment.tsx +173 -0
  63. package/src/components/chat/chat-area.tsx +46 -22
  64. package/src/components/chat/chat-header.tsx +457 -286
  65. package/src/components/chat/chat-preview-panel.tsx +1 -2
  66. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  67. package/src/components/chat/delegation-banner.tsx +371 -0
  68. package/src/components/chat/file-path-chip.tsx +146 -0
  69. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  70. package/src/components/chat/markdown-utils.ts +9 -0
  71. package/src/components/chat/message-bubble.tsx +356 -315
  72. package/src/components/chat/message-list.tsx +230 -8
  73. package/src/components/chat/streaming-bubble.tsx +104 -47
  74. package/src/components/chat/suggestions-bar.tsx +1 -1
  75. package/src/components/chat/thinking-indicator.tsx +72 -10
  76. package/src/components/chat/tool-call-bubble.tsx +111 -73
  77. package/src/components/chat/tool-request-banner.tsx +31 -7
  78. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  79. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  80. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  81. package/src/components/chatrooms/chatroom-list.tsx +130 -0
  82. package/src/components/chatrooms/chatroom-message.tsx +432 -0
  83. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  84. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  85. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  86. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  87. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  88. package/src/components/connectors/connector-list.tsx +168 -90
  89. package/src/components/connectors/connector-sheet.tsx +95 -56
  90. package/src/components/home/home-view.tsx +501 -0
  91. package/src/components/input/chat-input.tsx +107 -43
  92. package/src/components/knowledge/knowledge-list.tsx +31 -1
  93. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  94. package/src/components/layout/app-layout.tsx +194 -97
  95. package/src/components/layout/update-banner.tsx +2 -2
  96. package/src/components/logs/log-list.tsx +2 -2
  97. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  98. package/src/components/memory/memory-agent-list.tsx +143 -0
  99. package/src/components/memory/memory-browser.tsx +205 -0
  100. package/src/components/memory/memory-card.tsx +34 -7
  101. package/src/components/memory/memory-detail.tsx +359 -120
  102. package/src/components/memory/memory-sheet.tsx +157 -23
  103. package/src/components/plugins/plugin-list.tsx +1 -1
  104. package/src/components/plugins/plugin-sheet.tsx +1 -1
  105. package/src/components/projects/project-detail.tsx +509 -0
  106. package/src/components/projects/project-list.tsx +195 -59
  107. package/src/components/providers/provider-list.tsx +2 -2
  108. package/src/components/providers/provider-sheet.tsx +3 -3
  109. package/src/components/schedules/schedule-card.tsx +1 -1
  110. package/src/components/schedules/schedule-list.tsx +1 -1
  111. package/src/components/schedules/schedule-sheet.tsx +259 -126
  112. package/src/components/secrets/secret-sheet.tsx +47 -24
  113. package/src/components/secrets/secrets-list.tsx +18 -8
  114. package/src/components/sessions/new-session-sheet.tsx +33 -65
  115. package/src/components/sessions/session-card.tsx +45 -14
  116. package/src/components/sessions/session-list.tsx +35 -18
  117. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  118. package/src/components/shared/agent-picker-list.tsx +90 -0
  119. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  120. package/src/components/shared/attachment-chip.tsx +165 -0
  121. package/src/components/shared/avatar.tsx +10 -1
  122. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  123. package/src/components/shared/check-icon.tsx +12 -0
  124. package/src/components/shared/confirm-dialog.tsx +1 -1
  125. package/src/components/shared/connector-platform-icon.tsx +51 -4
  126. package/src/components/shared/empty-state.tsx +32 -0
  127. package/src/components/shared/file-preview.tsx +34 -0
  128. package/src/components/shared/form-styles.ts +2 -0
  129. package/src/components/shared/icon-button.tsx +16 -2
  130. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  131. package/src/components/shared/notification-center.tsx +44 -6
  132. package/src/components/shared/profile-sheet.tsx +115 -0
  133. package/src/components/shared/reply-quote.tsx +26 -0
  134. package/src/components/shared/search-dialog.tsx +31 -15
  135. package/src/components/shared/section-label.tsx +12 -0
  136. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  137. package/src/components/shared/settings/section-embedding.tsx +48 -13
  138. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  139. package/src/components/shared/settings/section-providers.tsx +1 -1
  140. package/src/components/shared/settings/section-secrets.tsx +1 -1
  141. package/src/components/shared/settings/section-storage.tsx +206 -0
  142. package/src/components/shared/settings/section-theme.tsx +95 -0
  143. package/src/components/shared/settings/section-user-preferences.tsx +57 -0
  144. package/src/components/shared/settings/section-voice.tsx +42 -21
  145. package/src/components/shared/settings/section-web-search.tsx +30 -6
  146. package/src/components/shared/settings/settings-page.tsx +182 -27
  147. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  148. package/src/components/shared/settings/storage-browser.tsx +259 -0
  149. package/src/components/shared/sheet-footer.tsx +33 -0
  150. package/src/components/skills/skill-list.tsx +61 -30
  151. package/src/components/skills/skill-sheet.tsx +81 -2
  152. package/src/components/tasks/task-board.tsx +448 -26
  153. package/src/components/tasks/task-card.tsx +59 -9
  154. package/src/components/tasks/task-column.tsx +62 -3
  155. package/src/components/tasks/task-list.tsx +12 -4
  156. package/src/components/tasks/task-sheet.tsx +416 -74
  157. package/src/components/ui/hover-card.tsx +52 -0
  158. package/src/components/usage/metrics-dashboard.tsx +90 -6
  159. package/src/components/usage/usage-list.tsx +1 -1
  160. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  161. package/src/hooks/use-continuous-speech.ts +10 -4
  162. package/src/hooks/use-view-router.ts +69 -19
  163. package/src/hooks/use-voice-conversation.ts +53 -10
  164. package/src/hooks/use-ws.ts +4 -2
  165. package/src/instrumentation.ts +15 -1
  166. package/src/lib/chat.ts +2 -0
  167. package/src/lib/memory.ts +3 -0
  168. package/src/lib/providers/anthropic.ts +13 -7
  169. package/src/lib/providers/index.ts +1 -0
  170. package/src/lib/providers/openai.ts +13 -7
  171. package/src/lib/server/chat-execution.ts +75 -15
  172. package/src/lib/server/chatroom-helpers.ts +146 -0
  173. package/src/lib/server/connectors/manager.ts +229 -7
  174. package/src/lib/server/context-manager.ts +225 -13
  175. package/src/lib/server/create-notification.ts +14 -2
  176. package/src/lib/server/daemon-state.ts +157 -10
  177. package/src/lib/server/execution-log.ts +1 -0
  178. package/src/lib/server/heartbeat-service.ts +48 -6
  179. package/src/lib/server/heartbeat-wake.ts +110 -0
  180. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  181. package/src/lib/server/main-agent-loop.ts +1 -1
  182. package/src/lib/server/memory-consolidation.ts +105 -0
  183. package/src/lib/server/memory-db.ts +183 -10
  184. package/src/lib/server/mime.ts +51 -0
  185. package/src/lib/server/openclaw-gateway.ts +9 -1
  186. package/src/lib/server/orchestrator-lg.ts +2 -0
  187. package/src/lib/server/orchestrator.ts +5 -2
  188. package/src/lib/server/playwright-proxy.mjs +2 -3
  189. package/src/lib/server/prompt-runtime-context.ts +53 -0
  190. package/src/lib/server/provider-health.ts +125 -0
  191. package/src/lib/server/queue.ts +56 -10
  192. package/src/lib/server/scheduler.ts +8 -0
  193. package/src/lib/server/session-run-manager.ts +4 -0
  194. package/src/lib/server/session-tools/canvas.ts +67 -0
  195. package/src/lib/server/session-tools/chatroom.ts +136 -0
  196. package/src/lib/server/session-tools/connector.ts +83 -9
  197. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  198. package/src/lib/server/session-tools/crud.ts +21 -0
  199. package/src/lib/server/session-tools/delegate.ts +68 -4
  200. package/src/lib/server/session-tools/git.ts +71 -0
  201. package/src/lib/server/session-tools/http.ts +57 -0
  202. package/src/lib/server/session-tools/index.ts +10 -0
  203. package/src/lib/server/session-tools/memory.ts +7 -1
  204. package/src/lib/server/session-tools/search-providers.ts +16 -8
  205. package/src/lib/server/session-tools/subagent.ts +106 -0
  206. package/src/lib/server/session-tools/web.ts +115 -4
  207. package/src/lib/server/storage.ts +53 -29
  208. package/src/lib/server/stream-agent-chat.ts +185 -57
  209. package/src/lib/server/system-events.ts +49 -0
  210. package/src/lib/server/task-mention.ts +41 -0
  211. package/src/lib/server/ws-hub.ts +11 -0
  212. package/src/lib/sessions.ts +10 -0
  213. package/src/lib/soul-library.ts +103 -0
  214. package/src/lib/soul-suggestions.ts +109 -0
  215. package/src/lib/task-dedupe.ts +26 -0
  216. package/src/lib/tasks.ts +4 -1
  217. package/src/lib/tool-definitions.ts +2 -0
  218. package/src/lib/tts.ts +2 -2
  219. package/src/lib/view-routes.ts +36 -1
  220. package/src/lib/ws-client.ts +14 -4
  221. package/src/stores/use-app-store.ts +41 -3
  222. package/src/stores/use-chat-store.ts +113 -5
  223. package/src/stores/use-chatroom-store.ts +276 -0
  224. package/src/types/index.ts +88 -4
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { memo, useState, useCallback, useEffect } 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,129 +9,26 @@ 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
- import { api } from '@/lib/api-client'
15
-
16
- const FILE_PATH_RE = /^(\/[\w./-]+\.\w{1,10})$/
17
- const DIR_PATH_RE = /^(\/[\w./-]+)\/?$/
18
- const PREVIEWABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx|json|md|txt|py|sh)$/i
19
- const SERVEABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx)$/i
20
-
21
- function FilePathChip({ filePath }: { filePath: string }) {
22
- const canPreview = PREVIEWABLE_EXT.test(filePath)
23
- const canServe = SERVEABLE_EXT.test(filePath)
24
- const serveUrl = `/api/files/serve?path=${encodeURIComponent(filePath)}`
25
-
26
- const [serverState, setServerState] = useState<{
27
- running: boolean; url?: string; loading: boolean; type?: string; framework?: string
28
- }>({ running: false, loading: false })
29
-
30
- // Check if a server is already running for this path on mount
31
- useEffect(() => {
32
- if (!canServe) return
33
- api<{ running: boolean; url?: string; type?: string }>('POST', '/preview-server', { action: 'status', path: filePath })
34
- .then((res) => { if (res.running) setServerState({ running: true, url: res.url, type: res.type, loading: false }) })
35
- .catch((err) => console.error('Dev server check failed:', err))
36
- }, [filePath, canServe])
37
-
38
- const handleStartServer = async () => {
39
- setServerState((s) => ({ ...s, loading: true }))
40
- try {
41
- const res = await api<{ running: boolean; url?: string; type?: string; framework?: string }>('POST', '/preview-server', { action: 'start', path: filePath })
42
- setServerState({ running: res.running, url: res.url, type: res.type, framework: res.framework, loading: false })
43
- } catch {
44
- setServerState((s) => ({ ...s, loading: false }))
45
- }
46
- }
47
-
48
- const handleStopServer = async () => {
49
- setServerState((s) => ({ ...s, loading: true }))
50
- try {
51
- await api('POST', '/preview-server', { action: 'stop', path: filePath })
52
- setServerState({ running: false, loading: false })
53
- } catch {
54
- setServerState((s) => ({ ...s, loading: false }))
55
- }
56
- }
57
-
58
- const frameworkLabel = serverState.framework
59
- ? serverState.framework.charAt(0).toUpperCase() + serverState.framework.slice(1)
60
- : null
14
+ import { AttachmentChip, parseAttachmentUrl } from '@/components/shared/attachment-chip'
15
+ import { isStructuredMarkdown } from './markdown-utils'
16
+ import { FilePathChip, FILE_PATH_RE, DIR_PATH_RE } from './file-path-chip'
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
+ }
61
28
 
62
- return (
63
- <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-[8px] bg-white/[0.06] border border-white/[0.08] font-mono text-[13px]">
64
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/50 shrink-0">
65
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
66
- <polyline points="14 2 14 8 20 8" />
67
- </svg>
68
- <span className="text-sky-400">{filePath}</span>
69
- {canPreview && !serverState.running && (
70
- <a
71
- href={serveUrl}
72
- target="_blank"
73
- rel="noopener noreferrer"
74
- className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-white/[0.06] hover:bg-white/[0.10] text-[10px] font-600 text-text-3 hover:text-text-2 no-underline transition-colors cursor-pointer"
75
- title="Open file"
76
- >
77
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
78
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
79
- <polyline points="15 3 21 3 21 9" />
80
- <line x1="10" y1="14" x2="21" y2="3" />
81
- </svg>
82
- Open
83
- </a>
84
- )}
85
- {canServe && !serverState.running && (
86
- <button
87
- onClick={handleStartServer}
88
- disabled={serverState.loading}
89
- className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-[10px] font-600 border-none cursor-pointer transition-colors disabled:opacity-50"
90
- title="Start preview server — auto-detects npm projects (React, Next, Vite, etc.) and runs the dev command"
91
- >
92
- <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
93
- <polygon points="5 3 19 12 5 21 5 3" />
94
- </svg>
95
- {serverState.loading ? 'Starting...' : 'Serve'}
96
- </button>
97
- )}
98
- {canServe && serverState.running && (
99
- <>
100
- {frameworkLabel && (
101
- <span className="px-1.5 py-0.5 rounded-[4px] bg-indigo-500/15 text-indigo-300 text-[9px] font-700 uppercase tracking-wider">
102
- {frameworkLabel}
103
- </span>
104
- )}
105
- {serverState.type === 'npm' && (
106
- <span className="px-1.5 py-0.5 rounded-[4px] bg-amber-500/15 text-amber-300 text-[9px] font-700 uppercase tracking-wider">
107
- npm
108
- </span>
109
- )}
110
- <a
111
- href={serverState.url}
112
- target="_blank"
113
- rel="noopener noreferrer"
114
- className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-[10px] font-600 no-underline transition-colors"
115
- title="Open preview server"
116
- >
117
- <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" style={{ animation: 'pulse 2s ease infinite' }} />
118
- {serverState.url}
119
- </a>
120
- <button
121
- onClick={handleStopServer}
122
- disabled={serverState.loading}
123
- className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-red-500/15 hover:bg-red-500/25 text-red-400 text-[10px] font-600 border-none cursor-pointer transition-colors disabled:opacity-50"
124
- title="Stop preview server"
125
- >
126
- <svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor">
127
- <rect x="4" y="4" width="16" height="16" rx="2" />
128
- </svg>
129
- Stop
130
- </button>
131
- </>
132
- )}
133
- </span>
134
- )
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 }
135
32
  }
136
33
 
137
34
  function fmtTime(ts: number): string {
@@ -151,9 +48,26 @@ function relativeTime(ts: number): string {
151
48
  return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
152
49
  }
153
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
+
154
67
  function heartbeatSummary(text: string): string {
155
68
  const clean = (text || '')
156
69
  .replace(/\bHEARTBEAT_OK\b/gi, '')
70
+ .replace(/\[AGENT_HEARTBEAT_META\]\s*\{[^\n]*\}/gi, '')
157
71
  .replace(/\*\*(.*?)\*\*/g, '$1')
158
72
  .replace(/\*(.*?)\*/g, '$1')
159
73
  .replace(/`([^`]+)`/g, '$1')
@@ -169,166 +83,15 @@ function heartbeatSummary(text: string): string {
169
83
  return clean.length > 180 ? `${clean.slice(0, 180)}...` : clean
170
84
  }
171
85
 
172
- const IMAGE_ATTACH_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i
173
- const PREVIEWABLE_ATTACH_RE = /\.(html?|svg)$/i
174
- const CODE_ATTACH_RE = /\.(js|jsx|ts|tsx|css|json|md|txt|py|sh|rb|go|rs|c|cpp|h|java|yaml|yml|toml|xml|sql|graphql)$/i
175
- const PDF_ATTACH_RE = /\.pdf$/i
176
- const FILE_TYPE_COLORS: Record<string, string> = {
177
- html: 'text-orange-400', htm: 'text-orange-400', svg: 'text-emerald-400',
178
- js: 'text-yellow-400', jsx: 'text-yellow-400', ts: 'text-blue-400', tsx: 'text-blue-400',
179
- py: 'text-green-400', json: 'text-amber-300', css: 'text-purple-400', scss: 'text-pink-400',
180
- md: 'text-text-2', txt: 'text-text-3', pdf: 'text-red-400',
181
- }
182
-
183
- function parseAttachmentUrl(filePath?: string, fileUrl?: string) {
184
- const url = fileUrl || (filePath ? `/api/uploads/${filePath.split('/').pop()}` : '')
185
- const rawName = filePath?.split('/').pop() || fileUrl?.split('/').pop() || 'file'
186
- const filename = rawName.replace(/^[a-f0-9]+-/, '').split('?')[0]
187
- return { url, filename }
86
+ const STATUS_COLORS: Record<string, string> = {
87
+ progress: '#F59E0B',
88
+ ok: '#22C55E',
89
+ idle: '#6B7280',
90
+ blocked: '#EF4444',
188
91
  }
189
92
 
190
- function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: string; isUserMsg?: boolean }) {
191
- const isImage = IMAGE_ATTACH_RE.test(filename)
192
- const isCode = CODE_ATTACH_RE.test(filename)
193
- const isPdf = PDF_ATTACH_RE.test(filename)
194
- const [lightbox, setLightbox] = useState(false)
195
- const [codePreview, setCodePreview] = useState<string | null>(null)
196
- const [codeExpanded, setCodeExpanded] = useState(false)
197
-
198
- if (isImage) {
199
- return (
200
- <>
201
- <img
202
- src={url} alt="Attached"
203
- loading="lazy"
204
- className="max-w-[240px] rounded-[12px] mb-2 border border-white/10 cursor-pointer hover:border-white/25 transition-colors"
205
- onClick={() => setLightbox(true)}
206
- onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
207
- />
208
- {lightbox && (
209
- <div
210
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm cursor-pointer"
211
- onClick={() => setLightbox(false)}
212
- >
213
- <img src={url} alt="Preview" className="max-w-[90vw] max-h-[90vh] rounded-[12px] shadow-2xl" />
214
- </div>
215
- )}
216
- </>
217
- )
218
- }
219
-
220
- if (isPdf) {
221
- return (
222
- <div className="mb-2 rounded-[12px] border border-white/[0.08] bg-[rgba(255,255,255,0.02)] overflow-hidden" style={{ maxWidth: 480 }}>
223
- <div className="flex items-center gap-3 px-4 py-2.5">
224
- <div className="flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 bg-red-500/10 text-red-400">
225
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
226
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
227
- <polyline points="14 2 14 8 20 8" />
228
- </svg>
229
- </div>
230
- <span className="text-[13px] font-500 truncate flex-1">{filename}</span>
231
- <a href={url} download={filename} className="text-[11px] font-600 text-text-3 hover:text-text-2 no-underline">Download</a>
232
- </div>
233
- <iframe src={url} loading="lazy" className="w-full h-[300px] border-t border-white/[0.06]" title={filename} />
234
- </div>
235
- )
236
- }
237
-
238
- const ext = filename.split('.').pop()?.toLowerCase() || ''
239
- const colorClass = FILE_TYPE_COLORS[ext] || 'text-text-3'
240
- const isPreviewable = PREVIEWABLE_ATTACH_RE.test(filename)
241
-
242
- const chipBg = isUserMsg
243
- ? 'bg-[rgba(0,0,0,0.25)] border-white/[0.12]'
244
- : 'bg-[rgba(255,255,255,0.04)] border-white/[0.08]'
245
- const iconBg = isUserMsg ? 'bg-white/[0.12]' : 'bg-white/[0.05]'
246
- const btnBg = isUserMsg
247
- ? 'bg-white/[0.12] hover:bg-white/[0.18] text-white/80'
248
- : 'bg-white/[0.06] hover:bg-white/[0.10] text-text-3'
249
-
250
- const handleCodePreview = async () => {
251
- if (codePreview !== null) { setCodeExpanded(!codeExpanded); return }
252
- try {
253
- const serveUrl = `/api/files/serve?path=${encodeURIComponent(url.replace('/api/uploads/', ''))}`
254
- const res = await fetch(url.startsWith('/api/files/') ? url : serveUrl)
255
- if (!res.ok) return
256
- const text = await res.text()
257
- setCodePreview(text)
258
- setCodeExpanded(true)
259
- } catch {
260
- // ignore
261
- }
262
- }
263
-
264
- return (
265
- <div className="mb-2">
266
- <div className={`flex items-center gap-3 px-4 py-2.5 rounded-[12px] border ${chipBg}`}>
267
- <div className={`flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 ${iconBg} ${colorClass}`}>
268
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
269
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
270
- <polyline points="14 2 14 8 20 8" />
271
- </svg>
272
- </div>
273
- <div className="flex flex-col flex-1 min-w-0">
274
- <span className={`text-[13px] font-500 truncate ${isUserMsg ? 'text-white' : 'text-text'}`}>{filename}</span>
275
- <span className={`text-[11px] uppercase tracking-wide ${isUserMsg ? 'text-white/50' : 'text-text-3/70'}`}>{ext || 'file'}</span>
276
- </div>
277
- {isCode && (
278
- <button
279
- onClick={handleCodePreview}
280
- className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 border-none cursor-pointer ${
281
- isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
282
- }`}
283
- >
284
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
285
- <polyline points="16 18 22 12 16 6" />
286
- <polyline points="8 6 2 12 8 18" />
287
- </svg>
288
- {codeExpanded ? 'Hide' : 'Preview'}
289
- </button>
290
- )}
291
- {isPreviewable && (
292
- <a href={url} target="_blank" rel="noopener noreferrer"
293
- className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${
294
- isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
295
- }`}
296
- title="Preview in new tab">
297
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
298
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
299
- <circle cx="12" cy="12" r="3" />
300
- </svg>
301
- Preview
302
- </a>
303
- )}
304
- <a href={url} download={filename}
305
- className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${btnBg}`}>
306
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
307
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
308
- <polyline points="7 10 12 15 17 10" />
309
- <line x1="12" y1="15" x2="12" y2="3" />
310
- </svg>
311
- Download
312
- </a>
313
- </div>
314
- {isCode && codeExpanded && codePreview !== null && (
315
- <div className="mt-1 rounded-[10px] border border-white/[0.06] overflow-hidden" style={{ animation: 'fade-in 0.2s ease' }}>
316
- <CodeBlock className={`language-${ext}`}>
317
- {codePreview.split('\n').slice(0, codeExpanded ? undefined : 10).join('\n')}
318
- </CodeBlock>
319
- {codePreview.split('\n').length > 10 && (
320
- <button
321
- onClick={() => setCodeExpanded((v) => !v)}
322
- className="w-full px-3 py-1.5 text-[10px] text-text-3 hover:text-text-2 bg-white/[0.02] hover:bg-white/[0.04] border-none border-t border-white/[0.06] cursor-pointer transition-colors"
323
- >
324
- {codePreview.split('\n').length > 10 ? `Show all ${codePreview.split('\n').length} lines` : 'Show less'}
325
- </button>
326
- )}
327
- </div>
328
- )}
329
- </div>
330
- )
331
- }
93
+ // AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS
94
+ // are now imported from @/components/shared/attachment-chip
332
95
 
333
96
  function renderAttachments(message: Message) {
334
97
  const isUser = message.role === 'user'
@@ -374,18 +137,11 @@ interface Props {
374
137
  onToggleBookmark?: (index: number) => void
375
138
  onEditResend?: (index: number, newText: string) => void
376
139
  onFork?: (index: number) => void
140
+ onTransferToAgent?: (messageIndex: number, agentId: string) => void
141
+ momentOverlay?: React.ReactNode
377
142
  }
378
143
 
379
- function isStructuredMarkdown(text: string): boolean {
380
- if (!text) return false
381
- return /```/.test(text)
382
- || /^#{1,4}\s/m.test(text)
383
- || /^[-*]\s/m.test(text)
384
- || /^\d+\.\s/m.test(text)
385
- || /\|.*\|.*\|/m.test(text)
386
- }
387
-
388
- export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork }: Props) {
144
+ export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork, onTransferToAgent, momentOverlay }: Props) {
389
145
  const isUser = message.role === 'user'
390
146
  const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
391
147
  const currentUser = useAppStore((s) => s.currentUser)
@@ -394,11 +150,61 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
394
150
  const [toolEventsExpanded, setToolEventsExpanded] = useState(false)
395
151
  const [editing, setEditing] = useState(false)
396
152
  const [editText, setEditText] = useState('')
153
+ const [transferPickerOpen, setTransferPickerOpen] = useState(false)
397
154
  const toolEvents = message.toolEvents || []
398
155
  const hasToolEvents = !isUser && toolEvents.length > 0
399
156
  const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
400
157
  const isStructured = !isUser && !isHeartbeat && isStructuredMarkdown(message.text)
401
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
+
402
208
  const handleCopy = useCallback(() => {
403
209
  navigator.clipboard.writeText(message.text).then(() => {
404
210
  setCopied(true)
@@ -408,14 +214,29 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
408
214
 
409
215
  return (
410
216
  <div
411
- className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start'}`}
217
+ className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start relative pl-[44px]'}`}
412
218
  style={{ animation: `${isUser ? 'msg-in-right' : 'msg-in-left'} 0.35s cubic-bezier(0.16, 1, 0.3, 1)` }}
413
219
  >
220
+ {/* Avatar on spine (assistant) */}
221
+ {!isUser && (
222
+ <div className="absolute left-[4px] top-0">
223
+ <div style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}>
224
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" />}
225
+ </div>
226
+ {momentOverlay}
227
+ </div>
228
+ )}
414
229
  {/* Sender label + timestamp */}
415
230
  <div className={`flex items-center gap-2.5 mb-2 px-1 ${isUser ? 'flex-row-reverse' : ''}`}>
416
- {!isUser && (agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" />)}
417
- <span className={`text-[12px] font-600 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
418
- {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')}
419
240
  </span>
420
241
  <span className="text-[11px] text-text-3/70 font-mono" title={message.time ? new Date(message.time).toLocaleString() : ''}>
421
242
  {message.time ? relativeTime(message.time) : ''}
@@ -435,23 +256,183 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
435
256
  </button>
436
257
  )}
437
258
  <div className={`${toolEventsExpanded ? 'max-h-[320px] overflow-y-auto pr-1 flex flex-col gap-2' : 'flex flex-col gap-2'}`}>
438
- {visibleToolEvents.map((event, i) => (
439
- <ToolCallBubble
440
- key={`${message.time}-tool-${toolEventsExpanded ? `all-${i}` : `latest-${toolEvents.length - 1}`}`}
441
- event={{
442
- id: `${message.time}-${toolEventsExpanded ? i : toolEvents.length - 1}`,
443
- name: event.name,
444
- input: event.input,
445
- output: event.output,
446
- 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
+ )
447
327
  }}
328
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
448
329
  />
449
- ))}
450
- </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
+ ))}
451
386
  </div>
452
387
  )}
453
388
 
454
- {/* Message bubble */}
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>
406
+ </div>
407
+ )}
408
+
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 */
455
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'}`}>
456
437
  {renderAttachments(message)}
457
438
 
@@ -464,12 +445,43 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
464
445
  >
465
446
  <div className="flex items-center justify-between gap-3">
466
447
  <div className="flex items-center gap-2">
467
- <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
+ })()}
468
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
+ })()}
469
460
  </div>
470
461
  <span className="text-[11px] text-text-3">{heartbeatExpanded ? 'Collapse' : 'Expand'}</span>
471
462
  </div>
472
- <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
+ })()}
473
485
  </button>
474
486
  {heartbeatExpanded && (
475
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">
@@ -487,7 +499,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
487
499
  },
488
500
  }}
489
501
  >
490
- {message.text}
502
+ {message.text.replace(/\[AGENT_HEARTBEAT_META\]\s*\{[^\n]*\}/gi, '').trim()}
491
503
  </ReactMarkdown>
492
504
  </div>
493
505
  )}
@@ -515,6 +527,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
515
527
  },
516
528
  img({ src, alt }) {
517
529
  if (!src || typeof src !== 'string') return null
530
+ // Skip images already rendered via tool events
531
+ if (toolEventMediaUrls?.has(src)) return null
518
532
  const isVideo = /\.(mp4|webm|mov|avi)$/i.test(src)
519
533
  if (isVideo) {
520
534
  return (
@@ -538,6 +552,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
538
552
  onClick={async () => {
539
553
  const store = useAppStore.getState()
540
554
  await store.loadTasks(true)
555
+ store.setTaskSheetViewOnly(true)
541
556
  store.setEditingTaskId(taskMatch[1])
542
557
  store.setTaskSheetOpen(true)
543
558
  }}
@@ -610,11 +625,12 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
610
625
  },
611
626
  }}
612
627
  >
613
- {message.text}
628
+ {displayText}
614
629
  </ReactMarkdown>
615
630
  </div>
616
631
  )}
617
632
  </div>
633
+ )}
618
634
 
619
635
  {/* Tool access request banners */}
620
636
  {!isUser && <ToolRequestBanner
@@ -625,10 +641,10 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
625
641
  {/* Bookmark indicator */}
626
642
  {message.bookmarked && (
627
643
  <div className={`flex items-center gap-1 mt-1 px-1 ${isUser ? 'justify-end' : ''}`}>
628
- <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">
629
645
  <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
630
646
  </svg>
631
- <span className="text-[10px] text-[#F59E0B]/70 font-600">Bookmarked</span>
647
+ <span className="text-[10px] text-amber-400/70 font-600">Bookmarked</span>
632
648
  </div>
633
649
  )}
634
650
 
@@ -651,9 +667,9 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
651
667
  <button
652
668
  onClick={() => onToggleBookmark(messageIndex)}
653
669
  aria-label={message.bookmarked ? 'Remove bookmark' : 'Bookmark message'}
654
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
655
- text-[11px] font-500 cursor-pointer hover:bg-white/[0.04] transition-all"
656
- 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' }}
657
673
  >
658
674
  <svg width="12" height="12" viewBox="0 0 24 24" fill={message.bookmarked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
659
675
  <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
@@ -709,6 +725,31 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
709
725
  Retry
710
726
  </button>
711
727
  )}
728
+ {!isUser && typeof messageIndex === 'number' && onTransferToAgent && (
729
+ <div className="relative">
730
+ <button
731
+ onClick={() => setTransferPickerOpen(!transferPickerOpen)}
732
+ aria-label="Transfer to another agent"
733
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
734
+ text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
735
+ style={{ fontFamily: 'inherit' }}
736
+ >
737
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
738
+ <path d="M8 3L4 7l4 4" />
739
+ <path d="M4 7h16" />
740
+ <path d="M16 21l4-4-4-4" />
741
+ <path d="M20 17H4" />
742
+ </svg>
743
+ Transfer
744
+ </button>
745
+ {transferPickerOpen && (
746
+ <TransferAgentPicker
747
+ onSelect={(agentId) => { onTransferToAgent(messageIndex, agentId); setTransferPickerOpen(false) }}
748
+ onClose={() => setTransferPickerOpen(false)}
749
+ />
750
+ )}
751
+ </div>
752
+ )}
712
753
  </div>
713
754
 
714
755
  {/* Inline edit mode */}