@swarmclawai/swarmclaw 0.8.2 → 0.8.3
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.
- package/README.md +8 -8
- package/package.json +2 -2
- package/src/app/api/agents/route.ts +6 -3
- package/src/app/api/auth/route.ts +20 -10
- package/src/app/api/chats/[id]/devserver/route.ts +74 -48
- package/src/app/api/chats/[id]/route.ts +16 -1
- package/src/app/api/chats/route.ts +14 -6
- package/src/app/api/daemon/route.ts +4 -3
- package/src/app/api/openclaw/approvals/route.ts +3 -3
- package/src/app/api/wallets/[id]/route.ts +18 -4
- package/src/app/page.tsx +19 -23
- package/src/cli/index.js +1 -1
- package/src/cli/spec.js +1 -1
- package/src/components/auth/access-key-gate.tsx +5 -3
- package/src/components/chat/chat-area.tsx +50 -29
- package/src/components/chat/chat-card.tsx +4 -7
- package/src/components/chat/chat-header.tsx +19 -13
- package/src/components/chat/chat-list.tsx +11 -9
- package/src/components/chat/chat-tool-toggles.tsx +2 -2
- package/src/components/home/home-view.tsx +6 -2
- package/src/components/layout/app-layout.tsx +2 -3
- package/src/hooks/use-ws.ts +33 -7
- package/src/instrumentation.ts +21 -11
- package/src/lib/api-client.test.ts +49 -0
- package/src/lib/api-client.ts +53 -30
- package/src/lib/chats.ts +3 -0
- package/src/lib/runtime-env.test.ts +28 -0
- package/src/lib/runtime-env.ts +13 -0
- package/src/lib/server/chat-execution.ts +1 -1
- package/src/lib/server/connectors/manager.ts +4 -2
- package/src/lib/server/daemon-state.test.ts +23 -0
- package/src/lib/server/daemon-state.ts +34 -16
- package/src/lib/server/heartbeat-service.ts +61 -8
- package/src/lib/server/plugins.ts +12 -9
- package/src/lib/server/queue.ts +6 -1
- package/src/lib/server/storage.ts +100 -8
- package/src/lib/server/wallet-portfolio.ts +6 -0
- package/src/lib/session-summary.test.ts +49 -0
- package/src/lib/session-summary.ts +59 -0
- package/src/lib/ws-client.ts +1 -2
- package/src/proxy.test.ts +40 -0
- package/src/proxy.ts +23 -17
- package/src/stores/use-app-store.ts +66 -22
- package/src/stores/use-chat-store.ts +2 -2
- package/src/types/index.ts +4 -0
|
@@ -23,6 +23,7 @@ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
|
|
|
23
23
|
import { speak } from '@/lib/tts'
|
|
24
24
|
import { api } from '@/lib/api-client'
|
|
25
25
|
import { messagesDiffer } from '@/lib/chat-streaming-state'
|
|
26
|
+
import { getSessionLastMessage } from '@/lib/session-summary'
|
|
26
27
|
|
|
27
28
|
const DIRECT_PROMPT_SUGGESTIONS = [
|
|
28
29
|
{ text: 'What can you help me with?', icon: 'book', gradient: 'from-[#6366F1]/10 to-[#818CF8]/5' },
|
|
@@ -47,7 +48,7 @@ export function ChatArea() {
|
|
|
47
48
|
const currentUser = useAppStore((s) => s.currentUser)
|
|
48
49
|
const setCurrentSession = useAppStore((s) => s.setCurrentSession)
|
|
49
50
|
const removeSessionFromStore = useAppStore((s) => s.removeSession)
|
|
50
|
-
const
|
|
51
|
+
const refreshSession = useAppStore((s) => s.refreshSession)
|
|
51
52
|
const appSettings = useAppStore((s) => s.appSettings)
|
|
52
53
|
const { messages, setMessages, streaming, streamingSessionId, sendMessage, stopStreaming, devServer: devServerStatus, setDevServer, debugOpen, setDebugOpen, ttsEnabled, previewContent, setPreviewContent } = useChatStore()
|
|
53
54
|
const isDesktop = useMediaQuery('(min-width: 768px)')
|
|
@@ -82,13 +83,17 @@ export function ChatArea() {
|
|
|
82
83
|
const [pluginChatActions, setPluginChatActions] = useState<Array<{ id: string; label: string; action: string; value: string; tooltip?: string }>>([])
|
|
83
84
|
const sessionHasBrowserPlugin = session?.plugins?.includes('browser') === true
|
|
84
85
|
|
|
86
|
+
const refreshPluginChatActions = useCallback(() => {
|
|
87
|
+
api<Array<{ id: string; label: string; action: string; value: string; tooltip?: string }>>('GET', '/plugins/ui?type=chat_actions').then((actions) => {
|
|
88
|
+
if (Array.isArray(actions)) setPluginChatActions(actions)
|
|
89
|
+
}).catch(() => {})
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
85
92
|
useEffect(() => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
}, [sessionId])
|
|
93
|
+
void refreshPluginChatActions()
|
|
94
|
+
}, [refreshPluginChatActions])
|
|
95
|
+
|
|
96
|
+
useWs('plugins', refreshPluginChatActions)
|
|
92
97
|
|
|
93
98
|
// Collect unique connector sources from messages for filter UI
|
|
94
99
|
const { connectorSources, hasDirectMessages } = useMemo(() => {
|
|
@@ -131,7 +136,12 @@ export function ChatArea() {
|
|
|
131
136
|
if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
|
|
132
137
|
console.error('Failed to load messages:', err)
|
|
133
138
|
const fallbackSession = useAppStore.getState().sessions[requestedSessionId]
|
|
134
|
-
|
|
139
|
+
const fallbackLastMessage = fallbackSession ? getSessionLastMessage(fallbackSession) : null
|
|
140
|
+
setMessages(
|
|
141
|
+
fallbackSession?.messages?.length
|
|
142
|
+
? fallbackSession.messages
|
|
143
|
+
: (fallbackLastMessage ? [fallbackLastMessage] : []),
|
|
144
|
+
)
|
|
135
145
|
}).finally(() => {
|
|
136
146
|
if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
|
|
137
147
|
setMessagesLoading(false)
|
|
@@ -142,30 +152,41 @@ export function ChatArea() {
|
|
|
142
152
|
useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
|
|
143
153
|
}
|
|
144
154
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (refreshed?.active) {
|
|
150
|
-
useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
|
|
151
|
-
}
|
|
152
|
-
}).catch((err) => console.error('Failed to refresh messages:', err))
|
|
155
|
+
return () => {
|
|
156
|
+
cancelled = true
|
|
157
|
+
}
|
|
158
|
+
}, [refreshSession, sessionId, setDevServer, setMessages])
|
|
153
159
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!sessionId || messagesLoading) return
|
|
162
|
+
let cancelled = false
|
|
163
|
+
const requestedSessionId = sessionId
|
|
164
|
+
const timer = window.setTimeout(() => {
|
|
165
|
+
void refreshSession(requestedSessionId).then(() => {
|
|
166
|
+
if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
|
|
167
|
+
const refreshed = useAppStore.getState().sessions[requestedSessionId]
|
|
168
|
+
if (refreshed?.active) {
|
|
169
|
+
useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
|
|
170
|
+
}
|
|
171
|
+
}).catch((err) => console.error('Failed to refresh session:', err))
|
|
172
|
+
|
|
173
|
+
void devServer(requestedSessionId, 'status').then((r) => {
|
|
174
|
+
if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
|
|
175
|
+
setDevServer(r.running ? r : null)
|
|
176
|
+
}).catch(() => {
|
|
177
|
+
if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
|
|
178
|
+
setDevServer(null)
|
|
179
|
+
})
|
|
180
|
+
}, 200)
|
|
161
181
|
|
|
162
182
|
return () => {
|
|
163
183
|
cancelled = true
|
|
184
|
+
window.clearTimeout(timer)
|
|
164
185
|
}
|
|
165
|
-
}, [
|
|
186
|
+
}, [messagesLoading, refreshSession, sessionId, setDevServer])
|
|
166
187
|
|
|
167
188
|
useEffect(() => {
|
|
168
|
-
if (!sessionId) return
|
|
189
|
+
if (!sessionId || messagesLoading) return
|
|
169
190
|
let cancelled = false
|
|
170
191
|
if (!sessionHasBrowserPlugin) {
|
|
171
192
|
setBrowserActive(false)
|
|
@@ -182,7 +203,7 @@ export function ChatArea() {
|
|
|
182
203
|
return () => {
|
|
183
204
|
cancelled = true
|
|
184
205
|
}
|
|
185
|
-
}, [sessionHasBrowserPlugin, sessionId])
|
|
206
|
+
}, [messagesLoading, sessionHasBrowserPlugin, sessionId])
|
|
186
207
|
|
|
187
208
|
// Auto-poll messages for sessions that are actively running on the server
|
|
188
209
|
const isServerActive = session?.active === true
|
|
@@ -214,7 +235,7 @@ export function ChatArea() {
|
|
|
214
235
|
}
|
|
215
236
|
}
|
|
216
237
|
}
|
|
217
|
-
if (isServerActiveRef.current) await
|
|
238
|
+
if (isServerActiveRef.current) await refreshSession(sessionId)
|
|
218
239
|
} catch (err) { console.error('Failed to refresh messages:', err) }
|
|
219
240
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
220
241
|
}, [sessionId])
|
|
@@ -279,8 +300,8 @@ export function ChatArea() {
|
|
|
279
300
|
if (!sessionId) return
|
|
280
301
|
await clearMessages(sessionId)
|
|
281
302
|
setMessages([])
|
|
282
|
-
|
|
283
|
-
}, [
|
|
303
|
+
await refreshSession(sessionId)
|
|
304
|
+
}, [refreshSession, sessionId, setMessages])
|
|
284
305
|
|
|
285
306
|
const handleDelete = useCallback(async () => {
|
|
286
307
|
setConfirmDelete(false)
|
|
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
|
|
4
4
|
import { DEFAULT_HEARTBEAT_INTERVAL_SEC } from '@/lib/heartbeat-defaults'
|
|
5
5
|
import type { Session } from '@/types'
|
|
6
6
|
import { api } from '@/lib/api-client'
|
|
7
|
+
import { getSessionLastAssistantAt, getSessionLastMessage } from '@/lib/session-summary'
|
|
7
8
|
import { useAppStore } from '@/stores/use-app-store'
|
|
8
9
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
9
10
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
|
|
@@ -69,9 +70,7 @@ export function ChatCard({ session, active, onClick }: Props) {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
const last = session
|
|
73
|
-
? session.messages[session.messages.length - 1]
|
|
74
|
-
: null
|
|
73
|
+
const last = getSessionLastMessage(session)
|
|
75
74
|
const preview = last
|
|
76
75
|
? (last.role === 'user' ? 'You: ' : '') + last.text.slice(0, 70)
|
|
77
76
|
: 'No messages'
|
|
@@ -132,12 +131,10 @@ export function ChatCard({ session, active, onClick }: Props) {
|
|
|
132
131
|
)}
|
|
133
132
|
{(() => {
|
|
134
133
|
const lastRead = lastReadTimestamps[session.id] || 0
|
|
135
|
-
const unread = (session
|
|
136
|
-
(m) => m.role === 'assistant' && (m.time || 0) > lastRead,
|
|
137
|
-
).length
|
|
134
|
+
const unread = (getSessionLastAssistantAt(session) || 0) > lastRead ? 1 : 0
|
|
138
135
|
return unread > 0 ? (
|
|
139
136
|
<span className="shrink-0 min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-accent-bright text-white text-[10px] font-600 px-1">
|
|
140
|
-
{unread
|
|
137
|
+
{unread}
|
|
141
138
|
</span>
|
|
142
139
|
) : null
|
|
143
140
|
})()}
|
|
@@ -138,7 +138,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
138
138
|
const setMemoryAgentFilter = useAppStore((s) => s.setMemoryAgentFilter)
|
|
139
139
|
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
|
|
140
140
|
const appSettings = useAppStore((s) => s.appSettings)
|
|
141
|
-
const
|
|
141
|
+
const refreshSession = useAppStore((s) => s.refreshSession)
|
|
142
142
|
const loadAgents = useAppStore((s) => s.loadAgents)
|
|
143
143
|
const inspectorOpen = useAppStore((s) => s.inspectorOpen)
|
|
144
144
|
const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
|
|
@@ -174,11 +174,17 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
174
174
|
const agentWalletIds = useMemo(() => getAgentWalletIds(agent), [agent])
|
|
175
175
|
const activeWalletId = useMemo(() => getAgentActiveWalletId(agent), [agent])
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
api<Array<{ id: string; label: string; icon?: string }>>('GET',
|
|
177
|
+
const refreshHeaderWidgets = useCallback(() => {
|
|
178
|
+
api<Array<{ id: string; label: string; icon?: string }>>('GET', '/plugins/ui?type=header').then((widgets) => {
|
|
179
179
|
if (Array.isArray(widgets)) setHeaderWidgets(widgets)
|
|
180
180
|
}).catch(() => {})
|
|
181
|
-
}, [
|
|
181
|
+
}, [])
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
void refreshHeaderWidgets()
|
|
185
|
+
}, [refreshHeaderWidgets])
|
|
186
|
+
|
|
187
|
+
useWs('plugins', refreshHeaderWidgets)
|
|
182
188
|
|
|
183
189
|
const fetchWalletBalance = useCallback(async () => {
|
|
184
190
|
if (!activeWalletId) {
|
|
@@ -186,7 +192,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
186
192
|
return
|
|
187
193
|
}
|
|
188
194
|
try {
|
|
189
|
-
const data = await api<{ balanceFormatted?: string; balanceSymbol?: string; portfolioSummary?: { nonZeroAssets?: number } }>('GET', `/wallets/${activeWalletId}`)
|
|
195
|
+
const data = await api<{ balanceFormatted?: string; balanceSymbol?: string; portfolioSummary?: { nonZeroAssets?: number } }>('GET', `/wallets/${activeWalletId}?cached=1`)
|
|
190
196
|
if (data.balanceFormatted && data.balanceSymbol) {
|
|
191
197
|
setWalletBalance({
|
|
192
198
|
formatted: data.balanceFormatted,
|
|
@@ -340,7 +346,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
340
346
|
opencodeSessionId: null,
|
|
341
347
|
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
|
|
342
348
|
})
|
|
343
|
-
await
|
|
349
|
+
await refreshSession(session.id)
|
|
344
350
|
} catch { /* best-effort */ }
|
|
345
351
|
}
|
|
346
352
|
|
|
@@ -401,10 +407,10 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
401
407
|
await api('PUT', `/agents/${session.agentId}`, { heartbeatEnabled: next })
|
|
402
408
|
// Clear any stale session-level override so the agent value wins
|
|
403
409
|
await api('PUT', `/chats/${session.id}`, { heartbeatEnabled: null })
|
|
404
|
-
await Promise.all([loadAgents(),
|
|
410
|
+
await Promise.all([loadAgents(), refreshSession(session.id)])
|
|
405
411
|
} else {
|
|
406
412
|
await api('PUT', `/chats/${session.id}`, { heartbeatEnabled: next })
|
|
407
|
-
await
|
|
413
|
+
await refreshSession(session.id)
|
|
408
414
|
}
|
|
409
415
|
toast.success(`Heartbeat ${next ? 'enabled' : 'disabled'}`)
|
|
410
416
|
} finally {
|
|
@@ -425,10 +431,10 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
425
431
|
})
|
|
426
432
|
// Clear stale session-level overrides
|
|
427
433
|
await api('PUT', `/chats/${session.id}`, { heartbeatIntervalSec: null, heartbeatEnabled: null })
|
|
428
|
-
await Promise.all([loadAgents(),
|
|
434
|
+
await Promise.all([loadAgents(), refreshSession(session.id)])
|
|
429
435
|
} else {
|
|
430
436
|
await api('PUT', `/chats/${session.id}`, { heartbeatIntervalSec: sec })
|
|
431
|
-
await
|
|
437
|
+
await refreshSession(session.id)
|
|
432
438
|
}
|
|
433
439
|
} finally {
|
|
434
440
|
setHeartbeatSaving(false)
|
|
@@ -455,7 +461,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
455
461
|
{ sessionKey: openclawSessionKey, epoch: preview.epoch, localSessionId: session.id },
|
|
456
462
|
)
|
|
457
463
|
setSyncResult(result.merged > 0 ? `Synced ${result.merged} message${result.merged !== 1 ? 's' : ''}.` : 'Already up to date.')
|
|
458
|
-
if (result.merged > 0) await
|
|
464
|
+
if (result.merged > 0) await refreshSession(session.id)
|
|
459
465
|
} catch (err: unknown) {
|
|
460
466
|
setSyncResult(err instanceof Error ? err.message : 'Sync failed.')
|
|
461
467
|
} finally {
|
|
@@ -548,7 +554,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
548
554
|
setModelSwitcherOpen(false)
|
|
549
555
|
try {
|
|
550
556
|
await api('PUT', `/chats/${session.id}`, { provider: nextProvider, model: nextModel })
|
|
551
|
-
await
|
|
557
|
+
await refreshSession(session.id)
|
|
552
558
|
} catch (err: unknown) {
|
|
553
559
|
toast.error(err instanceof Error ? err.message : 'Failed to switch model')
|
|
554
560
|
}
|
|
@@ -863,7 +869,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
863
869
|
</Tip>
|
|
864
870
|
{hbDropdownOpen && (
|
|
865
871
|
<div className="absolute top-full right-0 mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[88px]">
|
|
866
|
-
{[...(typeof window !== 'undefined' && window.location.hostname === 'localhost' ? [
|
|
872
|
+
{[...(typeof window !== 'undefined' && window.location.hostname === 'localhost' ? [60, 300] : []), 1800, 3600, 7200, 21600, 43200].map((sec) => (
|
|
867
873
|
<button
|
|
868
874
|
key={sec}
|
|
869
875
|
onClick={() => handleSelectHeartbeatInterval(sec)}
|
|
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
|
|
|
5
5
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
6
6
|
import { ChatCard } from './chat-card'
|
|
7
7
|
import { fetchMessages } from '@/lib/chats'
|
|
8
|
+
import { getSessionLastAssistantAt, getSessionLastMessage, getSessionMessageCount } from '@/lib/session-summary'
|
|
8
9
|
import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
|
|
9
10
|
import { toast } from 'sonner'
|
|
10
11
|
import { Skeleton } from '@/components/shared/skeleton'
|
|
@@ -25,7 +26,6 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
25
26
|
const currentUser = useAppStore((s) => s.currentUser)
|
|
26
27
|
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
27
28
|
const setCurrentSession = useAppStore((s) => s.setCurrentSession)
|
|
28
|
-
const loadSessions = useAppStore((s) => s.loadSessions)
|
|
29
29
|
const loadConnectors = useAppStore((s) => s.loadConnectors)
|
|
30
30
|
const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
|
|
31
31
|
const clearSessions = useAppStore((s) => s.clearSessions)
|
|
@@ -49,7 +49,10 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
49
49
|
}, [sessions, loaded])
|
|
50
50
|
|
|
51
51
|
useEffect(() => {
|
|
52
|
-
|
|
52
|
+
const timer = window.setTimeout(() => {
|
|
53
|
+
void loadConnectors()
|
|
54
|
+
}, 1200)
|
|
55
|
+
return () => window.clearTimeout(timer)
|
|
53
56
|
}, [loadConnectors])
|
|
54
57
|
|
|
55
58
|
useEffect(() => {
|
|
@@ -65,13 +68,11 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
65
68
|
const filtered = useMemo(() => {
|
|
66
69
|
return allUserSessions
|
|
67
70
|
.filter((s) => {
|
|
68
|
-
const unreadCount = (s
|
|
69
|
-
(m) => m.role === 'assistant' && (m.time || 0) > (lastReadTimestamps[s.id] || 0),
|
|
70
|
-
).length
|
|
71
|
+
const unreadCount = (getSessionLastAssistantAt(s) || 0) > (lastReadTimestamps[s.id] || 0) ? 1 : 0
|
|
71
72
|
if (search) {
|
|
72
73
|
const agent = s.agentId ? agents[s.agentId] : null
|
|
73
74
|
const connector = Object.values(connectors).find((item) => item.chatroomId == null && item.agentId === s.agentId && item.isEnabled !== false)
|
|
74
|
-
const lastMessage = s
|
|
75
|
+
const lastMessage = getSessionLastMessage(s)
|
|
75
76
|
const haystack = [
|
|
76
77
|
s.name,
|
|
77
78
|
agent?.name,
|
|
@@ -99,7 +100,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
99
100
|
if (!a.pinned && b.pinned) return 1
|
|
100
101
|
// Then by sort mode
|
|
101
102
|
if (sortMode === 'name') return a.name.localeCompare(b.name)
|
|
102
|
-
if (sortMode === 'messages') return (b
|
|
103
|
+
if (sortMode === 'messages') return getSessionMessageCount(b) - getSessionMessageCount(a)
|
|
103
104
|
return (b.lastActiveAt || 0) - (a.lastActiveAt || 0)
|
|
104
105
|
})
|
|
105
106
|
}, [agents, allUserSessions, connectors, lastReadTimestamps, search, sortMode, typeFilter])
|
|
@@ -114,9 +115,10 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
114
115
|
const msgs = await fetchMessages(id)
|
|
115
116
|
setMessages(msgs)
|
|
116
117
|
} catch {
|
|
117
|
-
|
|
118
|
+
const fallback = sessions[id]
|
|
119
|
+
const fallbackLastMessage = fallback ? getSessionLastMessage(fallback) : null
|
|
120
|
+
setMessages(fallback?.messages?.length ? fallback.messages : (fallbackLastMessage ? [fallbackLastMessage] : []))
|
|
118
121
|
}
|
|
119
|
-
await loadSessions()
|
|
120
122
|
onSelect?.()
|
|
121
123
|
}
|
|
122
124
|
|
|
@@ -22,7 +22,7 @@ interface Props {
|
|
|
22
22
|
export function ChatToolToggles({ session }: Props) {
|
|
23
23
|
const [open, setOpen] = useState(false)
|
|
24
24
|
const ref = useRef<HTMLDivElement>(null)
|
|
25
|
-
const
|
|
25
|
+
const refreshSession = useAppStore((s) => s.refreshSession)
|
|
26
26
|
const agents = useAppStore((s) => s.agents)
|
|
27
27
|
const skills = useAppStore((s) => s.skills)
|
|
28
28
|
|
|
@@ -46,7 +46,7 @@ export function ChatToolToggles({ session }: Props) {
|
|
|
46
46
|
? sessionTools.filter((t) => t !== toolId)
|
|
47
47
|
: [...sessionTools, toolId]
|
|
48
48
|
await api('PUT', `/chats/${session.id}`, { plugins: updated })
|
|
49
|
-
|
|
49
|
+
await refreshSession(session.id)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const enabledCount = sessionTools.length
|
|
@@ -7,6 +7,7 @@ import { useChatStore } from '@/stores/use-chat-store'
|
|
|
7
7
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
8
8
|
import { api } from '@/lib/api-client'
|
|
9
9
|
import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
|
|
10
|
+
import { getSessionLastMessage } from '@/lib/session-summary'
|
|
10
11
|
import { getNotificationActivityAt, getNotificationOccurrenceCount } from '@/lib/notification-utils'
|
|
11
12
|
import type { Agent, Session, ActivityEntry, BoardTask, AppNotification } from '@/types'
|
|
12
13
|
import { HintTip } from '@/components/shared/hint-tip'
|
|
@@ -150,7 +151,9 @@ export function HomeView() {
|
|
|
150
151
|
void loadActivity({ limit: 8 })
|
|
151
152
|
void loadSchedules()
|
|
152
153
|
void loadNotifications()
|
|
153
|
-
|
|
154
|
+
const connectorTimer = window.setTimeout(() => {
|
|
155
|
+
void loadConnectors()
|
|
156
|
+
}, 1200)
|
|
154
157
|
api<{ records: Array<{ estimatedCost: number }>; timeSeries: Array<{ cost: number; bucket: string }> }>('GET', '/usage?range=7d')
|
|
155
158
|
.then((data) => {
|
|
156
159
|
const series = (data.timeSeries || []).map((pt: { cost: number; bucket?: string }) => ({ cost: pt.cost, bucket: pt.bucket || '' }))
|
|
@@ -160,6 +163,7 @@ export function HomeView() {
|
|
|
160
163
|
setTodayCost(todayPt?.cost || 0)
|
|
161
164
|
})
|
|
162
165
|
.catch(() => {})
|
|
166
|
+
return () => window.clearTimeout(connectorTimer)
|
|
163
167
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
168
|
}, [])
|
|
165
169
|
|
|
@@ -563,7 +567,7 @@ export function HomeView() {
|
|
|
563
567
|
<div className="flex flex-col gap-1">
|
|
564
568
|
{recentChats.map((session) => {
|
|
565
569
|
const agent = session.agentId ? agents[session.agentId] : null
|
|
566
|
-
const lastMsg = session
|
|
570
|
+
const lastMsg = getSessionLastMessage(session)
|
|
567
571
|
const displayName = agent?.name || 'Chat'
|
|
568
572
|
return (
|
|
569
573
|
<button
|
|
@@ -155,9 +155,8 @@ export function AppLayout() {
|
|
|
155
155
|
useEffect(() => {
|
|
156
156
|
loadApprovals()
|
|
157
157
|
void loadExecApprovals()
|
|
158
|
+
pruneExecApprovals()
|
|
158
159
|
const interval = setInterval(() => {
|
|
159
|
-
loadApprovals()
|
|
160
|
-
void loadExecApprovals()
|
|
161
160
|
pruneExecApprovals()
|
|
162
161
|
}, 10000)
|
|
163
162
|
return () => clearInterval(interval)
|
|
@@ -256,7 +255,7 @@ export function AppLayout() {
|
|
|
256
255
|
|
|
257
256
|
useEffect(() => { refreshPluginState() }, [refreshPluginState])
|
|
258
257
|
|
|
259
|
-
useWs('plugins', refreshPluginState)
|
|
258
|
+
useWs('plugins', refreshPluginState, 30000)
|
|
260
259
|
|
|
261
260
|
const [railExpanded, setRailExpanded] = useState(() => {
|
|
262
261
|
const stored = safeStorageGet(RAIL_EXPANDED_KEY)
|
package/src/hooks/use-ws.ts
CHANGED
|
@@ -8,18 +8,42 @@ import { usePageActive } from './use-page-active'
|
|
|
8
8
|
* Subscribe to a WebSocket topic. Calls `handler` on push events.
|
|
9
9
|
* Falls back to polling at `fallbackMs` when WS is disconnected.
|
|
10
10
|
*/
|
|
11
|
-
export function useWs(topic: string, handler: () => void
|
|
11
|
+
export function useWs(topic: string, handler: () => void | Promise<void>, fallbackMs?: number) {
|
|
12
12
|
const isActive = usePageActive()
|
|
13
13
|
const handlerRef = useRef(handler)
|
|
14
|
-
handlerRef.current = handler
|
|
15
14
|
const fallbackMsRef = useRef(fallbackMs)
|
|
16
|
-
|
|
15
|
+
const inFlightRef = useRef<Promise<void> | null>(null)
|
|
16
|
+
const wasActiveRef = useRef(isActive)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
handlerRef.current = handler
|
|
20
|
+
fallbackMsRef.current = fallbackMs
|
|
21
|
+
}, [handler, fallbackMs])
|
|
22
|
+
|
|
23
|
+
const runHandler = () => {
|
|
24
|
+
if (inFlightRef.current) return
|
|
25
|
+
try {
|
|
26
|
+
const result = handlerRef.current()
|
|
27
|
+
if (result && typeof (result as PromiseLike<void>).then === 'function') {
|
|
28
|
+
const promise = Promise.resolve(result)
|
|
29
|
+
.catch(() => {})
|
|
30
|
+
.finally(() => {
|
|
31
|
+
if (inFlightRef.current === promise) {
|
|
32
|
+
inFlightRef.current = null
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
inFlightRef.current = promise
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Individual handlers already own their error reporting
|
|
39
|
+
}
|
|
40
|
+
}
|
|
17
41
|
|
|
18
42
|
// WS subscription — only re-runs when topic changes
|
|
19
43
|
useEffect(() => {
|
|
20
44
|
if (!topic) return
|
|
21
45
|
|
|
22
|
-
const cb = () =>
|
|
46
|
+
const cb = () => runHandler()
|
|
23
47
|
subscribeWs(topic, cb)
|
|
24
48
|
return () => { unsubscribeWs(topic, cb) }
|
|
25
49
|
}, [topic])
|
|
@@ -30,12 +54,15 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
|
|
|
30
54
|
if (!topic) return
|
|
31
55
|
|
|
32
56
|
let fallbackId: ReturnType<typeof setInterval> | null = null
|
|
33
|
-
const cb = () =>
|
|
57
|
+
const cb = () => runHandler()
|
|
58
|
+
|
|
59
|
+
const becameActive = !wasActiveRef.current && isActive
|
|
60
|
+
wasActiveRef.current = isActive
|
|
34
61
|
|
|
35
62
|
// When page becomes visible again, fire an immediate refresh —
|
|
36
63
|
// but only for topics that use fallback polling (i.e. data-fetch topics).
|
|
37
64
|
// Event-only topics (like heartbeat pulses) should never fire from this effect.
|
|
38
|
-
if (
|
|
65
|
+
if (becameActive && fallbackMsRef.current && fallbackMsRef.current > 0 && !isWsConnected()) {
|
|
39
66
|
cb()
|
|
40
67
|
}
|
|
41
68
|
|
|
@@ -75,6 +102,5 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
|
|
|
75
102
|
stopFallback()
|
|
76
103
|
clearInterval(checkId)
|
|
77
104
|
}
|
|
78
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
105
|
}, [topic, isActive])
|
|
80
106
|
}
|
package/src/instrumentation.ts
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
export async function register() {
|
|
2
2
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
3
|
-
const { startScheduler } = await import('./lib/server/scheduler')
|
|
4
|
-
const { resumeQueue } = await import('./lib/server/queue')
|
|
5
3
|
const { initWsServer, closeWsServer } = await import('./lib/server/ws-hub')
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
const { ensureDaemonStarted } = await import('./lib/server/daemon-state')
|
|
5
|
+
ensureDaemonStarted('instrumentation')
|
|
6
|
+
|
|
9
7
|
initWsServer()
|
|
10
8
|
|
|
11
9
|
// Graceful shutdown: stop background services and close WS connections
|
|
12
|
-
|
|
10
|
+
const shutdownState = (
|
|
11
|
+
(globalThis as Record<string, unknown>).__swarmclaw_shutdown_state__
|
|
12
|
+
??= { registered: false, shuttingDown: false }
|
|
13
|
+
) as { registered: boolean; shuttingDown: boolean }
|
|
14
|
+
|
|
13
15
|
const shutdown = async (signal: string) => {
|
|
14
|
-
if (shuttingDown) return
|
|
15
|
-
shuttingDown = true
|
|
16
|
+
if (shutdownState.shuttingDown) return
|
|
17
|
+
shutdownState.shuttingDown = true
|
|
16
18
|
console.log(`[server] ${signal} received, shutting down gracefully...`)
|
|
17
|
-
|
|
19
|
+
try {
|
|
20
|
+
const { stopDaemon } = await import('./lib/server/daemon-state')
|
|
21
|
+
stopDaemon({ source: signal })
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error('[instrumentation] Failed to stop daemon during shutdown:', err)
|
|
24
|
+
}
|
|
18
25
|
await closeWsServer()
|
|
19
26
|
process.exit(0)
|
|
20
27
|
}
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
if (!shutdownState.registered) {
|
|
29
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM') })
|
|
30
|
+
process.on('SIGINT', () => { void shutdown('SIGINT') })
|
|
31
|
+
shutdownState.registered = true
|
|
32
|
+
}
|
|
23
33
|
}
|
|
24
34
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { api } from './api-client'
|
|
5
|
+
|
|
6
|
+
const originalFetch = global.fetch
|
|
7
|
+
|
|
8
|
+
test.afterEach(() => {
|
|
9
|
+
global.fetch = originalFetch
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('dedupes concurrent GET requests for the same path', async () => {
|
|
13
|
+
let calls = 0
|
|
14
|
+
global.fetch = (async () => {
|
|
15
|
+
calls += 1
|
|
16
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
17
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
status: 200,
|
|
20
|
+
})
|
|
21
|
+
}) as typeof fetch
|
|
22
|
+
|
|
23
|
+
const [first, second] = await Promise.all([
|
|
24
|
+
api<{ ok: boolean }>('GET', '/dedupe-check'),
|
|
25
|
+
api<{ ok: boolean }>('GET', '/dedupe-check'),
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(first, { ok: true })
|
|
29
|
+
assert.deepEqual(second, { ok: true })
|
|
30
|
+
assert.equal(calls, 1)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('does not dedupe non-GET requests', async () => {
|
|
34
|
+
let calls = 0
|
|
35
|
+
global.fetch = (async () => {
|
|
36
|
+
calls += 1
|
|
37
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
status: 200,
|
|
40
|
+
})
|
|
41
|
+
}) as typeof fetch
|
|
42
|
+
|
|
43
|
+
await Promise.all([
|
|
44
|
+
api<{ ok: boolean }>('POST', '/dedupe-check', { hello: 'one' }),
|
|
45
|
+
api<{ ok: boolean }>('POST', '/dedupe-check', { hello: 'two' }),
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
assert.equal(calls, 2)
|
|
49
|
+
})
|