@swarmclawai/swarmclaw 0.7.3 → 0.7.4
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 +47 -40
- package/bin/package-manager.js +157 -0
- package/bin/package-manager.test.js +90 -0
- package/bin/server-cmd.js +38 -7
- package/bin/swarmclaw.js +54 -4
- package/bin/update-cmd.js +48 -10
- package/bin/update-cmd.test.js +55 -0
- package/package.json +8 -3
- package/scripts/postinstall.mjs +26 -0
- package/src/app/api/agents/[id]/route.ts +17 -0
- package/src/app/api/agents/[id]/thread/route.ts +3 -1
- package/src/app/api/agents/route.ts +23 -1
- package/src/app/api/auth/route.ts +1 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
- package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/route.ts +6 -0
- package/src/app/api/chats/[id]/route.ts +12 -0
- package/src/app/api/chats/heartbeat/route.ts +2 -1
- package/src/app/api/chats/route.ts +7 -1
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
- package/src/app/api/external-agents/[id]/route.ts +31 -0
- package/src/app/api/external-agents/register/route.ts +3 -0
- package/src/app/api/external-agents/route.ts +66 -0
- package/src/app/api/gateways/[id]/health/route.ts +28 -0
- package/src/app/api/gateways/[id]/route.ts +79 -0
- package/src/app/api/gateways/route.ts +57 -0
- package/src/app/api/openclaw/gateway/route.ts +10 -7
- package/src/app/api/openclaw/skills/route.ts +1 -1
- package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
- package/src/app/api/schedules/[id]/route.ts +38 -9
- package/src/app/api/schedules/route.ts +51 -28
- package/src/app/api/settings/route.ts +6 -10
- package/src/app/api/setup/doctor/route.ts +6 -4
- package/src/app/api/tasks/[id]/route.ts +2 -1
- package/src/app/api/tasks/bulk/route.ts +2 -2
- package/src/app/page.tsx +126 -15
- package/src/cli/binary.test.js +142 -0
- package/src/cli/index.js +34 -11
- package/src/cli/index.test.js +195 -0
- package/src/cli/index.ts +20 -4
- package/src/cli/server-cmd.test.js +59 -0
- package/src/cli/spec.js +20 -2
- package/src/components/agents/agent-sheet.tsx +249 -7
- package/src/components/agents/inspector-panel.tsx +3 -2
- package/src/components/agents/sandbox-env-panel.tsx +4 -1
- package/src/components/auth/setup-wizard.tsx +970 -275
- package/src/components/chat/chat-area.tsx +41 -14
- package/src/components/chat/chat-card.tsx +2 -1
- package/src/components/chat/chat-header.tsx +8 -13
- package/src/components/chat/chat-list.tsx +58 -20
- package/src/components/chat/message-list.tsx +142 -18
- package/src/components/chatrooms/chatroom-input.tsx +96 -33
- package/src/components/chatrooms/chatroom-list.tsx +141 -72
- package/src/components/chatrooms/chatroom-message.tsx +7 -6
- package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
- package/src/components/chatrooms/chatroom-view.tsx +157 -86
- package/src/components/chatrooms/reaction-picker.tsx +38 -33
- package/src/components/gateways/gateway-sheet.tsx +567 -0
- package/src/components/input/chat-input.tsx +135 -86
- package/src/components/layout/app-layout.tsx +2 -0
- package/src/components/memory/memory-browser.tsx +71 -6
- package/src/components/memory/memory-card.tsx +18 -0
- package/src/components/memory/memory-detail.tsx +58 -31
- package/src/components/memory/memory-sheet.tsx +32 -4
- package/src/components/projects/project-detail.tsx +7 -2
- package/src/components/providers/provider-list.tsx +158 -2
- package/src/components/providers/provider-sheet.tsx +81 -70
- package/src/components/shared/bottom-sheet.tsx +31 -15
- package/src/components/shared/confirm-dialog.tsx +45 -30
- package/src/components/shared/model-combobox.tsx +90 -8
- package/src/components/shared/settings/section-heartbeat.tsx +11 -6
- package/src/components/shared/settings/section-orchestrator.tsx +3 -0
- package/src/components/shared/settings/settings-page.tsx +5 -3
- package/src/components/tasks/approvals-panel.tsx +7 -1
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
- package/src/lib/heartbeat-defaults.ts +48 -0
- package/src/lib/memory-presentation.ts +59 -0
- package/src/lib/provider-model-discovery-client.ts +29 -0
- package/src/lib/providers/index.ts +12 -5
- package/src/lib/runtime-loop.ts +105 -3
- package/src/lib/safe-storage.ts +6 -1
- package/src/lib/server/agent-runtime-config.test.ts +141 -0
- package/src/lib/server/agent-runtime-config.ts +277 -0
- package/src/lib/server/approvals-auto-approve.test.ts +59 -0
- package/src/lib/server/build-llm.test.ts +13 -5
- package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
- package/src/lib/server/chat-execution.ts +159 -71
- package/src/lib/server/chatroom-helpers.test.ts +7 -0
- package/src/lib/server/chatroom-helpers.ts +99 -6
- package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
- package/src/lib/server/connectors/manager.ts +89 -61
- package/src/lib/server/connectors/slack.ts +1 -1
- package/src/lib/server/daemon-state.ts +3 -2
- package/src/lib/server/eval/agent-regression.test.ts +47 -0
- package/src/lib/server/eval/agent-regression.ts +1742 -0
- package/src/lib/server/eval/runner.ts +11 -1
- package/src/lib/server/eval/store.ts +2 -1
- package/src/lib/server/heartbeat-service.ts +10 -4
- package/src/lib/server/main-agent-loop.ts +13 -6
- package/src/lib/server/openclaw-exec-config.ts +4 -2
- package/src/lib/server/openclaw-gateway.ts +123 -36
- package/src/lib/server/orchestrator-lg.ts +1 -2
- package/src/lib/server/orchestrator.ts +3 -2
- package/src/lib/server/plugins.test.ts +9 -1
- package/src/lib/server/plugins.ts +12 -2
- package/src/lib/server/provider-model-discovery.ts +481 -0
- package/src/lib/server/queue.ts +1 -1
- package/src/lib/server/runtime-settings.test.ts +119 -0
- package/src/lib/server/runtime-settings.ts +12 -92
- package/src/lib/server/schedule-normalization.ts +187 -0
- package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
- package/src/lib/server/session-tools/crud.ts +27 -3
- package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
- package/src/lib/server/session-tools/discovery.ts +18 -8
- package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
- package/src/lib/server/session-tools/file.ts +8 -2
- package/src/lib/server/session-tools/http.ts +9 -3
- package/src/lib/server/session-tools/index.ts +31 -1
- package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
- package/src/lib/server/session-tools/monitor.ts +14 -7
- package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
- package/src/lib/server/session-tools/platform.ts +1 -1
- package/src/lib/server/session-tools/plugin-creator.ts +9 -2
- package/src/lib/server/session-tools/sandbox.ts +51 -92
- package/src/lib/server/session-tools/session-info.ts +22 -1
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
- package/src/lib/server/session-tools/shell.ts +2 -2
- package/src/lib/server/session-tools/subagent.ts +3 -1
- package/src/lib/server/session-tools/web.ts +73 -30
- package/src/lib/server/storage.ts +29 -3
- package/src/lib/server/stream-agent-chat.test.ts +61 -0
- package/src/lib/server/stream-agent-chat.ts +139 -4
- package/src/lib/server/structured-extract.ts +1 -1
- package/src/lib/server/task-mention.ts +0 -1
- package/src/lib/server/tool-aliases.ts +37 -6
- package/src/lib/server/tool-capability-policy.ts +1 -1
- package/src/lib/setup-defaults.ts +352 -11
- package/src/lib/tool-definitions.ts +3 -4
- package/src/lib/validation/schemas.ts +55 -1
- package/src/stores/use-app-store.ts +43 -1
- package/src/stores/use-chatroom-store.ts +153 -26
- package/src/types/index.ts +189 -6
- 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 ===
|
|
118
|
-
//
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
148
|
+
useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamText: '' })
|
|
142
149
|
}
|
|
143
150
|
}).catch((err) => console.error('Failed to refresh messages:', err))
|
|
144
|
-
devServer(
|
|
151
|
+
devServer(requestedSessionId, 'status').then((r) => {
|
|
152
|
+
if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
|
|
145
153
|
setDevServer(r.running ? r : null)
|
|
146
|
-
}).catch(() =>
|
|
154
|
+
}).catch(() => {
|
|
155
|
+
if (cancelled || useAppStore.getState().currentSessionId !== requestedSessionId) return
|
|
156
|
+
setDevServer(null)
|
|
157
|
+
})
|
|
147
158
|
// Check browser status
|
|
148
|
-
if (
|
|
149
|
-
checkBrowser(
|
|
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
|
-
|
|
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 ??
|
|
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) ??
|
|
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={() =>
|
|
890
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
891
|
-
<
|
|
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
|
-
|
|
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,
|
|
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
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 {
|
|
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 ??
|
|
83
|
-
const showAlerts = appSettings.heartbeatShowAlerts ??
|
|
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 =
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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="
|
|
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-
|
|
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
|
-
|
|
446
|
-
<
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|