@swarmclawai/swarmclaw 0.4.0 → 0.5.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 (209) hide show
  1. package/README.md +21 -4
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +13 -0
  4. package/package.json +3 -1
  5. package/src/app/api/agents/[id]/route.ts +39 -22
  6. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  7. package/src/app/api/agents/route.ts +3 -2
  8. package/src/app/api/agents/trash/route.ts +44 -0
  9. package/src/app/api/clawhub/install/route.ts +2 -2
  10. package/src/app/api/connectors/[id]/route.ts +17 -7
  11. package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
  12. package/src/app/api/connectors/route.ts +6 -3
  13. package/src/app/api/credentials/[id]/route.ts +2 -1
  14. package/src/app/api/credentials/route.ts +2 -2
  15. package/src/app/api/documents/route.ts +2 -2
  16. package/src/app/api/files/serve/route.ts +8 -0
  17. package/src/app/api/knowledge/[id]/route.ts +5 -4
  18. package/src/app/api/knowledge/upload/route.ts +2 -2
  19. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  20. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  21. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  22. package/src/app/api/mcp-servers/route.ts +2 -2
  23. package/src/app/api/memory/[id]/route.ts +9 -8
  24. package/src/app/api/memory/route.ts +2 -2
  25. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  26. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  27. package/src/app/api/openclaw/approvals/route.ts +46 -0
  28. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  29. package/src/app/api/openclaw/cron/route.ts +52 -0
  30. package/src/app/api/openclaw/directory/route.ts +27 -0
  31. package/src/app/api/openclaw/discover/route.ts +62 -0
  32. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  33. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  34. package/src/app/api/openclaw/gateway/route.ts +72 -0
  35. package/src/app/api/openclaw/history/route.ts +109 -0
  36. package/src/app/api/openclaw/media/route.ts +53 -0
  37. package/src/app/api/openclaw/models/route.ts +12 -0
  38. package/src/app/api/openclaw/permissions/route.ts +39 -0
  39. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  40. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  41. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  42. package/src/app/api/openclaw/skills/route.ts +82 -0
  43. package/src/app/api/openclaw/sync/route.ts +31 -0
  44. package/src/app/api/orchestrator/run/route.ts +2 -2
  45. package/src/app/api/projects/[id]/route.ts +55 -0
  46. package/src/app/api/projects/route.ts +27 -0
  47. package/src/app/api/providers/[id]/models/route.ts +2 -1
  48. package/src/app/api/providers/[id]/route.ts +13 -15
  49. package/src/app/api/providers/route.ts +2 -2
  50. package/src/app/api/schedules/[id]/route.ts +16 -18
  51. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  52. package/src/app/api/schedules/route.ts +2 -2
  53. package/src/app/api/secrets/[id]/route.ts +16 -17
  54. package/src/app/api/secrets/route.ts +2 -2
  55. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  56. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  57. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  58. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  59. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  60. package/src/app/api/sessions/[id]/messages/route.ts +20 -2
  61. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  62. package/src/app/api/sessions/[id]/route.ts +14 -4
  63. package/src/app/api/sessions/route.ts +8 -4
  64. package/src/app/api/skills/[id]/route.ts +23 -21
  65. package/src/app/api/skills/import/route.ts +2 -2
  66. package/src/app/api/skills/route.ts +2 -2
  67. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  68. package/src/app/api/tasks/[id]/route.ts +6 -5
  69. package/src/app/api/tasks/route.ts +2 -2
  70. package/src/app/api/tts/stream/route.ts +48 -0
  71. package/src/app/api/upload/route.ts +2 -2
  72. package/src/app/api/uploads/[filename]/route.ts +4 -1
  73. package/src/app/api/webhooks/[id]/route.ts +29 -31
  74. package/src/app/api/webhooks/route.ts +2 -2
  75. package/src/app/globals.css +14 -0
  76. package/src/app/layout.tsx +5 -20
  77. package/src/app/page.tsx +3 -24
  78. package/src/cli/index.js +60 -0
  79. package/src/cli/index.ts +1 -1
  80. package/src/cli/spec.js +42 -0
  81. package/src/components/agents/agent-avatar.tsx +45 -0
  82. package/src/components/agents/agent-card.tsx +19 -5
  83. package/src/components/agents/agent-chat-list.tsx +31 -24
  84. package/src/components/agents/agent-files-editor.tsx +185 -0
  85. package/src/components/agents/agent-list.tsx +84 -3
  86. package/src/components/agents/agent-sheet.tsx +147 -14
  87. package/src/components/agents/cron-job-form.tsx +137 -0
  88. package/src/components/agents/exec-config-panel.tsx +147 -0
  89. package/src/components/agents/inspector-panel.tsx +310 -0
  90. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  91. package/src/components/agents/permission-preset-selector.tsx +79 -0
  92. package/src/components/agents/personality-builder.tsx +111 -0
  93. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  94. package/src/components/agents/skill-install-dialog.tsx +102 -0
  95. package/src/components/agents/trash-list.tsx +109 -0
  96. package/src/components/chat/chat-area.tsx +41 -6
  97. package/src/components/chat/chat-header.tsx +305 -29
  98. package/src/components/chat/chat-preview-panel.tsx +113 -0
  99. package/src/components/chat/exec-approval-card.tsx +89 -0
  100. package/src/components/chat/message-bubble.tsx +218 -36
  101. package/src/components/chat/message-list.tsx +135 -31
  102. package/src/components/chat/streaming-bubble.tsx +59 -10
  103. package/src/components/chat/suggestions-bar.tsx +74 -0
  104. package/src/components/chat/thinking-indicator.tsx +20 -6
  105. package/src/components/chat/tool-call-bubble.tsx +98 -19
  106. package/src/components/chat/tool-request-banner.tsx +20 -2
  107. package/src/components/chat/trace-block.tsx +103 -0
  108. package/src/components/chat/voice-overlay.tsx +80 -0
  109. package/src/components/connectors/connector-list.tsx +6 -2
  110. package/src/components/connectors/connector-sheet.tsx +31 -7
  111. package/src/components/layout/app-layout.tsx +47 -25
  112. package/src/components/projects/project-list.tsx +123 -0
  113. package/src/components/projects/project-sheet.tsx +135 -0
  114. package/src/components/schedules/schedule-list.tsx +3 -1
  115. package/src/components/sessions/new-session-sheet.tsx +6 -6
  116. package/src/components/sessions/session-card.tsx +1 -1
  117. package/src/components/sessions/session-list.tsx +7 -7
  118. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  119. package/src/components/shared/avatar.tsx +13 -2
  120. package/src/components/shared/connector-platform-icon.tsx +4 -0
  121. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  122. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  123. package/src/components/shared/settings/section-web-search.tsx +56 -0
  124. package/src/components/shared/settings/settings-page.tsx +74 -0
  125. package/src/components/skills/skill-list.tsx +2 -1
  126. package/src/components/tasks/task-board.tsx +1 -1
  127. package/src/components/tasks/task-list.tsx +5 -2
  128. package/src/components/tasks/task-sheet.tsx +12 -12
  129. package/src/hooks/use-continuous-speech.ts +181 -0
  130. package/src/hooks/use-openclaw-gateway.ts +63 -0
  131. package/src/hooks/use-view-router.ts +52 -0
  132. package/src/hooks/use-voice-conversation.ts +80 -0
  133. package/src/lib/id.ts +6 -0
  134. package/src/lib/notification-sounds.ts +58 -0
  135. package/src/lib/personality-parser.ts +97 -0
  136. package/src/lib/projects.ts +13 -0
  137. package/src/lib/provider-sets.ts +5 -0
  138. package/src/lib/providers/anthropic.ts +14 -1
  139. package/src/lib/providers/index.ts +6 -0
  140. package/src/lib/providers/ollama.ts +9 -1
  141. package/src/lib/providers/openai.ts +9 -1
  142. package/src/lib/providers/openclaw.ts +28 -2
  143. package/src/lib/runtime-loop.ts +2 -2
  144. package/src/lib/server/api-routes.test.ts +5 -6
  145. package/src/lib/server/build-llm.ts +17 -4
  146. package/src/lib/server/chat-execution.ts +82 -6
  147. package/src/lib/server/collection-helpers.ts +54 -0
  148. package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
  149. package/src/lib/server/connectors/bluebubbles.ts +360 -0
  150. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  151. package/src/lib/server/connectors/googlechat.ts +51 -8
  152. package/src/lib/server/connectors/manager.ts +424 -13
  153. package/src/lib/server/connectors/media.ts +2 -2
  154. package/src/lib/server/connectors/openclaw.ts +65 -0
  155. package/src/lib/server/connectors/pairing.test.ts +99 -0
  156. package/src/lib/server/connectors/pairing.ts +256 -0
  157. package/src/lib/server/connectors/signal.ts +1 -0
  158. package/src/lib/server/connectors/teams.ts +5 -5
  159. package/src/lib/server/connectors/types.ts +10 -0
  160. package/src/lib/server/daemon-state.ts +11 -0
  161. package/src/lib/server/execution-log.ts +3 -3
  162. package/src/lib/server/heartbeat-service.ts +1 -1
  163. package/src/lib/server/knowledge-db.test.ts +2 -33
  164. package/src/lib/server/main-agent-loop.ts +8 -9
  165. package/src/lib/server/main-session.ts +21 -0
  166. package/src/lib/server/memory-db.ts +6 -6
  167. package/src/lib/server/openclaw-approvals.ts +105 -0
  168. package/src/lib/server/openclaw-config-sync.ts +107 -0
  169. package/src/lib/server/openclaw-exec-config.ts +52 -0
  170. package/src/lib/server/openclaw-gateway.ts +291 -0
  171. package/src/lib/server/openclaw-history-merge.ts +36 -0
  172. package/src/lib/server/openclaw-models.ts +56 -0
  173. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  174. package/src/lib/server/openclaw-sync.ts +497 -0
  175. package/src/lib/server/orchestrator-lg.ts +30 -9
  176. package/src/lib/server/orchestrator.ts +4 -4
  177. package/src/lib/server/process-manager.ts +2 -2
  178. package/src/lib/server/queue.ts +24 -11
  179. package/src/lib/server/scheduler.ts +2 -2
  180. package/src/lib/server/session-mailbox.ts +2 -2
  181. package/src/lib/server/session-run-manager.ts +2 -2
  182. package/src/lib/server/session-tools/connector.ts +53 -6
  183. package/src/lib/server/session-tools/crud.ts +3 -3
  184. package/src/lib/server/session-tools/delegate.ts +22 -6
  185. package/src/lib/server/session-tools/file.ts +192 -19
  186. package/src/lib/server/session-tools/index.ts +4 -2
  187. package/src/lib/server/session-tools/memory.ts +2 -2
  188. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  189. package/src/lib/server/session-tools/sandbox.ts +33 -0
  190. package/src/lib/server/session-tools/search-providers.ts +277 -0
  191. package/src/lib/server/session-tools/session-info.ts +2 -2
  192. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  193. package/src/lib/server/session-tools/shell.ts +1 -1
  194. package/src/lib/server/session-tools/web.ts +53 -72
  195. package/src/lib/server/storage.ts +74 -11
  196. package/src/lib/server/stream-agent-chat.ts +53 -4
  197. package/src/lib/server/suggestions.ts +20 -0
  198. package/src/lib/server/task-result.test.ts +44 -0
  199. package/src/lib/server/task-result.ts +14 -0
  200. package/src/lib/server/ws-hub.ts +14 -0
  201. package/src/lib/tool-definitions.ts +5 -3
  202. package/src/lib/tts-stream.ts +130 -0
  203. package/src/lib/view-routes.ts +28 -0
  204. package/src/proxy.ts +3 -0
  205. package/src/stores/use-app-store.ts +80 -1
  206. package/src/stores/use-approval-store.ts +78 -0
  207. package/src/stores/use-chat-store.ts +162 -6
  208. package/src/types/index.ts +154 -3
  209. package/tsconfig.json +13 -4
@@ -1,12 +1,16 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
4
4
  import type { Message } from '@/types'
5
5
  import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
+ import { api } from '@/lib/api-client'
7
8
  import { MessageBubble } from './message-bubble'
8
9
  import { StreamingBubble } from './streaming-bubble'
9
10
  import { ThinkingIndicator } from './thinking-indicator'
11
+ import { SuggestionsBar } from './suggestions-bar'
12
+ import { ExecApprovalCard } from './exec-approval-card'
13
+ import { useApprovalStore } from '@/stores/use-approval-store'
10
14
 
11
15
  function dateSeparator(ts: number): string {
12
16
  const d = new Date(ts)
@@ -26,14 +30,19 @@ interface Props {
26
30
  export function MessageList({ messages, streaming }: Props) {
27
31
  const scrollRef = useRef<HTMLDivElement>(null)
28
32
  const [showScrollToBottom, setShowScrollToBottom] = useState(false)
29
- const needsSnapRef = useRef(true)
33
+ const snapUntilRef = useRef(0)
30
34
  const prevSessionIdRef = useRef<string | null>(null)
31
- const streamText = useChatStore((s) => s.streamText)
35
+ const displayText = useChatStore((s) => s.displayText)
36
+ const setMessages = useChatStore((s) => s.setMessages)
32
37
  const retryLastMessage = useChatStore((s) => s.retryLastMessage)
38
+ const editAndResend = useChatStore((s) => s.editAndResend)
39
+ const sendMessage = useChatStore((s) => s.sendMessage)
40
+ const forkSession = useAppStore((s) => s.forkSession)
33
41
  const session = useAppStore((s) => {
34
42
  const id = s.currentSessionId
35
43
  return id ? s.sessions[id] : null
36
44
  })
45
+ const sessionId = session?.id ?? null
37
46
  const agents = useAppStore((s) => s.agents)
38
47
  const agent = session?.agentId ? agents[session.agentId] : null
39
48
  const appSettings = useAppStore((s) => s.appSettings)
@@ -49,6 +58,37 @@ export function MessageList({ messages, streaming }: Props) {
49
58
  const [unreadCount, setUnreadCount] = useState(0)
50
59
  const prevMsgCountRef = useRef(messages.length)
51
60
 
61
+ // Bookmark filter
62
+ const [bookmarkFilter, setBookmarkFilter] = useState(false)
63
+
64
+ const toggleBookmark = useCallback(async (index: number) => {
65
+ if (!sessionId) return
66
+ const msg = messages[index]
67
+ if (!msg) return
68
+ const next = !msg.bookmarked
69
+ try {
70
+ await api('PUT', `/sessions/${sessionId}/messages`, { messageIndex: index, bookmarked: next })
71
+ const updated = [...messages]
72
+ updated[index] = { ...updated[index], bookmarked: next }
73
+ setMessages(updated)
74
+ } catch (err: unknown) {
75
+ console.error('Failed to toggle bookmark:', err instanceof Error ? err.message : String(err))
76
+ }
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [sessionId, messages])
79
+
80
+ const handleEditResend = useCallback(async (index: number, newText: string) => {
81
+ if (!sessionId || !editAndResend) return
82
+ await editAndResend(index, newText)
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [sessionId])
85
+
86
+ const handleFork = useCallback(async (index: number) => {
87
+ if (!sessionId || !forkSession) return
88
+ await forkSession(sessionId, index)
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, [sessionId])
91
+
52
92
  // In-thread search
53
93
  const [searchOpen, setSearchOpen] = useState(false)
54
94
  const [searchQuery, setSearchQuery] = useState('')
@@ -56,9 +96,9 @@ export function MessageList({ messages, streaming }: Props) {
56
96
  const searchInputRef = useRef<HTMLInputElement>(null)
57
97
 
58
98
  const isHeartbeatMessage = (msg: Message) =>
59
- msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || ''))
99
+ msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || '') || /^\s*NO_MESSAGE\b/i.test(msg.text || ''))
60
100
  const isHeartbeatOk = (msg: Message) =>
61
- msg.suppressed === true || (msg.kind === 'heartbeat' && /^\s*HEARTBEAT_OK\b/i.test(msg.text || ''))
101
+ msg.suppressed === true || (msg.kind === 'heartbeat' && (/^\s*HEARTBEAT_OK\b/i.test(msg.text || '') || /^\s*NO_MESSAGE\b/i.test(msg.text || '')))
62
102
 
63
103
  const displayedMessages: Message[] = []
64
104
  for (const msg of messages) {
@@ -79,9 +119,14 @@ export function MessageList({ messages, streaming }: Props) {
79
119
  }
80
120
  }
81
121
 
122
+ // Apply bookmark filter
123
+ const filteredMessages = bookmarkFilter
124
+ ? displayedMessages.filter((msg) => msg.bookmarked)
125
+ : displayedMessages
126
+
82
127
  // Search matches
83
128
  const searchMatches = searchQuery.trim()
84
- ? displayedMessages
129
+ ? filteredMessages
85
130
  .map((msg, i) => ({ msg, i }))
86
131
  .filter(({ msg }) => msg.text.toLowerCase().includes(searchQuery.toLowerCase()))
87
132
  : []
@@ -99,6 +144,10 @@ export function MessageList({ messages, streaming }: Props) {
99
144
  const nearBottom = isNearBottom(el)
100
145
  wasAtBottomRef.current = nearBottom
101
146
  setShowScrollToBottom(!nearBottom)
147
+ // Cancel snap window if user manually scrolls away
148
+ if (!nearBottom && Date.now() < snapUntilRef.current) {
149
+ snapUntilRef.current = 0
150
+ }
102
151
  if (nearBottom && unreadRef.current > 0) {
103
152
  unreadRef.current = 0
104
153
  setUnreadCount(0)
@@ -115,31 +164,52 @@ export function MessageList({ messages, streaming }: Props) {
115
164
  }
116
165
  }, [messages.length, isNearBottom])
117
166
 
118
- // Detect session switch during render (no extra useEffect, no dep-array mismatch)
119
- const sessionId = session?.id ?? null
120
- if (sessionId !== prevSessionIdRef.current) {
121
- prevSessionIdRef.current = sessionId
122
- needsSnapRef.current = true
123
- wasAtBottomRef.current = true
124
- }
167
+ // Detect session switch set snap window and reset scroll state.
168
+ // Must fire before the scroll positioning layoutEffect below.
169
+ useLayoutEffect(() => {
170
+ if (sessionId !== prevSessionIdRef.current) {
171
+ prevSessionIdRef.current = sessionId
172
+ wasAtBottomRef.current = true
173
+ snapUntilRef.current = Date.now() + 2000
174
+ }
175
+ }, [sessionId])
125
176
 
126
- useEffect(() => {
177
+ // Position scroll before paint — no setState here to avoid cascading renders.
178
+ // The onScroll handler and the state-update effect below handle UI state.
179
+ useLayoutEffect(() => {
127
180
  const el = scrollRef.current
128
- if (!el) return
129
- if (needsSnapRef.current && messages.length > 0) {
130
- // First render after session switch — snap instantly, no visible scroll
131
- needsSnapRef.current = false
181
+ if (!el || messages.length === 0) return
182
+
183
+ const snapping = Date.now() < snapUntilRef.current
184
+
185
+ if (snapping || wasAtBottomRef.current) {
132
186
  el.scrollTop = el.scrollHeight
133
- setShowScrollToBottom(false)
134
187
  wasAtBottomRef.current = true
135
- return
136
- }
137
- // Auto-scroll if user was at bottom before new content arrived
138
- if (wasAtBottomRef.current) {
139
- el.scrollTop = el.scrollHeight
140
188
  }
189
+ }, [messages.length, displayText])
190
+
191
+ // Update scroll-related UI state after render (separate from layoutEffect to avoid cascading)
192
+ useEffect(() => {
193
+ const el = scrollRef.current
194
+ if (!el || messages.length === 0) return
141
195
  updateScrollState()
142
- }, [messages.length, streamText, isNearBottom, updateScrollState])
196
+ }, [messages.length, displayText, updateScrollState])
197
+
198
+ // Re-snap when content resizes during snap window (lazy images increasing scrollHeight)
199
+ useEffect(() => {
200
+ const el = scrollRef.current
201
+ if (!el) return
202
+ const content = el.firstElementChild as HTMLElement | null
203
+ if (!content) return
204
+
205
+ const observer = new ResizeObserver(() => {
206
+ if (Date.now() < snapUntilRef.current || wasAtBottomRef.current) {
207
+ el.scrollTop = el.scrollHeight
208
+ }
209
+ })
210
+ observer.observe(content)
211
+ return () => observer.disconnect()
212
+ }, [sessionId])
143
213
 
144
214
  const handleScrollToBottom = useCallback(() => {
145
215
  const el = scrollRef.current
@@ -179,7 +249,7 @@ export function MessageList({ messages, streaming }: Props) {
179
249
  }, [searchOpen])
180
250
 
181
251
  return (
182
- <div className="relative flex-1 min-h-0">
252
+ <div className="relative flex-1 min-h-0 min-w-0">
183
253
  {/* In-thread search bar */}
184
254
  {searchOpen && (
185
255
  <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]">
@@ -224,6 +294,15 @@ export function MessageList({ messages, streaming }: Props) {
224
294
  >
225
295
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="m6 9 6 6 6-6" /></svg>
226
296
  </button>
297
+ <button
298
+ onClick={() => setBookmarkFilter((v) => !v)}
299
+ aria-label={bookmarkFilter ? 'Show all messages' : 'Show bookmarked only'}
300
+ 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'}`}
301
+ >
302
+ <svg width="14" height="14" viewBox="0 0 24 24" fill={bookmarkFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
303
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
304
+ </svg>
305
+ </button>
227
306
  <button
228
307
  onClick={() => { setSearchOpen(false); setSearchQuery(''); setSearchIdx(0) }}
229
308
  aria-label="Close search"
@@ -240,14 +319,16 @@ export function MessageList({ messages, streaming }: Props) {
240
319
  className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6"
241
320
  >
242
321
  <div className="flex flex-col gap-6">
243
- {displayedMessages.map((msg, i) => {
322
+ {filteredMessages.map((msg, i) => {
323
+ // Find original index in the full messages array for API calls
324
+ const originalIndex = messages.indexOf(msg)
244
325
  const isLastAssistant = msg.role === 'assistant' && !streaming
245
- && displayedMessages.slice(i + 1).every((m) => m.role !== 'assistant')
326
+ && filteredMessages.slice(i + 1).every((m) => m.role !== 'assistant')
246
327
  const isSearchMatch = searchQuery && searchMatches.some((m) => m.i === i)
247
328
  const isCurrentMatch = searchQuery && searchMatches[searchIdx]?.i === i
248
329
 
249
330
  // Date separator
250
- const prevMsg = i > 0 ? displayedMessages[i - 1] : null
331
+ const prevMsg = i > 0 ? filteredMessages[i - 1] : null
251
332
  const showDateSep = msg.time && (!prevMsg?.time || new Date(msg.time).toDateString() !== new Date(prevMsg.time).toDateString())
252
333
 
253
334
  return (
@@ -265,15 +346,25 @@ export function MessageList({ messages, streaming }: Props) {
265
346
  <MessageBubble
266
347
  message={msg}
267
348
  assistantName={assistantName}
349
+ agentAvatarSeed={agent?.avatarSeed}
350
+ agentName={agent?.name}
268
351
  isLast={isLastAssistant}
269
352
  onRetry={isLastAssistant ? retryLastMessage : undefined}
353
+ messageIndex={originalIndex >= 0 ? originalIndex : undefined}
354
+ onToggleBookmark={toggleBookmark}
355
+ onEditResend={handleEditResend}
356
+ onFork={handleFork}
270
357
  />
271
358
  </div>
272
359
  </div>
273
360
  )
274
361
  })}
275
- {streaming && !streamText && <ThinkingIndicator assistantName={assistantName} />}
276
- {streaming && streamText && <StreamingBubble text={streamText} assistantName={assistantName} />}
362
+ <ApprovalCards agentId={agent?.id} />
363
+ {streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
364
+ {streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
365
+ {!streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
366
+ <SuggestionsBar lastMessage={filteredMessages[filteredMessages.length - 1]} onSend={sendMessage} />
367
+ )}
277
368
  </div>
278
369
  </div>
279
370
  {showScrollToBottom && (
@@ -297,3 +388,16 @@ export function MessageList({ messages, streaming }: Props) {
297
388
  </div>
298
389
  )
299
390
  }
391
+
392
+ function ApprovalCards({ agentId }: { agentId?: string | null }) {
393
+ const approvals = useApprovalStore((s) => s.approvals)
394
+ const cards = Object.values(approvals).filter((a) => !agentId || a.agentId === agentId)
395
+ if (!cards.length) return null
396
+ return (
397
+ <>
398
+ {cards.map((a) => (
399
+ <ExecApprovalCard key={a.id} approval={a} />
400
+ ))}
401
+ </>
402
+ )
403
+ }
@@ -1,22 +1,72 @@
1
1
  'use client'
2
2
 
3
- import { useMemo } from 'react'
3
+ import { useMemo, useState } from 'react'
4
4
  import ReactMarkdown from 'react-markdown'
5
5
  import remarkGfm from 'remark-gfm'
6
6
  import rehypeHighlight from 'rehype-highlight'
7
7
  import { AiAvatar } from '@/components/shared/avatar'
8
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
9
  import { CodeBlock } from './code-block'
9
10
  import { ToolCallBubble } from './tool-call-bubble'
10
- import { useChatStore } from '@/stores/use-chat-store'
11
+ import { useChatStore, type ToolEvent } from '@/stores/use-chat-store'
12
+
13
+ function ToolEventsSection({ toolEvents }: { toolEvents: ToolEvent[] }) {
14
+ const [expanded, setExpanded] = useState(false)
15
+ const shouldCollapse = toolEvents.length > 2
16
+ const latestTool = toolEvents[toolEvents.length - 1]
17
+
18
+ if (shouldCollapse && !expanded) {
19
+ return (
20
+ <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
21
+ <button
22
+ type="button"
23
+ onClick={() => setExpanded(true)}
24
+ className="self-start flex items-center gap-2 px-3 py-1.5 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] border border-white/[0.06] cursor-pointer transition-colors"
25
+ >
26
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/60">
27
+ <polyline points="6 9 12 15 18 9" />
28
+ </svg>
29
+ <span className="text-[11px] text-text-3 font-mono">
30
+ {toolEvents.length} tool calls
31
+ </span>
32
+ <span className="text-[10px] text-text-3/50">
33
+ latest: {latestTool?.name || 'unknown'}
34
+ </span>
35
+ </button>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ return (
41
+ <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
42
+ {shouldCollapse && (
43
+ <button
44
+ type="button"
45
+ onClick={() => setExpanded(false)}
46
+ className="self-start px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-text-3 border border-white/[0.06] cursor-pointer transition-colors"
47
+ >
48
+ Collapse tool calls
49
+ </button>
50
+ )}
51
+ {toolEvents.map((event) => (
52
+ <ToolCallBubble key={event.id} event={event} />
53
+ ))}
54
+ </div>
55
+ )
56
+ }
11
57
 
12
58
  interface Props {
13
59
  text: string
14
60
  assistantName?: string
61
+ agentAvatarSeed?: string
62
+ agentName?: string
15
63
  }
16
64
 
17
- export function StreamingBubble({ text, assistantName }: Props) {
65
+ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentName }: Props) {
18
66
  const rendered = useMemo(() => text, [text])
19
67
  const toolEvents = useChatStore((s) => s.toolEvents)
68
+ const streamPhase = useChatStore((s) => s.streamPhase)
69
+ const streamToolName = useChatStore((s) => s.streamToolName)
20
70
 
21
71
  return (
22
72
  <div
@@ -24,18 +74,17 @@ export function StreamingBubble({ text, assistantName }: Props) {
24
74
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}
25
75
  >
26
76
  <div className="flex items-center gap-2.5 mb-2 px-1">
27
- <AiAvatar size="sm" />
77
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
28
78
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
29
79
  <span className="w-2 h-2 rounded-full bg-accent-bright" style={{ animation: 'pulse 1.5s ease infinite' }} />
80
+ {streamPhase === 'tool' && streamToolName && (
81
+ <span className="text-[10px] text-text-3/50 font-mono">Using {streamToolName}...</span>
82
+ )}
30
83
  </div>
31
84
 
32
- {/* Tool call events */}
85
+ {/* Tool call events (collapsible when > 2) */}
33
86
  {toolEvents.length > 0 && (
34
- <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
35
- {toolEvents.map((event) => (
36
- <ToolCallBubble key={event.id} event={event} />
37
- ))}
38
- </div>
87
+ <ToolEventsSection toolEvents={toolEvents} />
39
88
  )}
40
89
 
41
90
  {rendered && (
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import type { Message } from '@/types'
4
+
5
+ interface Props {
6
+ lastMessage: Message | null
7
+ onSend: (text: string) => void
8
+ }
9
+
10
+ function getSuggestions(msg: Message | null): string[] {
11
+ if (!msg?.text) return ['Continue', 'Tell me more']
12
+ const text = msg.text
13
+
14
+ // Error patterns
15
+ if (/error|exception|failed|traceback|panic|ECONNREFUSED|ETIMEDOUT/i.test(text)) {
16
+ return ['Can you fix this?', 'Try an alternative approach', 'Explain the error']
17
+ }
18
+
19
+ // Code blocks present
20
+ if (/```[\s\S]*```/.test(text)) {
21
+ return ['Explain this code', 'Write tests for this', 'Any improvements?']
22
+ }
23
+
24
+ // File mentions
25
+ if (/`\/[\w./-]+\.\w+`/.test(text) || /\b(created|modified|updated|wrote|saved)\b.*\b(file|files)\b/i.test(text)) {
26
+ return ['Show me the file', 'Make changes to it', 'What else needs updating?']
27
+ }
28
+
29
+ // Task completion signals
30
+ if (/\b(done|complete|finished|ready|all set|successfully)\b/i.test(text)) {
31
+ return ["What's next?", 'Summarize what was done', 'Any remaining issues?']
32
+ }
33
+
34
+ // Question asked by assistant
35
+ if (/\?\s*$/.test(text.trim())) {
36
+ return ['Yes, go ahead', 'No, try a different approach', 'Tell me more about the options']
37
+ }
38
+
39
+ // List/steps presented
40
+ if (/^\s*(\d+\.|[-*])\s/m.test(text)) {
41
+ return ['Start with the first step', 'Can you elaborate?', 'Any alternatives?']
42
+ }
43
+
44
+ return ['Continue', 'Tell me more', 'Can you explain further?']
45
+ }
46
+
47
+ export function SuggestionsBar({ lastMessage, onSend }: Props) {
48
+ const suggestions = lastMessage?.suggestions?.length === 3
49
+ ? lastMessage.suggestions
50
+ : getSuggestions(lastMessage)
51
+
52
+ if (!suggestions.length) return null
53
+
54
+ return (
55
+ <div
56
+ className="flex flex-wrap gap-2 px-1 pt-2"
57
+ style={{ animation: 'fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1)' }}
58
+ >
59
+ {suggestions.map((text) => (
60
+ <button
61
+ key={text}
62
+ type="button"
63
+ onClick={() => onSend(text)}
64
+ className="rounded-full px-3.5 py-1.5 text-[12px] font-500 border border-white/[0.06] bg-white/[0.03]
65
+ text-text-3 hover:text-text-2 hover:bg-white/[0.06] hover:border-white/[0.10]
66
+ cursor-pointer transition-all active:scale-[0.97]"
67
+ style={{ fontFamily: 'inherit' }}
68
+ >
69
+ {text}
70
+ </button>
71
+ ))}
72
+ </div>
73
+ )
74
+ }
@@ -1,24 +1,38 @@
1
1
  'use client'
2
2
 
3
3
  import { AiAvatar } from '@/components/shared/avatar'
4
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
5
+ import { useChatStore } from '@/stores/use-chat-store'
4
6
 
5
7
  interface Props {
6
8
  assistantName?: string
9
+ agentAvatarSeed?: string
10
+ agentName?: string
7
11
  }
8
12
 
9
- export function ThinkingIndicator({ assistantName }: Props) {
13
+ export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentName }: Props) {
14
+ const streamPhase = useChatStore((s) => s.streamPhase)
15
+ const streamToolName = useChatStore((s) => s.streamToolName)
16
+
17
+ const statusText = streamPhase === 'tool' && streamToolName
18
+ ? `Using ${streamToolName}...`
19
+ : 'Thinking...'
20
+
10
21
  return (
11
22
  <div className="flex flex-col items-start"
12
23
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}>
13
24
  <div className="flex items-center gap-2.5 mb-2 px-1">
14
- <AiAvatar size="sm" />
25
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
15
26
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
16
27
  </div>
17
28
  <div className="bubble-ai px-6 py-5">
18
- <div className="flex gap-2">
19
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite' }} />
20
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.15s' }} />
21
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.3s' }} />
29
+ <div className="flex items-center gap-3">
30
+ <div className="flex gap-2">
31
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite' }} />
32
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.15s' }} />
33
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.3s' }} />
34
+ </div>
35
+ <span className="text-[12px] text-text-3/60 font-mono">{statusText}</span>
22
36
  </div>
23
37
  </div>
24
38
  </div>