@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.
Files changed (33) hide show
  1. package/README.md +13 -2
  2. package/package.json +1 -1
  3. package/src/app/api/swarmfeed/actions/route.ts +101 -0
  4. package/src/app/api/swarmfeed/bookmarks/route.ts +30 -0
  5. package/src/app/api/swarmfeed/notifications/route.ts +30 -0
  6. package/src/app/api/swarmfeed/posts/[postId]/replies/route.ts +23 -0
  7. package/src/app/api/swarmfeed/posts/[postId]/route.ts +20 -0
  8. package/src/app/api/swarmfeed/posts/route.ts +12 -52
  9. package/src/app/api/swarmfeed/profiles/[agentId]/posts/route.ts +25 -0
  10. package/src/app/api/swarmfeed/profiles/[agentId]/route.ts +32 -0
  11. package/src/app/api/swarmfeed/route.ts +15 -13
  12. package/src/app/api/swarmfeed/search/route.ts +30 -0
  13. package/src/app/api/swarmfeed/suggested/route.ts +25 -0
  14. package/src/cli/index.js +11 -0
  15. package/src/features/swarmfeed/agent-social-settings.tsx +7 -1
  16. package/src/features/swarmfeed/compose-post.tsx +72 -87
  17. package/src/features/swarmfeed/feed-page.tsx +607 -76
  18. package/src/features/swarmfeed/post-card.tsx +205 -73
  19. package/src/features/swarmfeed/post-thread-sheet.tsx +155 -0
  20. package/src/features/swarmfeed/profile-sheet.tsx +179 -0
  21. package/src/features/swarmfeed/queries.ts +191 -8
  22. package/src/lib/server/agents/agent-swarm-registration.ts +31 -8
  23. package/src/lib/server/runtime/heartbeat-service.ts +8 -1
  24. package/src/lib/server/runtime/queue/core.ts +2 -0
  25. package/src/lib/server/session-tools/swarmfeed.ts +226 -63
  26. package/src/lib/server/storage-normalization.ts +1 -0
  27. package/src/lib/server/swarmfeed-runtime.test.ts +188 -0
  28. package/src/lib/server/swarmfeed-runtime.ts +131 -0
  29. package/src/lib/server/tasks/task-route-service.ts +2 -0
  30. package/src/lib/swarmfeed-client.ts +130 -28
  31. package/src/lib/tool-definitions.ts +1 -1
  32. package/src/types/agent.ts +1 -0
  33. package/src/types/swarmfeed.ts +105 -5
@@ -1,106 +1,637 @@
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, 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 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 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
- const result = await fetchFeed(type, { limit: 50 })
26
- setPosts(result.posts)
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 load feed'
29
- setError(message)
30
- setPosts([])
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
- useEffect(() => {
37
- void loadFeed(activeTab)
38
- }, [activeTab, loadFeed])
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
- const handleTabChange = (tab: FeedType) => {
41
- setActiveTab(tab)
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-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>
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
- {/* 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
- ))}
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
- {/* 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>
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
- 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"
578
+ type="button"
579
+ onClick={() => onOpenProfile(notification.actorId)}
580
+ className="cursor-pointer border-none bg-transparent p-0 text-left"
79
581
  >
80
- Retry
582
+ <div className="text-[14px] font-700 text-text">
583
+ {notification.actorName || notification.actorId}
584
+ </div>
81
585
  </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>
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
- <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
- ))}
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
- </div>
104
- </MainContent>
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
  }