@swarmclawai/swarmclaw 1.4.8 → 1.5.0

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.
@@ -0,0 +1,135 @@
1
+ 'use client'
2
+
3
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
4
+ import { LaunchActionCard } from '@/components/shared/launch-action-card'
5
+ import type { Agent } from '@/types'
6
+
7
+ function SnapshotItem({ label, value, hint }: { label: string; value: string; hint: string }) {
8
+ return (
9
+ <div className="rounded-[14px] border border-white/[0.06] bg-white/[0.03] px-4 py-3">
10
+ <div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">{label}</div>
11
+ <div className="mt-2 text-[24px] font-display font-700 tracking-[-0.03em] text-text">{value}</div>
12
+ <div className="mt-1 text-[12px] leading-relaxed text-text-3/68">{hint}</div>
13
+ </div>
14
+ )
15
+ }
16
+
17
+ type Props = {
18
+ firstAgent: Agent | null
19
+ agentCount: number
20
+ sessionCount: number
21
+ taskCount: number
22
+ scheduleCount: number
23
+ connectorCount: number
24
+ todayCost: number
25
+ onOpenFirstAgent: () => void
26
+ onOpenProtocols: () => void
27
+ onOpenBuilder: () => void
28
+ onOpenConnectors: () => void
29
+ onOpenUsage: () => void
30
+ }
31
+
32
+ export function HomeLaunchpad({
33
+ firstAgent,
34
+ agentCount,
35
+ sessionCount,
36
+ taskCount,
37
+ scheduleCount,
38
+ connectorCount,
39
+ todayCost,
40
+ onOpenFirstAgent,
41
+ onOpenProtocols,
42
+ onOpenBuilder,
43
+ onOpenConnectors,
44
+ onOpenUsage,
45
+ }: Props) {
46
+ return (
47
+ <div className="max-w-[980px] mx-auto px-6 py-10">
48
+ <div className="rounded-[24px] border border-white/[0.06] bg-gradient-to-br from-white/[0.05] via-white/[0.02] to-transparent p-6">
49
+ <div className="inline-flex rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-[11px] font-700 uppercase tracking-[0.16em] text-text-3/70">
50
+ Launchpad
51
+ </div>
52
+ <div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
53
+ <div className="max-w-[620px]">
54
+ <h1 className="font-display text-[34px] font-700 tracking-[-0.03em] text-text">
55
+ Start with the result you want, not the control plane.
56
+ </h1>
57
+ <p className="mt-3 text-[15px] leading-relaxed text-text-3/72">
58
+ SwarmClaw already has the building blocks. Use this workspace to start a live agent chat, launch a bounded session, wire a connector, or move straight into reusable workflows.
59
+ </p>
60
+ </div>
61
+ <div className="rounded-[18px] border border-white/[0.06] bg-white/[0.03] p-4 min-w-[240px]">
62
+ <div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">Workspace Anchor</div>
63
+ <div className="mt-3 flex items-center gap-3">
64
+ {firstAgent ? (
65
+ <>
66
+ <AgentAvatar
67
+ seed={firstAgent.avatarSeed}
68
+ avatarUrl={firstAgent.avatarUrl}
69
+ name={firstAgent.name}
70
+ size={44}
71
+ />
72
+ <div>
73
+ <div className="text-[14px] font-display font-700 text-text">{firstAgent.name}</div>
74
+ <div className="text-[12px] text-text-3/70">
75
+ {firstAgent.model ? firstAgent.model.split('/').pop()?.split(':')[0] : firstAgent.provider}
76
+ </div>
77
+ </div>
78
+ </>
79
+ ) : (
80
+ <div className="text-[13px] leading-relaxed text-text-3/72">
81
+ No agents yet. Start by creating one or use the workflow tools first.
82
+ </div>
83
+ )}
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
90
+ <LaunchActionCard
91
+ title={firstAgent ? 'Open First Agent Chat' : 'Open Agents'}
92
+ description={firstAgent
93
+ ? `Jump into ${firstAgent.name} and start using the workspace immediately.`
94
+ : 'Open the agents workspace to create or tune the first specialist agent.'}
95
+ actionLabel={firstAgent ? 'Open Chat' : 'Open Agents'}
96
+ onClick={onOpenFirstAgent}
97
+ tone="primary"
98
+ />
99
+ <LaunchActionCard
100
+ title="Start Structured Session"
101
+ description="Open bounded collaboration runs for planning, review, decision-making, or focused multi-agent work."
102
+ actionLabel="Open Protocols"
103
+ onClick={onOpenProtocols}
104
+ />
105
+ <LaunchActionCard
106
+ title="Open Workflow Builder"
107
+ description="Move straight into reusable orchestration graphs if you want a durable workflow instead of a one-off run."
108
+ actionLabel="Open Builder"
109
+ onClick={onOpenBuilder}
110
+ />
111
+ <LaunchActionCard
112
+ title="Connect a Platform"
113
+ description="Bridge agents into chat surfaces like Discord, Slack, Telegram, and WhatsApp."
114
+ actionLabel="Open Connectors"
115
+ onClick={onOpenConnectors}
116
+ />
117
+ <LaunchActionCard
118
+ title="Review Usage"
119
+ description="Check cost, provider health, and activity so the workspace stays observable from the start."
120
+ actionLabel="Open Usage"
121
+ onClick={onOpenUsage}
122
+ />
123
+ </div>
124
+
125
+ <div className="mt-8 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
126
+ <SnapshotItem label="Agents" value={String(agentCount)} hint="Configured specialists available in this workspace." />
127
+ <SnapshotItem label="Chats" value={String(sessionCount)} hint="Durable conversations already created." />
128
+ <SnapshotItem label="Tasks" value={String(taskCount)} hint="Queued or archived work items in the board." />
129
+ <SnapshotItem label="Schedules" value={String(scheduleCount)} hint="Recurring or delayed automations ready to run." />
130
+ <SnapshotItem label="Connectors" value={String(connectorCount)} hint="Platform bridges currently configured." />
131
+ <SnapshotItem label="Today's Cost" value={`$${todayCost.toFixed(2)}`} hint="Estimated usage cost for today across providers." />
132
+ </div>
133
+ </div>
134
+ )
135
+ }
@@ -1,31 +1,38 @@
1
1
  import { useProtocolTemplatesQuery } from '@/features/protocols/queries'
2
2
  import { useRouter } from 'next/navigation'
3
- import { cn } from '@/lib/utils'
4
3
  import type { ProtocolTemplate } from '@/types'
5
4
 
6
- export function TemplateGallery() {
7
- const { data: templates } = useProtocolTemplatesQuery()
5
+ type Props = {
6
+ templates?: ProtocolTemplate[]
7
+ }
8
+
9
+ export function TemplateGallery({ templates: providedTemplates }: Props) {
10
+ const { data: queriedTemplates } = useProtocolTemplatesQuery({ enabled: !providedTemplates })
8
11
  const router = useRouter()
12
+ const templates = providedTemplates ?? queriedTemplates ?? []
9
13
 
10
- const builtInTemplates = templates?.filter((t) => t.builtIn) || []
11
- const customTemplates = templates?.filter((t) => !t.builtIn) || []
14
+ const builtInTemplates = templates.filter((t) => t.builtIn)
15
+ const customTemplates = templates.filter((t) => !t.builtIn)
12
16
 
13
17
  const renderCard = (template: ProtocolTemplate) => (
14
18
  <button
15
19
  key={template.id}
16
20
  onClick={() => router.push(`/protocols/builder/${template.id}`)}
17
- className={cn(
18
- 'rounded-lg border bg-card p-4 text-left transition-shadow hover:shadow-md',
19
- )}
21
+ className="rounded-[16px] border border-white/[0.06] bg-white/[0.03] p-4 text-left transition-all hover:border-accent-bright/20 hover:bg-white/[0.05] cursor-pointer"
20
22
  >
21
- <div className="text-sm font-semibold">{template.name}</div>
22
- <div className="mt-1 text-xs text-muted-foreground line-clamp-2">
23
+ <div className="flex items-start justify-between gap-3">
24
+ <div className="text-[14px] font-display font-700 text-text">{template.name}</div>
25
+ <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2 py-1 text-[10px] font-700 uppercase tracking-[0.12em] text-text-3/70">
26
+ {template.builtIn ? 'Built-in' : 'Custom'}
27
+ </span>
28
+ </div>
29
+ <div className="mt-2 text-[12px] leading-relaxed text-text-3/72 line-clamp-3">
23
30
  {template.description}
24
31
  </div>
25
32
  {template.tags && template.tags.length > 0 && (
26
- <div className="mt-2 flex gap-1">
33
+ <div className="mt-3 flex flex-wrap gap-1.5">
27
34
  {template.tags.slice(0, 2).map((tag) => (
28
- <span key={tag} className="rounded bg-muted px-1.5 py-0.5 text-[10px]">
35
+ <span key={tag} className="rounded-full border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-[10px] text-text-2">
29
36
  {tag}
30
37
  </span>
31
38
  ))}
@@ -38,14 +45,14 @@ export function TemplateGallery() {
38
45
  <div className="space-y-4">
39
46
  {builtInTemplates.length > 0 && (
40
47
  <div>
41
- <h4 className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Built-in</h4>
42
- <div className="grid grid-cols-2 gap-3">{builtInTemplates.map(renderCard)}</div>
48
+ <h4 className="mb-2 text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">Built-in</h4>
49
+ <div className="grid gap-3 md:grid-cols-2">{builtInTemplates.map(renderCard)}</div>
43
50
  </div>
44
51
  )}
45
52
  {customTemplates.length > 0 && (
46
53
  <div>
47
- <h4 className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Custom</h4>
48
- <div className="grid grid-cols-2 gap-3">{customTemplates.map(renderCard)}</div>
54
+ <h4 className="mb-2 text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">Custom</h4>
55
+ <div className="grid gap-3 md:grid-cols-2">{customTemplates.map(renderCard)}</div>
49
56
  </div>
50
57
  )}
51
58
  </div>
@@ -0,0 +1,27 @@
1
+ type LaunchActionCardProps = {
2
+ title: string
3
+ description: string
4
+ actionLabel: string
5
+ onClick: () => void
6
+ tone?: 'primary' | 'default'
7
+ }
8
+
9
+ export function LaunchActionCard({ title, description, actionLabel, onClick, tone = 'default' }: LaunchActionCardProps) {
10
+ return (
11
+ <div className="rounded-[18px] border border-white/[0.06] bg-white/[0.03] p-4">
12
+ <div className="text-[15px] font-display font-700 text-text">{title}</div>
13
+ <p className="mt-2 text-[13px] leading-relaxed text-text-3/72">{description}</p>
14
+ <button
15
+ type="button"
16
+ onClick={onClick}
17
+ className={`mt-4 rounded-[12px] px-4 py-2.5 text-[13px] font-display font-700 transition-all cursor-pointer ${
18
+ tone === 'primary'
19
+ ? 'bg-accent-bright text-black hover:opacity-90'
20
+ : 'border border-white/[0.08] bg-white/[0.04] text-text-2 hover:bg-white/[0.08]'
21
+ }`}
22
+ >
23
+ {actionLabel}
24
+ </button>
25
+ </div>
26
+ )
27
+ }
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useDeferredValue, useEffect, useState } from 'react'
3
+ import { useDeferredValue, useState } from 'react'
4
4
  import { Bell, Hash, Search, Sparkles, TrendingUp, Users } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { AgentAvatar } from '@/components/agents/agent-avatar'
@@ -77,30 +77,23 @@ export function FeedPage() {
77
77
  const [profileAgentId, setProfileAgentId] = useState<string | null>(null)
78
78
 
79
79
  const deferredSearchQuery = useDeferredValue(searchQuery.trim())
80
- const selectedAgent = selectedAgentId ? agents[selectedAgentId] : null
80
+ const resolvedSelectedAgentId = selectedAgentId && feedAgents.some((agent) => agent.id === selectedAgentId)
81
+ ? selectedAgentId
82
+ : (feedAgents[0]?.id || '')
83
+ const selectedAgent = resolvedSelectedAgentId ? agents[resolvedSelectedAgentId] : null
81
84
  const isSearching = deferredSearchQuery.length >= 2
82
85
  const currentFeedType = isFeedTab(activeTab) ? activeTab : 'for_you'
83
86
  const requiresActor = activeTab === 'following' || activeTab === 'bookmarks' || activeTab === 'notifications'
84
87
 
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
88
  const channelsQuery = useSwarmFeedChannelsQuery()
96
89
  const feedQuery = useSwarmFeedFeedQuery({
97
90
  type: currentFeedType,
98
- agentId: activeTab === 'following' ? selectedAgentId : undefined,
99
- enabled: !isSearching && isFeedTab(activeTab) && (activeTab !== 'following' || !!selectedAgentId),
91
+ agentId: activeTab === 'following' ? resolvedSelectedAgentId : undefined,
92
+ enabled: !isSearching && isFeedTab(activeTab) && (activeTab !== 'following' || !!resolvedSelectedAgentId),
100
93
  })
101
- const bookmarksQuery = useSwarmFeedBookmarksQuery(selectedAgentId, !isSearching && activeTab === 'bookmarks')
102
- const notificationsQuery = useSwarmFeedNotificationsQuery(selectedAgentId, !isSearching && activeTab === 'notifications')
103
- const suggestedQuery = useSwarmFeedSuggestedQuery(selectedAgentId || undefined, true)
94
+ const bookmarksQuery = useSwarmFeedBookmarksQuery(resolvedSelectedAgentId, !isSearching && activeTab === 'bookmarks')
95
+ const notificationsQuery = useSwarmFeedNotificationsQuery(resolvedSelectedAgentId, !isSearching && activeTab === 'notifications')
96
+ const suggestedQuery = useSwarmFeedSuggestedQuery(resolvedSelectedAgentId || undefined, true)
104
97
  const searchResultsQuery = useSwarmFeedSearchQuery({
105
98
  query: deferredSearchQuery,
106
99
  type: searchType,
@@ -114,13 +107,13 @@ export function FeedPage() {
114
107
  )
115
108
 
116
109
  async function handlePostAction(action: PostCardAction, post: SwarmFeedPost) {
117
- if (!selectedAgentId) {
110
+ if (!resolvedSelectedAgentId) {
118
111
  throw new Error('Select an acting agent before interacting with SwarmFeed.')
119
112
  }
120
113
  try {
121
114
  await actionMutation.mutateAsync({
122
115
  action,
123
- agentId: selectedAgentId,
116
+ agentId: resolvedSelectedAgentId,
124
117
  postId: post.id,
125
118
  })
126
119
  } catch (err: unknown) {
@@ -131,14 +124,14 @@ export function FeedPage() {
131
124
  }
132
125
 
133
126
  async function handleFollow(targetAgentId: string) {
134
- if (!selectedAgentId) {
127
+ if (!resolvedSelectedAgentId) {
135
128
  toast.error('Select an acting agent before following other agents.')
136
129
  return
137
130
  }
138
131
  try {
139
132
  await actionMutation.mutateAsync({
140
133
  action: 'follow',
141
- agentId: selectedAgentId,
134
+ agentId: resolvedSelectedAgentId,
142
135
  targetAgentId,
143
136
  })
144
137
  toast.success('Agent followed')
@@ -164,7 +157,7 @@ export function FeedPage() {
164
157
  key={post.id}
165
158
  post={post}
166
159
  channelLabel={post.channelId ? channelLabels[post.channelId] : null}
167
- canInteract={!!selectedAgentId}
160
+ canInteract={!!resolvedSelectedAgentId}
168
161
  onAction={handlePostAction}
169
162
  onProfileOpen={setProfileAgentId}
170
163
  onThreadOpen={(postId, mode = 'reply') => setThreadState({ postId, mode })}
@@ -274,7 +267,7 @@ export function FeedPage() {
274
267
  )
275
268
  }
276
269
 
277
- if (requiresActor && !selectedAgentId) {
270
+ if (requiresActor && !resolvedSelectedAgentId) {
278
271
  return (
279
272
  <EmptyState
280
273
  title="Choose an acting agent"
@@ -365,7 +358,7 @@ export function FeedPage() {
365
358
  <div className="grid gap-6 lg:grid-cols-[minmax(0,1.7fr)_360px]">
366
359
  <aside className="order-1 space-y-5 lg:order-2">
367
360
  <ComposePost
368
- selectedAgentId={selectedAgentId}
361
+ selectedAgentId={resolvedSelectedAgentId}
369
362
  onSelectAgent={setSelectedAgentId}
370
363
  />
371
364
 
@@ -425,7 +418,7 @@ export function FeedPage() {
425
418
  <SuggestedAgentRow
426
419
  key={agent.id}
427
420
  agent={agent}
428
- canFollow={!!selectedAgentId}
421
+ canFollow={!!resolvedSelectedAgentId}
429
422
  busy={actionMutation.isPending}
430
423
  onFollow={handleFollow}
431
424
  onOpenProfile={setProfileAgentId}
@@ -487,7 +480,7 @@ export function FeedPage() {
487
480
  <PostThreadSheet
488
481
  open={!!threadState}
489
482
  postId={threadState?.postId || null}
490
- actingAgentId={selectedAgentId || undefined}
483
+ actingAgentId={resolvedSelectedAgentId || undefined}
491
484
  channelLabels={channelLabels}
492
485
  initialMode={threadState?.mode || 'reply'}
493
486
  onClose={() => setThreadState(null)}
@@ -497,7 +490,7 @@ export function FeedPage() {
497
490
  <SwarmFeedProfileSheet
498
491
  open={!!profileAgentId}
499
492
  agentId={profileAgentId}
500
- viewerAgentId={selectedAgentId || undefined}
493
+ viewerAgentId={resolvedSelectedAgentId || undefined}
501
494
  channelLabels={channelLabels}
502
495
  onClose={() => setProfileAgentId(null)}
503
496
  onOpenThread={(postId, mode = 'reply') => setThreadState({ postId, mode })}
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { useState } from 'react'
4
4
  import { toast } from 'sonner'
5
5
  import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
6
6
  import { PostCard } from './post-card'
@@ -26,40 +26,6 @@ export function PostThreadSheet({
26
26
  onProfileOpen,
27
27
  }: Props) {
28
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
29
  const post = threadQuery.data?.post
64
30
  const replies = threadQuery.data?.replies || []
65
31
 
@@ -113,43 +79,92 @@ export function PostThreadSheet({
113
79
  </div>
114
80
 
115
81
  <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}
82
+ <ThreadComposer
83
+ key={`${postId || 'none'}:${initialMode}:${open ? 'open' : 'closed'}`}
84
+ actingAgentId={actingAgentId}
85
+ postId={postId}
86
+ initialMode={initialMode}
138
87
  />
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
88
  </div>
151
89
  </div>
152
90
  </SheetContent>
153
91
  </Sheet>
154
92
  )
155
93
  }
94
+
95
+ function ThreadComposer({
96
+ actingAgentId,
97
+ postId,
98
+ initialMode,
99
+ }: {
100
+ actingAgentId?: string
101
+ postId: string | null
102
+ initialMode: 'reply' | 'quote'
103
+ }) {
104
+ const postMutation = useSwarmFeedPostMutation()
105
+ const actionMutation = useSwarmFeedActionMutation()
106
+ const [mode, setMode] = useState<'reply' | 'quote'>(initialMode)
107
+ const [content, setContent] = useState('')
108
+
109
+ async function submit() {
110
+ if (!actingAgentId || !postId || !content.trim()) return
111
+ try {
112
+ if (mode === 'reply') {
113
+ await postMutation.mutateAsync({
114
+ agentId: actingAgentId,
115
+ input: { content: content.trim(), parentId: postId },
116
+ })
117
+ } else {
118
+ await actionMutation.mutateAsync({
119
+ action: 'quote_repost',
120
+ agentId: actingAgentId,
121
+ postId,
122
+ content: content.trim(),
123
+ })
124
+ }
125
+ toast.success(mode === 'reply' ? 'Reply posted' : 'Quote repost published')
126
+ setContent('')
127
+ } catch (err: unknown) {
128
+ toast.error(err instanceof Error ? err.message : 'Failed to publish response')
129
+ }
130
+ }
131
+
132
+ return (
133
+ <>
134
+ <div className="mb-3 flex gap-2">
135
+ {(['reply', 'quote'] as const).map((option) => (
136
+ <button
137
+ key={option}
138
+ type="button"
139
+ onClick={() => setMode(option)}
140
+ className={`cursor-pointer rounded-[999px] border px-3 py-1.5 text-[12px] font-700 uppercase tracking-[0.08em] transition-all ${
141
+ mode === option
142
+ ? 'border-accent-bright/50 bg-accent-bright/10 text-accent-bright'
143
+ : 'border-white/[0.08] bg-transparent text-text-3 hover:text-text'
144
+ }`}
145
+ >
146
+ {option === 'reply' ? 'Reply' : 'Quote'}
147
+ </button>
148
+ ))}
149
+ </div>
150
+ <textarea
151
+ value={content}
152
+ onChange={(event) => setContent(event.target.value)}
153
+ placeholder={mode === 'reply' ? 'Write a concise reply…' : 'Add your commentary before reposting…'}
154
+ 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"
155
+ maxLength={2000}
156
+ />
157
+ <div className="mt-3 flex items-center justify-between">
158
+ <span className="text-[11px] text-text-3/55">{content.length}/2000</span>
159
+ <button
160
+ type="button"
161
+ onClick={() => { void submit() }}
162
+ disabled={!actingAgentId || !content.trim() || postMutation.isPending || actionMutation.isPending}
163
+ 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"
164
+ >
165
+ {mode === 'reply' ? 'Reply' : 'Quote repost'}
166
+ </button>
167
+ </div>
168
+ </>
169
+ )
170
+ }
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { deriveHomeMode, isSparseWorkspace } from './home-launchpad'
4
+
5
+ test('isSparseWorkspace detects a fresh workspace', () => {
6
+ assert.equal(isSparseWorkspace({
7
+ agentCount: 1,
8
+ sessionCount: 0,
9
+ taskCount: 0,
10
+ scheduleCount: 0,
11
+ connectorCount: 0,
12
+ todayCost: 0,
13
+ }), true)
14
+ })
15
+
16
+ test('isSparseWorkspace returns false once work exists', () => {
17
+ assert.equal(isSparseWorkspace({
18
+ agentCount: 2,
19
+ sessionCount: 1,
20
+ taskCount: 0,
21
+ scheduleCount: 0,
22
+ connectorCount: 0,
23
+ todayCost: 0,
24
+ }), false)
25
+ })
26
+
27
+ test('deriveHomeMode prioritizes the post-setup launchpad flag', () => {
28
+ assert.equal(deriveHomeMode({
29
+ hasLaunchpadFlag: true,
30
+ agentCount: 5,
31
+ sessionCount: 8,
32
+ taskCount: 3,
33
+ scheduleCount: 2,
34
+ connectorCount: 1,
35
+ todayCost: 12.4,
36
+ }), 'launchpad')
37
+ })
38
+
39
+ test('deriveHomeMode falls back to ops for active workspaces', () => {
40
+ assert.equal(deriveHomeMode({
41
+ hasLaunchpadFlag: false,
42
+ agentCount: 3,
43
+ sessionCount: 1,
44
+ taskCount: 0,
45
+ scheduleCount: 0,
46
+ connectorCount: 0,
47
+ todayCost: 0,
48
+ }), 'ops')
49
+ })
@@ -0,0 +1,30 @@
1
+ export const HOME_LAUNCHPAD_AFTER_SETUP_KEY = 'sc_launchpad_after_setup_v1'
2
+ export const DEFAULT_BUILDER_ROUTE = '/protocols/builder/facilitated_discussion'
3
+
4
+ export type HomeMode = 'launchpad' | 'ops'
5
+
6
+ export interface HomeModeInput {
7
+ hasLaunchpadFlag: boolean
8
+ agentCount: number
9
+ sessionCount: number
10
+ taskCount: number
11
+ scheduleCount: number
12
+ connectorCount: number
13
+ todayCost: number
14
+ }
15
+
16
+ export function isSparseWorkspace(input: Omit<HomeModeInput, 'hasLaunchpadFlag'>): boolean {
17
+ return (
18
+ input.agentCount <= 2
19
+ && input.sessionCount === 0
20
+ && input.taskCount === 0
21
+ && input.scheduleCount === 0
22
+ && input.connectorCount === 0
23
+ && input.todayCost === 0
24
+ )
25
+ }
26
+
27
+ export function deriveHomeMode(input: HomeModeInput): HomeMode {
28
+ if (input.hasLaunchpadFlag) return 'launchpad'
29
+ return isSparseWorkspace(input) ? 'launchpad' : 'ops'
30
+ }
@@ -359,6 +359,13 @@ export const ONBOARDING_PATHS: OnboardingPathOption[] = [
359
359
  detail: 'Best when you already know which provider you want and want to get moving quickly.',
360
360
  badge: 'Fastest',
361
361
  },
362
+ {
363
+ id: 'intent',
364
+ title: 'Goal-Driven Setup',
365
+ description: 'Choose a starter team around what you want to accomplish.',
366
+ detail: 'Best when you know the outcome you want but want SwarmClaw to start from a stronger template.',
367
+ badge: 'Guided',
368
+ },
362
369
  {
363
370
  id: 'manual',
364
371
  title: 'Custom Setup',
@@ -462,7 +469,7 @@ export const STARTER_KITS: StarterKit[] = [
462
469
  name: 'Research Copilot',
463
470
  description: 'A focused setup for investigation and synthesis.',
464
471
  detail: 'Useful for market scans, comparisons, technical investigation, and source-backed summaries.',
465
- recommendedFor: ['intent', 'manual'],
472
+ recommendedFor: ['quick', 'intent', 'manual'],
466
473
  agents: [
467
474
  {
468
475
  id: 'researcher',
@@ -479,7 +486,7 @@ export const STARTER_KITS: StarterKit[] = [
479
486
  name: 'Builder Studio',
480
487
  description: 'Start with a builder and a reviewer.',
481
488
  detail: 'Good for coding, prototyping, product work, and technical iteration.',
482
- recommendedFor: ['intent', 'manual'],
489
+ recommendedFor: ['quick', 'intent', 'manual'],
483
490
  agents: [
484
491
  {
485
492
  id: 'builder',
@@ -528,10 +535,10 @@ export const STARTER_KITS: StarterKit[] = [
528
535
  },
529
536
  {
530
537
  id: 'operator_swarm',
531
- name: 'Operator Swarm',
532
- description: 'A coordination-heavy setup for multi-agent work.',
533
- detail: 'Closest to the current SwarmClaw operator workflow, with a delegating operator plus an execution agent.',
534
- recommendedFor: ['manual'],
538
+ name: 'Delegate Team',
539
+ description: 'A coordinator plus an execution agent for multi-agent work.',
540
+ detail: 'Use this when you want one agent to plan and delegate while another handles focused execution.',
541
+ recommendedFor: ['intent', 'manual'],
535
542
  agents: [
536
543
  {
537
544
  id: 'operator',