@swarmclawai/swarmclaw 1.4.7 → 1.4.9

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