@swarmclawai/swarmclaw 1.4.7 → 1.4.8
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 +13 -2
- package/package.json +1 -1
- package/src/app/api/swarmfeed/actions/route.ts +101 -0
- package/src/app/api/swarmfeed/bookmarks/route.ts +30 -0
- package/src/app/api/swarmfeed/notifications/route.ts +30 -0
- package/src/app/api/swarmfeed/posts/[postId]/replies/route.ts +23 -0
- package/src/app/api/swarmfeed/posts/[postId]/route.ts +20 -0
- package/src/app/api/swarmfeed/posts/route.ts +12 -52
- package/src/app/api/swarmfeed/profiles/[agentId]/posts/route.ts +25 -0
- package/src/app/api/swarmfeed/profiles/[agentId]/route.ts +32 -0
- package/src/app/api/swarmfeed/route.ts +15 -13
- package/src/app/api/swarmfeed/search/route.ts +30 -0
- package/src/app/api/swarmfeed/suggested/route.ts +25 -0
- package/src/cli/index.js +11 -0
- package/src/features/swarmfeed/agent-social-settings.tsx +7 -1
- package/src/features/swarmfeed/compose-post.tsx +72 -87
- package/src/features/swarmfeed/feed-page.tsx +607 -76
- package/src/features/swarmfeed/post-card.tsx +205 -73
- package/src/features/swarmfeed/post-thread-sheet.tsx +155 -0
- package/src/features/swarmfeed/profile-sheet.tsx +179 -0
- package/src/features/swarmfeed/queries.ts +191 -8
- package/src/lib/server/agents/agent-swarm-registration.ts +31 -8
- package/src/lib/server/runtime/heartbeat-service.ts +8 -1
- package/src/lib/server/runtime/queue/core.ts +2 -0
- package/src/lib/server/session-tools/swarmfeed.ts +226 -63
- package/src/lib/server/storage-normalization.ts +1 -0
- package/src/lib/server/swarmfeed-runtime.test.ts +188 -0
- package/src/lib/server/swarmfeed-runtime.ts +131 -0
- package/src/lib/server/tasks/task-route-service.ts +2 -0
- package/src/lib/swarmfeed-client.ts +130 -28
- package/src/lib/tool-definitions.ts +1 -1
- package/src/types/agent.ts +1 -0
- package/src/types/swarmfeed.ts +105 -5
|
@@ -1,106 +1,637 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { useDeferredValue, useEffect, useState } from 'react'
|
|
4
|
+
import { Bell, Hash, Search, Sparkles, TrendingUp, Users } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
6
7
|
import { MainContent } from '@/components/layout/main-content'
|
|
7
8
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
8
|
-
import
|
|
9
|
+
import { useAppStore } from '@/stores/use-app-store'
|
|
10
|
+
import { ComposePost } from './compose-post'
|
|
11
|
+
import { PostCard, type PostCardAction } from './post-card'
|
|
12
|
+
import { PostThreadSheet } from './post-thread-sheet'
|
|
13
|
+
import { SwarmFeedProfileSheet } from './profile-sheet'
|
|
14
|
+
import {
|
|
15
|
+
useSwarmFeedActionMutation,
|
|
16
|
+
useSwarmFeedBookmarksQuery,
|
|
17
|
+
useSwarmFeedChannelsQuery,
|
|
18
|
+
useSwarmFeedFeedQuery,
|
|
19
|
+
useSwarmFeedNotificationsQuery,
|
|
20
|
+
useSwarmFeedSearchQuery,
|
|
21
|
+
useSwarmFeedSuggestedQuery,
|
|
22
|
+
} from './queries'
|
|
23
|
+
import type { Agent } from '@/types'
|
|
24
|
+
import type {
|
|
25
|
+
FeedType,
|
|
26
|
+
SwarmFeedAgentSummary,
|
|
27
|
+
SwarmFeedNotification,
|
|
28
|
+
SwarmFeedPost,
|
|
29
|
+
SwarmFeedSearchType,
|
|
30
|
+
} from '@/types/swarmfeed'
|
|
9
31
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
{ key: '
|
|
32
|
+
type FeedTab = 'for_you' | 'following' | 'trending' | 'bookmarks' | 'notifications'
|
|
33
|
+
|
|
34
|
+
const FEED_TABS: Array<{ key: FeedTab; label: string; icon: typeof Sparkles }> = [
|
|
35
|
+
{ key: 'for_you', label: 'For You', icon: Sparkles },
|
|
36
|
+
{ key: 'following', label: 'Following', icon: Users },
|
|
37
|
+
{ key: 'trending', label: 'Trending', icon: TrendingUp },
|
|
38
|
+
{ key: 'bookmarks', label: 'Bookmarks', icon: Hash },
|
|
39
|
+
{ key: 'notifications', label: 'Notifications', icon: Bell },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const SEARCH_FILTERS: Array<{ key?: SwarmFeedSearchType; label: string }> = [
|
|
43
|
+
{ label: 'All' },
|
|
44
|
+
{ key: 'posts', label: 'Posts' },
|
|
45
|
+
{ key: 'agents', label: 'Agents' },
|
|
46
|
+
{ key: 'channels', label: 'Channels' },
|
|
47
|
+
{ key: 'hashtags', label: 'Hashtags' },
|
|
14
48
|
]
|
|
15
49
|
|
|
50
|
+
function isFeedTab(tab: FeedTab): tab is Extract<FeedType, FeedTab> {
|
|
51
|
+
return tab === 'for_you' || tab === 'following' || tab === 'trending'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatTimestamp(iso: string): string {
|
|
55
|
+
const diff = Date.now() - new Date(iso).getTime()
|
|
56
|
+
const min = Math.floor(diff / 60_000)
|
|
57
|
+
if (min < 1) return 'just now'
|
|
58
|
+
if (min < 60) return `${min}m ago`
|
|
59
|
+
const hrs = Math.floor(min / 60)
|
|
60
|
+
if (hrs < 24) return `${hrs}h ago`
|
|
61
|
+
const days = Math.floor(hrs / 24)
|
|
62
|
+
if (days < 7) return `${days}d ago`
|
|
63
|
+
return new Date(iso).toLocaleDateString()
|
|
64
|
+
}
|
|
65
|
+
|
|
16
66
|
export function FeedPage() {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
67
|
+
const agents = useAppStore((s) => s.agents)
|
|
68
|
+
const feedAgents = Object.values(agents).filter(
|
|
69
|
+
(agent: Agent) => agent.swarmfeedEnabled && !agent.disabled && !agent.trashedAt,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const [activeTab, setActiveTab] = useState<FeedTab>('for_you')
|
|
73
|
+
const [selectedAgentId, setSelectedAgentId] = useState('')
|
|
74
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
75
|
+
const [searchType, setSearchType] = useState<SwarmFeedSearchType | undefined>()
|
|
76
|
+
const [threadState, setThreadState] = useState<{ postId: string; mode: 'reply' | 'quote' } | null>(null)
|
|
77
|
+
const [profileAgentId, setProfileAgentId] = useState<string | null>(null)
|
|
78
|
+
|
|
79
|
+
const deferredSearchQuery = useDeferredValue(searchQuery.trim())
|
|
80
|
+
const selectedAgent = selectedAgentId ? agents[selectedAgentId] : null
|
|
81
|
+
const isSearching = deferredSearchQuery.length >= 2
|
|
82
|
+
const currentFeedType = isFeedTab(activeTab) ? activeTab : 'for_you'
|
|
83
|
+
const requiresActor = activeTab === 'following' || activeTab === 'bookmarks' || activeTab === 'notifications'
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (feedAgents.length === 0) {
|
|
87
|
+
setSelectedAgentId('')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (!selectedAgentId || !feedAgents.some((agent) => agent.id === selectedAgentId)) {
|
|
91
|
+
setSelectedAgentId(feedAgents[0].id)
|
|
92
|
+
}
|
|
93
|
+
}, [feedAgents, selectedAgentId])
|
|
94
|
+
|
|
95
|
+
const channelsQuery = useSwarmFeedChannelsQuery()
|
|
96
|
+
const feedQuery = useSwarmFeedFeedQuery({
|
|
97
|
+
type: currentFeedType,
|
|
98
|
+
agentId: activeTab === 'following' ? selectedAgentId : undefined,
|
|
99
|
+
enabled: !isSearching && isFeedTab(activeTab) && (activeTab !== 'following' || !!selectedAgentId),
|
|
100
|
+
})
|
|
101
|
+
const bookmarksQuery = useSwarmFeedBookmarksQuery(selectedAgentId, !isSearching && activeTab === 'bookmarks')
|
|
102
|
+
const notificationsQuery = useSwarmFeedNotificationsQuery(selectedAgentId, !isSearching && activeTab === 'notifications')
|
|
103
|
+
const suggestedQuery = useSwarmFeedSuggestedQuery(selectedAgentId || undefined, true)
|
|
104
|
+
const searchResultsQuery = useSwarmFeedSearchQuery({
|
|
105
|
+
query: deferredSearchQuery,
|
|
106
|
+
type: searchType,
|
|
107
|
+
enabled: isSearching,
|
|
108
|
+
})
|
|
109
|
+
const actionMutation = useSwarmFeedActionMutation()
|
|
110
|
+
|
|
111
|
+
const channels = channelsQuery.data || []
|
|
112
|
+
const channelLabels = Object.fromEntries(
|
|
113
|
+
channels.map((channel) => [channel.id, `#${channel.handle}`]),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async function handlePostAction(action: PostCardAction, post: SwarmFeedPost) {
|
|
117
|
+
if (!selectedAgentId) {
|
|
118
|
+
throw new Error('Select an acting agent before interacting with SwarmFeed.')
|
|
119
|
+
}
|
|
24
120
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
121
|
+
await actionMutation.mutateAsync({
|
|
122
|
+
action,
|
|
123
|
+
agentId: selectedAgentId,
|
|
124
|
+
postId: post.id,
|
|
125
|
+
})
|
|
27
126
|
} catch (err: unknown) {
|
|
28
|
-
const message = err instanceof Error ? err.message : 'Failed to
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} finally {
|
|
32
|
-
setLoading(false)
|
|
127
|
+
const message = err instanceof Error ? err.message : 'Failed to update SwarmFeed action'
|
|
128
|
+
toast.error(message)
|
|
129
|
+
throw err
|
|
33
130
|
}
|
|
34
|
-
}
|
|
131
|
+
}
|
|
35
132
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
133
|
+
async function handleFollow(targetAgentId: string) {
|
|
134
|
+
if (!selectedAgentId) {
|
|
135
|
+
toast.error('Select an acting agent before following other agents.')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await actionMutation.mutateAsync({
|
|
140
|
+
action: 'follow',
|
|
141
|
+
agentId: selectedAgentId,
|
|
142
|
+
targetAgentId,
|
|
143
|
+
})
|
|
144
|
+
toast.success('Agent followed')
|
|
145
|
+
} catch (err: unknown) {
|
|
146
|
+
toast.error(err instanceof Error ? err.message : 'Failed to follow agent')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
39
149
|
|
|
40
|
-
|
|
41
|
-
|
|
150
|
+
function renderPosts(posts: SwarmFeedPost[]) {
|
|
151
|
+
if (posts.length === 0) {
|
|
152
|
+
return (
|
|
153
|
+
<EmptyState
|
|
154
|
+
title="Nothing here yet"
|
|
155
|
+
description="The feed is quiet right now. Try another tab, run a search, or direct one of your agents to publish an update."
|
|
156
|
+
/>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="space-y-4">
|
|
162
|
+
{posts.map((post) => (
|
|
163
|
+
<PostCard
|
|
164
|
+
key={post.id}
|
|
165
|
+
post={post}
|
|
166
|
+
channelLabel={post.channelId ? channelLabels[post.channelId] : null}
|
|
167
|
+
canInteract={!!selectedAgentId}
|
|
168
|
+
onAction={handlePostAction}
|
|
169
|
+
onProfileOpen={setProfileAgentId}
|
|
170
|
+
onThreadOpen={(postId, mode = 'reply') => setThreadState({ postId, mode })}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderMainColumn() {
|
|
178
|
+
if (isSearching) {
|
|
179
|
+
if (searchResultsQuery.isLoading) return <PageLoader />
|
|
180
|
+
if (searchResultsQuery.error) {
|
|
181
|
+
return (
|
|
182
|
+
<ErrorState
|
|
183
|
+
message={searchResultsQuery.error instanceof Error ? searchResultsQuery.error.message : 'Failed to search SwarmFeed'}
|
|
184
|
+
onRetry={() => { void searchResultsQuery.refetch() }}
|
|
185
|
+
/>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = searchResultsQuery.data
|
|
190
|
+
return (
|
|
191
|
+
<div className="space-y-5">
|
|
192
|
+
<div className="rounded-[18px] border border-white/[0.06] bg-surface/70 p-4">
|
|
193
|
+
<div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/60">Search Results</div>
|
|
194
|
+
<div className="mt-2 text-[14px] text-text">
|
|
195
|
+
{result?.total || 0} result{result?.total === 1 ? '' : 's'} for <span className="font-700 text-accent-bright">{deferredSearchQuery}</span>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{result?.posts?.length ? (
|
|
200
|
+
<section className="space-y-4">
|
|
201
|
+
<SectionTitle>Posts</SectionTitle>
|
|
202
|
+
{renderPosts(result.posts)}
|
|
203
|
+
</section>
|
|
204
|
+
) : null}
|
|
205
|
+
|
|
206
|
+
{result?.agents?.length ? (
|
|
207
|
+
<section className="space-y-3">
|
|
208
|
+
<SectionTitle>Agents</SectionTitle>
|
|
209
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
210
|
+
{result.agents.map((agent) => (
|
|
211
|
+
<button
|
|
212
|
+
key={agent.id}
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={() => setProfileAgentId(agent.id)}
|
|
215
|
+
className="cursor-pointer rounded-[18px] border border-white/[0.06] bg-surface/75 p-4 text-left transition-all hover:bg-surface/90"
|
|
216
|
+
>
|
|
217
|
+
<div className="flex items-start gap-3">
|
|
218
|
+
<AgentAvatar seed={agent.id} avatarUrl={agent.avatar || null} name={agent.name} size={36} />
|
|
219
|
+
<div className="min-w-0 flex-1">
|
|
220
|
+
<div className="truncate text-[14px] font-700 text-text">{agent.name}</div>
|
|
221
|
+
<div className="mt-1 text-[11px] uppercase tracking-[0.1em] text-text-3/55">
|
|
222
|
+
{agent.framework || 'unknown'}
|
|
223
|
+
</div>
|
|
224
|
+
{agent.bio && (
|
|
225
|
+
<p className="mt-2 line-clamp-3 text-[12px] leading-[1.6] text-text-3/75">{agent.bio}</p>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</button>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
</section>
|
|
233
|
+
) : null}
|
|
234
|
+
|
|
235
|
+
{result?.channels?.length ? (
|
|
236
|
+
<section className="space-y-3">
|
|
237
|
+
<SectionTitle>Channels</SectionTitle>
|
|
238
|
+
<div className="flex flex-wrap gap-2">
|
|
239
|
+
{result.channels.map((channel) => (
|
|
240
|
+
<div
|
|
241
|
+
key={channel.id}
|
|
242
|
+
className="rounded-[999px] border border-white/[0.08] bg-surface/75 px-3 py-2 text-[12px] font-700 text-text-2"
|
|
243
|
+
>
|
|
244
|
+
#{channel.handle} · {channel.displayName}
|
|
245
|
+
</div>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
</section>
|
|
249
|
+
) : null}
|
|
250
|
+
|
|
251
|
+
{result?.hashtags?.length ? (
|
|
252
|
+
<section className="space-y-3">
|
|
253
|
+
<SectionTitle>Hashtags</SectionTitle>
|
|
254
|
+
<div className="flex flex-wrap gap-2">
|
|
255
|
+
{result.hashtags.map((tag) => (
|
|
256
|
+
<div
|
|
257
|
+
key={tag.tag}
|
|
258
|
+
className="rounded-[999px] border border-white/[0.08] bg-surface/75 px-3 py-2 text-[12px] font-700 text-text-2"
|
|
259
|
+
>
|
|
260
|
+
#{tag.tag} · {tag.postCount} posts
|
|
261
|
+
</div>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
</section>
|
|
265
|
+
) : null}
|
|
266
|
+
|
|
267
|
+
{!result?.posts?.length && !result?.agents?.length && !result?.channels?.length && !result?.hashtags?.length ? (
|
|
268
|
+
<EmptyState
|
|
269
|
+
title="No matches"
|
|
270
|
+
description="Try a broader query, or change the search filter to a different result type."
|
|
271
|
+
/>
|
|
272
|
+
) : null}
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (requiresActor && !selectedAgentId) {
|
|
278
|
+
return (
|
|
279
|
+
<EmptyState
|
|
280
|
+
title="Choose an acting agent"
|
|
281
|
+
description="Following, bookmarks, and notifications are agent-scoped. Select a SwarmFeed-enabled agent first."
|
|
282
|
+
/>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (activeTab === 'bookmarks') {
|
|
287
|
+
if (bookmarksQuery.isLoading) return <PageLoader />
|
|
288
|
+
if (bookmarksQuery.error) {
|
|
289
|
+
return (
|
|
290
|
+
<ErrorState
|
|
291
|
+
message={bookmarksQuery.error instanceof Error ? bookmarksQuery.error.message : 'Failed to load bookmarks'}
|
|
292
|
+
onRetry={() => { void bookmarksQuery.refetch() }}
|
|
293
|
+
/>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
return renderPosts(bookmarksQuery.data || [])
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (activeTab === 'notifications') {
|
|
300
|
+
if (notificationsQuery.isLoading) return <PageLoader />
|
|
301
|
+
if (notificationsQuery.error) {
|
|
302
|
+
return (
|
|
303
|
+
<ErrorState
|
|
304
|
+
message={notificationsQuery.error instanceof Error ? notificationsQuery.error.message : 'Failed to load notifications'}
|
|
305
|
+
onRetry={() => { void notificationsQuery.refetch() }}
|
|
306
|
+
/>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
return (
|
|
310
|
+
<NotificationsList
|
|
311
|
+
notifications={notificationsQuery.data || []}
|
|
312
|
+
onOpenProfile={setProfileAgentId}
|
|
313
|
+
onOpenThread={(postId) => setThreadState({ postId, mode: 'reply' })}
|
|
314
|
+
/>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (feedQuery.isLoading) return <PageLoader />
|
|
319
|
+
if (feedQuery.error) {
|
|
320
|
+
return (
|
|
321
|
+
<ErrorState
|
|
322
|
+
message={feedQuery.error instanceof Error ? feedQuery.error.message : 'Failed to load feed'}
|
|
323
|
+
onRetry={() => { void feedQuery.refetch() }}
|
|
324
|
+
/>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return renderPosts(feedQuery.data?.posts || [])
|
|
42
329
|
}
|
|
43
330
|
|
|
44
331
|
return (
|
|
45
332
|
<MainContent>
|
|
46
333
|
<div className="flex-1 overflow-y-auto overscroll-contain">
|
|
47
|
-
<div className="mx-auto max-w-
|
|
48
|
-
<div className="mb-6">
|
|
49
|
-
<
|
|
50
|
-
|
|
334
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6">
|
|
335
|
+
<div className="mb-6 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
|
336
|
+
<div>
|
|
337
|
+
<h1 className="font-display text-[24px] font-700 tracking-[-0.02em] text-text">SwarmFeed</h1>
|
|
338
|
+
<p className="mt-1 max-w-2xl text-[13px] leading-[1.7] text-text-3/75">
|
|
339
|
+
A social network for agents. Humans can direct an update, but every post, follow, and reaction is executed as the selected agent identity.
|
|
340
|
+
</p>
|
|
341
|
+
</div>
|
|
342
|
+
<div className="rounded-[16px] border border-white/[0.06] bg-surface/65 px-4 py-3">
|
|
343
|
+
<div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/60">Acting As</div>
|
|
344
|
+
{selectedAgent ? (
|
|
345
|
+
<div className="mt-2 flex items-center gap-2">
|
|
346
|
+
<AgentAvatar
|
|
347
|
+
seed={selectedAgent.avatarSeed || selectedAgent.id}
|
|
348
|
+
avatarUrl={selectedAgent.avatarUrl}
|
|
349
|
+
name={selectedAgent.name}
|
|
350
|
+
size={28}
|
|
351
|
+
/>
|
|
352
|
+
<div className="min-w-0">
|
|
353
|
+
<div className="truncate text-[13px] font-700 text-text">{selectedAgent.name}</div>
|
|
354
|
+
<div className="text-[11px] uppercase tracking-[0.1em] text-text-3/55">
|
|
355
|
+
{selectedAgent.model || selectedAgent.provider}
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
) : (
|
|
360
|
+
<div className="mt-2 text-[12px] text-text-3/70">No SwarmFeed-enabled agents available yet.</div>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
51
363
|
</div>
|
|
52
364
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
365
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.7fr)_360px]">
|
|
366
|
+
<aside className="order-1 space-y-5 lg:order-2">
|
|
367
|
+
<ComposePost
|
|
368
|
+
selectedAgentId={selectedAgentId}
|
|
369
|
+
onSelectAgent={setSelectedAgentId}
|
|
370
|
+
/>
|
|
371
|
+
|
|
372
|
+
<div className="rounded-[20px] border border-white/[0.08] bg-surface/80 p-5">
|
|
373
|
+
<div className="mb-3 flex items-center gap-2">
|
|
374
|
+
<Search size={14} className="text-text-3/60" />
|
|
375
|
+
<div className="text-[13px] font-700 uppercase tracking-[0.1em] text-text-3/60">Search SwarmFeed</div>
|
|
376
|
+
</div>
|
|
377
|
+
<input
|
|
378
|
+
value={searchQuery}
|
|
379
|
+
onChange={(event) => setSearchQuery(event.target.value)}
|
|
380
|
+
placeholder="Find posts, agents, channels, hashtags…"
|
|
381
|
+
className="w-full rounded-[14px] border border-white/[0.08] bg-bg/65 px-4 py-3 text-[14px] text-text outline-none transition-all placeholder:text-text-3/50 focus-glow"
|
|
382
|
+
/>
|
|
383
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
384
|
+
{SEARCH_FILTERS.map((filter) => (
|
|
385
|
+
<button
|
|
386
|
+
key={filter.key || 'all'}
|
|
387
|
+
type="button"
|
|
388
|
+
onClick={() => setSearchType(filter.key)}
|
|
389
|
+
className={`cursor-pointer rounded-[999px] border px-3 py-1.5 text-[12px] font-700 transition-all ${
|
|
390
|
+
searchType === filter.key
|
|
391
|
+
? 'border-accent-bright/45 bg-accent-bright/10 text-accent-bright'
|
|
392
|
+
: 'border-white/[0.08] bg-transparent text-text-3 hover:text-text'
|
|
393
|
+
}`}
|
|
394
|
+
>
|
|
395
|
+
{filter.label}
|
|
396
|
+
</button>
|
|
397
|
+
))}
|
|
398
|
+
</div>
|
|
399
|
+
<div className="mt-3 text-[11px] text-text-3/60">
|
|
400
|
+
{searchQuery.trim().length === 1
|
|
401
|
+
? 'Type at least 2 characters to search.'
|
|
402
|
+
: isSearching
|
|
403
|
+
? searchResultsQuery.isLoading
|
|
404
|
+
? 'Searching…'
|
|
405
|
+
: `${searchResultsQuery.data?.total || 0} results ready`
|
|
406
|
+
: 'Search is public. Posting and follow actions still run as the selected agent.'}
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<div className="rounded-[20px] border border-white/[0.08] bg-surface/80 p-5">
|
|
411
|
+
<div className="mb-3 flex items-center gap-2">
|
|
412
|
+
<Users size={14} className="text-text-3/60" />
|
|
413
|
+
<div className="text-[13px] font-700 uppercase tracking-[0.1em] text-text-3/60">Suggested Follows</div>
|
|
414
|
+
</div>
|
|
415
|
+
|
|
416
|
+
{suggestedQuery.isLoading ? (
|
|
417
|
+
<div className="text-[13px] text-text-3/70">Loading suggestions…</div>
|
|
418
|
+
) : suggestedQuery.error ? (
|
|
419
|
+
<div className="text-[13px] text-red-200">
|
|
420
|
+
{suggestedQuery.error instanceof Error ? suggestedQuery.error.message : 'Failed to load suggestions'}
|
|
421
|
+
</div>
|
|
422
|
+
) : suggestedQuery.data?.agents?.length ? (
|
|
423
|
+
<div className="space-y-3">
|
|
424
|
+
{suggestedQuery.data.agents.slice(0, 6).map((agent) => (
|
|
425
|
+
<SuggestedAgentRow
|
|
426
|
+
key={agent.id}
|
|
427
|
+
agent={agent}
|
|
428
|
+
canFollow={!!selectedAgentId}
|
|
429
|
+
busy={actionMutation.isPending}
|
|
430
|
+
onFollow={handleFollow}
|
|
431
|
+
onOpenProfile={setProfileAgentId}
|
|
432
|
+
/>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
) : (
|
|
436
|
+
<div className="text-[13px] text-text-3/70">No suggestions available right now.</div>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
{channels.length > 0 ? (
|
|
441
|
+
<div className="rounded-[20px] border border-white/[0.08] bg-surface/80 p-5">
|
|
442
|
+
<div className="mb-3 text-[13px] font-700 uppercase tracking-[0.1em] text-text-3/60">Channels</div>
|
|
443
|
+
<div className="flex flex-wrap gap-2">
|
|
444
|
+
{channels.slice(0, 12).map((channel) => (
|
|
445
|
+
<div
|
|
446
|
+
key={channel.id}
|
|
447
|
+
className="rounded-[999px] border border-white/[0.08] bg-bg/55 px-3 py-1.5 text-[12px] font-700 text-text-2"
|
|
448
|
+
>
|
|
449
|
+
#{channel.handle}
|
|
450
|
+
</div>
|
|
451
|
+
))}
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
) : null}
|
|
455
|
+
</aside>
|
|
456
|
+
|
|
457
|
+
<div className="order-2 space-y-5 lg:order-1">
|
|
458
|
+
<div className="rounded-[18px] border border-white/[0.06] bg-surface/70 p-2">
|
|
459
|
+
<div className="flex flex-wrap gap-1">
|
|
460
|
+
{FEED_TABS.map((tab) => {
|
|
461
|
+
const Icon = tab.icon
|
|
462
|
+
return (
|
|
463
|
+
<button
|
|
464
|
+
key={tab.key}
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={() => setActiveTab(tab.key)}
|
|
467
|
+
className={`flex cursor-pointer items-center gap-2 rounded-[12px] px-4 py-2.5 text-[13px] font-700 transition-all ${
|
|
468
|
+
activeTab === tab.key
|
|
469
|
+
? 'bg-accent-bright/14 text-accent-bright'
|
|
470
|
+
: 'bg-transparent text-text-3 hover:bg-white/[0.04] hover:text-text'
|
|
471
|
+
}`}
|
|
472
|
+
>
|
|
473
|
+
<Icon size={14} />
|
|
474
|
+
<span>{tab.label}</span>
|
|
475
|
+
</button>
|
|
476
|
+
)
|
|
477
|
+
})}
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
{renderMainColumn()}
|
|
482
|
+
</div>
|
|
68
483
|
</div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<PostThreadSheet
|
|
488
|
+
open={!!threadState}
|
|
489
|
+
postId={threadState?.postId || null}
|
|
490
|
+
actingAgentId={selectedAgentId || undefined}
|
|
491
|
+
channelLabels={channelLabels}
|
|
492
|
+
initialMode={threadState?.mode || 'reply'}
|
|
493
|
+
onClose={() => setThreadState(null)}
|
|
494
|
+
onProfileOpen={setProfileAgentId}
|
|
495
|
+
/>
|
|
69
496
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
497
|
+
<SwarmFeedProfileSheet
|
|
498
|
+
open={!!profileAgentId}
|
|
499
|
+
agentId={profileAgentId}
|
|
500
|
+
viewerAgentId={selectedAgentId || undefined}
|
|
501
|
+
channelLabels={channelLabels}
|
|
502
|
+
onClose={() => setProfileAgentId(null)}
|
|
503
|
+
onOpenThread={(postId, mode = 'reply') => setThreadState({ postId, mode })}
|
|
504
|
+
/>
|
|
505
|
+
</MainContent>
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function SuggestedAgentRow({
|
|
510
|
+
agent,
|
|
511
|
+
canFollow,
|
|
512
|
+
busy,
|
|
513
|
+
onFollow,
|
|
514
|
+
onOpenProfile,
|
|
515
|
+
}: {
|
|
516
|
+
agent: SwarmFeedAgentSummary
|
|
517
|
+
canFollow: boolean
|
|
518
|
+
busy: boolean
|
|
519
|
+
onFollow: (agentId: string) => Promise<void>
|
|
520
|
+
onOpenProfile: (agentId: string) => void
|
|
521
|
+
}) {
|
|
522
|
+
return (
|
|
523
|
+
<div className="flex items-center gap-3 rounded-[16px] border border-white/[0.06] bg-bg/45 p-3">
|
|
524
|
+
<button
|
|
525
|
+
type="button"
|
|
526
|
+
onClick={() => onOpenProfile(agent.id)}
|
|
527
|
+
className="cursor-pointer rounded-full border-none bg-transparent p-0"
|
|
528
|
+
>
|
|
529
|
+
<AgentAvatar seed={agent.id} avatarUrl={agent.avatar || null} name={agent.name} size={34} />
|
|
530
|
+
</button>
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
onClick={() => onOpenProfile(agent.id)}
|
|
534
|
+
className="min-w-0 flex-1 cursor-pointer border-none bg-transparent p-0 text-left"
|
|
535
|
+
>
|
|
536
|
+
<div className="truncate text-[13px] font-700 text-text">{agent.name}</div>
|
|
537
|
+
<div className="mt-1 text-[11px] uppercase tracking-[0.08em] text-text-3/55">
|
|
538
|
+
{agent.framework || 'unknown'}{typeof agent.followerCount === 'number' ? ` · ${agent.followerCount} followers` : ''}
|
|
539
|
+
</div>
|
|
540
|
+
</button>
|
|
541
|
+
<button
|
|
542
|
+
type="button"
|
|
543
|
+
onClick={() => { void onFollow(agent.id) }}
|
|
544
|
+
disabled={!canFollow || busy}
|
|
545
|
+
className="cursor-pointer rounded-[10px] border border-accent-bright/35 bg-accent-bright/10 px-3 py-2 text-[12px] font-700 text-accent-bright transition-all hover:bg-accent-bright/15 disabled:cursor-not-allowed disabled:opacity-40"
|
|
546
|
+
>
|
|
547
|
+
Follow
|
|
548
|
+
</button>
|
|
549
|
+
</div>
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function NotificationsList({
|
|
554
|
+
notifications,
|
|
555
|
+
onOpenProfile,
|
|
556
|
+
onOpenThread,
|
|
557
|
+
}: {
|
|
558
|
+
notifications: SwarmFeedNotification[]
|
|
559
|
+
onOpenProfile: (agentId: string) => void
|
|
560
|
+
onOpenThread: (postId: string) => void
|
|
561
|
+
}) {
|
|
562
|
+
if (notifications.length === 0) {
|
|
563
|
+
return (
|
|
564
|
+
<EmptyState
|
|
565
|
+
title="No notifications"
|
|
566
|
+
description="When other agents mention, react to, or follow the selected agent, activity will show up here."
|
|
567
|
+
/>
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
<div className="space-y-3">
|
|
573
|
+
{notifications.map((notification) => (
|
|
574
|
+
<div key={notification.id} className="rounded-[18px] border border-white/[0.06] bg-surface/75 p-4">
|
|
575
|
+
<div className="flex items-start justify-between gap-3">
|
|
576
|
+
<div className="min-w-0 flex-1">
|
|
76
577
|
<button
|
|
77
|
-
|
|
78
|
-
|
|
578
|
+
type="button"
|
|
579
|
+
onClick={() => onOpenProfile(notification.actorId)}
|
|
580
|
+
className="cursor-pointer border-none bg-transparent p-0 text-left"
|
|
79
581
|
>
|
|
80
|
-
|
|
582
|
+
<div className="text-[14px] font-700 text-text">
|
|
583
|
+
{notification.actorName || notification.actorId}
|
|
584
|
+
</div>
|
|
81
585
|
</button>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-8 text-center">
|
|
85
|
-
<div className="w-12 h-12 rounded-full bg-white/[0.04] flex items-center justify-center mx-auto mb-4">
|
|
86
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/40">
|
|
87
|
-
<path d="M4 11a9 9 0 0 1 9 9" /><path d="M4 4a16 16 0 0 1 16 16" /><circle cx="5" cy="19" r="1" />
|
|
88
|
-
</svg>
|
|
586
|
+
<div className="mt-1 text-[12px] uppercase tracking-[0.1em] text-text-3/55">
|
|
587
|
+
{notification.type} · {formatTimestamp(notification.createdAt)}
|
|
89
588
|
</div>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
) : (
|
|
96
|
-
<div className="space-y-4">
|
|
97
|
-
{posts.map((post) => (
|
|
98
|
-
<PostCard key={post.id} post={post} />
|
|
99
|
-
))}
|
|
589
|
+
{notification.content ? (
|
|
590
|
+
<p className="mt-3 whitespace-pre-wrap break-words text-[13px] leading-[1.6] text-text-2/85">
|
|
591
|
+
{notification.content}
|
|
592
|
+
</p>
|
|
593
|
+
) : null}
|
|
100
594
|
</div>
|
|
101
|
-
|
|
595
|
+
{notification.postId ? (
|
|
596
|
+
<button
|
|
597
|
+
type="button"
|
|
598
|
+
onClick={() => onOpenThread(notification.postId!)}
|
|
599
|
+
className="cursor-pointer rounded-[10px] border border-white/[0.08] bg-bg/55 px-3 py-2 text-[12px] font-700 text-text-2 transition-all hover:bg-bg/75"
|
|
600
|
+
>
|
|
601
|
+
Open
|
|
602
|
+
</button>
|
|
603
|
+
) : null}
|
|
604
|
+
</div>
|
|
102
605
|
</div>
|
|
103
|
-
|
|
104
|
-
</
|
|
606
|
+
))}
|
|
607
|
+
</div>
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function SectionTitle({ children }: { children: string }) {
|
|
612
|
+
return <div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/60">{children}</div>
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function EmptyState({ title, description }: { title: string; description: string }) {
|
|
616
|
+
return (
|
|
617
|
+
<div className="rounded-[18px] border border-white/[0.06] bg-surface/75 p-8 text-center">
|
|
618
|
+
<p className="text-[14px] font-700 text-text">{title}</p>
|
|
619
|
+
<p className="mx-auto mt-2 max-w-xl text-[13px] leading-[1.7] text-text-3/75">{description}</p>
|
|
620
|
+
</div>
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
625
|
+
return (
|
|
626
|
+
<div className="rounded-[18px] border border-red-500/20 bg-red-500/5 p-8 text-center">
|
|
627
|
+
<p className="text-[14px] text-red-200">{message}</p>
|
|
628
|
+
<button
|
|
629
|
+
type="button"
|
|
630
|
+
onClick={onRetry}
|
|
631
|
+
className="mt-4 cursor-pointer rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-[13px] font-700 text-text transition-all hover:bg-white/[0.08]"
|
|
632
|
+
>
|
|
633
|
+
Retry
|
|
634
|
+
</button>
|
|
635
|
+
</div>
|
|
105
636
|
)
|
|
106
637
|
}
|