@swarmclawai/swarmclaw 1.3.6 → 1.4.2

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 (126) hide show
  1. package/README.md +16 -52
  2. package/next.config.ts +9 -4
  3. package/package.json +18 -10
  4. package/scripts/build-bootstrap-env.mjs +24 -0
  5. package/scripts/run-next-build.mjs +74 -0
  6. package/scripts/run-next-typegen.mjs +61 -0
  7. package/src/app/api/.well-known/agent-card/route.ts +46 -0
  8. package/src/app/api/a2a/route.ts +56 -0
  9. package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
  10. package/src/app/api/approvals/route.test.ts +29 -3
  11. package/src/app/api/approvals/route.ts +13 -7
  12. package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
  13. package/src/app/api/chats/[id]/chat/route.ts +24 -8
  14. package/src/app/api/chats/[id]/deploy/route.ts +2 -2
  15. package/src/app/api/chats/chat-route.test.ts +68 -0
  16. package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
  18. package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
  19. package/src/app/api/logs/route.test.ts +61 -0
  20. package/src/app/api/logs/route.ts +35 -0
  21. package/src/app/api/openclaw/sync/route.ts +1 -1
  22. package/src/app/api/swarmfeed/channels/route.ts +14 -0
  23. package/src/app/api/swarmfeed/posts/route.ts +60 -0
  24. package/src/app/api/swarmfeed/route.ts +37 -0
  25. package/src/app/api/tts/route.test.ts +82 -0
  26. package/src/app/api/tts/route.ts +13 -6
  27. package/src/app/api/tts/stream/route.ts +12 -5
  28. package/src/app/error.tsx +32 -0
  29. package/src/app/global-error.tsx +33 -0
  30. package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
  31. package/src/app/protocols/page.tsx +16 -7
  32. package/src/app/swarmfeed/page.tsx +7 -0
  33. package/src/cli/index.js +22 -0
  34. package/src/cli/spec.js +9 -0
  35. package/src/components/agents/agent-avatar.tsx +2 -5
  36. package/src/components/agents/agent-sheet.tsx +10 -0
  37. package/src/components/auth/access-key-gate.tsx +25 -0
  38. package/src/components/layout/error-boundary.tsx +12 -30
  39. package/src/components/layout/error-fallback.tsx +61 -0
  40. package/src/components/layout/sidebar-rail.tsx +52 -0
  41. package/src/components/protocols/builder/edge-editor.tsx +43 -0
  42. package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
  43. package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
  44. package/src/components/protocols/builder/edge-types/index.ts +3 -0
  45. package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
  46. package/src/components/protocols/builder/node-inspector.tsx +227 -0
  47. package/src/components/protocols/builder/node-palette.tsx +97 -0
  48. package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
  49. package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
  50. package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
  51. package/src/components/protocols/builder/node-types/index.ts +9 -0
  52. package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
  53. package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
  54. package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
  55. package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
  56. package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
  57. package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
  58. package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
  59. package/src/components/protocols/builder/run-overlay.tsx +29 -0
  60. package/src/components/protocols/builder/template-gallery.tsx +53 -0
  61. package/src/components/protocols/builder/validation-panel.tsx +57 -0
  62. package/src/components/skills/skills-workspace.tsx +1 -9
  63. package/src/features/protocols/builder/hooks/index.ts +2 -0
  64. package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
  65. package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
  66. package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
  67. package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
  68. package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
  69. package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
  70. package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
  71. package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
  72. package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
  73. package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
  74. package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
  75. package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
  76. package/src/features/swarmfeed/compose-post.tsx +139 -0
  77. package/src/features/swarmfeed/feed-page.tsx +136 -0
  78. package/src/features/swarmfeed/post-card.tsx +114 -0
  79. package/src/features/swarmfeed/queries.ts +28 -0
  80. package/src/lib/a2a/agent-card.ts +61 -0
  81. package/src/lib/a2a/auth.ts +54 -0
  82. package/src/lib/a2a/client.ts +133 -0
  83. package/src/lib/a2a/discovery.ts +116 -0
  84. package/src/lib/a2a/handlers.ts +176 -0
  85. package/src/lib/a2a/json-rpc-router.ts +38 -0
  86. package/src/lib/a2a/types.ts +95 -0
  87. package/src/lib/app/navigation.ts +1 -0
  88. package/src/lib/app/report-client-error.ts +52 -0
  89. package/src/lib/app/view-constants.ts +9 -1
  90. package/src/lib/providers/anthropic.ts +119 -107
  91. package/src/lib/providers/ollama.ts +34 -14
  92. package/src/lib/providers/openai.ts +154 -142
  93. package/src/lib/providers/openclaw.ts +3 -3
  94. package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
  95. package/src/lib/server/agents/main-agent-loop.ts +377 -41
  96. package/src/lib/server/chat-execution/chat-execution.ts +12 -7
  97. package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
  98. package/src/lib/server/connectors/swarmdock.ts +1 -1
  99. package/src/lib/server/extensions.ts +11 -0
  100. package/src/lib/server/messages/message-repository.ts +31 -0
  101. package/src/lib/server/openclaw/sync.ts +4 -4
  102. package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
  103. package/src/lib/server/protocols/protocol-normalization.ts +1 -0
  104. package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
  105. package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
  106. package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
  107. package/src/lib/server/protocols/protocol-types.ts +1 -0
  108. package/src/lib/server/provider-health.ts +19 -3
  109. package/src/lib/server/safe-parse-body.test.ts +32 -0
  110. package/src/lib/server/safe-parse-body.ts +20 -3
  111. package/src/lib/server/session-tools/delegate.ts +151 -77
  112. package/src/lib/server/storage-auth.ts +10 -2
  113. package/src/lib/server/storage-normalization.ts +11 -0
  114. package/src/lib/server/storage.ts +113 -4
  115. package/src/lib/server/working-state/service.test.ts +2 -3
  116. package/src/lib/server/working-state/service.ts +37 -6
  117. package/src/lib/swarmfeed-client.ts +157 -0
  118. package/src/lib/validation/schemas.ts +1 -1
  119. package/src/stores/slices/data-slice.ts +3 -0
  120. package/src/stores/use-approval-store.ts +4 -1
  121. package/src/types/agent.ts +31 -1
  122. package/src/types/index.ts +1 -0
  123. package/src/types/protocol.ts +19 -0
  124. package/src/types/session.ts +1 -1
  125. package/src/types/swarmfeed.ts +30 -0
  126. package/tsconfig.json +1 -2
@@ -0,0 +1,119 @@
1
+ import type { BuilderNode, BuilderEdge, ValidationError, ValidationWarning } from '../protocol-builder-store'
2
+
3
+ /**
4
+ * BFS from startId through directed edges. Returns the set of reachable node IDs
5
+ * (including startId itself).
6
+ */
7
+ export function getReachableNodes(
8
+ startId: string,
9
+ edges: BuilderEdge[],
10
+ allNodeIds: string[],
11
+ ): Set<string> {
12
+ const nodeSet = new Set(allNodeIds)
13
+ const reachable = new Set<string>()
14
+ const queue: string[] = [startId]
15
+ reachable.add(startId)
16
+
17
+ while (queue.length > 0) {
18
+ const current = queue.shift()!
19
+ for (const edge of edges) {
20
+ if (edge.source === current) {
21
+ const target = edge.target
22
+ if (nodeSet.has(target) && !reachable.has(target)) {
23
+ reachable.add(target)
24
+ queue.push(target)
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ return reachable
31
+ }
32
+
33
+ /**
34
+ * Validates the DAG structure of a workflow graph.
35
+ * Returns errors (blocking issues) and warnings (informational).
36
+ */
37
+ export function validateDAG(
38
+ nodes: BuilderNode[],
39
+ edges: BuilderEdge[],
40
+ ): { errors: ValidationError[]; warnings: ValidationWarning[] } {
41
+ if (nodes.length === 0) {
42
+ return { errors: [], warnings: [] }
43
+ }
44
+
45
+ const errors: ValidationError[] = []
46
+ const warnings: ValidationWarning[] = []
47
+
48
+ const allNodeIds = nodes.map((n) => n.id)
49
+ const entryNode = nodes[0]
50
+
51
+ // Build adjacency sets for quick lookup
52
+ const nodesWithAnyEdge = new Set<string>()
53
+ const nodesWithOutgoing = new Set<string>()
54
+
55
+ for (const edge of edges) {
56
+ nodesWithAnyEdge.add(edge.source)
57
+ nodesWithAnyEdge.add(edge.target)
58
+ nodesWithOutgoing.add(edge.source)
59
+ }
60
+
61
+ // Orphan detection: nodes not connected to any edge
62
+ // Skip: the entry node (first node), nodes of kind 'complete'
63
+ const orphanNodeIds = new Set<string>()
64
+ for (const node of nodes) {
65
+ if (node.id === entryNode.id) continue
66
+ if (node.data.kind === 'complete') continue
67
+ if (!nodesWithAnyEdge.has(node.id)) {
68
+ orphanNodeIds.add(node.id)
69
+ errors.push({
70
+ nodeId: node.id,
71
+ message: `Node "${node.data.label}" is not connected to any edge`,
72
+ })
73
+ }
74
+ }
75
+
76
+ // Unreachable detection: connected nodes not reachable from entry via BFS
77
+ // Don't double-flag orphans
78
+ const reachable = getReachableNodes(entryNode.id, edges, allNodeIds)
79
+ for (const node of nodes) {
80
+ if (node.id === entryNode.id) continue
81
+ if (orphanNodeIds.has(node.id)) continue
82
+ if (!reachable.has(node.id)) {
83
+ errors.push({
84
+ nodeId: node.id,
85
+ message: `Node "${node.data.label}" is not reachable from the entry node`,
86
+ })
87
+ }
88
+ }
89
+
90
+ // Branch validation: each branchCase must have a matching outgoing edge
91
+ for (const node of nodes) {
92
+ if (node.data.kind !== 'branch') continue
93
+ const cases = node.data.branchCases ?? []
94
+ for (const branchCase of cases) {
95
+ const hasEdge = edges.some(
96
+ (e) => e.source === node.id && e.data?.branchCaseId === branchCase.id,
97
+ )
98
+ if (!hasEdge) {
99
+ errors.push({
100
+ nodeId: node.id,
101
+ message: `Branch node "${node.data.label}" has case "${branchCase.label}" without a connected edge`,
102
+ })
103
+ }
104
+ }
105
+ }
106
+
107
+ // Warnings: nodes with no outgoing edge (except 'complete' kind)
108
+ for (const node of nodes) {
109
+ if (node.data.kind === 'complete') continue
110
+ if (!nodesWithOutgoing.has(node.id)) {
111
+ warnings.push({
112
+ nodeId: node.id,
113
+ message: `Node "${node.data.label}" has no outgoing edge`,
114
+ })
115
+ }
116
+ }
117
+
118
+ return { errors, warnings }
119
+ }
@@ -0,0 +1,277 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useEffect } from 'react'
4
+ import { updateAgent } from '@/lib/agents'
5
+ import { toast } from 'sonner'
6
+ import { HintTip } from '@/components/shared/hint-tip'
7
+ import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section'
8
+ import { fetchChannels } from './queries'
9
+ import type { Agent, SwarmFeedHeartbeatConfig } from '@/types'
10
+ import type { SwarmFeedChannel } from '@/types/swarmfeed'
11
+
12
+ const DEFAULT_HEARTBEAT: SwarmFeedHeartbeatConfig = {
13
+ enabled: false,
14
+ browseFeed: false,
15
+ postFrequency: 'manual_only',
16
+ autoReply: false,
17
+ autoFollow: false,
18
+ channelsToMonitor: [],
19
+ }
20
+
21
+ export function AgentSocialSettings({ agent, onUpdate }: {
22
+ agent: Agent
23
+ onUpdate?: (agent: Agent) => void
24
+ }) {
25
+ const [enabled, setEnabled] = useState(agent.swarmfeedEnabled || false)
26
+ const [bio, setBio] = useState(agent.swarmfeedBio || '')
27
+ const [autoPost, setAutoPost] = useState(agent.swarmfeedAutoPost || false)
28
+ const [autoPostChannels, setAutoPostChannels] = useState<string[]>(agent.swarmfeedAutoPostChannels || [])
29
+ const [heartbeat, setHeartbeat] = useState<SwarmFeedHeartbeatConfig>(agent.swarmfeedHeartbeat || DEFAULT_HEARTBEAT)
30
+ const [showAdvanced, setShowAdvanced] = useState(false)
31
+ const [channels, setChannels] = useState<SwarmFeedChannel[]>([])
32
+ const [saving, setSaving] = useState(false)
33
+
34
+ useEffect(() => {
35
+ fetchChannels().then(setChannels).catch(() => { /* channels load is best-effort */ })
36
+ }, [])
37
+
38
+ const handleSave = useCallback(async () => {
39
+ setSaving(true)
40
+ try {
41
+ const updated = await updateAgent(agent.id, {
42
+ swarmfeedEnabled: enabled,
43
+ swarmfeedBio: bio.trim() || null,
44
+ swarmfeedAutoPost: autoPost,
45
+ swarmfeedAutoPostChannels: autoPostChannels,
46
+ swarmfeedJoinedAt: enabled && !agent.swarmfeedJoinedAt ? Date.now() : agent.swarmfeedJoinedAt,
47
+ swarmfeedHeartbeat: heartbeat.enabled ? heartbeat : null,
48
+ })
49
+ toast.success('Social settings saved')
50
+ onUpdate?.(updated)
51
+ } catch {
52
+ toast.error('Failed to save social settings')
53
+ } finally {
54
+ setSaving(false)
55
+ }
56
+ }, [agent.id, agent.swarmfeedJoinedAt, enabled, bio, autoPost, autoPostChannels, heartbeat, onUpdate])
57
+
58
+ const toggleChannel = useCallback((channelId: string) => {
59
+ setAutoPostChannels((prev) =>
60
+ prev.includes(channelId) ? prev.filter((c) => c !== channelId) : [...prev, channelId],
61
+ )
62
+ }, [])
63
+
64
+ const toggleMonitorChannel = useCallback((channelId: string) => {
65
+ setHeartbeat((prev) => ({
66
+ ...prev,
67
+ channelsToMonitor: prev.channelsToMonitor.includes(channelId)
68
+ ? prev.channelsToMonitor.filter((c) => c !== channelId)
69
+ : [...prev.channelsToMonitor, channelId],
70
+ }))
71
+ }, [])
72
+
73
+ return (
74
+ <div className="space-y-5">
75
+ {/* Enable/Disable toggle */}
76
+ <div className="flex items-center justify-between gap-4 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4">
77
+ <div className="min-w-0">
78
+ <div className="flex items-center gap-2">
79
+ <p className="text-[14px] font-600 text-text">SwarmFeed</p>
80
+ <HintTip text="Enable this agent to participate in the SwarmFeed social network" />
81
+ </div>
82
+ <p className="mt-1 text-[12px] leading-[1.6] text-text-3/75">
83
+ Let this agent post, follow, and engage on the social feed.
84
+ </p>
85
+ </div>
86
+ <button
87
+ type="button"
88
+ onClick={() => setEnabled((c) => !c)}
89
+ className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${enabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
90
+ aria-pressed={enabled}
91
+ >
92
+ <span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${enabled ? 'translate-x-5' : 'translate-x-0'}`} />
93
+ </button>
94
+ </div>
95
+
96
+ {enabled && (
97
+ <>
98
+ {/* Bio */}
99
+ <div>
100
+ <label className="flex items-center gap-2 text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
101
+ Bio <HintTip text="A short bio shown on the agent's social profile" />
102
+ </label>
103
+ <textarea
104
+ value={bio}
105
+ onChange={(e) => setBio(e.target.value)}
106
+ placeholder="A brief description of this agent for social..."
107
+ className="w-full min-h-[80px] px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[14px] outline-none transition-all placeholder:text-text-3/50 focus-glow resize-y"
108
+ style={{ fontFamily: 'inherit' }}
109
+ maxLength={500}
110
+ />
111
+ </div>
112
+
113
+ {/* Auto-post toggle */}
114
+ <div className="flex items-center justify-between gap-4 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-3">
115
+ <div className="min-w-0">
116
+ <div className="flex items-center gap-2">
117
+ <p className="text-[13px] font-600 text-text">Auto-post</p>
118
+ <HintTip text="Automatically post updates from this agent's activity" />
119
+ </div>
120
+ </div>
121
+ <button
122
+ type="button"
123
+ onClick={() => setAutoPost((c) => !c)}
124
+ className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${autoPost ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
125
+ aria-pressed={autoPost}
126
+ >
127
+ <span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${autoPost ? 'translate-x-5' : 'translate-x-0'}`} />
128
+ </button>
129
+ </div>
130
+
131
+ {/* Auto-post channels */}
132
+ {autoPost && channels.length > 0 && (
133
+ <div>
134
+ <label className="block text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
135
+ Auto-post Channels
136
+ </label>
137
+ <div className="flex flex-wrap gap-2">
138
+ {channels.map((ch) => (
139
+ <button
140
+ key={ch.id}
141
+ onClick={() => toggleChannel(ch.id)}
142
+ className={`px-3 py-1.5 rounded-[10px] border text-[12px] font-500 transition-all cursor-pointer bg-transparent
143
+ ${autoPostChannels.includes(ch.id)
144
+ ? 'border-accent-bright/40 bg-accent-bright/10 text-accent-bright'
145
+ : 'border-white/[0.08] text-text-3 hover:text-text hover:bg-white/[0.04]'
146
+ }`}
147
+ >
148
+ #{ch.handle}
149
+ </button>
150
+ ))}
151
+ </div>
152
+ </div>
153
+ )}
154
+
155
+ {/* Advanced: Heartbeat config */}
156
+ <AdvancedSettingsSection
157
+ open={showAdvanced}
158
+ onToggle={() => setShowAdvanced((c) => !c)}
159
+ summary={heartbeat.enabled ? 'Active' : undefined}
160
+ badges={heartbeat.enabled ? [heartbeat.postFrequency.replace(/_/g, ' ')] : []}
161
+ >
162
+ <div className="space-y-4">
163
+ <div className="flex items-center justify-between gap-4">
164
+ <div className="min-w-0">
165
+ <div className="flex items-center gap-2">
166
+ <p className="text-[13px] font-600 text-text">Feed Heartbeat</p>
167
+ <HintTip text="When enabled, this agent will periodically browse and interact with the feed" />
168
+ </div>
169
+ </div>
170
+ <button
171
+ type="button"
172
+ onClick={() => setHeartbeat((h) => ({ ...h, enabled: !h.enabled }))}
173
+ className={`relative h-6 w-11 shrink-0 rounded-full border-none transition-colors duration-200 ${heartbeat.enabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
174
+ aria-pressed={heartbeat.enabled}
175
+ >
176
+ <span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200 ${heartbeat.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
177
+ </button>
178
+ </div>
179
+
180
+ {heartbeat.enabled && (
181
+ <>
182
+ <label className="flex items-center gap-3 cursor-pointer">
183
+ <div
184
+ onClick={() => setHeartbeat((h) => ({ ...h, browseFeed: !h.browseFeed }))}
185
+ className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${heartbeat.browseFeed ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
186
+ >
187
+ <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${heartbeat.browseFeed ? 'left-[22px]' : 'left-0.5'}`} />
188
+ </div>
189
+ <span className="flex items-center gap-2 text-[13px] text-text-2">
190
+ Browse feed <HintTip text="Agent reads the feed during heartbeat cycles" />
191
+ </span>
192
+ </label>
193
+
194
+ <label className="flex items-center gap-3 cursor-pointer">
195
+ <div
196
+ onClick={() => setHeartbeat((h) => ({ ...h, autoReply: !h.autoReply }))}
197
+ className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${heartbeat.autoReply ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
198
+ >
199
+ <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${heartbeat.autoReply ? 'left-[22px]' : 'left-0.5'}`} />
200
+ </div>
201
+ <span className="flex items-center gap-2 text-[13px] text-text-2">
202
+ Auto-reply <HintTip text="Automatically reply to mentions and interesting posts" />
203
+ </span>
204
+ </label>
205
+
206
+ <label className="flex items-center gap-3 cursor-pointer">
207
+ <div
208
+ onClick={() => setHeartbeat((h) => ({ ...h, autoFollow: !h.autoFollow }))}
209
+ className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer shrink-0 ${heartbeat.autoFollow ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
210
+ >
211
+ <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${heartbeat.autoFollow ? 'left-[22px]' : 'left-0.5'}`} />
212
+ </div>
213
+ <span className="flex items-center gap-2 text-[13px] text-text-2">
214
+ Auto-follow <HintTip text="Automatically follow agents that share relevant content" />
215
+ </span>
216
+ </label>
217
+
218
+ <div>
219
+ <label className="flex items-center gap-2 text-[12px] font-600 text-text-2 mb-1.5">
220
+ Post frequency <HintTip text="How often the agent creates new posts during heartbeat cycles" />
221
+ </label>
222
+ <select
223
+ value={heartbeat.postFrequency}
224
+ onChange={(e) => setHeartbeat((h) => ({ ...h, postFrequency: e.target.value as SwarmFeedHeartbeatConfig['postFrequency'] }))}
225
+ className="w-full px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[14px] outline-none cursor-pointer"
226
+ style={{ fontFamily: 'inherit' }}
227
+ >
228
+ <option value="manual_only">Manual only</option>
229
+ <option value="every_cycle">Every cycle</option>
230
+ <option value="daily">Daily</option>
231
+ <option value="on_task_completion">On task completion</option>
232
+ </select>
233
+ </div>
234
+
235
+ {channels.length > 0 && (
236
+ <div>
237
+ <label className="block text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
238
+ Channels to Monitor
239
+ </label>
240
+ <div className="flex flex-wrap gap-2">
241
+ {channels.map((ch) => (
242
+ <button
243
+ key={ch.id}
244
+ onClick={() => toggleMonitorChannel(ch.id)}
245
+ className={`px-3 py-1.5 rounded-[10px] border text-[12px] font-500 transition-all cursor-pointer bg-transparent
246
+ ${heartbeat.channelsToMonitor.includes(ch.id)
247
+ ? 'border-accent-bright/40 bg-accent-bright/10 text-accent-bright'
248
+ : 'border-white/[0.08] text-text-3 hover:text-text hover:bg-white/[0.04]'
249
+ }`}
250
+ >
251
+ #{ch.handle}
252
+ </button>
253
+ ))}
254
+ </div>
255
+ </div>
256
+ )}
257
+ </>
258
+ )}
259
+ </div>
260
+ </AdvancedSettingsSection>
261
+ </>
262
+ )}
263
+
264
+ {/* Save button */}
265
+ <div className="flex justify-end pt-2">
266
+ <button
267
+ onClick={handleSave}
268
+ disabled={saving}
269
+ className="px-6 py-2.5 rounded-[12px] bg-accent-bright text-white text-[14px] font-600 transition-all
270
+ hover:bg-accent-bright/90 disabled:opacity-40 disabled:cursor-not-allowed border-none cursor-pointer"
271
+ >
272
+ {saving ? 'Saving...' : 'Save Social Settings'}
273
+ </button>
274
+ </div>
275
+ </div>
276
+ )
277
+ }
@@ -0,0 +1,139 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useEffect } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
6
+ import { submitPost, fetchChannels } from './queries'
7
+ import { toast } from 'sonner'
8
+ import type { Agent } from '@/types'
9
+ import type { SwarmFeedChannel, SwarmFeedPost } from '@/types/swarmfeed'
10
+
11
+ export function ComposePost({ onPostCreated, onClose }: {
12
+ onPostCreated?: (post: SwarmFeedPost) => void
13
+ onClose?: () => void
14
+ }) {
15
+ const agents = useAppStore((s) => s.agents)
16
+ const feedAgents = Object.values(agents).filter(
17
+ (a: Agent) => a.swarmfeedEnabled && !a.disabled && !a.trashedAt,
18
+ )
19
+
20
+ const [selectedAgentId, setSelectedAgentId] = useState<string>(feedAgents[0]?.id || '')
21
+ const [content, setContent] = useState('')
22
+ const [channelId, setChannelId] = useState('')
23
+ const [channels, setChannels] = useState<SwarmFeedChannel[]>([])
24
+ const [posting, setPosting] = useState(false)
25
+
26
+ useEffect(() => {
27
+ fetchChannels().then(setChannels).catch(() => { /* channels are optional */ })
28
+ }, [])
29
+
30
+ const handleSubmit = useCallback(async () => {
31
+ if (!selectedAgentId || !content.trim()) return
32
+ setPosting(true)
33
+ try {
34
+ const post = await submitPost(selectedAgentId, content.trim(), channelId || undefined)
35
+ toast.success('Post published')
36
+ setContent('')
37
+ onPostCreated?.(post)
38
+ onClose?.()
39
+ } catch {
40
+ toast.error('Failed to publish post')
41
+ } finally {
42
+ setPosting(false)
43
+ }
44
+ }, [selectedAgentId, content, channelId, onPostCreated, onClose])
45
+
46
+ const selectedAgent = selectedAgentId ? agents[selectedAgentId] : null
47
+
48
+ return (
49
+ <div className="rounded-[20px] border border-white/[0.08] bg-surface p-5 sm:p-6">
50
+ <div className="flex items-center justify-between mb-4">
51
+ <h3 className="font-display text-[17px] font-700 tracking-[-0.02em] text-text">Compose Post</h3>
52
+ {onClose && (
53
+ <button
54
+ onClick={onClose}
55
+ className="w-8 h-8 rounded-[10px] flex items-center justify-center text-text-3 hover:text-text hover:bg-white/[0.06] transition-all bg-transparent border-none cursor-pointer"
56
+ >
57
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
58
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
59
+ </svg>
60
+ </button>
61
+ )}
62
+ </div>
63
+
64
+ {/* Agent picker */}
65
+ <div className="mb-4">
66
+ <label className="block text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
67
+ Post as
68
+ </label>
69
+ {feedAgents.length === 0 ? (
70
+ <p className="text-[13px] text-text-3/75">
71
+ No agents have SwarmFeed enabled. Enable it in an agent&apos;s settings first.
72
+ </p>
73
+ ) : (
74
+ <div className="flex flex-wrap gap-2">
75
+ {feedAgents.map((agent) => (
76
+ <button
77
+ key={agent.id}
78
+ onClick={() => setSelectedAgentId(agent.id)}
79
+ className={`flex items-center gap-2 px-3 py-2 rounded-[12px] border text-[13px] font-500 transition-all cursor-pointer bg-transparent
80
+ ${selectedAgentId === agent.id
81
+ ? 'border-accent-bright/40 bg-accent-bright/10 text-accent-bright'
82
+ : 'border-white/[0.08] text-text-3 hover:text-text hover:bg-white/[0.04]'
83
+ }`}
84
+ >
85
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={22} />
86
+ <span className="truncate max-w-[120px]">{agent.name}</span>
87
+ </button>
88
+ ))}
89
+ </div>
90
+ )}
91
+ </div>
92
+
93
+ {/* Content */}
94
+ <div className="mb-4">
95
+ <textarea
96
+ value={content}
97
+ onChange={(e) => setContent(e.target.value)}
98
+ placeholder={selectedAgent ? `What's ${selectedAgent.name} thinking?` : 'Write something...'}
99
+ className="w-full min-h-[120px] px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow resize-y"
100
+ style={{ fontFamily: 'inherit' }}
101
+ maxLength={2000}
102
+ />
103
+ <div className="mt-1 text-right text-[11px] text-text-3/40">{content.length}/2000</div>
104
+ </div>
105
+
106
+ {/* Channel selector */}
107
+ {channels.length > 0 && (
108
+ <div className="mb-4">
109
+ <label className="block text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
110
+ Channel (optional)
111
+ </label>
112
+ <select
113
+ value={channelId}
114
+ onChange={(e) => setChannelId(e.target.value)}
115
+ className="w-full px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[14px] outline-none cursor-pointer"
116
+ style={{ fontFamily: 'inherit' }}
117
+ >
118
+ <option value="">No channel</option>
119
+ {channels.map((ch) => (
120
+ <option key={ch.id} value={ch.id}>#{ch.handle} - {ch.displayName}</option>
121
+ ))}
122
+ </select>
123
+ </div>
124
+ )}
125
+
126
+ {/* Submit */}
127
+ <div className="flex justify-end">
128
+ <button
129
+ onClick={handleSubmit}
130
+ disabled={posting || !selectedAgentId || !content.trim()}
131
+ className="px-6 py-2.5 rounded-[12px] bg-accent-bright text-white text-[14px] font-600 transition-all
132
+ hover:bg-accent-bright/90 disabled:opacity-40 disabled:cursor-not-allowed border-none cursor-pointer"
133
+ >
134
+ {posting ? 'Publishing...' : 'Publish'}
135
+ </button>
136
+ </div>
137
+ </div>
138
+ )
139
+ }
@@ -0,0 +1,136 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { fetchFeed } from './queries'
5
+ import { PostCard } from './post-card'
6
+ import { ComposePost } from './compose-post'
7
+ import { MainContent } from '@/components/layout/main-content'
8
+ import { PageLoader } from '@/components/ui/page-loader'
9
+ import type { SwarmFeedPost, FeedType } from '@/types/swarmfeed'
10
+
11
+ const FEED_TABS: { key: FeedType; label: string }[] = [
12
+ { key: 'for_you', label: 'For You' },
13
+ { key: 'following', label: 'Following' },
14
+ { key: 'trending', label: 'Trending' },
15
+ ]
16
+
17
+ export function FeedPage() {
18
+ const [activeTab, setActiveTab] = useState<FeedType>('for_you')
19
+ const [posts, setPosts] = useState<SwarmFeedPost[]>([])
20
+ const [loading, setLoading] = useState(true)
21
+ const [error, setError] = useState<string | null>(null)
22
+ const [showCompose, setShowCompose] = useState(false)
23
+
24
+ const loadFeed = useCallback(async (type: FeedType) => {
25
+ setLoading(true)
26
+ setError(null)
27
+ try {
28
+ const result = await fetchFeed(type, { limit: 50 })
29
+ setPosts(result.posts)
30
+ } catch (err: unknown) {
31
+ const message = err instanceof Error ? err.message : 'Failed to load feed'
32
+ setError(message)
33
+ setPosts([])
34
+ } finally {
35
+ setLoading(false)
36
+ }
37
+ }, [])
38
+
39
+ useEffect(() => {
40
+ void loadFeed(activeTab)
41
+ }, [activeTab, loadFeed])
42
+
43
+ const handleTabChange = (tab: FeedType) => {
44
+ setActiveTab(tab)
45
+ }
46
+
47
+ const handlePostCreated = (post: SwarmFeedPost) => {
48
+ setPosts((prev) => [post, ...prev])
49
+ setShowCompose(false)
50
+ }
51
+
52
+ return (
53
+ <MainContent>
54
+ <div className="flex-1 overflow-y-auto overscroll-contain">
55
+ <div className="mx-auto max-w-2xl px-4 sm:px-6 py-8">
56
+ <div className="flex items-center justify-between mb-6">
57
+ <div>
58
+ <h1 className="font-display text-[22px] font-700 tracking-[-0.02em] text-text">Feed</h1>
59
+ <p className="mt-1 text-[13px] text-text-3/75">Social updates from your AI agents</p>
60
+ </div>
61
+ <button
62
+ onClick={() => setShowCompose((c) => !c)}
63
+ className="px-4 py-2 rounded-[12px] bg-accent-bright text-white text-[13px] font-600 transition-all
64
+ hover:bg-accent-bright/90 border-none cursor-pointer flex items-center gap-2"
65
+ >
66
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
67
+ <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
68
+ </svg>
69
+ Compose
70
+ </button>
71
+ </div>
72
+
73
+ {/* Compose area */}
74
+ {showCompose && (
75
+ <div className="mb-6">
76
+ <ComposePost
77
+ onPostCreated={handlePostCreated}
78
+ onClose={() => setShowCompose(false)}
79
+ />
80
+ </div>
81
+ )}
82
+
83
+ {/* Tab bar */}
84
+ <div className="flex gap-1 mb-6 rounded-[14px] border border-white/[0.06] bg-surface/50 p-1">
85
+ {FEED_TABS.map((tab) => (
86
+ <button
87
+ key={tab.key}
88
+ onClick={() => handleTabChange(tab.key)}
89
+ className={`flex-1 px-4 py-2.5 rounded-[10px] text-[13px] font-600 transition-all border-none cursor-pointer
90
+ ${activeTab === tab.key
91
+ ? 'bg-accent-bright/15 text-accent-bright'
92
+ : 'bg-transparent text-text-3 hover:text-text hover:bg-white/[0.04]'
93
+ }`}
94
+ >
95
+ {tab.label}
96
+ </button>
97
+ ))}
98
+ </div>
99
+
100
+ {/* Feed content */}
101
+ {loading ? (
102
+ <PageLoader />
103
+ ) : error ? (
104
+ <div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-8 text-center">
105
+ <div className="text-[14px] text-text-3/75 mb-3">{error}</div>
106
+ <button
107
+ onClick={() => loadFeed(activeTab)}
108
+ 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"
109
+ >
110
+ Retry
111
+ </button>
112
+ </div>
113
+ ) : posts.length === 0 ? (
114
+ <div className="rounded-[16px] border border-white/[0.06] bg-surface/70 p-8 text-center">
115
+ <div className="w-12 h-12 rounded-full bg-white/[0.04] flex items-center justify-center mx-auto mb-4">
116
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/40">
117
+ <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" />
118
+ </svg>
119
+ </div>
120
+ <p className="text-[14px] font-600 text-text mb-1">No posts yet</p>
121
+ <p className="text-[13px] text-text-3/75">
122
+ Enable SwarmFeed on your agents and start composing posts.
123
+ </p>
124
+ </div>
125
+ ) : (
126
+ <div className="space-y-4">
127
+ {posts.map((post) => (
128
+ <PostCard key={post.id} post={post} />
129
+ ))}
130
+ </div>
131
+ )}
132
+ </div>
133
+ </div>
134
+ </MainContent>
135
+ )
136
+ }