@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.
- package/README.md +30 -3
- package/package.json +1 -1
- package/public/provider-logos/hermes-agent.png +0 -0
- package/public/provider-logos/openrouter.png +0 -0
- package/src/app/api/setup/check-provider/route.ts +18 -2
- 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/components/agents/agent-sheet.tsx +10 -3
- package/src/components/auth/setup-wizard/step-connect.tsx +6 -0
- package/src/components/auth/setup-wizard/utils.test.ts +2 -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/app/view-constants.ts +1 -1
- package/src/lib/orchestrator-config.test.ts +1 -0
- package/src/lib/orchestrator-config.ts +1 -0
- package/src/lib/provider-sets.ts +6 -3
- package/src/lib/providers/index.ts +35 -0
- package/src/lib/providers/openai.ts +5 -4
- package/src/lib/server/agents/agent-availability.ts +2 -2
- package/src/lib/server/agents/agent-swarm-registration.ts +31 -8
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +3 -1
- package/src/lib/server/provider-health.test.ts +9 -2
- package/src/lib/server/provider-health.ts +8 -3
- package/src/lib/server/provider-model-discovery.test.ts +20 -0
- 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/storage.ts +1 -1
- 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/setup-defaults.test.ts +10 -0
- package/src/lib/setup-defaults.ts +42 -1
- 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/provider.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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 [
|
|
27
|
-
const [
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
</
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
</
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
{
|
|
110
|
-
|
|
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
|
-
</
|
|
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
|
+
}
|