@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
@@ -6,12 +6,16 @@ import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { api } from '@/lib/api-client'
8
8
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
+ import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
9
10
  import { MessageBubble } from './message-bubble'
10
11
  import { StreamingBubble } from './streaming-bubble'
11
12
  import { ThinkingIndicator } from './thinking-indicator'
12
13
  import { SuggestionsBar } from './suggestions-bar'
13
14
  import { ExecApprovalCard } from './exec-approval-card'
15
+ import { HeartbeatMoment, ActivityMoment, isNotableTool } from './activity-moment'
14
16
  import { useApprovalStore } from '@/stores/use-approval-store'
17
+ import { useWs } from '@/hooks/use-ws'
18
+ import { GatewayDisconnectOverlay, useGatewayStatus } from '@/components/settings/gateway-disconnect-overlay'
15
19
 
16
20
  const INTRO_GREETINGS = [
17
21
  'What can I help you with?',
@@ -55,6 +59,10 @@ export function MessageList({ messages, streaming }: Props) {
55
59
  const retryLastMessage = useChatStore((s) => s.retryLastMessage)
56
60
  const editAndResend = useChatStore((s) => s.editAndResend)
57
61
  const sendMessage = useChatStore((s) => s.sendMessage)
62
+ const hasMoreMessages = useChatStore((s) => s.hasMoreMessages)
63
+ const loadingMore = useChatStore((s) => s.loadingMore)
64
+ const totalMessages = useChatStore((s) => s.totalMessages)
65
+ const loadMoreMessages = useChatStore((s) => s.loadMoreMessages)
58
66
  const forkSession = useAppStore((s) => s.forkSession)
59
67
  const session = useAppStore((s) => {
60
68
  const id = s.currentSessionId
@@ -71,6 +79,38 @@ export function MessageList({ messages, streaming }: Props) {
71
79
  const showOk = appSettings.heartbeatShowOk ?? false
72
80
  const showAlerts = appSettings.heartbeatShowAlerts ?? true
73
81
 
82
+ // Gateway disconnect overlay for openclaw agents
83
+ const isOpenClaw = agent?.provider === 'openclaw'
84
+ const gatewayStatus = useGatewayStatus()
85
+ const showGatewayOverlay = isOpenClaw && gatewayStatus === 'disconnected'
86
+
87
+ // Moment overlay for last assistant message (heartbeat or tool events)
88
+ type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
89
+ const [currentMoment, setCurrentMoment] = useState<MomentType | null>(null)
90
+
91
+ const heartbeatTopic = agent?.id ? `heartbeat:agent:${agent.id}` : ''
92
+ useWs(heartbeatTopic, () => {
93
+ setCurrentMoment({ kind: 'heartbeat' })
94
+ })
95
+
96
+ // Detect notable tool events on latest assistant message when messages change
97
+ const prevToolKeyRef = useRef<string | null>(null)
98
+ useEffect(() => {
99
+ const last = messages[messages.length - 1]
100
+ if (!last || last.role !== 'assistant' || !last.toolEvents?.length) return
101
+ const events = last.toolEvents
102
+ for (let i = events.length - 1; i >= 0; i--) {
103
+ if (isNotableTool(events[i].name)) {
104
+ const key = `${last.time}-${events[i].name}-${i}`
105
+ if (key !== prevToolKeyRef.current) {
106
+ prevToolKeyRef.current = key
107
+ setCurrentMoment({ kind: 'tool', name: events[i].name, input: events[i].input || '' })
108
+ }
109
+ return
110
+ }
111
+ }
112
+ }, [messages])
113
+
74
114
  // Unread count tracking
75
115
  const unreadRef = useRef(0)
76
116
  const [unreadCount, setUnreadCount] = useState(0)
@@ -79,6 +119,10 @@ export function MessageList({ messages, streaming }: Props) {
79
119
  // Bookmark filter
80
120
  const [bookmarkFilter, setBookmarkFilter] = useState(false)
81
121
 
122
+ // Connector source filter
123
+ const [connectorFilter, setConnectorFilter] = useState<string | null>(null)
124
+ const [connectorFilterCollapsed, setConnectorFilterCollapsed] = useState(false)
125
+
82
126
  const toggleBookmark = useCallback(async (index: number) => {
83
127
  if (!sessionId) return
84
128
  const msg = messages[index]
@@ -137,10 +181,24 @@ export function MessageList({ messages, streaming }: Props) {
137
181
  }
138
182
  }
139
183
 
140
- // Apply bookmark filter
141
- const filteredMessages = bookmarkFilter
184
+ // Collect unique connector sources for filter UI
185
+ const connectorSources = new Map<string, { platform: string; connectorName: string }>()
186
+ for (const msg of displayedMessages) {
187
+ if (msg.source?.connectorId && !connectorSources.has(msg.source.connectorId)) {
188
+ connectorSources.set(msg.source.connectorId, {
189
+ platform: msg.source.platform,
190
+ connectorName: msg.source.connectorName,
191
+ })
192
+ }
193
+ }
194
+
195
+ // Apply bookmark + connector filter
196
+ let filteredMessages = bookmarkFilter
142
197
  ? displayedMessages.filter((msg) => msg.bookmarked)
143
198
  : displayedMessages
199
+ if (connectorFilter) {
200
+ filteredMessages = filteredMessages.filter((msg) => msg.source?.connectorId === connectorFilter)
201
+ }
144
202
 
145
203
  // Search matches
146
204
  const searchMatches = searchQuery.trim()
@@ -245,6 +303,23 @@ export function MessageList({ messages, streaming }: Props) {
245
303
  return () => window.removeEventListener('swarmclaw:scroll-bottom', handler)
246
304
  }, [handleScrollToBottom])
247
305
 
306
+ // Scroll to a specific message by index (used by search)
307
+ useEffect(() => {
308
+ if (typeof window === 'undefined') return
309
+ const handler = (e: Event) => {
310
+ const idx = (e as CustomEvent).detail?.index
311
+ if (typeof idx !== 'number') return
312
+ const el = scrollRef.current?.querySelector(`[data-message-index="${idx}"]`) as HTMLElement | null
313
+ if (el) {
314
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
315
+ el.classList.add('bg-accent-bright/10')
316
+ setTimeout(() => el.classList.remove('bg-accent-bright/10'), 2000)
317
+ }
318
+ }
319
+ window.addEventListener('swarmclaw:scroll-to-message', handler)
320
+ return () => window.removeEventListener('swarmclaw:scroll-to-message', handler)
321
+ }, [])
322
+
248
323
  // Ctrl+F search toggle
249
324
  useEffect(() => {
250
325
  const handler = (e: KeyboardEvent) => {
@@ -267,7 +342,7 @@ export function MessageList({ messages, streaming }: Props) {
267
342
  }, [searchOpen])
268
343
 
269
344
  return (
270
- <div className="relative flex-1 min-h-0 min-w-0">
345
+ <div className="relative flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden">
271
346
  {/* In-thread search bar */}
272
347
  {searchOpen && (
273
348
  <div className="absolute top-0 left-0 right-0 z-20 flex items-center gap-2 px-6 md:px-12 lg:px-16 py-2 bg-surface/95 backdrop-blur-sm border-b border-white/[0.06]">
@@ -281,6 +356,7 @@ export function MessageList({ messages, streaming }: Props) {
281
356
  value={searchQuery}
282
357
  onChange={(e) => { setSearchQuery(e.target.value); setSearchIdx(0) }}
283
358
  placeholder="Search in conversation..."
359
+ aria-label="Search messages"
284
360
  className="flex-1 bg-transparent text-text text-[13px] outline-none placeholder:text-text-3/50"
285
361
  style={{ fontFamily: 'inherit' }}
286
362
  onKeyDown={(e) => {
@@ -315,7 +391,7 @@ export function MessageList({ messages, streaming }: Props) {
315
391
  <button
316
392
  onClick={() => setBookmarkFilter((v) => !v)}
317
393
  aria-label={bookmarkFilter ? 'Show all messages' : 'Show bookmarked only'}
318
- className={`p-1 rounded-[6px] hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors ${bookmarkFilter ? 'text-[#F59E0B]' : 'text-text-3 hover:text-text-2'}`}
394
+ className={`p-1 rounded-[6px] hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors ${bookmarkFilter ? 'text-amber-500' : 'text-text-3 hover:text-text-2'}`}
319
395
  >
320
396
  <svg width="14" height="14" viewBox="0 0 24 24" fill={bookmarkFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
321
397
  <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
@@ -331,12 +407,104 @@ export function MessageList({ messages, streaming }: Props) {
331
407
  </div>
332
408
  )}
333
409
 
410
+ {/* Connector source filter — shown when connector messages exist */}
411
+ {connectorSources.size > 0 && (
412
+ <div className="flex items-center gap-1.5 px-6 md:px-12 lg:px-16 py-1.5 border-b border-white/[0.04]">
413
+ <button
414
+ onClick={() => setConnectorFilterCollapsed((c) => !c)}
415
+ className="flex items-center gap-1 text-[10px] text-text-3/50 uppercase tracking-wider font-600 mr-1 bg-transparent border-none cursor-pointer hover:text-text-3/70 transition-colors p-0"
416
+ title={connectorFilterCollapsed ? 'Expand source filter' : 'Collapse source filter'}
417
+ >
418
+ <svg
419
+ width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
420
+ className="transition-transform duration-200"
421
+ style={{ transform: connectorFilterCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
422
+ >
423
+ <polyline points="6 9 12 15 18 9" />
424
+ </svg>
425
+ Source
426
+ {connectorFilterCollapsed && connectorFilter && (
427
+ <span className="text-accent-bright/70 normal-case tracking-normal">
428
+ ({connectorSources.get(connectorFilter)?.connectorName || connectorFilter})
429
+ </span>
430
+ )}
431
+ </button>
432
+ {!connectorFilterCollapsed && (
433
+ <>
434
+ <button
435
+ onClick={() => setConnectorFilter(null)}
436
+ className={`px-2 py-1 rounded-[6px] text-[11px] font-600 cursor-pointer border-none transition-all ${
437
+ !connectorFilter ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'
438
+ }`}
439
+ style={{ fontFamily: 'inherit' }}
440
+ >
441
+ All
442
+ </button>
443
+ {Array.from(connectorSources.entries()).map(([cid, info]) => {
444
+ const active = connectorFilter === cid
445
+ const meta = CONNECTOR_PLATFORM_META[info.platform as keyof typeof CONNECTOR_PLATFORM_META]
446
+ return (
447
+ <button
448
+ key={cid}
449
+ onClick={() => setConnectorFilter(active ? null : cid)}
450
+ className={`flex items-center gap-1.5 px-2 py-1 rounded-[6px] text-[11px] font-600 cursor-pointer border-none transition-all ${
451
+ active ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'
452
+ }`}
453
+ style={{ fontFamily: 'inherit' }}
454
+ >
455
+ <ConnectorPlatformIcon platform={info.platform as keyof typeof CONNECTOR_PLATFORM_META} size={12} />
456
+ {info.connectorName || meta?.label || info.platform}
457
+ </button>
458
+ )
459
+ })}
460
+ </>
461
+ )}
462
+ </div>
463
+ )}
464
+
334
465
  <div
335
466
  ref={scrollRef}
336
467
  onScroll={updateScrollState}
337
- className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6 fade-up"
468
+ className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 md:px-12 lg:px-16 pt-6 pb-10 fade-up"
338
469
  >
339
- <div className="flex flex-col gap-6">
470
+ <div className="flex flex-col gap-6 relative">
471
+ {/* Chat spine — vertical line for assistant messages */}
472
+ <div className="absolute left-[15px] top-0 bottom-0 w-px bg-white/[0.06] pointer-events-none" />
473
+ {hasMoreMessages && (
474
+ <div className="flex justify-center py-3">
475
+ <button
476
+ onClick={async () => {
477
+ const el = scrollRef.current
478
+ const prevHeight = el?.scrollHeight ?? 0
479
+ await loadMoreMessages()
480
+ // Preserve scroll position after prepending
481
+ if (el) {
482
+ requestAnimationFrame(() => {
483
+ el.scrollTop += el.scrollHeight - prevHeight
484
+ })
485
+ }
486
+ }}
487
+ disabled={loadingMore}
488
+ className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-white/[0.08] bg-surface/80 text-text-3 text-[12px] font-600 hover:bg-surface-2 hover:text-text-2 transition-colors cursor-pointer disabled:opacity-50"
489
+ >
490
+ {loadingMore ? (
491
+ <>
492
+ <span className="w-3 h-3 rounded-full border-2 border-text-3/30 border-t-text-2 animate-spin" />
493
+ Loading...
494
+ </>
495
+ ) : (
496
+ <>
497
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
498
+ <path d="M12 19V5" />
499
+ <path d="m5 12 7-7 7 7" />
500
+ </svg>
501
+ Load earlier messages
502
+ <span className="text-text-3/50">({totalMessages - messages.length} more)</span>
503
+ </>
504
+ )}
505
+ </button>
506
+ </div>
507
+ )}
340
508
  {filteredMessages.length === 0 && !streaming && (
341
509
  <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
510
  <AgentAvatar seed={agent?.avatarSeed || null} name={agent?.name || 'Agent'} size={48} />
@@ -347,6 +515,41 @@ export function MessageList({ messages, streaming }: Props) {
347
515
  </div>
348
516
  )}
349
517
  {filteredMessages.map((msg, i) => {
518
+ // Context-clear divider — render a visual separator instead of a bubble
519
+ if (msg.kind === 'context-clear') {
520
+ const originalIndex = messages.indexOf(msg)
521
+ return (
522
+ <div key={`ctx-clear-${msg.time}-${i}`} className="group/ctx flex items-center gap-4 py-3">
523
+ <div className="flex-1 h-px bg-amber-400/20" />
524
+ <span className="flex items-center gap-1.5 text-[10px] font-600 text-amber-400/60 uppercase tracking-[0.1em]">
525
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="shrink-0">
526
+ <line x1="2" y1="12" x2="22" y2="12" />
527
+ <polyline points="8 8 4 12 8 16" />
528
+ <polyline points="16 8 20 12 16 16" />
529
+ </svg>
530
+ New context
531
+ {msg.time ? ` · ${new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''}
532
+ </span>
533
+ {sessionId && originalIndex >= 0 && (
534
+ <button
535
+ type="button"
536
+ onClick={async () => {
537
+ try {
538
+ await api('DELETE', `/sessions/${sessionId}/messages`, { messageIndex: originalIndex })
539
+ setMessages(messages.filter((_: Message, idx: number) => idx !== originalIndex))
540
+ } catch { /* best-effort */ }
541
+ }}
542
+ className="opacity-0 group-hover/ctx:opacity-100 text-[10px] font-600 text-amber-400/60 hover:text-amber-400 bg-transparent border-none cursor-pointer transition-all px-1.5 py-0.5 rounded-[4px] hover:bg-amber-400/10"
543
+ title="Undo — restore full context"
544
+ >
545
+ Undo
546
+ </button>
547
+ )}
548
+ <div className="flex-1 h-px bg-amber-400/20" />
549
+ </div>
550
+ )
551
+ }
552
+
350
553
  // Find original index in the full messages array for API calls
351
554
  const originalIndex = messages.indexOf(msg)
352
555
  const isLastAssistant = msg.role === 'assistant' && !streaming
@@ -358,8 +561,25 @@ export function MessageList({ messages, streaming }: Props) {
358
561
  const prevMsg = i > 0 ? filteredMessages[i - 1] : null
359
562
  const showDateSep = msg.time && (!prevMsg?.time || new Date(msg.time).toDateString() !== new Date(prevMsg.time).toDateString())
360
563
 
564
+ // Moment overlay — only on the last assistant message
565
+ let momentOverlay: React.ReactNode = null
566
+ if (isLastAssistant && currentMoment && !streaming) {
567
+ if (currentMoment.kind === 'heartbeat') {
568
+ momentOverlay = <HeartbeatMoment onDismiss={() => setCurrentMoment(null)} />
569
+ } else {
570
+ momentOverlay = (
571
+ <ActivityMoment
572
+ key={`${currentMoment.name}-${Date.now()}`}
573
+ toolName={currentMoment.name}
574
+ toolInput={currentMoment.input}
575
+ onDismiss={() => setCurrentMoment(null)}
576
+ />
577
+ )
578
+ }
579
+ }
580
+
361
581
  return (
362
- <div key={`${msg.time}-${i}`}>
582
+ <div key={`${msg.time}-${i}`} data-message-index={i}>
363
583
  {showDateSep && (
364
584
  <div className="flex items-center gap-4 py-2 mb-2">
365
585
  <div className="flex-1 h-px bg-white/[0.06]" />
@@ -381,6 +601,7 @@ export function MessageList({ messages, streaming }: Props) {
381
601
  onToggleBookmark={toggleBookmark}
382
602
  onEditResend={handleEditResend}
383
603
  onFork={handleFork}
604
+ momentOverlay={momentOverlay}
384
605
  />
385
606
  </div>
386
607
  </div>
@@ -389,11 +610,12 @@ export function MessageList({ messages, streaming }: Props) {
389
610
  <ApprovalCards agentId={agent?.id} />
390
611
  {streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
391
612
  {streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
392
- {!streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
613
+ {appSettings.suggestionsEnabled !== false && !streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
393
614
  <SuggestionsBar lastMessage={filteredMessages[filteredMessages.length - 1]} onSend={sendMessage} />
394
615
  )}
395
616
  </div>
396
617
  </div>
618
+ {showGatewayOverlay && <GatewayDisconnectOverlay />}
397
619
  {showScrollToBottom && (
398
620
  <button
399
621
  onClick={handleScrollToBottom}
@@ -1,20 +1,38 @@
1
1
  'use client'
2
2
 
3
3
  import { useMemo, useState } from 'react'
4
- import ReactMarkdown from 'react-markdown'
5
- import remarkGfm from 'remark-gfm'
6
- import rehypeHighlight from 'rehype-highlight'
7
4
  import { AiAvatar } from '@/components/shared/avatar'
8
5
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
- import { CodeBlock } from './code-block'
10
- import { ToolCallBubble } from './tool-call-bubble'
6
+ import { ToolCallBubble, extractMedia } from './tool-call-bubble'
7
+ import { ActivityMoment, isNotableTool } from './activity-moment'
11
8
  import { useChatStore, type ToolEvent } from '@/stores/use-chat-store'
9
+ import { isStructuredMarkdown } from './markdown-utils'
12
10
 
13
11
  function ToolEventsSection({ toolEvents }: { toolEvents: ToolEvent[] }) {
14
12
  const [expanded, setExpanded] = useState(false)
15
13
  const shouldCollapse = toolEvents.length > 2
16
14
  const latestTool = toolEvents[toolEvents.length - 1]
17
15
 
16
+ // When collapsed, collect deduplicated media from all tool events so files remain visible
17
+ const collapsedMedia = useMemo(() => {
18
+ if (!shouldCollapse || expanded) return null
19
+ const seen = new Set<string>()
20
+ const images: string[] = []
21
+ const videos: string[] = []
22
+ const pdfs: { name: string; url: string }[] = []
23
+ const files: { name: string; url: string }[] = []
24
+ for (const ev of toolEvents) {
25
+ if (!ev.output) continue
26
+ const m = extractMedia(ev.output)
27
+ for (const url of m.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
28
+ for (const url of m.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
29
+ for (const p of m.pdfs) { if (!seen.has(p.url)) { seen.add(p.url); pdfs.push(p) } }
30
+ for (const f of m.files) { if (!seen.has(f.url)) { seen.add(f.url); files.push(f) } }
31
+ }
32
+ if (!images.length && !videos.length && !pdfs.length && !files.length) return null
33
+ return { images, videos, pdfs, files }
34
+ }, [toolEvents, shouldCollapse, expanded])
35
+
18
36
  if (shouldCollapse && !expanded) {
19
37
  return (
20
38
  <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
@@ -33,6 +51,33 @@ function ToolEventsSection({ toolEvents }: { toolEvents: ToolEvent[] }) {
33
51
  latest: {latestTool?.name || 'unknown'}
34
52
  </span>
35
53
  </button>
54
+ {collapsedMedia && (
55
+ <>
56
+ {collapsedMedia.images.map((src, i) => (
57
+ // eslint-disable-next-line @next/next/no-img-element
58
+ <img key={`ci-${i}`} src={src} alt={`Screenshot ${i + 1}`} loading="lazy"
59
+ className="max-w-[400px] rounded-[10px] border border-white/10"
60
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
61
+ ))}
62
+ {collapsedMedia.videos.map((src, i) => (
63
+ <video key={`cv-${i}`} src={src} controls playsInline preload="none" className="max-w-full rounded-[10px] border border-white/10" />
64
+ ))}
65
+ {collapsedMedia.pdfs.map((file, i) => (
66
+ <div key={`cp-${i}`} className="rounded-[10px] border border-white/10 overflow-hidden">
67
+ <iframe src={file.url} loading="lazy" className="w-full h-[400px] bg-white" title={file.name} />
68
+ </div>
69
+ ))}
70
+ {collapsedMedia.files.map((file, i) => (
71
+ <a key={`cf-${i}`} href={file.url} download className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/10 bg-surface/60 text-[13px] text-text-2 no-underline">
72
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
73
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
74
+ <polyline points="14 2 14 8 20 8" />
75
+ </svg>
76
+ {file.name}
77
+ </a>
78
+ ))}
79
+ </>
80
+ )}
36
81
  </div>
37
82
  )
38
83
  }
@@ -63,18 +108,46 @@ interface Props {
63
108
  }
64
109
 
65
110
  export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentName }: Props) {
66
- const rendered = useMemo(() => text, [text])
67
111
  const toolEvents = useChatStore((s) => s.toolEvents)
68
112
  const streamPhase = useChatStore((s) => s.streamPhase)
69
113
  const streamToolName = useChatStore((s) => s.streamToolName)
114
+ const thinkingText = useChatStore((s) => s.thinkingText)
115
+ const wide = useMemo(() => isStructuredMarkdown(text), [text])
116
+
117
+ // Track which activity moments have been dismissed
118
+ const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set())
119
+
120
+ // Find the latest completed notable tool event that hasn't been dismissed
121
+ let currentMoment: { id: string; name: string; input: string } | null = null
122
+ for (let i = toolEvents.length - 1; i >= 0; i--) {
123
+ const event = toolEvents[i]
124
+ if (event.status === 'done' && isNotableTool(event.name) && !dismissedIds.has(event.id)) {
125
+ currentMoment = { id: event.id, name: event.name, input: event.input }
126
+ break
127
+ }
128
+ }
129
+
130
+ const handleDismiss = (momentId: string) => {
131
+ setDismissedIds((prev) => new Set(prev).add(momentId))
132
+ }
70
133
 
71
134
  return (
72
135
  <div
73
- className="flex flex-col items-start"
136
+ className="flex flex-col items-start relative pl-[44px]"
74
137
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}
75
138
  >
139
+ <div className="absolute left-[4px] top-0 relative">
140
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
141
+ {currentMoment && (
142
+ <ActivityMoment
143
+ key={currentMoment.id}
144
+ toolName={currentMoment.name}
145
+ toolInput={currentMoment.input}
146
+ onDismiss={() => handleDismiss(currentMoment!.id)}
147
+ />
148
+ )}
149
+ </div>
76
150
  <div className="flex items-center gap-2.5 mb-2 px-1">
77
- {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
78
151
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
79
152
  <span className="w-2 h-2 rounded-full bg-accent-bright" style={{ animation: 'pulse 1.5s ease infinite' }} />
80
153
  {streamPhase === 'tool' && streamToolName && (
@@ -82,50 +155,34 @@ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentNam
82
155
  )}
83
156
  </div>
84
157
 
158
+ {/* Collapsed thinking section (shown when text has started but thinking exists) */}
159
+ {text && thinkingText && (
160
+ <div className="max-w-[85%] md:max-w-[72%] mb-2">
161
+ <details className="group rounded-[12px] border border-purple-500/15 bg-purple-500/[0.04]">
162
+ <summary className="flex items-center gap-2 px-3.5 py-2.5 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
163
+ <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">
164
+ <polyline points="9 18 15 12 9 6" />
165
+ </svg>
166
+ <span className="text-[11px] font-600 text-purple-400/70 uppercase tracking-[0.05em]">Thinking</span>
167
+ </summary>
168
+ <div className="px-3.5 pb-3 pt-1 max-h-[300px] overflow-y-auto">
169
+ <div className="text-[13px] leading-[1.6] text-text-3/70 whitespace-pre-wrap break-words">
170
+ {thinkingText}
171
+ </div>
172
+ </div>
173
+ </details>
174
+ </div>
175
+ )}
176
+
85
177
  {/* Tool call events (collapsible when > 2) */}
86
178
  {toolEvents.length > 0 && (
87
179
  <ToolEventsSection toolEvents={toolEvents} />
88
180
  )}
89
181
 
90
- {rendered && (
91
- <div className="max-w-[85%] md:max-w-[72%] bubble-ai px-5 py-3.5">
92
- <div className="msg-content streaming-cursor text-[15px] leading-[1.7] break-words text-text">
93
- <ReactMarkdown
94
- remarkPlugins={[remarkGfm]}
95
- rehypePlugins={[rehypeHighlight]}
96
- components={{
97
- pre({ children }) {
98
- return <pre>{children}</pre>
99
- },
100
- code({ className, children }) {
101
- const isBlock = className?.startsWith('language-') || className?.startsWith('hljs')
102
- if (isBlock) {
103
- return <CodeBlock className={className}>{children}</CodeBlock>
104
- }
105
- return <code className={className}>{children}</code>
106
- },
107
- a({ href, children }) {
108
- if (!href) return <>{children}</>
109
- const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/)
110
- if (ytMatch) {
111
- return (
112
- <div className="my-2">
113
- <iframe
114
- src={`https://www.youtube-nocookie.com/embed/${ytMatch[1]}`}
115
- className="w-full aspect-video rounded-[10px] border border-white/10"
116
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
117
- allowFullScreen
118
- title="YouTube video"
119
- />
120
- </div>
121
- )
122
- }
123
- return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
124
- },
125
- }}
126
- >
127
- {rendered}
128
- </ReactMarkdown>
182
+ {text && (
183
+ <div className={`${wide ? 'max-w-[92%] md:max-w-[85%]' : 'max-w-[85%] md:max-w-[72%]'} bubble-ai px-5 py-3.5`}>
184
+ <div className="streaming-cursor text-[15px] leading-[1.7] break-words text-text whitespace-pre-wrap">
185
+ {text}
129
186
  </div>
130
187
  </div>
131
188
  )}
@@ -53,7 +53,7 @@ export function SuggestionsBar({ lastMessage, onSend }: Props) {
53
53
 
54
54
  return (
55
55
  <div
56
- className="flex flex-wrap gap-2 px-1 pt-2"
56
+ className="flex flex-wrap gap-2 px-1 pt-2 ml-10"
57
57
  style={{ animation: 'fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1)' }}
58
58
  >
59
59
  {suggestions.map((text) => (