@swarmclawai/swarmclaw 0.7.3 → 0.7.5

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 (152) hide show
  1. package/README.md +47 -40
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +17 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +4 -87
  12. package/src/app/api/agents/route.ts +23 -1
  13. package/src/app/api/auth/route.ts +1 -1
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/route.ts +12 -0
  19. package/src/app/api/chats/heartbeat/route.ts +2 -1
  20. package/src/app/api/chats/route.ts +7 -1
  21. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  22. package/src/app/api/external-agents/[id]/route.ts +31 -0
  23. package/src/app/api/external-agents/register/route.ts +3 -0
  24. package/src/app/api/external-agents/route.ts +66 -0
  25. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  26. package/src/app/api/gateways/[id]/route.ts +79 -0
  27. package/src/app/api/gateways/route.ts +57 -0
  28. package/src/app/api/openclaw/gateway/route.ts +10 -7
  29. package/src/app/api/openclaw/skills/route.ts +1 -1
  30. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  31. package/src/app/api/schedules/[id]/route.ts +38 -9
  32. package/src/app/api/schedules/route.ts +51 -28
  33. package/src/app/api/settings/route.ts +6 -10
  34. package/src/app/api/setup/doctor/route.ts +6 -4
  35. package/src/app/api/tasks/[id]/route.ts +2 -1
  36. package/src/app/api/tasks/bulk/route.ts +2 -2
  37. package/src/app/page.tsx +126 -15
  38. package/src/cli/binary.test.js +142 -0
  39. package/src/cli/index.js +34 -11
  40. package/src/cli/index.test.js +195 -0
  41. package/src/cli/index.ts +20 -4
  42. package/src/cli/server-cmd.test.js +59 -0
  43. package/src/cli/spec.js +20 -2
  44. package/src/components/agents/agent-sheet.tsx +249 -7
  45. package/src/components/agents/inspector-panel.tsx +3 -2
  46. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  47. package/src/components/auth/setup-wizard.tsx +970 -275
  48. package/src/components/chat/chat-area.tsx +41 -14
  49. package/src/components/chat/chat-card.tsx +2 -1
  50. package/src/components/chat/chat-header.tsx +8 -13
  51. package/src/components/chat/chat-list.tsx +58 -20
  52. package/src/components/chat/message-list.tsx +142 -18
  53. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  54. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  55. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  56. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +157 -86
  59. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  60. package/src/components/gateways/gateway-sheet.tsx +567 -0
  61. package/src/components/input/chat-input.tsx +135 -86
  62. package/src/components/layout/app-layout.tsx +2 -0
  63. package/src/components/memory/memory-browser.tsx +71 -6
  64. package/src/components/memory/memory-card.tsx +18 -0
  65. package/src/components/memory/memory-detail.tsx +58 -31
  66. package/src/components/memory/memory-sheet.tsx +32 -4
  67. package/src/components/projects/project-detail.tsx +7 -2
  68. package/src/components/providers/provider-list.tsx +158 -2
  69. package/src/components/providers/provider-sheet.tsx +81 -70
  70. package/src/components/shared/bottom-sheet.tsx +31 -15
  71. package/src/components/shared/confirm-dialog.tsx +45 -30
  72. package/src/components/shared/model-combobox.tsx +90 -8
  73. package/src/components/shared/settings/section-heartbeat.tsx +11 -6
  74. package/src/components/shared/settings/section-orchestrator.tsx +3 -0
  75. package/src/components/shared/settings/settings-page.tsx +5 -3
  76. package/src/components/tasks/approvals-panel.tsx +7 -1
  77. package/src/components/ui/dialog.tsx +2 -2
  78. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  79. package/src/lib/heartbeat-defaults.ts +48 -0
  80. package/src/lib/memory-presentation.ts +59 -0
  81. package/src/lib/provider-model-discovery-client.ts +29 -0
  82. package/src/lib/providers/index.ts +12 -5
  83. package/src/lib/runtime-loop.ts +105 -3
  84. package/src/lib/safe-storage.ts +6 -1
  85. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  86. package/src/lib/server/agent-runtime-config.ts +277 -0
  87. package/src/lib/server/agent-thread-session.test.ts +85 -0
  88. package/src/lib/server/agent-thread-session.ts +123 -0
  89. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  90. package/src/lib/server/build-llm.test.ts +13 -5
  91. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  92. package/src/lib/server/chat-execution.ts +159 -71
  93. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  94. package/src/lib/server/chatroom-helpers.ts +99 -6
  95. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  96. package/src/lib/server/connectors/manager.ts +89 -61
  97. package/src/lib/server/connectors/slack.ts +1 -1
  98. package/src/lib/server/daemon-state.ts +3 -2
  99. package/src/lib/server/data-dir.test.ts +56 -0
  100. package/src/lib/server/data-dir.ts +15 -9
  101. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  102. package/src/lib/server/eval/agent-regression.ts +1742 -0
  103. package/src/lib/server/eval/runner.ts +11 -1
  104. package/src/lib/server/eval/store.ts +2 -1
  105. package/src/lib/server/heartbeat-service.ts +23 -8
  106. package/src/lib/server/heartbeat-wake.ts +6 -2
  107. package/src/lib/server/main-agent-loop.ts +13 -6
  108. package/src/lib/server/openclaw-exec-config.ts +4 -2
  109. package/src/lib/server/openclaw-gateway.ts +123 -36
  110. package/src/lib/server/orchestrator-lg.ts +1 -2
  111. package/src/lib/server/orchestrator.ts +3 -2
  112. package/src/lib/server/plugins.test.ts +9 -1
  113. package/src/lib/server/plugins.ts +12 -2
  114. package/src/lib/server/provider-model-discovery.ts +481 -0
  115. package/src/lib/server/queue.ts +1 -1
  116. package/src/lib/server/runtime-settings.test.ts +119 -0
  117. package/src/lib/server/runtime-settings.ts +12 -92
  118. package/src/lib/server/schedule-normalization.ts +187 -0
  119. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  120. package/src/lib/server/session-tools/crud.ts +27 -3
  121. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  122. package/src/lib/server/session-tools/discovery.ts +18 -8
  123. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  124. package/src/lib/server/session-tools/file.ts +8 -2
  125. package/src/lib/server/session-tools/http.ts +9 -3
  126. package/src/lib/server/session-tools/index.ts +31 -1
  127. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  128. package/src/lib/server/session-tools/monitor.ts +14 -7
  129. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  130. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  131. package/src/lib/server/session-tools/platform.ts +1 -1
  132. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  133. package/src/lib/server/session-tools/sandbox.ts +51 -92
  134. package/src/lib/server/session-tools/session-info.ts +22 -1
  135. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  136. package/src/lib/server/session-tools/shell.ts +2 -2
  137. package/src/lib/server/session-tools/subagent.ts +3 -1
  138. package/src/lib/server/session-tools/web.ts +73 -30
  139. package/src/lib/server/storage.ts +29 -3
  140. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  141. package/src/lib/server/stream-agent-chat.ts +139 -4
  142. package/src/lib/server/structured-extract.ts +1 -1
  143. package/src/lib/server/task-mention.ts +0 -1
  144. package/src/lib/server/tool-aliases.ts +37 -6
  145. package/src/lib/server/tool-capability-policy.ts +1 -1
  146. package/src/lib/setup-defaults.ts +352 -11
  147. package/src/lib/tool-definitions.ts +3 -4
  148. package/src/lib/validation/schemas.ts +55 -1
  149. package/src/stores/use-app-store.ts +43 -1
  150. package/src/stores/use-chatroom-store.ts +153 -26
  151. package/src/types/index.ts +189 -6
  152. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
@@ -80,6 +80,7 @@ export function ChatArea() {
80
80
  const [messagesLoading, setMessagesLoading] = useState(true)
81
81
  const [connectorFilter, setConnectorFilter] = useState<string | null>(null)
82
82
  const [pluginChatActions, setPluginChatActions] = useState<Array<{ id: string; label: string; action: string; value: string; tooltip?: string }>>([])
83
+ const sessionHasBrowserPlugin = session?.plugins?.includes('browser') === true
83
84
 
84
85
  useEffect(() => {
85
86
  if (sessionId) {
@@ -113,44 +114,64 @@ export function ChatArea() {
113
114
 
114
115
  useEffect(() => {
115
116
  if (!sessionId) return
117
+ let cancelled = false
118
+ const requestedSessionId = sessionId
116
119
  const chatState = useChatStore.getState()
117
- const preserveLocalStream = chatState.streaming && chatState.streamingSessionId === sessionId
118
- // Clear stale state from the previous session, but keep active local stream state for this session.
120
+ const preserveLocalStream = chatState.streaming && chatState.streamingSessionId === requestedSessionId
121
+ // Keep the previous thread visible while the next one loads to avoid blank flashes.
119
122
  setMessagesLoading(true)
120
- setMessages([])
121
123
  if (!preserveLocalStream) {
122
124
  useChatStore.setState({ streaming: false, streamingSessionId: null, streamText: '', toolEvents: [] })
123
125
  }
124
- fetchMessagesPaginated(sessionId, 100).then((data) => {
126
+ fetchMessagesPaginated(requestedSessionId, 100).then((data) => {
127
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
125
128
  setMessages(data.messages)
126
129
  useChatStore.setState({ hasMoreMessages: data.hasMore, totalMessages: data.total })
127
130
  }).catch((err) => {
131
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
128
132
  console.error('Failed to load messages:', err)
129
- setMessages(session?.messages || [])
133
+ const fallbackSession = useAppStore.getState().sessions[requestedSessionId]
134
+ setMessages(fallbackSession?.messages || [])
130
135
  }).finally(() => {
136
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
131
137
  setMessagesLoading(false)
132
138
  })
133
139
  // If server reports session is still active, show streaming state
134
140
  if (session?.active) {
135
- useChatStore.setState({ streaming: true, streamingSessionId: sessionId, streamText: '' })
141
+ useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
136
142
  }
137
143
  // Refresh active state from server so returning to a session restores typing indicator.
138
144
  loadSessions().then(() => {
139
- const refreshed = useAppStore.getState().sessions[sessionId]
145
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
146
+ const refreshed = useAppStore.getState().sessions[requestedSessionId]
140
147
  if (refreshed?.active) {
141
- useChatStore.setState({ streaming: true, streamingSessionId: sessionId, streamText: '' })
148
+ useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
142
149
  }
143
150
  }).catch((err) => console.error('Failed to refresh messages:', err))
144
- devServer(sessionId, 'status').then((r) => {
151
+ devServer(requestedSessionId, 'status').then((r) => {
152
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
145
153
  setDevServer(r.running ? r : null)
146
- }).catch(() => setDevServer(null))
154
+ }).catch(() => {
155
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
156
+ setDevServer(null)
157
+ })
147
158
  // Check browser status
148
- if (session?.plugins?.includes('browser')) {
149
- checkBrowser(sessionId).then((r) => setBrowserActive(r.active)).catch((err) => { console.error('Browser check failed:', err); setBrowserActive(false) })
159
+ if (sessionHasBrowserPlugin) {
160
+ checkBrowser(requestedSessionId).then((r) => {
161
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
162
+ setBrowserActive(r.active)
163
+ }).catch((err) => {
164
+ if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
165
+ console.error('Browser check failed:', err)
166
+ setBrowserActive(false)
167
+ })
150
168
  } else {
151
169
  setBrowserActive(false)
152
170
  }
153
- }, [sessionId])
171
+ return () => {
172
+ cancelled = true
173
+ }
174
+ }, [loadSessions, session?.active, sessionHasBrowserPlugin, sessionId, setDevServer, setMessages])
154
175
 
155
176
  // Auto-poll messages for sessions that are actively running on the server
156
177
  const isServerActive = session?.active === true
@@ -424,7 +445,7 @@ export function ChatArea() {
424
445
  </div>
425
446
  </div>
426
447
  ) : (
427
- <MessageList messages={messages} streaming={streamingForThisSession} connectorFilter={connectorFilter} />
448
+ <MessageList messages={messages} streaming={streamingForThisSession} connectorFilter={connectorFilter} loading={messagesLoading} />
428
449
  )}
429
450
 
430
451
  {voice.active && (
@@ -450,6 +471,12 @@ export function ChatArea() {
450
471
  />
451
472
 
452
473
  <Dropdown open={menuOpen} onClose={() => setMenuOpen(false)}>
474
+ <DropdownItem onClick={() => {
475
+ setMenuOpen(false)
476
+ setDebugOpen(!debugOpen)
477
+ }}>
478
+ {debugOpen ? 'Hide Debug Panel' : 'Show Debug Panel'}
479
+ </DropdownItem>
453
480
  <DropdownItem onClick={() => { setMenuOpen(false); setConfirmClear(true) }}>
454
481
  Clear History
455
482
  </DropdownItem>
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import { DEFAULT_HEARTBEAT_INTERVAL_SEC } from '@/lib/heartbeat-defaults'
3
4
  import type { Session } from '@/types'
4
5
  import { api } from '@/lib/api-client'
5
6
  import { useAppStore } from '@/stores/use-app-store'
@@ -72,7 +73,7 @@ export function ChatCard({ session, active, onClick }: Props) {
72
73
  const connector = getSessionConnector(session, connectors)
73
74
  const loopIsOngoing = appSettings.loopMode === 'ongoing'
74
75
  const explicitOptIn = session.heartbeatEnabled === true || agent?.heartbeatEnabled === true
75
- const intervalRaw = session.heartbeatIntervalSec ?? agent?.heartbeatIntervalSec ?? appSettings.heartbeatIntervalSec ?? 120
76
+ const intervalRaw = session.heartbeatIntervalSec ?? agent?.heartbeatIntervalSec ?? appSettings.heartbeatIntervalSec ?? DEFAULT_HEARTBEAT_INTERVAL_SEC
76
77
  const intervalNum = typeof intervalRaw === 'number' ? intervalRaw : Number.parseInt(String(intervalRaw), 10)
77
78
  const intervalEnabled = Number.isFinite(intervalNum) ? intervalNum > 0 : true
78
79
  const heartbeatEnabled =
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import { DEFAULT_HEARTBEAT_INTERVAL_SEC } from '@/lib/heartbeat-defaults'
3
4
  import { useEffect, useState, useMemo, useRef, useCallback, type ReactNode } from 'react'
4
5
  import type { Session } from '@/types'
5
6
  import { useAppStore } from '@/stores/use-app-store'
@@ -113,8 +114,6 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
113
114
  const toggleTts = useChatStore((s) => s.toggleTts)
114
115
  const soundEnabled = useChatStore((s) => s.soundEnabled)
115
116
  const toggleSound = useChatStore((s) => s.toggleSound)
116
- const debugOpen = useChatStore((s) => s.debugOpen)
117
- const setDebugOpen = useChatStore((s) => s.setDebugOpen)
118
117
  const agentStatus = useChatStore((s) => s.agentStatus)
119
118
  const agents = useAppStore((s) => s.agents)
120
119
  const tasks = useAppStore((s) => s.tasks)
@@ -337,7 +336,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
337
336
  return null
338
337
  }
339
338
  // Global defaults
340
- let sec = resolveFrom(appSettings) ?? 1800
339
+ let sec = resolveFrom(appSettings) ?? DEFAULT_HEARTBEAT_INTERVAL_SEC
341
340
  let enabled = sec > 0
342
341
  let explicitOptIn = false
343
342
  // Agent layer
@@ -739,6 +738,9 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
739
738
  onChange={(m) => void handleModelSwitch(session.provider, m)}
740
739
  models={currentModels}
741
740
  defaultModels={currentProviderInfo?.defaultModels}
741
+ credentialId={session.credentialId}
742
+ apiEndpoint={session.apiEndpoint}
743
+ supportsDiscovery={currentProviderInfo?.supportsModelDiscovery}
742
744
  className="px-2.5 py-1.5 rounded-[7px] text-[12px] font-mono bg-white/[0.04] hover:bg-white/[0.06] transition-colors"
743
745
  />
744
746
  </div>
@@ -886,18 +888,11 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
886
888
  </IconButton>
887
889
  )}
888
890
  <div className="w-px h-3.5 bg-white/[0.06] mx-0.5" />
889
- <IconButton onClick={() => setDebugOpen(!debugOpen)} active={debugOpen} tooltip="Debug" aria-label="Toggle debug panel" size="sm">
890
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
891
- <path d="M12 20V10" /><path d="M18 20V4" /><path d="M6 20v-4" />
891
+ <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} tooltip="More" aria-label="Chat menu" size="sm">
892
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
893
+ <circle cx="12" cy="6" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="18" r="1" />
892
894
  </svg>
893
895
  </IconButton>
894
- {(!agent || mobile) && (
895
- <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} tooltip="Menu" aria-label="Chat menu" size="sm">
896
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
897
- <circle cx="12" cy="6" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="18" r="1" />
898
- </svg>
899
- </IconButton>
900
- )}
901
896
  {agent && (
902
897
  <IconButton onClick={() => setInspectorOpen(!inspectorOpen)} active={inspectorOpen} tooltip="Settings" aria-label="Toggle inspector" size="sm">
903
898
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -8,13 +8,14 @@ import { fetchMessages } from '@/lib/chats'
8
8
  import { toast } from 'sonner'
9
9
  import { Skeleton } from '@/components/shared/skeleton'
10
10
  import { EmptyState } from '@/components/shared/empty-state'
11
+ import { Dropdown, DropdownItem } from '@/components/shared/dropdown'
11
12
 
12
13
  interface Props {
13
14
  inSidebar?: boolean
14
15
  onSelect?: () => void
15
16
  }
16
17
 
17
- type SessionFilter = 'all' | 'active'
18
+ type SessionFilter = 'all' | 'active' | 'unread'
18
19
  type SortMode = 'lastActive' | 'name' | 'messages'
19
20
 
20
21
  export function ChatList({ inSidebar, onSelect }: Props) {
@@ -28,11 +29,15 @@ export function ChatList({ inSidebar, onSelect }: Props) {
28
29
  const clearSessions = useAppStore((s) => s.clearSessions)
29
30
  const togglePinSession = useAppStore((s) => s.togglePinSession)
30
31
  const markChatRead = useAppStore((s) => s.markChatRead)
32
+ const lastReadTimestamps = useAppStore((s) => s.lastReadTimestamps)
33
+ const agents = useAppStore((s) => s.agents)
34
+ const connectors = useAppStore((s) => s.connectors)
31
35
  const setMessages = useChatStore((s) => s.setMessages)
32
36
  const [search, setSearch] = useState('')
33
37
  const [typeFilter, setTypeFilter] = useState<SessionFilter>('all')
34
38
  const [sortMode, setSortMode] = useState<SortMode>('lastActive')
35
39
  const [loaded, setLoaded] = useState(Object.keys(sessions).length > 0)
40
+ const [bulkMenuOpen, setBulkMenuOpen] = useState(false)
36
41
 
37
42
  useEffect(() => {
38
43
  if (Object.keys(sessions).length > 0 && !loaded) setLoaded(true)
@@ -56,8 +61,32 @@ export function ChatList({ inSidebar, onSelect }: Props) {
56
61
  const filtered = useMemo(() => {
57
62
  return allUserSessions
58
63
  .filter((s) => {
59
- if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false
64
+ const unreadCount = (s.messages || []).filter(
65
+ (m) => m.role === 'assistant' && (m.time || 0) > (lastReadTimestamps[s.id] || 0),
66
+ ).length
67
+ if (search) {
68
+ const agent = s.agentId ? agents[s.agentId] : null
69
+ const connector = Object.values(connectors).find((item) => item.chatroomId == null && item.agentId === s.agentId && item.isEnabled !== false)
70
+ const lastMessage = s.messages?.[s.messages.length - 1]
71
+ const haystack = [
72
+ s.name,
73
+ agent?.name,
74
+ s.provider,
75
+ s.model,
76
+ s.cwd,
77
+ connector?.name,
78
+ connector?.platform,
79
+ lastMessage?.text,
80
+ lastMessage?.source?.senderName,
81
+ lastMessage?.source?.platform,
82
+ ]
83
+ .filter(Boolean)
84
+ .join(' ')
85
+ .toLowerCase()
86
+ if (!haystack.includes(search.toLowerCase())) return false
87
+ }
60
88
  if (typeFilter === 'active' && !s.active) return false
89
+ if (typeFilter === 'unread' && unreadCount === 0) return false
61
90
  return true
62
91
  })
63
92
  .sort((a, b) => {
@@ -69,7 +98,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
69
98
  if (sortMode === 'messages') return (b.messages?.length || 0) - (a.messages?.length || 0)
70
99
  return (b.lastActiveAt || 0) - (a.lastActiveAt || 0)
71
100
  })
72
- }, [allUserSessions, search, typeFilter, sortMode])
101
+ }, [agents, allUserSessions, connectors, lastReadTimestamps, search, sortMode, typeFilter])
73
102
 
74
103
  const handleSelect = async (id: string) => {
75
104
  setCurrentSession(id)
@@ -123,7 +152,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
123
152
  <div className="flex-1 flex flex-col overflow-y-auto">
124
153
  {/* Filter tabs — always visible when sessions exist */}
125
154
  <div className="flex items-center gap-1 px-4 pt-2 pb-1 shrink-0">
126
- {(['all', 'active'] as SessionFilter[]).map((f) => (
155
+ {(['all', 'active', 'unread'] as SessionFilter[]).map((f) => (
127
156
  <button
128
157
  key={f}
129
158
  onClick={() => setTypeFilter(f)}
@@ -131,25 +160,34 @@ export function ChatList({ inSidebar, onSelect }: Props) {
131
160
  ${typeFilter === f ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2'}`}
132
161
  style={{ fontFamily: 'inherit' }}
133
162
  >
134
- {f === 'all' ? 'All' : 'Active'}
163
+ {f === 'all' ? 'All' : f === 'active' ? 'Active' : 'Unread'}
135
164
  </button>
136
165
  ))}
137
166
  {filtered.length > 0 && (
138
- <button
139
- onClick={async () => {
140
- if (!window.confirm(`Delete ${filtered.length} chat${filtered.length === 1 ? '' : 's'}?`)) return
141
- await clearSessions(filtered.map((s) => s.id))
142
- toast.success(`${filtered.length} chat${filtered.length === 1 ? '' : 's'} deleted`)
143
- }}
144
- className="ml-auto p-1.5 rounded-[8px] text-text-3/70 hover:text-red-400 hover:bg-red-400/[0.06]
145
- cursor-pointer transition-all bg-transparent border-none"
146
- title="Clear all chats"
147
- >
148
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
149
- <polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
150
- <path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
151
- </svg>
152
- </button>
167
+ <div className="ml-auto relative">
168
+ <button
169
+ onClick={() => setBulkMenuOpen((open) => !open)}
170
+ className="p-1.5 rounded-[8px] text-text-3/70 hover:text-text-2 hover:bg-white/[0.04]
171
+ cursor-pointer transition-all bg-transparent border-none"
172
+ title="More actions"
173
+ >
174
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
175
+ <circle cx="5" cy="12" r="1.75" />
176
+ <circle cx="12" cy="12" r="1.75" />
177
+ <circle cx="19" cy="12" r="1.75" />
178
+ </svg>
179
+ </button>
180
+ <Dropdown open={bulkMenuOpen} onClose={() => setBulkMenuOpen(false)}>
181
+ <DropdownItem onClick={async () => {
182
+ setBulkMenuOpen(false)
183
+ if (!window.confirm(`Delete ${filtered.length} chat${filtered.length === 1 ? '' : 's'}?`)) return
184
+ await clearSessions(filtered.map((s) => s.id))
185
+ toast.success(`${filtered.length} chat${filtered.length === 1 ? '' : 's'} deleted`)
186
+ }}>
187
+ Clear filtered chats
188
+ </DropdownItem>
189
+ </Dropdown>
190
+ </div>
153
191
  )}
154
192
  </div>
155
193
 
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import { DEFAULT_HEARTBEAT_SHOW_ALERTS, DEFAULT_HEARTBEAT_SHOW_OK } from '@/lib/heartbeat-defaults'
4
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4
5
  import type { Message } from '@/types'
5
6
  import { useChatStore } from '@/stores/use-chat-store'
6
7
  import { useAppStore } from '@/stores/use-app-store'
@@ -50,9 +51,10 @@ interface Props {
50
51
  messages: Message[]
51
52
  streaming: boolean
52
53
  connectorFilter?: string | null
54
+ loading?: boolean
53
55
  }
54
56
 
55
- export function MessageList({ messages, streaming, connectorFilter = null }: Props) {
57
+ export function MessageList({ messages, streaming, connectorFilter = null, loading = false }: Props) {
56
58
  const scrollRef = useRef<HTMLDivElement>(null)
57
59
  const [showScrollToBottom, setShowScrollToBottom] = useState(false)
58
60
  const snapUntilRef = useRef(0)
@@ -79,8 +81,8 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
79
81
  || (session?.provider === 'claude-cli' ? undefined : session?.model || session?.provider)
80
82
  || undefined
81
83
 
82
- const showOk = appSettings.heartbeatShowOk ?? false
83
- const showAlerts = appSettings.heartbeatShowAlerts ?? true
84
+ const showOk = appSettings.heartbeatShowOk ?? DEFAULT_HEARTBEAT_SHOW_OK
85
+ const showAlerts = appSettings.heartbeatShowAlerts ?? DEFAULT_HEARTBEAT_SHOW_ALERTS
84
86
 
85
87
  // Gateway disconnect overlay for openclaw agents
86
88
  const isOpenClaw = agent?.provider === 'openclaw'
@@ -157,6 +159,10 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
157
159
  const [searchQuery, setSearchQuery] = useState('')
158
160
  const [searchIdx, setSearchIdx] = useState(0)
159
161
  const searchInputRef = useRef<HTMLInputElement>(null)
162
+ const openSearch = useCallback(() => {
163
+ setSearchOpen(true)
164
+ setTimeout(() => searchInputRef.current?.focus(), 50)
165
+ }, [])
160
166
 
161
167
  const isHeartbeatMessage = (msg: Message) =>
162
168
  msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || '') || /^\s*NO_MESSAGE\b/i.test(msg.text || ''))
@@ -192,11 +198,13 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
192
198
  }
193
199
 
194
200
  // Search matches
195
- const searchMatches = searchQuery.trim()
196
- ? filteredMessages
197
- .map((msg, i) => ({ msg, i }))
198
- .filter(({ msg }) => msg.text.toLowerCase().includes(searchQuery.toLowerCase()))
199
- : []
201
+ const searchMatches = useMemo(() => {
202
+ const normalizedQuery = searchQuery.trim().toLowerCase()
203
+ if (!normalizedQuery) return []
204
+ return filteredMessages
205
+ .map((msg, i) => ({ msg, i }))
206
+ .filter(({ msg }) => msg.text.toLowerCase().includes(normalizedQuery))
207
+ }, [filteredMessages, searchQuery])
200
208
 
201
209
  // Track whether user is at/near bottom so we know whether to auto-scroll on new content
202
210
  const wasAtBottomRef = useRef(true)
@@ -311,6 +319,15 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
311
319
  return () => window.removeEventListener('swarmclaw:scroll-to-message', handler)
312
320
  }, [])
313
321
 
322
+ useEffect(() => {
323
+ if (!searchQuery || !searchMatches.length) return
324
+ const currentMatch = searchMatches[searchIdx]
325
+ if (!currentMatch) return
326
+ const el = scrollRef.current?.querySelector(`[data-message-index="${currentMatch.i}"]`) as HTMLElement | null
327
+ if (!el) return
328
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
329
+ }, [searchIdx, searchMatches, searchQuery])
330
+
314
331
  // Ctrl+F search toggle
315
332
  useEffect(() => {
316
333
  const handler = (e: KeyboardEvent) => {
@@ -334,9 +351,81 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
334
351
 
335
352
  return (
336
353
  <div className="relative flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden">
354
+ <div className="shrink-0 px-4 md:px-12 lg:px-16 pt-3">
355
+ <div className="flex flex-wrap items-center gap-2 rounded-[14px] border border-white/[0.06] bg-surface/55 px-3 py-2 backdrop-blur-sm">
356
+ <button
357
+ type="button"
358
+ onClick={() => {
359
+ if (searchOpen) {
360
+ setSearchOpen(false)
361
+ setSearchQuery('')
362
+ setSearchIdx(0)
363
+ } else {
364
+ openSearch()
365
+ }
366
+ }}
367
+ className={`inline-flex items-center gap-1.5 rounded-[9px] border px-2.5 py-1.5 text-[11px] font-600 transition-colors cursor-pointer ${
368
+ searchOpen
369
+ ? 'border-accent-bright/25 bg-accent-soft/60 text-accent-bright'
370
+ : 'border-white/[0.06] bg-white/[0.03] text-text-3 hover:text-text-2 hover:bg-white/[0.06]'
371
+ }`}
372
+ >
373
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
374
+ <circle cx="11" cy="11" r="8" />
375
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
376
+ </svg>
377
+ Find
378
+ <span className="hidden sm:inline text-text-3/50">Cmd/Ctrl+F</span>
379
+ </button>
380
+ <button
381
+ type="button"
382
+ onClick={() => setBookmarkFilter((v) => !v)}
383
+ className={`inline-flex items-center gap-1.5 rounded-[9px] border px-2.5 py-1.5 text-[11px] font-600 transition-colors cursor-pointer ${
384
+ bookmarkFilter
385
+ ? 'border-amber-400/25 bg-amber-500/10 text-amber-300'
386
+ : 'border-white/[0.06] bg-white/[0.03] text-text-3 hover:text-text-2 hover:bg-white/[0.06]'
387
+ }`}
388
+ >
389
+ <svg width="12" height="12" viewBox="0 0 24 24" fill={bookmarkFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
390
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
391
+ </svg>
392
+ {bookmarkFilter ? 'Bookmarked' : 'Bookmarks'}
393
+ </button>
394
+ {(searchQuery || bookmarkFilter) && (
395
+ <button
396
+ type="button"
397
+ onClick={() => {
398
+ setSearchOpen(false)
399
+ setSearchQuery('')
400
+ setSearchIdx(0)
401
+ setBookmarkFilter(false)
402
+ }}
403
+ className="inline-flex items-center gap-1.5 rounded-[9px] border border-white/[0.06] bg-transparent px-2.5 py-1.5 text-[11px] font-600 text-text-3 hover:text-text-2 hover:bg-white/[0.04] cursor-pointer transition-colors"
404
+ >
405
+ Reset filters
406
+ </button>
407
+ )}
408
+ <div className="ml-auto flex items-center gap-2 text-[11px] text-text-3/60">
409
+ {searchQuery ? (
410
+ <span className="tabular-nums">
411
+ {searchMatches.length > 0 ? `${searchIdx + 1}/${searchMatches.length}` : '0 results'}
412
+ </span>
413
+ ) : (
414
+ <span>{filteredMessages.length} message{filteredMessages.length === 1 ? '' : 's'}</span>
415
+ )}
416
+ {loading && (
417
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2 py-1 text-text-3/70">
418
+ <span className="w-2 h-2 rounded-full bg-accent-bright animate-pulse" />
419
+ Loading thread
420
+ </span>
421
+ )}
422
+ </div>
423
+ </div>
424
+ </div>
425
+
337
426
  {/* In-thread search bar */}
338
427
  {searchOpen && (
339
- <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]">
428
+ <div className="shrink-0 z-20 flex items-center gap-2 px-4 md:px-12 lg:px-16 py-2 bg-surface/95 backdrop-blur-sm border-b border-white/[0.06]">
340
429
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3 shrink-0">
341
430
  <circle cx="11" cy="11" r="8" />
342
431
  <line x1="21" y1="21" x2="16.65" y2="16.65" />
@@ -401,7 +490,7 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
401
490
  <div
402
491
  ref={scrollRef}
403
492
  onScroll={updateScrollState}
404
- className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 md:px-12 lg:px-16 pt-6 pb-[120px] md:pb-10 fade-up"
493
+ className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 md:px-12 lg:px-16 pt-4 pb-[120px] md:pb-10 fade-up"
405
494
  >
406
495
  <div className="flex flex-col gap-6 relative">
407
496
  {/* Chat spine — vertical line for assistant messages */}
@@ -442,13 +531,48 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
442
531
  </div>
443
532
  )}
444
533
  {filteredMessages.length === 0 && !streaming && (
445
- <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' }}>
446
- <AgentAvatar seed={agent?.avatarSeed || null} avatarUrl={agent?.avatarUrl} name={agent?.name || 'Agent'} size={48} />
447
- <span className="font-display text-[16px] font-600 text-text-2">{agent?.name || 'Assistant'}</span>
448
- <span className="text-[14px] text-text-3/60">
449
- {INTRO_GREETINGS[stableHash(agent?.id || session?.id || '') % INTRO_GREETINGS.length]}
450
- </span>
451
- </div>
534
+ searchQuery.trim() || bookmarkFilter || connectorFilter ? (
535
+ <div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
536
+ <div className="w-12 h-12 rounded-full bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
537
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" className="text-text-3/70">
538
+ <circle cx="11" cy="11" r="8" />
539
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
540
+ </svg>
541
+ </div>
542
+ <span className="font-display text-[16px] font-600 text-text-2">
543
+ {bookmarkFilter ? 'No bookmarked messages here' : 'No messages match these filters'}
544
+ </span>
545
+ <span className="text-[13px] text-text-3/60 max-w-[360px]">
546
+ {searchQuery.trim()
547
+ ? `Nothing in this thread matches "${searchQuery.trim()}".`
548
+ : connectorFilter
549
+ ? 'Try another source filter or reset the thread filters.'
550
+ : 'Try another keyword or turn off bookmarks-only mode.'}
551
+ </span>
552
+ {(searchQuery.trim() || bookmarkFilter) && (
553
+ <button
554
+ type="button"
555
+ onClick={() => {
556
+ setSearchOpen(false)
557
+ setSearchQuery('')
558
+ setSearchIdx(0)
559
+ setBookmarkFilter(false)
560
+ }}
561
+ className="rounded-[10px] border border-white/[0.06] bg-white/[0.03] px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.06] cursor-pointer transition-colors"
562
+ >
563
+ Clear thread filters
564
+ </button>
565
+ )}
566
+ </div>
567
+ ) : (
568
+ <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' }}>
569
+ <AgentAvatar seed={agent?.avatarSeed || null} avatarUrl={agent?.avatarUrl} name={agent?.name || 'Agent'} size={48} />
570
+ <span className="font-display text-[16px] font-600 text-text-2">{agent?.name || 'Assistant'}</span>
571
+ <span className="text-[14px] text-text-3/60">
572
+ {INTRO_GREETINGS[stableHash(agent?.id || session?.id || '') % INTRO_GREETINGS.length]}
573
+ </span>
574
+ </div>
575
+ )
452
576
  )}
453
577
  {filteredMessages.map((msg, i) => {
454
578
  // Context-clear divider — render a visual separator instead of a bubble