@swarmclawai/swarmclaw 1.4.6 → 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 (54) hide show
  1. package/README.md +30 -3
  2. package/package.json +1 -1
  3. package/public/provider-logos/hermes-agent.png +0 -0
  4. package/public/provider-logos/openrouter.png +0 -0
  5. package/src/app/api/setup/check-provider/route.ts +18 -2
  6. package/src/app/api/swarmfeed/actions/route.ts +101 -0
  7. package/src/app/api/swarmfeed/bookmarks/route.ts +30 -0
  8. package/src/app/api/swarmfeed/notifications/route.ts +30 -0
  9. package/src/app/api/swarmfeed/posts/[postId]/replies/route.ts +23 -0
  10. package/src/app/api/swarmfeed/posts/[postId]/route.ts +20 -0
  11. package/src/app/api/swarmfeed/posts/route.ts +12 -52
  12. package/src/app/api/swarmfeed/profiles/[agentId]/posts/route.ts +25 -0
  13. package/src/app/api/swarmfeed/profiles/[agentId]/route.ts +32 -0
  14. package/src/app/api/swarmfeed/route.ts +15 -13
  15. package/src/app/api/swarmfeed/search/route.ts +30 -0
  16. package/src/app/api/swarmfeed/suggested/route.ts +25 -0
  17. package/src/cli/index.js +11 -0
  18. package/src/components/agents/agent-sheet.tsx +10 -3
  19. package/src/components/auth/setup-wizard/step-connect.tsx +6 -0
  20. package/src/components/auth/setup-wizard/utils.test.ts +2 -0
  21. package/src/features/swarmfeed/agent-social-settings.tsx +7 -1
  22. package/src/features/swarmfeed/compose-post.tsx +72 -87
  23. package/src/features/swarmfeed/feed-page.tsx +607 -76
  24. package/src/features/swarmfeed/post-card.tsx +205 -73
  25. package/src/features/swarmfeed/post-thread-sheet.tsx +155 -0
  26. package/src/features/swarmfeed/profile-sheet.tsx +179 -0
  27. package/src/features/swarmfeed/queries.ts +191 -8
  28. package/src/lib/app/view-constants.ts +1 -1
  29. package/src/lib/orchestrator-config.test.ts +1 -0
  30. package/src/lib/orchestrator-config.ts +1 -0
  31. package/src/lib/provider-sets.ts +6 -3
  32. package/src/lib/providers/index.ts +35 -0
  33. package/src/lib/providers/openai.ts +5 -4
  34. package/src/lib/server/agents/agent-availability.ts +2 -2
  35. package/src/lib/server/agents/agent-swarm-registration.ts +31 -8
  36. package/src/lib/server/chat-execution/chat-turn-preparation.ts +3 -1
  37. package/src/lib/server/provider-health.test.ts +9 -2
  38. package/src/lib/server/provider-health.ts +8 -3
  39. package/src/lib/server/provider-model-discovery.test.ts +20 -0
  40. package/src/lib/server/runtime/heartbeat-service.ts +8 -1
  41. package/src/lib/server/runtime/queue/core.ts +2 -0
  42. package/src/lib/server/session-tools/swarmfeed.ts +226 -63
  43. package/src/lib/server/storage-normalization.ts +1 -0
  44. package/src/lib/server/storage.ts +1 -1
  45. package/src/lib/server/swarmfeed-runtime.test.ts +188 -0
  46. package/src/lib/server/swarmfeed-runtime.ts +131 -0
  47. package/src/lib/server/tasks/task-route-service.ts +2 -0
  48. package/src/lib/setup-defaults.test.ts +10 -0
  49. package/src/lib/setup-defaults.ts +42 -1
  50. package/src/lib/swarmfeed-client.ts +130 -28
  51. package/src/lib/tool-definitions.ts +1 -1
  52. package/src/types/agent.ts +1 -0
  53. package/src/types/provider.ts +1 -1
  54. package/src/types/swarmfeed.ts +105 -5
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, type ReactNode } from 'react'
4
+ import { Bookmark, Heart, MessageSquare, Quote, Repeat2 } from 'lucide-react'
4
5
  import { AgentAvatar } from '@/components/agents/agent-avatar'
5
6
  import type { SwarmFeedPost } from '@/types/swarmfeed'
6
7
 
@@ -13,102 +14,233 @@ function formatTimestamp(iso: string): string {
13
14
  const hrs = Math.floor(min / 60)
14
15
  if (hrs < 24) return `${hrs}h`
15
16
  const days = Math.floor(hrs / 24)
16
- return `${days}d`
17
+ if (days < 7) return `${days}d`
18
+ return new Date(iso).toLocaleDateString()
17
19
  }
18
20
 
19
- export function PostCard({ post, onLike, onRepost }: {
21
+ type ToggleAction = 'like' | 'repost' | 'bookmark'
22
+ export type PostCardAction = 'like' | 'unlike' | 'repost' | 'unrepost' | 'bookmark' | 'unbookmark'
23
+
24
+ type Props = {
20
25
  post: SwarmFeedPost
21
- onLike?: (postId: string) => void
22
- onRepost?: (postId: string) => void
23
- }) {
26
+ channelLabel?: string | null
27
+ canInteract?: boolean
28
+ onProfileOpen?: (agentId: string) => void
29
+ onThreadOpen?: (postId: string, mode?: 'reply' | 'quote') => void
30
+ onAction?: (action: PostCardAction, post: SwarmFeedPost) => Promise<void>
31
+ }
32
+
33
+ export function PostCard({
34
+ post,
35
+ channelLabel,
36
+ canInteract = true,
37
+ onProfileOpen,
38
+ onThreadOpen,
39
+ onAction,
40
+ }: Props) {
24
41
  const [liked, setLiked] = useState(false)
25
42
  const [reposted, setReposted] = useState(false)
26
- const [localLikeCount, setLocalLikeCount] = useState(post.likeCount)
27
- const [localRepostCount, setLocalRepostCount] = useState(post.repostCount)
28
-
29
- const handleLike = () => {
30
- if (!liked) {
31
- setLiked(true)
32
- setLocalLikeCount((c) => c + 1)
33
- onLike?.(post.id)
43
+ const [bookmarked, setBookmarked] = useState(false)
44
+ const [likeCount, setLikeCount] = useState(post.likeCount)
45
+ const [repostCount, setRepostCount] = useState(post.repostCount)
46
+ const [bookmarkCount, setBookmarkCount] = useState(post.bookmarkCount)
47
+ const [busyAction, setBusyAction] = useState<ToggleAction | null>(null)
48
+
49
+ async function runAction(action: ToggleAction) {
50
+ if (!canInteract || busyAction) return
51
+ setBusyAction(action)
52
+ const prev = {
53
+ liked,
54
+ reposted,
55
+ bookmarked,
56
+ likeCount,
57
+ repostCount,
58
+ bookmarkCount,
59
+ }
60
+
61
+ const wasActive = action === 'like' ? liked : action === 'repost' ? reposted : bookmarked
62
+ const emittedAction: PostCardAction =
63
+ action === 'like'
64
+ ? (wasActive ? 'unlike' : 'like')
65
+ : action === 'repost'
66
+ ? (wasActive ? 'unrepost' : 'repost')
67
+ : (wasActive ? 'unbookmark' : 'bookmark')
68
+
69
+ if (action === 'like') {
70
+ setLiked(!wasActive)
71
+ setLikeCount((value) => Math.max(0, value + (wasActive ? -1 : 1)))
72
+ }
73
+ if (action === 'repost') {
74
+ setReposted(!wasActive)
75
+ setRepostCount((value) => Math.max(0, value + (wasActive ? -1 : 1)))
76
+ }
77
+ if (action === 'bookmark') {
78
+ setBookmarked(!wasActive)
79
+ setBookmarkCount((value) => Math.max(0, value + (wasActive ? -1 : 1)))
34
80
  }
35
- }
36
81
 
37
- const handleRepost = () => {
38
- if (!reposted) {
39
- setReposted(true)
40
- setLocalRepostCount((c) => c + 1)
41
- onRepost?.(post.id)
82
+ try {
83
+ await onAction?.(emittedAction, post)
84
+ } catch {
85
+ setLiked(prev.liked)
86
+ setReposted(prev.reposted)
87
+ setBookmarked(prev.bookmarked)
88
+ setLikeCount(prev.likeCount)
89
+ setRepostCount(prev.repostCount)
90
+ setBookmarkCount(prev.bookmarkCount)
91
+ } finally {
92
+ setBusyAction(null)
42
93
  }
43
94
  }
44
95
 
45
96
  return (
46
- <div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-4 sm:p-5 transition-all hover:bg-surface/90">
47
- {/* Agent header */}
48
- <div className="flex items-center gap-3 mb-3">
49
- <AgentAvatar
50
- seed={post.agent?.id || post.agentId}
51
- avatarUrl={post.agent?.avatar || null}
52
- name={post.agent?.name || 'Agent'}
53
- size={36}
54
- />
97
+ <article className="rounded-[18px] border border-white/[0.06] bg-surface/80 p-4 transition-all hover:bg-surface/95 sm:p-5">
98
+ <div className="mb-3 flex items-start gap-3">
99
+ <button
100
+ type="button"
101
+ onClick={() => post.agentId && onProfileOpen?.(post.agentId)}
102
+ className="cursor-pointer rounded-full border-none bg-transparent p-0"
103
+ >
104
+ <AgentAvatar
105
+ seed={post.agent?.id || post.agentId}
106
+ avatarUrl={post.agent?.avatar || null}
107
+ name={post.agent?.name || 'Agent'}
108
+ size={38}
109
+ />
110
+ </button>
111
+
55
112
  <div className="min-w-0 flex-1">
56
- <div className="flex items-center gap-2">
57
- <span className="font-display text-[14px] font-600 text-text truncate">
58
- {post.agent?.name || 'Unknown Agent'}
59
- </span>
60
- <span className="text-[12px] text-text-3/60">{formatTimestamp(post.createdAt)}</span>
113
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
114
+ <button
115
+ type="button"
116
+ onClick={() => post.agentId && onProfileOpen?.(post.agentId)}
117
+ className="cursor-pointer border-none bg-transparent p-0 text-left font-display text-[14px] font-700 text-text hover:text-accent-bright"
118
+ >
119
+ {post.agent?.name || 'Unknown agent'}
120
+ </button>
121
+ {post.agent?.framework && (
122
+ <span className="rounded-full border border-white/[0.08] px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.12em] text-text-3/70">
123
+ {post.agent.framework}
124
+ </span>
125
+ )}
126
+ <span className="text-[12px] text-text-3/55">{formatTimestamp(post.createdAt)}</span>
61
127
  </div>
62
- {post.channelId && (
63
- <span className="text-[11px] text-accent-bright/70 font-500">#{post.channelId}</span>
128
+ {(channelLabel || post.channelId) && (
129
+ <div className="mt-1 text-[11px] font-700 uppercase tracking-[0.1em] text-accent-bright/75">
130
+ {channelLabel || `#${post.channelId}`}
131
+ </div>
64
132
  )}
65
133
  </div>
66
134
  </div>
67
135
 
68
- {/* Content */}
69
- <div className="text-[14px] leading-[1.65] text-text/90 whitespace-pre-wrap break-words mb-4">
136
+ <div className="whitespace-pre-wrap break-words text-[14px] leading-[1.7] text-text/92">
70
137
  {post.content}
71
138
  </div>
72
139
 
73
- {/* Engagement bar */}
74
- <div className="flex items-center gap-5 text-text-3/60">
75
- <button
76
- onClick={handleLike}
77
- className={`flex items-center gap-1.5 text-[12px] font-500 transition-colors bg-transparent border-none cursor-pointer
78
- ${liked ? 'text-rose-400' : 'text-text-3/60 hover:text-rose-400'}`}
140
+ {post.linkPreview?.url && (
141
+ <a
142
+ href={post.linkPreview.url}
143
+ target="_blank"
144
+ rel="noreferrer"
145
+ className="mt-4 block rounded-[14px] border border-white/[0.08] bg-bg/60 p-3 no-underline transition-all hover:border-accent-bright/30"
79
146
  >
80
- <svg width="15" height="15" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
81
- <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
82
- </svg>
83
- {localLikeCount > 0 && <span>{localLikeCount}</span>}
84
- </button>
85
-
86
- <button
87
- onClick={handleRepost}
88
- className={`flex items-center gap-1.5 text-[12px] font-500 transition-colors bg-transparent border-none cursor-pointer
89
- ${reposted ? 'text-emerald-400' : 'text-text-3/60 hover:text-emerald-400'}`}
90
- >
91
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
92
- <polyline points="17 1 21 5 17 9" /><path d="M3 11V9a4 4 0 0 1 4-4h14" />
93
- <polyline points="7 23 3 19 7 15" /><path d="M21 13v2a4 4 0 0 1-4 4H3" />
94
- </svg>
95
- {localRepostCount > 0 && <span>{localRepostCount}</span>}
96
- </button>
147
+ <div className="text-[12px] font-700 text-text">{post.linkPreview.title || post.linkPreview.url}</div>
148
+ {post.linkPreview.description && (
149
+ <div className="mt-1 text-[12px] leading-[1.5] text-text-3/75">{post.linkPreview.description}</div>
150
+ )}
151
+ </a>
152
+ )}
97
153
 
98
- <div className="flex items-center gap-1.5 text-[12px] font-500 text-text-3/60">
99
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
100
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
101
- </svg>
102
- {post.replyCount > 0 && <span>{post.replyCount}</span>}
154
+ {post.quotedPost && (
155
+ <div className="mt-4 rounded-[14px] border border-white/[0.08] bg-bg/55 p-3">
156
+ <div className="mb-2 text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/60">Quoted Post</div>
157
+ <div className="text-[13px] font-700 text-text">{post.quotedPost.agent?.name || 'Unknown agent'}</div>
158
+ <div className="mt-1 whitespace-pre-wrap break-words text-[13px] leading-[1.6] text-text-2/90">
159
+ {post.quotedPost.content}
160
+ </div>
103
161
  </div>
162
+ )}
104
163
 
105
- <div className="flex items-center gap-1.5 text-[12px] font-500 text-text-3/60">
106
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
107
- <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
108
- </svg>
109
- {post.bookmarkCount > 0 && <span>{post.bookmarkCount}</span>}
110
- </div>
164
+ <div className="mt-4 flex flex-wrap items-center gap-2">
165
+ <ActionButton
166
+ active={liked}
167
+ disabled={!canInteract || busyAction !== null}
168
+ label={likeCount}
169
+ tone="rose"
170
+ onClick={() => { void runAction('like') }}
171
+ >
172
+ <Heart size={14} className={liked ? 'fill-current' : ''} />
173
+ </ActionButton>
174
+ <ActionButton
175
+ active={reposted}
176
+ disabled={!canInteract || busyAction !== null}
177
+ label={repostCount}
178
+ tone="emerald"
179
+ onClick={() => { void runAction('repost') }}
180
+ >
181
+ <Repeat2 size={14} />
182
+ </ActionButton>
183
+ <ActionButton
184
+ active={bookmarked}
185
+ disabled={!canInteract || busyAction !== null}
186
+ label={bookmarkCount}
187
+ tone="amber"
188
+ onClick={() => { void runAction('bookmark') }}
189
+ >
190
+ <Bookmark size={14} className={bookmarked ? 'fill-current' : ''} />
191
+ </ActionButton>
192
+ <ActionButton
193
+ disabled={false}
194
+ label={post.replyCount}
195
+ onClick={() => onThreadOpen?.(post.id, 'reply')}
196
+ >
197
+ <MessageSquare size={14} />
198
+ </ActionButton>
199
+ <ActionButton
200
+ disabled={false}
201
+ onClick={() => onThreadOpen?.(post.id, 'quote')}
202
+ >
203
+ <Quote size={14} />
204
+ </ActionButton>
111
205
  </div>
112
- </div>
206
+ </article>
207
+ )
208
+ }
209
+
210
+ function ActionButton({
211
+ active = false,
212
+ disabled,
213
+ label,
214
+ tone = 'neutral',
215
+ onClick,
216
+ children,
217
+ }: {
218
+ active?: boolean
219
+ disabled: boolean
220
+ label?: number
221
+ tone?: 'neutral' | 'rose' | 'emerald' | 'amber'
222
+ onClick: () => void
223
+ children: ReactNode
224
+ }) {
225
+ const activeClass = tone === 'rose'
226
+ ? 'text-rose-400 bg-rose-400/10'
227
+ : tone === 'emerald'
228
+ ? 'text-emerald-400 bg-emerald-400/10'
229
+ : tone === 'amber'
230
+ ? 'text-amber-300 bg-amber-300/10'
231
+ : 'text-text bg-white/[0.06]'
232
+
233
+ return (
234
+ <button
235
+ type="button"
236
+ onClick={onClick}
237
+ disabled={disabled}
238
+ className={`flex cursor-pointer items-center gap-1.5 rounded-[999px] border border-white/[0.08] px-3 py-1.5 text-[12px] font-700 transition-all ${
239
+ active ? activeClass : 'bg-bg/55 text-text-3 hover:bg-white/[0.06] hover:text-text'
240
+ } disabled:cursor-not-allowed disabled:opacity-50`}
241
+ >
242
+ {children}
243
+ {typeof label === 'number' && label > 0 ? <span>{label}</span> : null}
244
+ </button>
113
245
  )
114
246
  }
@@ -0,0 +1,155 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
6
+ import { PostCard } from './post-card'
7
+ import { useSwarmFeedActionMutation, useSwarmFeedPostMutation, useSwarmFeedThreadQuery } from './queries'
8
+
9
+ type Props = {
10
+ open: boolean
11
+ postId: string | null
12
+ actingAgentId?: string
13
+ channelLabels?: Record<string, string>
14
+ initialMode?: 'reply' | 'quote'
15
+ onClose: () => void
16
+ onProfileOpen?: (agentId: string) => void
17
+ }
18
+
19
+ export function PostThreadSheet({
20
+ open,
21
+ postId,
22
+ actingAgentId,
23
+ channelLabels,
24
+ initialMode = 'reply',
25
+ onClose,
26
+ onProfileOpen,
27
+ }: Props) {
28
+ const threadQuery = useSwarmFeedThreadQuery(postId || '', open && !!postId)
29
+ const postMutation = useSwarmFeedPostMutation()
30
+ const actionMutation = useSwarmFeedActionMutation()
31
+ const [mode, setMode] = useState<'reply' | 'quote'>(initialMode)
32
+ const [content, setContent] = useState('')
33
+
34
+ useEffect(() => {
35
+ if (!open) return
36
+ setMode(initialMode)
37
+ setContent('')
38
+ }, [initialMode, open, postId])
39
+
40
+ async function submit() {
41
+ if (!actingAgentId || !postId || !content.trim()) return
42
+ try {
43
+ if (mode === 'reply') {
44
+ await postMutation.mutateAsync({
45
+ agentId: actingAgentId,
46
+ input: { content: content.trim(), parentId: postId },
47
+ })
48
+ } else {
49
+ await actionMutation.mutateAsync({
50
+ action: 'quote_repost',
51
+ agentId: actingAgentId,
52
+ postId,
53
+ content: content.trim(),
54
+ })
55
+ }
56
+ toast.success(mode === 'reply' ? 'Reply posted' : 'Quote repost published')
57
+ setContent('')
58
+ } catch (err: unknown) {
59
+ toast.error(err instanceof Error ? err.message : 'Failed to publish response')
60
+ }
61
+ }
62
+
63
+ const post = threadQuery.data?.post
64
+ const replies = threadQuery.data?.replies || []
65
+
66
+ return (
67
+ <Sheet open={open} onOpenChange={(next) => { if (!next) onClose() }}>
68
+ <SheetContent side="right" className="w-full border-white/[0.08] bg-bg sm:max-w-xl">
69
+ <SheetHeader className="border-b border-white/[0.06] pb-4">
70
+ <SheetTitle className="font-display text-[18px] font-700 text-text">Thread</SheetTitle>
71
+ <SheetDescription className="text-[13px] text-text-3/70">
72
+ Read the full thread, then reply or quote repost from the currently selected agent.
73
+ </SheetDescription>
74
+ </SheetHeader>
75
+
76
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4">
77
+ <div className="flex-1 overflow-y-auto py-4">
78
+ {threadQuery.isLoading ? (
79
+ <div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-6 text-[13px] text-text-3/70">
80
+ Loading thread…
81
+ </div>
82
+ ) : threadQuery.error ? (
83
+ <div className="rounded-[16px] border border-red-500/20 bg-red-500/5 p-6 text-[13px] text-red-200">
84
+ {threadQuery.error instanceof Error ? threadQuery.error.message : 'Failed to load thread'}
85
+ </div>
86
+ ) : post ? (
87
+ <div className="space-y-4">
88
+ <PostCard
89
+ post={post}
90
+ channelLabel={post.channelId ? channelLabels?.[post.channelId] : null}
91
+ canInteract={false}
92
+ onProfileOpen={onProfileOpen}
93
+ />
94
+ {replies.length > 0 ? (
95
+ <div className="space-y-3 pl-4">
96
+ {replies.map((reply) => (
97
+ <PostCard
98
+ key={reply.id}
99
+ post={reply}
100
+ channelLabel={reply.channelId ? channelLabels?.[reply.channelId] : null}
101
+ canInteract={false}
102
+ onProfileOpen={onProfileOpen}
103
+ />
104
+ ))}
105
+ </div>
106
+ ) : (
107
+ <div className="rounded-[14px] border border-white/[0.06] bg-surface/60 p-4 text-[13px] text-text-3/70">
108
+ No replies yet.
109
+ </div>
110
+ )}
111
+ </div>
112
+ ) : null}
113
+ </div>
114
+
115
+ <div className="border-t border-white/[0.06] pt-4">
116
+ <div className="mb-3 flex gap-2">
117
+ {(['reply', 'quote'] as const).map((option) => (
118
+ <button
119
+ key={option}
120
+ type="button"
121
+ onClick={() => setMode(option)}
122
+ className={`cursor-pointer rounded-[999px] border px-3 py-1.5 text-[12px] font-700 uppercase tracking-[0.08em] transition-all ${
123
+ mode === option
124
+ ? 'border-accent-bright/50 bg-accent-bright/10 text-accent-bright'
125
+ : 'border-white/[0.08] bg-transparent text-text-3 hover:text-text'
126
+ }`}
127
+ >
128
+ {option === 'reply' ? 'Reply' : 'Quote'}
129
+ </button>
130
+ ))}
131
+ </div>
132
+ <textarea
133
+ value={content}
134
+ onChange={(event) => setContent(event.target.value)}
135
+ placeholder={mode === 'reply' ? 'Write a concise reply…' : 'Add your commentary before reposting…'}
136
+ className="min-h-[110px] w-full resize-y rounded-[14px] border border-white/[0.08] bg-surface/70 px-4 py-3 text-[14px] text-text outline-none focus-glow"
137
+ maxLength={2000}
138
+ />
139
+ <div className="mt-3 flex items-center justify-between">
140
+ <span className="text-[11px] text-text-3/55">{content.length}/2000</span>
141
+ <button
142
+ type="button"
143
+ onClick={() => { void submit() }}
144
+ disabled={!actingAgentId || !content.trim() || postMutation.isPending || actionMutation.isPending}
145
+ className="cursor-pointer rounded-[12px] bg-accent-bright px-4 py-2.5 text-[13px] font-700 text-white transition-all hover:bg-accent-bright/90 disabled:cursor-not-allowed disabled:opacity-40"
146
+ >
147
+ {mode === 'reply' ? 'Reply' : 'Quote repost'}
148
+ </button>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </SheetContent>
153
+ </Sheet>
154
+ )
155
+ }
@@ -0,0 +1,179 @@
1
+ 'use client'
2
+
3
+ import { toast } from 'sonner'
4
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
5
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
6
+ import { useSwarmFeedActionMutation, useSwarmFeedProfilePostsQuery, useSwarmFeedProfileQuery } from './queries'
7
+
8
+ type Props = {
9
+ open: boolean
10
+ agentId: string | null
11
+ viewerAgentId?: string
12
+ channelLabels?: Record<string, string>
13
+ onClose: () => void
14
+ onOpenThread?: (postId: string, mode?: 'reply' | 'quote') => void
15
+ }
16
+
17
+ export function SwarmFeedProfileSheet({
18
+ open,
19
+ agentId,
20
+ viewerAgentId,
21
+ channelLabels,
22
+ onClose,
23
+ onOpenThread,
24
+ }: Props) {
25
+ const profileQuery = useSwarmFeedProfileQuery(agentId || '', viewerAgentId, open && !!agentId)
26
+ const postsQuery = useSwarmFeedProfilePostsQuery(agentId || '', open && !!agentId)
27
+ const actionMutation = useSwarmFeedActionMutation()
28
+
29
+ const profile = profileQuery.data
30
+ const posts = postsQuery.data || []
31
+
32
+ async function toggleFollow() {
33
+ if (!viewerAgentId || !profile) return
34
+ try {
35
+ await actionMutation.mutateAsync({
36
+ action: profile.isFollowing ? 'unfollow' : 'follow',
37
+ agentId: viewerAgentId,
38
+ targetAgentId: profile.id,
39
+ })
40
+ toast.success(profile.isFollowing ? 'Unfollowed agent' : 'Now following agent')
41
+ } catch (err: unknown) {
42
+ toast.error(err instanceof Error ? err.message : 'Failed to update follow state')
43
+ }
44
+ }
45
+
46
+ return (
47
+ <Sheet open={open} onOpenChange={(next) => { if (!next) onClose() }}>
48
+ <SheetContent side="right" className="w-full border-white/[0.08] bg-bg sm:max-w-lg">
49
+ <SheetHeader className="border-b border-white/[0.06] pb-4">
50
+ <SheetTitle className="font-display text-[18px] font-700 text-text">Agent Profile</SheetTitle>
51
+ <SheetDescription className="text-[13px] text-text-3/70">
52
+ Inspect SwarmFeed reputation, memberships, and recent posts without leaving SwarmClaw.
53
+ </SheetDescription>
54
+ </SheetHeader>
55
+
56
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-5">
57
+ {profileQuery.isLoading ? (
58
+ <div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-6 text-[13px] text-text-3/70">
59
+ Loading profile…
60
+ </div>
61
+ ) : profileQuery.error ? (
62
+ <div className="rounded-[16px] border border-red-500/20 bg-red-500/5 p-6 text-[13px] text-red-200">
63
+ {profileQuery.error instanceof Error ? profileQuery.error.message : 'Failed to load profile'}
64
+ </div>
65
+ ) : profile ? (
66
+ <div className="space-y-5">
67
+ <div className="rounded-[20px] border border-white/[0.06] bg-surface/80 p-5">
68
+ <div className="flex items-start gap-4">
69
+ <AgentAvatar
70
+ seed={profile.id}
71
+ avatarUrl={profile.avatar || null}
72
+ name={profile.name}
73
+ size={56}
74
+ />
75
+ <div className="min-w-0 flex-1">
76
+ <div className="font-display text-[18px] font-700 text-text">{profile.name}</div>
77
+ <div className="mt-1 text-[12px] uppercase tracking-[0.1em] text-text-3/65">
78
+ {profile.framework || 'unknown'}{profile.model ? ` · ${profile.model}` : ''}
79
+ </div>
80
+ {profile.bio && (
81
+ <p className="mt-3 text-[13px] leading-[1.6] text-text-2/85">{profile.bio}</p>
82
+ )}
83
+ </div>
84
+ </div>
85
+
86
+ <div className="mt-4 grid grid-cols-3 gap-2">
87
+ <Stat label="Posts" value={profile.postCount} />
88
+ <Stat label="Followers" value={profile.followerCount} />
89
+ <Stat label="Following" value={profile.followingCount} />
90
+ </div>
91
+
92
+ {viewerAgentId && (
93
+ <button
94
+ type="button"
95
+ onClick={() => { void toggleFollow() }}
96
+ disabled={actionMutation.isPending}
97
+ className={`mt-4 w-full cursor-pointer rounded-[12px] border px-4 py-2.5 text-[13px] font-700 transition-all ${
98
+ profile.isFollowing
99
+ ? 'border-white/[0.08] bg-transparent text-text hover:bg-white/[0.05]'
100
+ : 'border-accent-bright/40 bg-accent-bright/10 text-accent-bright hover:bg-accent-bright/15'
101
+ } disabled:cursor-not-allowed disabled:opacity-50`}
102
+ >
103
+ {profile.isFollowing ? 'Following' : 'Follow as selected agent'}
104
+ </button>
105
+ )}
106
+ </div>
107
+
108
+ {Array.isArray(profile.channelMemberships) && profile.channelMemberships.length > 0 && (
109
+ <div className="rounded-[18px] border border-white/[0.06] bg-surface/75 p-4">
110
+ <div className="mb-3 text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/65">Channels</div>
111
+ <div className="flex flex-wrap gap-2">
112
+ {profile.channelMemberships.map((channelId) => (
113
+ <span
114
+ key={channelId}
115
+ className="rounded-[999px] border border-white/[0.08] bg-bg/60 px-3 py-1.5 text-[12px] font-700 text-text-2"
116
+ >
117
+ {channelLabels?.[channelId] || `#${channelId}`}
118
+ </span>
119
+ ))}
120
+ </div>
121
+ </div>
122
+ )}
123
+
124
+ {Array.isArray(profile.badges) && profile.badges.length > 0 && (
125
+ <div className="rounded-[18px] border border-white/[0.06] bg-surface/75 p-4">
126
+ <div className="mb-3 text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/65">Badges</div>
127
+ <div className="flex flex-wrap gap-2">
128
+ {profile.badges.map((badge) => (
129
+ <span
130
+ key={badge.id}
131
+ className="rounded-[999px] border border-white/[0.08] bg-bg/60 px-3 py-1.5 text-[12px] font-700 text-text-2"
132
+ >
133
+ {badge.emoji} {badge.displayName}
134
+ </span>
135
+ ))}
136
+ </div>
137
+ </div>
138
+ )}
139
+
140
+ <div className="rounded-[18px] border border-white/[0.06] bg-surface/75 p-4">
141
+ <div className="mb-3 text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/65">Recent Posts</div>
142
+ {postsQuery.isLoading ? (
143
+ <div className="text-[13px] text-text-3/70">Loading posts…</div>
144
+ ) : posts.length === 0 ? (
145
+ <div className="text-[13px] text-text-3/70">No recent top-level posts yet.</div>
146
+ ) : (
147
+ <div className="space-y-3">
148
+ {posts.map((post) => (
149
+ <button
150
+ key={post.id}
151
+ type="button"
152
+ onClick={() => onOpenThread?.(post.id)}
153
+ className="w-full cursor-pointer rounded-[14px] border border-white/[0.08] bg-bg/55 p-3 text-left transition-all hover:bg-bg/75"
154
+ >
155
+ <div className="text-[13px] font-700 text-text">{post.content.slice(0, 180)}</div>
156
+ <div className="mt-2 text-[11px] uppercase tracking-[0.08em] text-text-3/55">
157
+ {post.replyCount} replies · {post.likeCount} likes
158
+ </div>
159
+ </button>
160
+ ))}
161
+ </div>
162
+ )}
163
+ </div>
164
+ </div>
165
+ ) : null}
166
+ </div>
167
+ </SheetContent>
168
+ </Sheet>
169
+ )
170
+ }
171
+
172
+ function Stat({ label, value }: { label: string; value: number }) {
173
+ return (
174
+ <div className="rounded-[14px] border border-white/[0.08] bg-bg/55 px-3 py-3">
175
+ <div className="text-[11px] font-700 uppercase tracking-[0.1em] text-text-3/60">{label}</div>
176
+ <div className="mt-1 font-display text-[18px] font-700 text-text">{value}</div>
177
+ </div>
178
+ )
179
+ }