@swarmclawai/swarmclaw 0.3.0 → 0.4.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.
Files changed (118) hide show
  1. package/README.md +20 -11
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +2 -0
  6. package/package.json +3 -1
  7. package/src/app/api/agents/[id]/route.ts +3 -0
  8. package/src/app/api/agents/[id]/thread/route.ts +2 -1
  9. package/src/app/api/agents/route.ts +5 -1
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/connectors/[id]/route.ts +4 -0
  13. package/src/app/api/connectors/route.ts +6 -1
  14. package/src/app/api/credentials/route.ts +3 -1
  15. package/src/app/api/daemon/route.ts +6 -1
  16. package/src/app/api/ip/route.ts +3 -1
  17. package/src/app/api/mcp-servers/route.ts +3 -1
  18. package/src/app/api/orchestrator/graph/route.ts +25 -0
  19. package/src/app/api/plugins/marketplace/route.ts +3 -1
  20. package/src/app/api/plugins/route.ts +3 -1
  21. package/src/app/api/providers/[id]/route.ts +3 -0
  22. package/src/app/api/providers/configs/route.ts +3 -1
  23. package/src/app/api/providers/route.ts +5 -1
  24. package/src/app/api/schedules/[id]/route.ts +3 -0
  25. package/src/app/api/schedules/route.ts +6 -1
  26. package/src/app/api/secrets/route.ts +3 -1
  27. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  28. package/src/app/api/sessions/route.ts +9 -2
  29. package/src/app/api/settings/route.ts +3 -1
  30. package/src/app/api/setup/doctor/route.ts +1 -0
  31. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  32. package/src/app/api/skills/route.ts +3 -1
  33. package/src/app/api/tasks/[id]/approve/route.ts +73 -0
  34. package/src/app/api/tasks/[id]/route.ts +3 -0
  35. package/src/app/api/tasks/route.ts +3 -0
  36. package/src/app/api/usage/route.ts +3 -1
  37. package/src/app/api/version/route.ts +3 -1
  38. package/src/app/api/webhooks/[id]/route.ts +2 -1
  39. package/src/app/api/webhooks/route.ts +3 -1
  40. package/src/app/icon.svg +58 -0
  41. package/src/app/page.tsx +8 -2
  42. package/src/cli/index.js +1 -9
  43. package/src/cli/index.ts +51 -1
  44. package/src/cli/spec.js +0 -8
  45. package/src/components/agents/agent-card.tsx +1 -1
  46. package/src/components/agents/agent-sheet.tsx +63 -80
  47. package/src/components/chat/chat-area.tsx +44 -30
  48. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  49. package/src/components/chat/message-bubble.tsx +110 -42
  50. package/src/components/chat/tool-call-bubble.tsx +41 -3
  51. package/src/components/chat/tool-request-banner.tsx +1 -9
  52. package/src/components/connectors/connector-list.tsx +3 -8
  53. package/src/components/connectors/connector-sheet.tsx +24 -29
  54. package/src/components/input/chat-input.tsx +72 -56
  55. package/src/components/knowledge/knowledge-list.tsx +27 -31
  56. package/src/components/layout/app-layout.tsx +92 -71
  57. package/src/components/layout/daemon-indicator.tsx +3 -5
  58. package/src/components/logs/log-list.tsx +5 -9
  59. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  60. package/src/components/memory/memory-detail.tsx +1 -1
  61. package/src/components/plugins/plugin-list.tsx +227 -27
  62. package/src/components/providers/provider-list.tsx +46 -13
  63. package/src/components/providers/provider-sheet.tsx +0 -45
  64. package/src/components/runs/run-list.tsx +6 -15
  65. package/src/components/schedules/schedule-card.tsx +54 -4
  66. package/src/components/schedules/schedule-list.tsx +6 -3
  67. package/src/components/schedules/schedule-sheet.tsx +0 -47
  68. package/src/components/secrets/secrets-list.tsx +20 -2
  69. package/src/components/sessions/new-session-sheet.tsx +8 -9
  70. package/src/components/shared/connector-platform-icon.tsx +22 -20
  71. package/src/components/shared/model-combobox.tsx +148 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +7 -39
  73. package/src/components/shared/settings/section-orchestrator.tsx +8 -9
  74. package/src/components/skills/skill-list.tsx +260 -34
  75. package/src/components/skills/skill-sheet.tsx +0 -45
  76. package/src/components/tasks/task-board.tsx +3 -6
  77. package/src/components/tasks/task-card.tsx +43 -1
  78. package/src/components/tasks/task-list.tsx +3 -5
  79. package/src/components/tasks/task-sheet.tsx +0 -44
  80. package/src/components/usage/usage-list.tsx +12 -4
  81. package/src/hooks/use-ws.ts +66 -0
  82. package/src/instrumentation.ts +2 -0
  83. package/src/lib/chat.ts +14 -2
  84. package/src/lib/providers/anthropic.ts +1 -1
  85. package/src/lib/providers/index.ts +2 -0
  86. package/src/lib/providers/ollama.ts +1 -1
  87. package/src/lib/providers/openai.ts +33 -12
  88. package/src/lib/server/chat-execution.ts +19 -4
  89. package/src/lib/server/connectors/manager.ts +9 -3
  90. package/src/lib/server/context-manager.ts +1 -1
  91. package/src/lib/server/daemon-state.ts +3 -0
  92. package/src/lib/server/data-dir.ts +1 -0
  93. package/src/lib/server/heartbeat-service.ts +67 -3
  94. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  95. package/src/lib/server/main-agent-loop.ts +61 -2
  96. package/src/lib/server/orchestrator-lg.ts +394 -13
  97. package/src/lib/server/orchestrator.ts +25 -5
  98. package/src/lib/server/queue.ts +17 -3
  99. package/src/lib/server/session-run-manager.ts +6 -1
  100. package/src/lib/server/session-tools/delegate.ts +2 -2
  101. package/src/lib/server/session-tools/index.ts +2 -0
  102. package/src/lib/server/session-tools/sandbox.ts +164 -0
  103. package/src/lib/server/storage-mcp.test.ts +25 -2
  104. package/src/lib/server/storage.ts +24 -7
  105. package/src/lib/server/stream-agent-chat.ts +77 -22
  106. package/src/lib/server/task-validation.test.ts +23 -0
  107. package/src/lib/server/task-validation.ts +5 -3
  108. package/src/lib/server/ws-hub.ts +85 -0
  109. package/src/lib/tool-definitions.ts +42 -0
  110. package/src/lib/upload.ts +7 -1
  111. package/src/lib/ws-client.ts +124 -0
  112. package/src/stores/use-chat-store.ts +33 -13
  113. package/src/types/index.ts +8 -1
  114. package/src/app/api/agents/generate/route.ts +0 -42
  115. package/src/app/api/generate/info/route.ts +0 -12
  116. package/src/app/api/generate/route.ts +0 -106
  117. package/src/app/favicon.ico +0 -0
  118. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -1,7 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useEffect } from 'react'
3
+ import { useEffect, useState, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { api } from '@/lib/api-client'
6
+ import type { MarketplacePlugin } from '@/types'
5
7
 
6
8
  export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
7
9
  const plugins = useAppStore((s) => s.plugins)
@@ -9,10 +11,31 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
9
11
  const setPluginSheetOpen = useAppStore((s) => s.setPluginSheetOpen)
10
12
  const setEditingPluginFilename = useAppStore((s) => s.setEditingPluginFilename)
11
13
 
14
+ const [tab, setTab] = useState<'installed' | 'marketplace'>('installed')
15
+ const [marketplace, setMarketplace] = useState<MarketplacePlugin[]>([])
16
+ const [mpLoading, setMpLoading] = useState(false)
17
+ const [installing, setInstalling] = useState<string | null>(null)
18
+ const [search, setSearch] = useState('')
19
+ const [activeTag, setActiveTag] = useState<string | null>(null)
20
+ const [sort, setSort] = useState<'name' | 'downloads'>('downloads')
21
+
12
22
  useEffect(() => {
13
23
  loadPlugins()
14
24
  }, [])
15
25
 
26
+ const loadMarketplace = useCallback(async () => {
27
+ setMpLoading(true)
28
+ try {
29
+ const data = await api<MarketplacePlugin[]>('GET', '/plugins/marketplace')
30
+ if (Array.isArray(data)) setMarketplace(data)
31
+ } catch { /* ignore */ }
32
+ setMpLoading(false)
33
+ }, [])
34
+
35
+ useEffect(() => {
36
+ if (!inSidebar && tab === 'marketplace') loadMarketplace()
37
+ }, [tab, inSidebar, loadMarketplace])
38
+
16
39
  const pluginList = Object.values(plugins)
17
40
 
18
41
  const handleEdit = (filename: string) => {
@@ -20,41 +43,218 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
20
43
  setPluginSheetOpen(true)
21
44
  }
22
45
 
23
- return (
24
- <div className={`flex-1 overflow-y-auto ${inSidebar ? 'px-3 pb-4' : 'px-4'}`}>
25
- {pluginList.length === 0 ? (
26
- <div className="text-center py-12">
27
- <p className="text-[13px] text-text-3/60">No plugins installed</p>
46
+ const handleToggle = async (e: React.MouseEvent, filename: string, enabled: boolean) => {
47
+ e.stopPropagation()
48
+ await api('POST', '/plugins', { filename, enabled: !enabled })
49
+ loadPlugins()
50
+ }
51
+
52
+ const handleDelete = async (e: React.MouseEvent, filename: string) => {
53
+ e.stopPropagation()
54
+ await api('DELETE', `/plugins/${encodeURIComponent(filename)}`)
55
+ loadPlugins()
56
+ }
57
+
58
+ const installFromMarketplace = async (p: MarketplacePlugin) => {
59
+ setInstalling(p.id)
60
+ try {
61
+ await api('POST', '/plugins/install', { url: p.url, filename: `${p.id}.js` })
62
+ await loadPlugins()
63
+ } catch { /* ignore */ }
64
+ setInstalling(null)
65
+ }
66
+
67
+ const installedFilenames = new Set(Object.keys(plugins))
68
+
69
+ const tabClass = (t: string) =>
70
+ `py-1.5 px-3.5 rounded-[8px] text-[12px] font-600 cursor-pointer transition-all border
71
+ ${tab === t
72
+ ? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
73
+ : 'bg-transparent border-transparent text-text-3 hover:text-text-2'}`
74
+
75
+ // Marketplace tab content (full-width only)
76
+ const renderMarketplace = () => {
77
+ if (mpLoading) return <p className="text-[12px] text-text-3/70 py-8 text-center">Loading marketplace...</p>
78
+ if (marketplace.length === 0) return <p className="text-[12px] text-text-3/70 py-8 text-center">No plugins available</p>
79
+
80
+ const allTags = Array.from(new Set(marketplace.flatMap((p) => p.tags))).sort()
81
+ const q = search.toLowerCase()
82
+ const filtered = marketplace
83
+ .filter((p) => {
84
+ if (q && !p.name.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q) && !p.tags.some((t) => t.toLowerCase().includes(q))) return false
85
+ if (activeTag && !p.tags.includes(activeTag)) return false
86
+ return true
87
+ })
88
+ .sort((a, b) => sort === 'downloads' ? b.downloads - a.downloads : a.name.localeCompare(b.name))
89
+
90
+ return (
91
+ <div className="space-y-3">
92
+ <input
93
+ value={search}
94
+ onChange={(e) => setSearch(e.target.value)}
95
+ placeholder="Search plugins..."
96
+ className="w-full px-3 py-2.5 rounded-[10px] bg-surface border border-white/[0.06] text-[12px] text-text placeholder:text-text-3/50 outline-none focus:border-accent-bright/30"
97
+ style={{ fontFamily: 'inherit' }}
98
+ />
99
+ <div className="flex items-center gap-1.5 flex-wrap">
28
100
  <button
29
- onClick={() => { setEditingPluginFilename(null); setPluginSheetOpen(true) }}
30
- className="mt-3 px-4 py-2 rounded-[10px] bg-transparent text-accent-bright text-[13px] font-600 cursor-pointer border border-accent-bright/20 hover:bg-accent-soft transition-all"
31
- style={{ fontFamily: 'inherit' }}
101
+ onClick={() => setActiveTag(null)}
102
+ className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${
103
+ !activeTag ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3'
104
+ }`}
32
105
  >
33
- + Add Plugin
106
+ All
34
107
  </button>
35
- </div>
36
- ) : (
37
- <div className="space-y-2">
38
- {pluginList.map((plugin) => (
108
+ {allTags.map((t) => (
39
109
  <button
40
- key={plugin.filename}
41
- onClick={() => handleEdit(plugin.filename)}
42
- className="w-full text-left p-4 rounded-[14px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer"
110
+ key={t}
111
+ onClick={() => setActiveTag(activeTag === t ? null : t)}
112
+ className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${
113
+ activeTag === t ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3'
114
+ }`}
43
115
  >
44
- <div className="flex items-center justify-between mb-1">
45
- <span className="font-display text-[14px] font-600 text-text truncate">{plugin.name}</span>
46
- <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full shrink-0 ml-2 ${plugin.enabled ? 'text-emerald-400 bg-emerald-400/10' : 'text-text-3/50 bg-white/[0.04]'}`}>
47
- {plugin.enabled ? 'Enabled' : 'Disabled'}
48
- </span>
49
- </div>
50
- <div className="text-[11px] font-mono text-text-3/50 mb-1">{plugin.filename}</div>
51
- {plugin.description && (
52
- <p className="text-[12px] text-text-3/60 line-clamp-2">{plugin.description}</p>
53
- )}
116
+ {t}
54
117
  </button>
55
118
  ))}
119
+ <div className="flex-1" />
120
+ <select
121
+ value={sort}
122
+ onChange={(e) => setSort(e.target.value as 'name' | 'downloads')}
123
+ className="px-2 py-1 rounded-[6px] bg-surface border border-white/[0.06] text-[10px] text-text-3 outline-none cursor-pointer appearance-none"
124
+ style={{ fontFamily: 'inherit' }}
125
+ >
126
+ <option value="downloads">Popular</option>
127
+ <option value="name">A-Z</option>
128
+ </select>
129
+ </div>
130
+ {filtered.length === 0 ? (
131
+ <p className="text-[12px] text-text-3/50 text-center py-4">No plugins match your search</p>
132
+ ) : (
133
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
134
+ {filtered.map((p) => {
135
+ const isInstalled = installedFilenames.has(`${p.id}.js`)
136
+ return (
137
+ <div key={p.id} className="py-3.5 px-4 rounded-[14px] bg-surface border border-white/[0.06]">
138
+ <div className="flex items-start gap-3">
139
+ <div className="flex-1 min-w-0">
140
+ <div className="flex items-center gap-2">
141
+ <span className="text-[14px] font-600 text-text">{p.name}</span>
142
+ <span className="text-[10px] font-mono text-text-3/70">v{p.version}</span>
143
+ {p.openclaw && <span className="text-[9px] font-600 text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded-full">OpenClaw</span>}
144
+ </div>
145
+ <div className="text-[11px] text-text-3/60 mt-1 line-clamp-2">{p.description}</div>
146
+ <div className="flex items-center gap-2 mt-2">
147
+ <span className="text-[10px] text-text-3/70">by {p.author}</span>
148
+ <span className="text-[10px] text-text-3/50">&middot;</span>
149
+ {p.tags.slice(0, 3).map((t) => (
150
+ <button
151
+ key={t}
152
+ onClick={() => setActiveTag(activeTag === t ? null : t)}
153
+ className={`text-[9px] font-600 px-1.5 py-0.5 rounded-full cursor-pointer transition-all border-none ${
154
+ activeTag === t ? 'text-accent-bright bg-accent-soft' : 'text-text-3/50 bg-white/[0.04] hover:text-text-3'
155
+ }`}
156
+ >
157
+ {t}
158
+ </button>
159
+ ))}
160
+ </div>
161
+ </div>
162
+ <button
163
+ onClick={() => !isInstalled && installFromMarketplace(p)}
164
+ disabled={isInstalled || installing === p.id}
165
+ className={`shrink-0 py-2 px-4 rounded-[10px] text-[12px] font-600 transition-all cursor-pointer
166
+ ${isInstalled
167
+ ? 'bg-white/[0.04] text-text-3/70 cursor-default'
168
+ : installing === p.id
169
+ ? 'bg-accent-soft text-accent-bright animate-pulse'
170
+ : 'bg-accent-soft text-accent-bright hover:bg-accent-soft/80 border border-accent-bright/20'}`}
171
+ style={{ fontFamily: 'inherit' }}
172
+ >
173
+ {isInstalled ? 'Installed' : installing === p.id ? 'Installing...' : 'Install'}
174
+ </button>
175
+ </div>
176
+ </div>
177
+ )
178
+ })}
179
+ </div>
180
+ )}
181
+ </div>
182
+ )
183
+ }
184
+
185
+ return (
186
+ <div className={`flex-1 overflow-y-auto ${inSidebar ? 'px-3 pb-4' : 'px-5 pb-6'}`}>
187
+ {/* Tabs — full-width only */}
188
+ {!inSidebar && (
189
+ <div className="flex gap-1 mb-4">
190
+ <button onClick={() => setTab('installed')} className={tabClass('installed')} style={{ fontFamily: 'inherit' }}>
191
+ Installed
192
+ </button>
193
+ <button onClick={() => setTab('marketplace')} className={tabClass('marketplace')} style={{ fontFamily: 'inherit' }}>
194
+ Marketplace
195
+ </button>
56
196
  </div>
57
197
  )}
198
+
199
+ {(!inSidebar && tab === 'marketplace') ? renderMarketplace() : (
200
+ pluginList.length === 0 ? (
201
+ <div className="text-center py-12">
202
+ <p className="text-[13px] text-text-3/60">No plugins installed</p>
203
+ <button
204
+ onClick={() => { setEditingPluginFilename(null); setPluginSheetOpen(true) }}
205
+ className="mt-3 px-4 py-2 rounded-[10px] bg-transparent text-accent-bright text-[13px] font-600 cursor-pointer border border-accent-bright/20 hover:bg-accent-soft transition-all"
206
+ style={{ fontFamily: 'inherit' }}
207
+ >
208
+ + Add Plugin
209
+ </button>
210
+ </div>
211
+ ) : (
212
+ <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
213
+ {pluginList.map((plugin) => (
214
+ <button
215
+ key={plugin.filename}
216
+ onClick={() => handleEdit(plugin.filename)}
217
+ className="w-full text-left p-4 rounded-[14px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer"
218
+ >
219
+ <div className="flex items-center justify-between mb-1">
220
+ <span className="font-display text-[14px] font-600 text-text truncate">{plugin.name}</span>
221
+ <div className="flex items-center gap-2 shrink-0 ml-2">
222
+ {!inSidebar ? (
223
+ <>
224
+ <div
225
+ onClick={(e) => handleToggle(e, plugin.filename, plugin.enabled)}
226
+ className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0
227
+ ${plugin.enabled ? 'bg-[#6366F1]' : 'bg-white/[0.08]'}`}
228
+ >
229
+ <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all
230
+ ${plugin.enabled ? 'left-[18px]' : 'left-0.5'}`} />
231
+ </div>
232
+ <button
233
+ onClick={(e) => handleDelete(e, plugin.filename)}
234
+ className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
235
+ title="Delete"
236
+ >
237
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
238
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
239
+ </svg>
240
+ </button>
241
+ </>
242
+ ) : (
243
+ <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${plugin.enabled ? 'text-emerald-400 bg-emerald-400/10' : 'text-text-3/50 bg-white/[0.04]'}`}>
244
+ {plugin.enabled ? 'Enabled' : 'Disabled'}
245
+ </span>
246
+ )}
247
+ </div>
248
+ </div>
249
+ <div className="text-[11px] font-mono text-text-3/50 mb-1">{plugin.filename}</div>
250
+ {plugin.description && (
251
+ <p className="text-[12px] text-text-3/60 line-clamp-2">{plugin.description}</p>
252
+ )}
253
+ </button>
254
+ ))}
255
+ </div>
256
+ )
257
+ )}
58
258
  </div>
59
259
  )
60
260
  }
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { useWs } from '@/hooks/use-ws'
6
+ import { api } from '@/lib/api-client'
5
7
 
6
8
  export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
7
9
  const providers = useAppStore((s) => s.providers)
@@ -19,20 +21,26 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
19
21
  setLoaded(true)
20
22
  }, [loadProviders, loadProviderConfigs, loadCredentials])
21
23
 
22
- useEffect(() => {
23
- const bootstrap = setTimeout(() => { void refresh() }, 0)
24
- const poll = setInterval(() => { void loadProviders() }, 20_000)
25
- return () => {
26
- clearTimeout(bootstrap)
27
- clearInterval(poll)
28
- }
29
- }, [refresh, loadProviders])
24
+ useEffect(() => { void refresh() }, [refresh])
25
+ useWs('providers', loadProviders, 20_000)
30
26
 
31
27
  const handleEdit = (id: string) => {
32
28
  setEditingProviderId(id)
33
29
  setProviderSheetOpen(true)
34
30
  }
35
31
 
32
+ const handleToggle = async (e: React.MouseEvent, id: string, currentEnabled: boolean) => {
33
+ e.stopPropagation()
34
+ await api('PUT', `/providers/${id}`, { isEnabled: !currentEnabled })
35
+ await loadProviderConfigs()
36
+ }
37
+
38
+ const handleDelete = async (e: React.MouseEvent, id: string) => {
39
+ e.stopPropagation()
40
+ await api('DELETE', `/providers/${id}`)
41
+ await loadProviderConfigs()
42
+ }
43
+
36
44
  // Merge built-in providers with custom configs
37
45
  const builtinItems = providers.map((p) => ({
38
46
  id: p.id,
@@ -58,15 +66,15 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
58
66
 
59
67
  if (!loaded) {
60
68
  return (
61
- <div className={`flex-1 flex items-center justify-center ${inSidebar ? 'px-3 pb-4' : 'px-4'}`}>
69
+ <div className={`flex-1 flex items-center justify-center ${inSidebar ? 'px-3 pb-4' : 'px-5'}`}>
62
70
  <p className="text-[13px] text-text-3">Loading providers...</p>
63
71
  </div>
64
72
  )
65
73
  }
66
74
 
67
75
  return (
68
- <div className={`flex-1 overflow-y-auto ${inSidebar ? 'px-3 pb-4' : 'px-4'}`}>
69
- <div className="space-y-2">
76
+ <div className={`flex-1 overflow-y-auto ${inSidebar ? 'px-3 pb-4' : 'px-5 pb-6'}`}>
77
+ <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
70
78
  {allItems.map((item) => (
71
79
  <button
72
80
  key={item.id}
@@ -81,12 +89,37 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
81
89
  ${item.type === 'builtin' ? 'bg-white/[0.04] text-text-3' : 'bg-[#6366F1]/10 text-[#6366F1]'}`}>
82
90
  {item.type === 'builtin' ? 'Built-in' : 'Custom'}
83
91
  </span>
92
+ {!inSidebar && item.type === 'custom' && (
93
+ <>
94
+ <div
95
+ onClick={(e) => handleToggle(e, item.id, item.isEnabled)}
96
+ className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0
97
+ ${item.isEnabled ? 'bg-[#6366F1]' : 'bg-white/[0.08]'}`}
98
+ >
99
+ <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all
100
+ ${item.isEnabled ? 'left-[18px]' : 'left-0.5'}`} />
101
+ </div>
102
+ <button
103
+ onClick={(e) => handleDelete(e, item.id)}
104
+ className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
105
+ title="Delete provider"
106
+ >
107
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
108
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
109
+ </svg>
110
+ </button>
111
+ </>
112
+ )}
84
113
  <span className={`w-2 h-2 rounded-full ${item.isConnected ? 'bg-emerald-400' : 'bg-white/10'}`} />
85
114
  </div>
86
115
  </div>
87
116
  <div className="text-[12px] text-text-3/60 font-mono truncate">
88
- {item.models.slice(0, 3).join(', ')}
89
- {item.models.length > 3 && ` +${item.models.length - 3}`}
117
+ {!inSidebar ? item.models.join(', ') : (
118
+ <>
119
+ {item.models.slice(0, 3).join(', ')}
120
+ {item.models.length > 3 && ` +${item.models.length - 3}`}
121
+ </>
122
+ )}
90
123
  </div>
91
124
  </button>
92
125
  ))}
@@ -5,7 +5,6 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { createProviderConfig, updateProviderConfig, deleteProviderConfig } from '@/lib/provider-config'
6
6
  import { api } from '@/lib/api-client'
7
7
  import { BottomSheet } from '@/components/shared/bottom-sheet'
8
- import { AiGenBlock } from '@/components/shared/ai-gen-block'
9
8
  import { toast } from 'sonner'
10
9
 
11
10
  export function ProviderSheet() {
@@ -41,51 +40,15 @@ export function ProviderSheet() {
41
40
  const [localLoading, setLocalLoading] = useState(false)
42
41
  const [localError, setLocalError] = useState('')
43
42
 
44
- // AI generation state
45
- const [aiPrompt, setAiPrompt] = useState('')
46
- const [generating, setGenerating] = useState(false)
47
- const [generated, setGenerated] = useState(false)
48
- const [genError, setGenError] = useState('')
49
- const appSettings = useAppStore((s) => s.appSettings)
50
- const loadSettings = useAppStore((s) => s.loadSettings)
51
-
52
43
  // Find editing provider in custom configs OR built-in list
53
44
  const editingCustom = editingId ? providerConfigs.find((c) => c.id === editingId) : null
54
45
  const editingBuiltin = editingId ? providers.find((p) => p.id === editingId) : null
55
46
  const isBuiltin = !!editingBuiltin && !editingCustom
56
47
  const editing = editingCustom || editingBuiltin
57
48
 
58
- const handleGenerate = async () => {
59
- if (!aiPrompt.trim()) return
60
- setGenerating(true)
61
- setGenError('')
62
- try {
63
- const result = await api<{ name?: string; baseUrl?: string; models?: string; requiresApiKey?: boolean; error?: string }>('POST', '/generate', { type: 'provider', prompt: aiPrompt })
64
- if (result.error) {
65
- setGenError(result.error)
66
- } else if (result.name || result.baseUrl) {
67
- if (result.name) setName(result.name)
68
- if (result.baseUrl) setBaseUrl(result.baseUrl)
69
- if (result.models) setModels(result.models)
70
- if (result.requiresApiKey !== undefined) setRequiresApiKey(result.requiresApiKey)
71
- setGenerated(true)
72
- } else {
73
- setGenError('AI returned empty response — try again')
74
- }
75
- } catch (err: unknown) {
76
- setGenError(err instanceof Error ? err.message : 'Generation failed')
77
- }
78
- setGenerating(false)
79
- }
80
-
81
49
  useEffect(() => {
82
50
  if (open) {
83
51
  loadCredentials()
84
- loadSettings()
85
- setAiPrompt('')
86
- setGenerating(false)
87
- setGenerated(false)
88
- setGenError('')
89
52
  setNewModel('')
90
53
  setLocalModels([])
91
54
  setLocalError('')
@@ -248,14 +211,6 @@ export function ProviderSheet() {
248
211
  </p>
249
212
  </div>
250
213
 
251
- {/* AI Generation — only for new custom providers */}
252
- {isNew && <AiGenBlock
253
- aiPrompt={aiPrompt} setAiPrompt={setAiPrompt}
254
- generating={generating} generated={generated} genError={genError}
255
- onGenerate={handleGenerate} appSettings={appSettings}
256
- placeholder='Name a provider, e.g. "Groq", "Together AI", "z.ai", "DeepSeek"'
257
- />}
258
-
259
214
  {/* Name */}
260
215
  <div className="mb-8">
261
216
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Name</label>
@@ -1,7 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState, useRef, useCallback } from 'react'
3
+ import { useEffect, useState, useCallback } from 'react'
4
4
  import { api } from '@/lib/api-client'
5
+ import { useWs } from '@/hooks/use-ws'
5
6
  import { BottomSheet } from '@/components/shared/bottom-sheet'
6
7
  import type { SessionRunRecord, SessionRunStatus } from '@/types'
7
8
 
@@ -37,7 +38,6 @@ export function RunList() {
37
38
  const [autoRefresh, setAutoRefresh] = useState(false)
38
39
  const [statusFilter, setStatusFilter] = useState<SessionRunStatus | null>(null)
39
40
  const [selected, setSelected] = useState<SessionRunRecord | null>(null)
40
- const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
41
41
 
42
42
  const fetchRuns = useCallback(async () => {
43
43
  try {
@@ -54,16 +54,7 @@ export function RunList() {
54
54
  fetchRuns()
55
55
  }, [fetchRuns])
56
56
 
57
- useEffect(() => {
58
- if (!autoRefresh) {
59
- if (timerRef.current) clearInterval(timerRef.current)
60
- return
61
- }
62
- timerRef.current = setInterval(fetchRuns, 3000)
63
- return () => {
64
- if (timerRef.current) clearInterval(timerRef.current)
65
- }
66
- }, [autoRefresh, fetchRuns])
57
+ useWs('runs', fetchRuns, autoRefresh ? 3000 : undefined)
67
58
 
68
59
  const filtered = statusFilter ? runs.filter((r) => r.status === statusFilter) : runs
69
60
 
@@ -78,7 +69,7 @@ export function RunList() {
78
69
  return (
79
70
  <div className="flex-1 flex flex-col overflow-hidden">
80
71
  {/* Controls */}
81
- <div className="px-4 py-2 space-y-2 shrink-0">
72
+ <div className="px-5 py-2 space-y-2 shrink-0">
82
73
  {/* Status filter + auto-refresh */}
83
74
  <div className="flex items-center gap-1.5 flex-wrap">
84
75
  <button
@@ -113,12 +104,12 @@ export function RunList() {
113
104
  </div>
114
105
 
115
106
  {/* Count */}
116
- <div className="px-4 py-1 text-[10px] text-text-3/60">
107
+ <div className="px-5 py-1 text-[10px] text-text-3/60">
117
108
  {filtered.length} run{filtered.length !== 1 ? 's' : ''}
118
109
  </div>
119
110
 
120
111
  {/* Run list */}
121
- <div className="flex-1 overflow-y-auto px-2 pb-20">
112
+ <div className="flex-1 overflow-y-auto px-4 pb-8">
122
113
  {filtered.length === 0 ? (
123
114
  <div className="flex items-center justify-center h-32 text-text-3 text-[12px]">
124
115
  No runs found
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { Schedule } from '@/types'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { api } from '@/lib/api-client'
5
6
 
6
7
  const STATUS_COLORS: Record<string, string> = {
7
8
  active: 'text-emerald-400 bg-emerald-400/[0.08]',
@@ -24,11 +25,13 @@ function formatNext(ts?: number): string {
24
25
 
25
26
  interface Props {
26
27
  schedule: Schedule
28
+ inSidebar?: boolean
27
29
  }
28
30
 
29
- export function ScheduleCard({ schedule }: Props) {
31
+ export function ScheduleCard({ schedule, inSidebar }: Props) {
30
32
  const setEditingScheduleId = useAppStore((s) => s.setEditingScheduleId)
31
33
  const setScheduleSheetOpen = useAppStore((s) => s.setScheduleSheetOpen)
34
+ const loadSchedules = useAppStore((s) => s.loadSchedules)
32
35
  const agents = useAppStore((s) => s.agents)
33
36
 
34
37
  const handleClick = () => {
@@ -36,8 +39,22 @@ export function ScheduleCard({ schedule }: Props) {
36
39
  setScheduleSheetOpen(true)
37
40
  }
38
41
 
42
+ const handleToggle = async (e: React.MouseEvent) => {
43
+ e.stopPropagation()
44
+ const newStatus = schedule.status === 'active' ? 'paused' : 'active'
45
+ await api('PUT', `/schedules/${schedule.id}`, { status: newStatus })
46
+ loadSchedules()
47
+ }
48
+
49
+ const handleDelete = async (e: React.MouseEvent) => {
50
+ e.stopPropagation()
51
+ await api('DELETE', `/schedules/${schedule.id}`)
52
+ loadSchedules()
53
+ }
54
+
39
55
  const agent = agents[schedule.agentId]
40
56
  const statusClass = STATUS_COLORS[schedule.status] || STATUS_COLORS.paused
57
+ const canToggle = schedule.status === 'active' || schedule.status === 'paused'
41
58
 
42
59
  return (
43
60
  <div
@@ -48,12 +65,45 @@ export function ScheduleCard({ schedule }: Props) {
48
65
  >
49
66
  <div className="flex items-center gap-2.5">
50
67
  <span className="font-display text-[14px] font-600 truncate flex-1 tracking-[-0.01em]">{schedule.name}</span>
51
- <span className={`shrink-0 text-[10px] font-600 uppercase tracking-wider px-2 py-0.5 rounded-[6px] ${statusClass}`}>
52
- {schedule.status}
53
- </span>
68
+ <div className="flex items-center gap-2 shrink-0">
69
+ {!inSidebar && canToggle && (
70
+ <div
71
+ onClick={handleToggle}
72
+ className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0
73
+ ${schedule.status === 'active' ? 'bg-[#6366F1]' : 'bg-white/[0.08]'}`}
74
+ >
75
+ <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all
76
+ ${schedule.status === 'active' ? 'left-[18px]' : 'left-0.5'}`} />
77
+ </div>
78
+ )}
79
+ <span className={`text-[10px] font-600 uppercase tracking-wider px-2 py-0.5 rounded-[6px] ${statusClass}`}>
80
+ {schedule.status}
81
+ </span>
82
+ {!inSidebar && (
83
+ <button
84
+ onClick={handleDelete}
85
+ className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
86
+ title="Delete"
87
+ >
88
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
89
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
90
+ </svg>
91
+ </button>
92
+ )}
93
+ </div>
54
94
  </div>
55
95
  <div className="text-[12px] text-text-3/70 mt-1.5 truncate">
56
96
  {agent?.name || 'Unknown agent'} &middot; {schedule.scheduleType}
97
+ {!inSidebar && schedule.scheduleType === 'cron' && schedule.cron && (
98
+ <span className="font-mono text-text-3/50 ml-1">({schedule.cron})</span>
99
+ )}
100
+ {!inSidebar && schedule.scheduleType === 'interval' && schedule.intervalMs && (
101
+ <span className="text-text-3/50 ml-1">
102
+ (every {schedule.intervalMs >= 3600000
103
+ ? `${Math.round(schedule.intervalMs / 3600000)}h`
104
+ : `${Math.round(schedule.intervalMs / 60000)}m`})
105
+ </span>
106
+ )}
57
107
  </div>
58
108
  <div className="text-[11px] text-text-3/60 mt-1">
59
109
  Next: {formatNext(schedule.nextRunAt)}
@@ -54,7 +54,7 @@ export function ScheduleList({ inSidebar }: Props) {
54
54
  return (
55
55
  <div className="flex-1 overflow-y-auto">
56
56
  {(filtered.length > 3 || search) && (
57
- <div className="px-4 py-2.5">
57
+ <div className={inSidebar ? 'px-4 py-2.5' : 'px-5 py-2.5'}>
58
58
  <input
59
59
  type="text"
60
60
  value={search}
@@ -66,9 +66,12 @@ export function ScheduleList({ inSidebar }: Props) {
66
66
  />
67
67
  </div>
68
68
  )}
69
- <div className="flex flex-col gap-1 px-2 pb-4">
69
+ <div className={inSidebar
70
+ ? 'flex flex-col gap-1 px-2 pb-4'
71
+ : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3 px-5 pb-6'
72
+ }>
70
73
  {filtered.map((s) => (
71
- <ScheduleCard key={s.id} schedule={s} />
74
+ <ScheduleCard key={s.id} schedule={s} inSidebar={inSidebar} />
72
75
  ))}
73
76
  </div>
74
77
  </div>