@swarmclawai/swarmclaw 0.5.2 → 0.6.0

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 (173) hide show
  1. package/README.md +42 -7
  2. package/bin/swarmclaw.js +76 -16
  3. package/next.config.ts +11 -1
  4. package/package.json +4 -2
  5. package/public/screenshots/agents.png +0 -0
  6. package/public/screenshots/dashboard.png +0 -0
  7. package/public/screenshots/providers.png +0 -0
  8. package/public/screenshots/tasks.png +0 -0
  9. package/scripts/postinstall.mjs +18 -0
  10. package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
  11. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  12. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  13. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  14. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  15. package/src/app/api/chatrooms/route.ts +50 -0
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/knowledge/[id]/route.ts +13 -2
  18. package/src/app/api/knowledge/route.ts +8 -1
  19. package/src/app/api/memory/route.ts +8 -0
  20. package/src/app/api/notifications/[id]/route.ts +27 -0
  21. package/src/app/api/notifications/route.ts +68 -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 +155 -0
  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/route.ts +3 -3
  29. package/src/app/api/settings/route.ts +9 -0
  30. package/src/app/api/setup/check-provider/route.ts +3 -16
  31. package/src/app/api/skills/[id]/route.ts +6 -0
  32. package/src/app/api/skills/route.ts +6 -0
  33. package/src/app/api/tasks/[id]/route.ts +20 -0
  34. package/src/app/api/tasks/bulk/route.ts +100 -0
  35. package/src/app/api/tasks/route.ts +1 -0
  36. package/src/app/api/usage/route.ts +45 -0
  37. package/src/app/api/webhooks/[id]/route.ts +15 -1
  38. package/src/app/globals.css +58 -15
  39. package/src/app/page.tsx +142 -13
  40. package/src/cli/index.js +42 -0
  41. package/src/cli/index.test.js +30 -0
  42. package/src/cli/spec.js +32 -0
  43. package/src/components/agents/agent-avatar.tsx +57 -10
  44. package/src/components/agents/agent-card.tsx +48 -15
  45. package/src/components/agents/agent-chat-list.tsx +123 -10
  46. package/src/components/agents/agent-list.tsx +50 -19
  47. package/src/components/agents/agent-sheet.tsx +56 -63
  48. package/src/components/auth/access-key-gate.tsx +10 -3
  49. package/src/components/auth/setup-wizard.tsx +2 -2
  50. package/src/components/auth/user-picker.tsx +31 -3
  51. package/src/components/chat/activity-moment.tsx +169 -0
  52. package/src/components/chat/chat-header.tsx +2 -0
  53. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  54. package/src/components/chat/file-path-chip.tsx +125 -0
  55. package/src/components/chat/markdown-utils.ts +9 -0
  56. package/src/components/chat/message-bubble.tsx +46 -295
  57. package/src/components/chat/message-list.tsx +50 -1
  58. package/src/components/chat/streaming-bubble.tsx +36 -46
  59. package/src/components/chat/suggestions-bar.tsx +1 -1
  60. package/src/components/chat/thinking-indicator.tsx +72 -10
  61. package/src/components/chat/tool-call-bubble.tsx +66 -70
  62. package/src/components/chat/tool-request-banner.tsx +31 -7
  63. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  64. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  65. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  66. package/src/components/chatrooms/chatroom-list.tsx +123 -0
  67. package/src/components/chatrooms/chatroom-message.tsx +427 -0
  68. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  69. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  70. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  71. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  72. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  73. package/src/components/connectors/connector-sheet.tsx +34 -47
  74. package/src/components/home/home-view.tsx +501 -0
  75. package/src/components/input/chat-input.tsx +79 -41
  76. package/src/components/knowledge/knowledge-list.tsx +31 -1
  77. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  78. package/src/components/layout/app-layout.tsx +209 -83
  79. package/src/components/layout/mobile-header.tsx +2 -0
  80. package/src/components/layout/update-banner.tsx +2 -2
  81. package/src/components/logs/log-list.tsx +2 -2
  82. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  83. package/src/components/memory/memory-agent-list.tsx +143 -0
  84. package/src/components/memory/memory-browser.tsx +205 -0
  85. package/src/components/memory/memory-card.tsx +34 -7
  86. package/src/components/memory/memory-detail.tsx +359 -120
  87. package/src/components/memory/memory-sheet.tsx +157 -23
  88. package/src/components/plugins/plugin-list.tsx +1 -1
  89. package/src/components/plugins/plugin-sheet.tsx +1 -1
  90. package/src/components/projects/project-detail.tsx +509 -0
  91. package/src/components/projects/project-list.tsx +195 -59
  92. package/src/components/providers/provider-list.tsx +2 -2
  93. package/src/components/providers/provider-sheet.tsx +3 -3
  94. package/src/components/schedules/schedule-card.tsx +3 -2
  95. package/src/components/schedules/schedule-list.tsx +1 -1
  96. package/src/components/schedules/schedule-sheet.tsx +25 -25
  97. package/src/components/secrets/secret-sheet.tsx +47 -24
  98. package/src/components/secrets/secrets-list.tsx +18 -8
  99. package/src/components/sessions/new-session-sheet.tsx +33 -65
  100. package/src/components/sessions/session-card.tsx +45 -14
  101. package/src/components/sessions/session-list.tsx +35 -18
  102. package/src/components/shared/agent-picker-list.tsx +90 -0
  103. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  104. package/src/components/shared/attachment-chip.tsx +165 -0
  105. package/src/components/shared/avatar.tsx +10 -1
  106. package/src/components/shared/check-icon.tsx +12 -0
  107. package/src/components/shared/confirm-dialog.tsx +1 -1
  108. package/src/components/shared/empty-state.tsx +32 -0
  109. package/src/components/shared/file-preview.tsx +34 -0
  110. package/src/components/shared/form-styles.ts +2 -0
  111. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  112. package/src/components/shared/notification-center.tsx +223 -0
  113. package/src/components/shared/profile-sheet.tsx +115 -0
  114. package/src/components/shared/reply-quote.tsx +26 -0
  115. package/src/components/shared/search-dialog.tsx +296 -0
  116. package/src/components/shared/section-label.tsx +12 -0
  117. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  118. package/src/components/shared/settings/section-providers.tsx +1 -1
  119. package/src/components/shared/settings/section-secrets.tsx +1 -1
  120. package/src/components/shared/settings/section-theme.tsx +95 -0
  121. package/src/components/shared/settings/section-user-preferences.tsx +39 -0
  122. package/src/components/shared/settings/settings-page.tsx +180 -27
  123. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  124. package/src/components/shared/sheet-footer.tsx +33 -0
  125. package/src/components/skills/skill-list.tsx +61 -30
  126. package/src/components/skills/skill-sheet.tsx +81 -2
  127. package/src/components/tasks/task-board.tsx +448 -26
  128. package/src/components/tasks/task-card.tsx +46 -9
  129. package/src/components/tasks/task-column.tsx +62 -3
  130. package/src/components/tasks/task-list.tsx +12 -4
  131. package/src/components/tasks/task-sheet.tsx +89 -72
  132. package/src/components/ui/hover-card.tsx +52 -0
  133. package/src/components/usage/metrics-dashboard.tsx +78 -0
  134. package/src/components/usage/usage-list.tsx +1 -1
  135. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  136. package/src/hooks/use-view-router.ts +69 -19
  137. package/src/instrumentation.ts +15 -1
  138. package/src/lib/chat.ts +2 -0
  139. package/src/lib/cron-human.ts +114 -0
  140. package/src/lib/memory.ts +3 -0
  141. package/src/lib/server/chat-execution.ts +24 -4
  142. package/src/lib/server/connectors/manager.ts +11 -0
  143. package/src/lib/server/context-manager.ts +225 -13
  144. package/src/lib/server/create-notification.ts +42 -0
  145. package/src/lib/server/daemon-state.ts +165 -10
  146. package/src/lib/server/execution-log.ts +1 -0
  147. package/src/lib/server/heartbeat-service.ts +40 -5
  148. package/src/lib/server/heartbeat-wake.ts +110 -0
  149. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  150. package/src/lib/server/memory-consolidation.ts +92 -0
  151. package/src/lib/server/memory-db.ts +51 -6
  152. package/src/lib/server/openclaw-gateway.ts +9 -1
  153. package/src/lib/server/provider-health.ts +125 -0
  154. package/src/lib/server/queue.ts +5 -4
  155. package/src/lib/server/scheduler.ts +8 -0
  156. package/src/lib/server/session-run-manager.ts +4 -0
  157. package/src/lib/server/session-tools/chatroom.ts +136 -0
  158. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  159. package/src/lib/server/session-tools/index.ts +2 -0
  160. package/src/lib/server/session-tools/memory.ts +6 -1
  161. package/src/lib/server/storage.ts +80 -29
  162. package/src/lib/server/stream-agent-chat.ts +153 -47
  163. package/src/lib/server/system-events.ts +49 -0
  164. package/src/lib/server/ws-hub.ts +11 -0
  165. package/src/lib/soul-suggestions.ts +109 -0
  166. package/src/lib/tasks.ts +4 -1
  167. package/src/lib/view-routes.ts +36 -1
  168. package/src/lib/ws-client.ts +14 -4
  169. package/src/proxy.ts +79 -2
  170. package/src/stores/use-app-store.ts +94 -3
  171. package/src/stores/use-chat-store.ts +48 -3
  172. package/src/stores/use-chatroom-store.ts +276 -0
  173. package/src/types/index.ts +69 -2
@@ -0,0 +1,125 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { api } from '@/lib/api-client'
5
+
6
+ export const FILE_PATH_RE = /^(\/[\w./-]+\.\w{1,10})$/
7
+ export const DIR_PATH_RE = /^(\/[\w./-]+)\/?$/
8
+ const PREVIEWABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx|json|md|txt|py|sh)$/i
9
+ const SERVEABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx)$/i
10
+
11
+ export function FilePathChip({ filePath }: { filePath: string }) {
12
+ const canPreview = PREVIEWABLE_EXT.test(filePath)
13
+ const canServe = SERVEABLE_EXT.test(filePath)
14
+ const serveUrl = `/api/files/serve?path=${encodeURIComponent(filePath)}`
15
+
16
+ const [serverState, setServerState] = useState<{
17
+ running: boolean; url?: string; loading: boolean; type?: string; framework?: string
18
+ }>({ running: false, loading: false })
19
+
20
+ // Check if a server is already running for this path on mount
21
+ useEffect(() => {
22
+ if (!canServe) return
23
+ api<{ running: boolean; url?: string; type?: string }>('POST', '/preview-server', { action: 'status', path: filePath })
24
+ .then((res) => { if (res.running) setServerState({ running: true, url: res.url, type: res.type, loading: false }) })
25
+ .catch((err: unknown) => console.error('Dev server check failed:', err))
26
+ }, [filePath, canServe])
27
+
28
+ const handleStartServer = async () => {
29
+ setServerState((s) => ({ ...s, loading: true }))
30
+ try {
31
+ const res = await api<{ running: boolean; url?: string; type?: string; framework?: string }>('POST', '/preview-server', { action: 'start', path: filePath })
32
+ setServerState({ running: res.running, url: res.url, type: res.type, framework: res.framework, loading: false })
33
+ } catch {
34
+ setServerState((s) => ({ ...s, loading: false }))
35
+ }
36
+ }
37
+
38
+ const handleStopServer = async () => {
39
+ setServerState((s) => ({ ...s, loading: true }))
40
+ try {
41
+ await api('POST', '/preview-server', { action: 'stop', path: filePath })
42
+ setServerState({ running: false, loading: false })
43
+ } catch {
44
+ setServerState((s) => ({ ...s, loading: false }))
45
+ }
46
+ }
47
+
48
+ const frameworkLabel = serverState.framework
49
+ ? serverState.framework.charAt(0).toUpperCase() + serverState.framework.slice(1)
50
+ : null
51
+
52
+ return (
53
+ <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]">
54
+ <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">
55
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
56
+ <polyline points="14 2 14 8 20 8" />
57
+ </svg>
58
+ <span className="text-sky-400">{filePath}</span>
59
+ {canPreview && !serverState.running && (
60
+ <a
61
+ href={serveUrl}
62
+ target="_blank"
63
+ rel="noopener noreferrer"
64
+ 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"
65
+ title="Open file"
66
+ >
67
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
68
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
69
+ <polyline points="15 3 21 3 21 9" />
70
+ <line x1="10" y1="14" x2="21" y2="3" />
71
+ </svg>
72
+ Open
73
+ </a>
74
+ )}
75
+ {canServe && !serverState.running && (
76
+ <button
77
+ onClick={handleStartServer}
78
+ disabled={serverState.loading}
79
+ 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"
80
+ title="Start preview server — auto-detects npm projects (React, Next, Vite, etc.) and runs the dev command"
81
+ >
82
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
83
+ <polygon points="5 3 19 12 5 21 5 3" />
84
+ </svg>
85
+ {serverState.loading ? 'Starting...' : 'Serve'}
86
+ </button>
87
+ )}
88
+ {canServe && serverState.running && (
89
+ <>
90
+ {frameworkLabel && (
91
+ <span className="px-1.5 py-0.5 rounded-[4px] bg-indigo-500/15 text-indigo-300 text-[9px] font-700 uppercase tracking-wider">
92
+ {frameworkLabel}
93
+ </span>
94
+ )}
95
+ {serverState.type === 'npm' && (
96
+ <span className="px-1.5 py-0.5 rounded-[4px] bg-amber-500/15 text-amber-300 text-[9px] font-700 uppercase tracking-wider">
97
+ npm
98
+ </span>
99
+ )}
100
+ <a
101
+ href={serverState.url}
102
+ target="_blank"
103
+ rel="noopener noreferrer"
104
+ 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"
105
+ title="Open preview server"
106
+ >
107
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" style={{ animation: 'pulse 2s ease infinite' }} />
108
+ {serverState.url}
109
+ </a>
110
+ <button
111
+ onClick={handleStopServer}
112
+ disabled={serverState.loading}
113
+ 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"
114
+ title="Stop preview server"
115
+ >
116
+ <svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor">
117
+ <rect x="4" y="4" width="16" height="16" rx="2" />
118
+ </svg>
119
+ Stop
120
+ </button>
121
+ </>
122
+ )}
123
+ </span>
124
+ )
125
+ }
@@ -0,0 +1,9 @@
1
+ /** Detect if text contains structured markdown (code blocks, headings, lists, tables) */
2
+ export function isStructuredMarkdown(text: string): boolean {
3
+ if (!text) return false
4
+ return /```/.test(text)
5
+ || /^#{1,4}\s/m.test(text)
6
+ || /^[-*]\s/m.test(text)
7
+ || /^\d+\.\s/m.test(text)
8
+ || /\|.*\|.*\|/m.test(text)
9
+ }
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { memo, useState, useCallback, useEffect } from 'react'
3
+ import { memo, useState, useCallback } from 'react'
4
4
  import ReactMarkdown from 'react-markdown'
5
5
  import remarkGfm from 'remark-gfm'
6
6
  import rehypeHighlight from 'rehype-highlight'
@@ -11,128 +11,10 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
11
11
  import { CodeBlock } from './code-block'
12
12
  import { ToolCallBubble } 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
61
-
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
- )
135
- }
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'
136
18
 
137
19
  function fmtTime(ts: number): string {
138
20
  return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -169,166 +51,8 @@ function heartbeatSummary(text: string): string {
169
51
  return clean.length > 180 ? `${clean.slice(0, 180)}...` : clean
170
52
  }
171
53
 
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 }
188
- }
189
-
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
- }
54
+ // AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS
55
+ // are now imported from @/components/shared/attachment-chip
332
56
 
333
57
  function renderAttachments(message: Message) {
334
58
  const isUser = message.role === 'user'
@@ -374,18 +98,11 @@ interface Props {
374
98
  onToggleBookmark?: (index: number) => void
375
99
  onEditResend?: (index: number, newText: string) => void
376
100
  onFork?: (index: number) => void
101
+ onTransferToAgent?: (messageIndex: number, agentId: string) => void
102
+ momentOverlay?: React.ReactNode
377
103
  }
378
104
 
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) {
105
+ export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork, onTransferToAgent, momentOverlay }: Props) {
389
106
  const isUser = message.role === 'user'
390
107
  const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
391
108
  const currentUser = useAppStore((s) => s.currentUser)
@@ -394,6 +111,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
394
111
  const [toolEventsExpanded, setToolEventsExpanded] = useState(false)
395
112
  const [editing, setEditing] = useState(false)
396
113
  const [editText, setEditText] = useState('')
114
+ const [transferPickerOpen, setTransferPickerOpen] = useState(false)
397
115
  const toolEvents = message.toolEvents || []
398
116
  const hasToolEvents = !isUser && toolEvents.length > 0
399
117
  const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
@@ -408,12 +126,20 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
408
126
 
409
127
  return (
410
128
  <div
411
- className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start'}`}
129
+ className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start relative pl-[44px]'}`}
412
130
  style={{ animation: `${isUser ? 'msg-in-right' : 'msg-in-left'} 0.35s cubic-bezier(0.16, 1, 0.3, 1)` }}
413
131
  >
132
+ {/* Avatar on spine (assistant) */}
133
+ {!isUser && (
134
+ <div className="absolute left-[4px] top-0">
135
+ <div style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}>
136
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" />}
137
+ </div>
138
+ {momentOverlay}
139
+ </div>
140
+ )}
414
141
  {/* Sender label + timestamp */}
415
142
  <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
143
  <span className={`text-[12px] font-600 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
418
144
  {isUser ? (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You') : (assistantName || 'Claude')}
419
145
  </span>
@@ -709,6 +435,31 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
709
435
  Retry
710
436
  </button>
711
437
  )}
438
+ {!isUser && typeof messageIndex === 'number' && onTransferToAgent && (
439
+ <div className="relative">
440
+ <button
441
+ onClick={() => setTransferPickerOpen(!transferPickerOpen)}
442
+ aria-label="Transfer to another agent"
443
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
444
+ text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
445
+ style={{ fontFamily: 'inherit' }}
446
+ >
447
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
448
+ <path d="M8 3L4 7l4 4" />
449
+ <path d="M4 7h16" />
450
+ <path d="M16 21l4-4-4-4" />
451
+ <path d="M20 17H4" />
452
+ </svg>
453
+ Transfer
454
+ </button>
455
+ {transferPickerOpen && (
456
+ <TransferAgentPicker
457
+ onSelect={(agentId) => { onTransferToAgent(messageIndex, agentId); setTransferPickerOpen(false) }}
458
+ onClose={() => setTransferPickerOpen(false)}
459
+ />
460
+ )}
461
+ </div>
462
+ )}
712
463
  </div>
713
464
 
714
465
  {/* Inline edit mode */}
@@ -11,7 +11,9 @@ import { StreamingBubble } from './streaming-bubble'
11
11
  import { ThinkingIndicator } from './thinking-indicator'
12
12
  import { SuggestionsBar } from './suggestions-bar'
13
13
  import { ExecApprovalCard } from './exec-approval-card'
14
+ import { HeartbeatMoment, ActivityMoment, isNotableTool } from './activity-moment'
14
15
  import { useApprovalStore } from '@/stores/use-approval-store'
16
+ import { useWs } from '@/hooks/use-ws'
15
17
 
16
18
  const INTRO_GREETINGS = [
17
19
  'What can I help you with?',
@@ -71,6 +73,33 @@ export function MessageList({ messages, streaming }: Props) {
71
73
  const showOk = appSettings.heartbeatShowOk ?? false
72
74
  const showAlerts = appSettings.heartbeatShowAlerts ?? true
73
75
 
76
+ // Moment overlay for last assistant message (heartbeat or tool events)
77
+ type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
78
+ const [currentMoment, setCurrentMoment] = useState<MomentType | null>(null)
79
+
80
+ const heartbeatTopic = agent?.id ? `heartbeat:agent:${agent.id}` : ''
81
+ useWs(heartbeatTopic, () => {
82
+ setCurrentMoment({ kind: 'heartbeat' })
83
+ })
84
+
85
+ // Detect notable tool events on latest assistant message when messages change
86
+ const prevToolKeyRef = useRef<string | null>(null)
87
+ useEffect(() => {
88
+ const last = messages[messages.length - 1]
89
+ if (!last || last.role !== 'assistant' || !last.toolEvents?.length) return
90
+ const events = last.toolEvents
91
+ for (let i = events.length - 1; i >= 0; i--) {
92
+ if (isNotableTool(events[i].name)) {
93
+ const key = `${last.time}-${events[i].name}-${i}`
94
+ if (key !== prevToolKeyRef.current) {
95
+ prevToolKeyRef.current = key
96
+ setCurrentMoment({ kind: 'tool', name: events[i].name, input: events[i].input || '' })
97
+ }
98
+ return
99
+ }
100
+ }
101
+ }, [messages])
102
+
74
103
  // Unread count tracking
75
104
  const unreadRef = useRef(0)
76
105
  const [unreadCount, setUnreadCount] = useState(0)
@@ -336,7 +365,9 @@ export function MessageList({ messages, streaming }: Props) {
336
365
  onScroll={updateScrollState}
337
366
  className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6 fade-up"
338
367
  >
339
- <div className="flex flex-col gap-6">
368
+ <div className="flex flex-col gap-6 relative">
369
+ {/* Chat spine — vertical line for assistant messages */}
370
+ <div className="absolute left-[15px] top-0 bottom-0 w-px bg-white/[0.06] pointer-events-none" />
340
371
  {filteredMessages.length === 0 && !streaming && (
341
372
  <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' }}>
342
373
  <AgentAvatar seed={agent?.avatarSeed || null} name={agent?.name || 'Agent'} size={48} />
@@ -358,6 +389,23 @@ export function MessageList({ messages, streaming }: Props) {
358
389
  const prevMsg = i > 0 ? filteredMessages[i - 1] : null
359
390
  const showDateSep = msg.time && (!prevMsg?.time || new Date(msg.time).toDateString() !== new Date(prevMsg.time).toDateString())
360
391
 
392
+ // Moment overlay — only on the last assistant message
393
+ let momentOverlay: React.ReactNode = null
394
+ if (isLastAssistant && currentMoment && !streaming) {
395
+ if (currentMoment.kind === 'heartbeat') {
396
+ momentOverlay = <HeartbeatMoment onDismiss={() => setCurrentMoment(null)} />
397
+ } else {
398
+ momentOverlay = (
399
+ <ActivityMoment
400
+ key={`${currentMoment.name}-${Date.now()}`}
401
+ toolName={currentMoment.name}
402
+ toolInput={currentMoment.input}
403
+ onDismiss={() => setCurrentMoment(null)}
404
+ />
405
+ )
406
+ }
407
+ }
408
+
361
409
  return (
362
410
  <div key={`${msg.time}-${i}`}>
363
411
  {showDateSep && (
@@ -381,6 +429,7 @@ export function MessageList({ messages, streaming }: Props) {
381
429
  onToggleBookmark={toggleBookmark}
382
430
  onEditResend={handleEditResend}
383
431
  onFork={handleFork}
432
+ momentOverlay={momentOverlay}
384
433
  />
385
434
  </div>
386
435
  </div>