@swarmclawai/swarmclaw 0.5.2 → 0.6.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 (173) hide show
  1. package/README.md +42 -7
  2. package/bin/swarmclaw.js +76 -16
  3. package/next.config.ts +11 -1
  4. package/package.json +4 -2
  5. package/public/screenshots/agents.png +0 -0
  6. package/public/screenshots/dashboard.png +0 -0
  7. package/public/screenshots/providers.png +0 -0
  8. package/public/screenshots/tasks.png +0 -0
  9. package/scripts/postinstall.mjs +18 -0
  10. package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
  11. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  12. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  13. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  14. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  15. package/src/app/api/chatrooms/route.ts +50 -0
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/knowledge/[id]/route.ts +13 -2
  18. package/src/app/api/knowledge/route.ts +8 -1
  19. package/src/app/api/memory/route.ts +8 -0
  20. package/src/app/api/notifications/[id]/route.ts +27 -0
  21. package/src/app/api/notifications/route.ts +68 -0
  22. package/src/app/api/orchestrator/run/route.ts +1 -1
  23. package/src/app/api/plugins/install/route.ts +2 -2
  24. package/src/app/api/search/route.ts +155 -0
  25. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  26. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  27. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  28. package/src/app/api/sessions/route.ts +3 -3
  29. package/src/app/api/settings/route.ts +9 -0
  30. package/src/app/api/setup/check-provider/route.ts +3 -16
  31. package/src/app/api/skills/[id]/route.ts +6 -0
  32. package/src/app/api/skills/route.ts +6 -0
  33. package/src/app/api/tasks/[id]/route.ts +20 -0
  34. package/src/app/api/tasks/bulk/route.ts +100 -0
  35. package/src/app/api/tasks/route.ts +1 -0
  36. package/src/app/api/usage/route.ts +45 -0
  37. package/src/app/api/webhooks/[id]/route.ts +15 -1
  38. package/src/app/globals.css +58 -15
  39. package/src/app/page.tsx +142 -13
  40. package/src/cli/index.js +42 -0
  41. package/src/cli/index.test.js +30 -0
  42. package/src/cli/spec.js +32 -0
  43. package/src/components/agents/agent-avatar.tsx +57 -10
  44. package/src/components/agents/agent-card.tsx +48 -15
  45. package/src/components/agents/agent-chat-list.tsx +123 -10
  46. package/src/components/agents/agent-list.tsx +50 -19
  47. package/src/components/agents/agent-sheet.tsx +56 -63
  48. package/src/components/auth/access-key-gate.tsx +10 -3
  49. package/src/components/auth/setup-wizard.tsx +2 -2
  50. package/src/components/auth/user-picker.tsx +31 -3
  51. package/src/components/chat/activity-moment.tsx +169 -0
  52. package/src/components/chat/chat-header.tsx +2 -0
  53. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  54. package/src/components/chat/file-path-chip.tsx +125 -0
  55. package/src/components/chat/markdown-utils.ts +9 -0
  56. package/src/components/chat/message-bubble.tsx +46 -295
  57. package/src/components/chat/message-list.tsx +50 -1
  58. package/src/components/chat/streaming-bubble.tsx +36 -46
  59. package/src/components/chat/suggestions-bar.tsx +1 -1
  60. package/src/components/chat/thinking-indicator.tsx +72 -10
  61. package/src/components/chat/tool-call-bubble.tsx +66 -70
  62. package/src/components/chat/tool-request-banner.tsx +31 -7
  63. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  64. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  65. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  66. package/src/components/chatrooms/chatroom-list.tsx +123 -0
  67. package/src/components/chatrooms/chatroom-message.tsx +427 -0
  68. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  69. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  70. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  71. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  72. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  73. package/src/components/connectors/connector-sheet.tsx +34 -47
  74. package/src/components/home/home-view.tsx +501 -0
  75. package/src/components/input/chat-input.tsx +79 -41
  76. package/src/components/knowledge/knowledge-list.tsx +31 -1
  77. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  78. package/src/components/layout/app-layout.tsx +209 -83
  79. package/src/components/layout/mobile-header.tsx +2 -0
  80. package/src/components/layout/update-banner.tsx +2 -2
  81. package/src/components/logs/log-list.tsx +2 -2
  82. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  83. package/src/components/memory/memory-agent-list.tsx +143 -0
  84. package/src/components/memory/memory-browser.tsx +205 -0
  85. package/src/components/memory/memory-card.tsx +34 -7
  86. package/src/components/memory/memory-detail.tsx +359 -120
  87. package/src/components/memory/memory-sheet.tsx +157 -23
  88. package/src/components/plugins/plugin-list.tsx +1 -1
  89. package/src/components/plugins/plugin-sheet.tsx +1 -1
  90. package/src/components/projects/project-detail.tsx +509 -0
  91. package/src/components/projects/project-list.tsx +195 -59
  92. package/src/components/providers/provider-list.tsx +2 -2
  93. package/src/components/providers/provider-sheet.tsx +3 -3
  94. package/src/components/schedules/schedule-card.tsx +3 -2
  95. package/src/components/schedules/schedule-list.tsx +1 -1
  96. package/src/components/schedules/schedule-sheet.tsx +25 -25
  97. package/src/components/secrets/secret-sheet.tsx +47 -24
  98. package/src/components/secrets/secrets-list.tsx +18 -8
  99. package/src/components/sessions/new-session-sheet.tsx +33 -65
  100. package/src/components/sessions/session-card.tsx +45 -14
  101. package/src/components/sessions/session-list.tsx +35 -18
  102. package/src/components/shared/agent-picker-list.tsx +90 -0
  103. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  104. package/src/components/shared/attachment-chip.tsx +165 -0
  105. package/src/components/shared/avatar.tsx +10 -1
  106. package/src/components/shared/check-icon.tsx +12 -0
  107. package/src/components/shared/confirm-dialog.tsx +1 -1
  108. package/src/components/shared/empty-state.tsx +32 -0
  109. package/src/components/shared/file-preview.tsx +34 -0
  110. package/src/components/shared/form-styles.ts +2 -0
  111. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  112. package/src/components/shared/notification-center.tsx +223 -0
  113. package/src/components/shared/profile-sheet.tsx +115 -0
  114. package/src/components/shared/reply-quote.tsx +26 -0
  115. package/src/components/shared/search-dialog.tsx +296 -0
  116. package/src/components/shared/section-label.tsx +12 -0
  117. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  118. package/src/components/shared/settings/section-providers.tsx +1 -1
  119. package/src/components/shared/settings/section-secrets.tsx +1 -1
  120. package/src/components/shared/settings/section-theme.tsx +95 -0
  121. package/src/components/shared/settings/section-user-preferences.tsx +39 -0
  122. package/src/components/shared/settings/settings-page.tsx +180 -27
  123. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  124. package/src/components/shared/sheet-footer.tsx +33 -0
  125. package/src/components/skills/skill-list.tsx +61 -30
  126. package/src/components/skills/skill-sheet.tsx +81 -2
  127. package/src/components/tasks/task-board.tsx +448 -26
  128. package/src/components/tasks/task-card.tsx +46 -9
  129. package/src/components/tasks/task-column.tsx +62 -3
  130. package/src/components/tasks/task-list.tsx +12 -4
  131. package/src/components/tasks/task-sheet.tsx +89 -72
  132. package/src/components/ui/hover-card.tsx +52 -0
  133. package/src/components/usage/metrics-dashboard.tsx +78 -0
  134. package/src/components/usage/usage-list.tsx +1 -1
  135. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  136. package/src/hooks/use-view-router.ts +69 -19
  137. package/src/instrumentation.ts +15 -1
  138. package/src/lib/chat.ts +2 -0
  139. package/src/lib/cron-human.ts +114 -0
  140. package/src/lib/memory.ts +3 -0
  141. package/src/lib/server/chat-execution.ts +24 -4
  142. package/src/lib/server/connectors/manager.ts +11 -0
  143. package/src/lib/server/context-manager.ts +225 -13
  144. package/src/lib/server/create-notification.ts +42 -0
  145. package/src/lib/server/daemon-state.ts +165 -10
  146. package/src/lib/server/execution-log.ts +1 -0
  147. package/src/lib/server/heartbeat-service.ts +40 -5
  148. package/src/lib/server/heartbeat-wake.ts +110 -0
  149. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  150. package/src/lib/server/memory-consolidation.ts +92 -0
  151. package/src/lib/server/memory-db.ts +51 -6
  152. package/src/lib/server/openclaw-gateway.ts +9 -1
  153. package/src/lib/server/provider-health.ts +125 -0
  154. package/src/lib/server/queue.ts +5 -4
  155. package/src/lib/server/scheduler.ts +8 -0
  156. package/src/lib/server/session-run-manager.ts +4 -0
  157. package/src/lib/server/session-tools/chatroom.ts +136 -0
  158. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  159. package/src/lib/server/session-tools/index.ts +2 -0
  160. package/src/lib/server/session-tools/memory.ts +6 -1
  161. package/src/lib/server/storage.ts +80 -29
  162. package/src/lib/server/stream-agent-chat.ts +153 -47
  163. package/src/lib/server/system-events.ts +49 -0
  164. package/src/lib/server/ws-hub.ts +11 -0
  165. package/src/lib/soul-suggestions.ts +109 -0
  166. package/src/lib/tasks.ts +4 -1
  167. package/src/lib/view-routes.ts +36 -1
  168. package/src/lib/ws-client.ts +14 -4
  169. package/src/proxy.ts +79 -2
  170. package/src/stores/use-app-store.ts +94 -3
  171. package/src/stores/use-chat-store.ts +48 -3
  172. package/src/stores/use-chatroom-store.ts +276 -0
  173. package/src/types/index.ts +69 -2
@@ -3,6 +3,7 @@
3
3
  import { useEffect, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { BottomSheet } from '@/components/shared/bottom-sheet'
6
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
6
7
  import { api } from '@/lib/api-client'
7
8
 
8
9
  const inputClass = 'w-full px-4 py-3 rounded-[14px] bg-bg border border-white/[0.06] text-text text-[14px] outline-none focus:border-accent-bright/40 transition-colors placeholder:text-text-3/70'
@@ -25,7 +26,7 @@ export function SecretSheet() {
25
26
  const [saving, setSaving] = useState(false)
26
27
 
27
28
  const editing = editingId ? secrets[editingId] : null
28
- const orchestrators = Object.values(agents).filter((p) => p.isOrchestrator)
29
+ const agentList = Object.values(agents)
29
30
 
30
31
  useEffect(() => {
31
32
  if (open) loadAgents()
@@ -74,8 +75,8 @@ export function SecretSheet() {
74
75
  }
75
76
  await loadSecrets()
76
77
  handleClose()
77
- } catch (err: any) {
78
- console.error('Failed to save secret:', err.message)
78
+ } catch (err: unknown) {
79
+ console.error('Failed to save secret:', err instanceof Error ? err.message : String(err))
79
80
  } finally {
80
81
  setSaving(false)
81
82
  }
@@ -87,11 +88,21 @@ export function SecretSheet() {
87
88
  await api('DELETE', `/secrets/${editing.id}`)
88
89
  await loadSecrets()
89
90
  handleClose()
90
- } catch (err: any) {
91
- console.error('Failed to delete secret:', err.message)
91
+ } catch (err: unknown) {
92
+ console.error('Failed to delete secret:', err instanceof Error ? err.message : String(err))
92
93
  }
93
94
  }
94
95
 
96
+ const toggleAgent = (id: string) => {
97
+ setAgentIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])
98
+ }
99
+
100
+ const scopeHelperText = scope === 'global'
101
+ ? 'This secret will be accessible to all agents'
102
+ : agentIds.length === 0
103
+ ? 'Select which agents can access this secret'
104
+ : `${agentIds.length} agent(s) selected`
105
+
95
106
  return (
96
107
  <BottomSheet open={open} onClose={handleClose}>
97
108
  <div className="space-y-5">
@@ -125,30 +136,42 @@ export function SecretSheet() {
125
136
  }`}
126
137
  style={{ fontFamily: 'inherit' }}
127
138
  >
128
- {s === 'global' ? 'All Orchestrators' : 'Specific'}
139
+ {s === 'global' ? 'Global' : 'Specific'}
129
140
  </button>
130
141
  ))}
131
142
  </div>
143
+ <p className="text-[11px] text-text-3/60 mt-1.5 pl-1">{scopeHelperText}</p>
132
144
  </div>
133
145
 
134
- {scope === 'agent' && orchestrators.length > 0 && (
146
+ {scope === 'agent' && (
135
147
  <div>
136
- <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Orchestrators</label>
137
- <div className="flex flex-wrap gap-2">
138
- {orchestrators.map((p) => (
139
- <button
140
- key={p.id}
141
- onClick={() => setAgentIds((prev) => prev.includes(p.id) ? prev.filter((x) => x !== p.id) : [...prev, p.id])}
142
- className={`px-3 py-2 rounded-[10px] text-[12px] font-600 cursor-pointer transition-all border ${
143
- agentIds.includes(p.id)
144
- ? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
145
- : 'bg-bg border-white/[0.06] text-text-3 hover:text-text-2'
146
- }`}
147
- style={{ fontFamily: 'inherit' }}
148
- >
149
- {p.name}
150
- </button>
151
- ))}
148
+ <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Agents</label>
149
+ <div className="max-h-[240px] overflow-y-auto rounded-[12px] border border-white/[0.06] bg-white/[0.03]">
150
+ {agentList.length === 0 ? (
151
+ <p className="p-3 text-[12px] text-text-3">No agents available</p>
152
+ ) : (
153
+ agentList.map((agent) => {
154
+ const selected = agentIds.includes(agent.id)
155
+ return (
156
+ <button
157
+ key={agent.id}
158
+ onClick={() => toggleAgent(agent.id)}
159
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-all cursor-pointer ${
160
+ selected ? 'bg-accent-soft/40' : 'hover:bg-white/[0.04]'
161
+ }`}
162
+ style={{ fontFamily: 'inherit' }}
163
+ >
164
+ <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
165
+ <span className="text-[13px] text-text flex-1 truncate">{agent.name}</span>
166
+ {selected && (
167
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-accent-bright shrink-0">
168
+ <polyline points="20 6 9 17 4 12" />
169
+ </svg>
170
+ )}
171
+ </button>
172
+ )
173
+ })
174
+ )}
152
175
  </div>
153
176
  </div>
154
177
  )}
@@ -168,7 +191,7 @@ export function SecretSheet() {
168
191
  <button
169
192
  onClick={handleSave}
170
193
  disabled={saving || !name.trim() || (!editing && !value.trim())}
171
- className="px-8 py-3 rounded-[14px] border-none bg-[#6366F1] text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
194
+ className="px-8 py-3 rounded-[14px] border-none bg-accent-bright text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
172
195
  style={{ fontFamily: 'inherit' }}
173
196
  >
174
197
  {saving ? 'Saving...' : editing ? 'Update' : 'Save'}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
5
6
  import { api } from '@/lib/api-client'
6
7
 
7
8
  interface Props {
@@ -37,7 +38,7 @@ export function SecretsList({ inSidebar }: Props) {
37
38
  </svg>
38
39
  </div>
39
40
  <p className="text-[13px] text-text-3 mb-1 font-600">No secrets yet</p>
40
- <p className="text-[12px] text-text-3/60">Add API keys & credentials for orchestrators</p>
41
+ <p className="text-[12px] text-text-3/60">Add API keys & credentials for your agents</p>
41
42
  <button
42
43
  onClick={() => { setEditingSecretId(null); setSecretSheetOpen(true) }}
43
44
  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"
@@ -54,11 +55,11 @@ export function SecretsList({ inSidebar }: Props) {
54
55
  <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
55
56
  {secretList.map((secret) => {
56
57
  const scopeLabel = secret.scope === 'global'
57
- ? 'All orchestrators'
58
- : `${secret.agentIds.length} orchestrator(s)`
59
- const scopedNames = secret.scope === 'agent'
60
- ? secret.agentIds.map((id) => agents[id]?.name).filter(Boolean).join(', ')
61
- : null
58
+ ? 'Global'
59
+ : `${secret.agentIds.length} agent(s)`
60
+ const scopedAgents = secret.scope === 'agent'
61
+ ? secret.agentIds.map((id) => agents[id]).filter(Boolean)
62
+ : []
62
63
  return (
63
64
  <button
64
65
  key={secret.id}
@@ -97,8 +98,17 @@ export function SecretsList({ inSidebar }: Props) {
97
98
  {scopeLabel}
98
99
  </span>
99
100
  </div>
100
- {scopedNames && (
101
- <div className="text-[10px] text-text-3/50 mt-1 pl-[22px] truncate">{scopedNames}</div>
101
+ {scopedAgents.length > 0 && (
102
+ <div className="flex items-center gap-1.5 mt-1.5 pl-[22px]">
103
+ <div className="flex items-center -space-x-1.5">
104
+ {scopedAgents.slice(0, 5).map((agent) => (
105
+ <AgentAvatar key={agent.id} seed={agent.avatarSeed} name={agent.name} size={16} className="ring-1 ring-surface" />
106
+ ))}
107
+ </div>
108
+ {scopedAgents.length > 5 && (
109
+ <span className="text-[10px] font-600 text-text-3/60 ml-0.5">+{scopedAgents.length - 5}</span>
110
+ )}
111
+ </div>
102
112
  )}
103
113
  </button>
104
114
  )
@@ -8,7 +8,11 @@ import { BottomSheet } from '@/components/shared/bottom-sheet'
8
8
  import { DirBrowser } from '@/components/shared/dir-browser'
9
9
  import { TOOL_LABELS, TOOL_DESCRIPTIONS } from '@/components/chat/tool-call-bubble'
10
10
  import { ModelCombobox } from '@/components/shared/model-combobox'
11
+ import { AgentPickerList } from '@/components/shared/agent-picker-list'
12
+ import { SheetFooter } from '@/components/shared/sheet-footer'
13
+ import { inputClass } from '@/components/shared/form-styles'
11
14
  import type { ProviderType, SessionTool } from '@/types'
15
+ import { SectionLabel } from '@/components/shared/section-label'
12
16
 
13
17
  export function NewSessionSheet() {
14
18
  const open = useAppStore((s) => s.newSessionOpen)
@@ -61,14 +65,14 @@ export function NewSessionSheet() {
61
65
  // Auto-select last used agent, or default agent if no history
62
66
  const agentsList = Object.values(agents)
63
67
  const lastAgentId = typeof window !== 'undefined' ? localStorage.getItem('swarmclaw-last-agent') : null
64
- const lastAgent = lastAgentId ? agentsList.find((a: any) => a.id === lastAgentId) : null
65
- const defaultAgent = lastAgent || agentsList.find((a: any) => a.id === 'default') || agentsList[0]
68
+ const lastAgent = lastAgentId ? agentsList.find((a) => a.id === lastAgentId) : null
69
+ const defaultAgent = lastAgent || agentsList.find((a) => a.id === 'default') || agentsList[0]
66
70
  if (defaultAgent) {
67
- setSelectedAgentId((defaultAgent as any).id)
68
- setProvider((defaultAgent as any).provider || 'claude-cli')
69
- setModel((defaultAgent as any).model || '')
70
- setCredentialId((defaultAgent as any).credentialId || null)
71
- if ((defaultAgent as any).apiEndpoint) setEndpoint((defaultAgent as any).apiEndpoint)
71
+ setSelectedAgentId(defaultAgent.id)
72
+ setProvider(defaultAgent.provider || 'claude-cli')
73
+ setModel(defaultAgent.model || '')
74
+ setCredentialId(defaultAgent.credentialId || null)
75
+ if (defaultAgent.apiEndpoint) setEndpoint(defaultAgent.apiEndpoint)
72
76
  } else {
73
77
  setSelectedAgentId(null)
74
78
  }
@@ -168,8 +172,6 @@ export function NewSessionSheet() {
168
172
  return true
169
173
  }
170
174
 
171
- const inputClass = "w-full 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"
172
-
173
175
  return (
174
176
  <BottomSheet open={open} onClose={onClose} wide>
175
177
  {/* Header */}
@@ -180,9 +182,7 @@ export function NewSessionSheet() {
180
182
 
181
183
  {/* Name */}
182
184
  <div className="mb-8">
183
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
184
- Chat Name
185
- </label>
185
+ <SectionLabel>Chat Name</SectionLabel>
186
186
  <input
187
187
  type="text"
188
188
  value={name}
@@ -196,20 +196,14 @@ export function NewSessionSheet() {
196
196
  {/* Agent (optional) */}
197
197
  {Object.keys(agents).length > 0 && (
198
198
  <div className="mb-8">
199
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
200
- Agent <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
201
- </label>
202
- <select
203
- value={selectedAgentId || ''}
204
- onChange={(e) => handleSelectAgent(e.target.value || null)}
205
- className={`${inputClass} appearance-none cursor-pointer`}
206
- style={{ fontFamily: 'inherit' }}
207
- >
208
- <option value="">None — manual configuration</option>
209
- {Object.values(agents).map((p) => (
210
- <option key={p.id} value={p.id}>{p.name}{p.isOrchestrator ? ' (Orchestrator)' : ''}</option>
211
- ))}
212
- </select>
199
+ <SectionLabel>Agent <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel>
200
+ <AgentPickerList
201
+ agents={Object.values(agents).sort((a, b) => a.name.localeCompare(b.name))}
202
+ selected={selectedAgentId || ''}
203
+ onSelect={(id) => handleSelectAgent(id)}
204
+ noneOption={{ label: 'None — manual config', onSelect: () => handleSelectAgent(null) }}
205
+ showOrchBadge={true}
206
+ />
213
207
  </div>
214
208
  )}
215
209
 
@@ -218,9 +212,7 @@ export function NewSessionSheet() {
218
212
  <>
219
213
  {/* Provider */}
220
214
  <div className="mb-8">
221
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
222
- Provider
223
- </label>
215
+ <SectionLabel>Provider</SectionLabel>
224
216
  <div className="grid grid-cols-3 gap-3">
225
217
  {providers.map((p) => (
226
218
  <button
@@ -242,9 +234,7 @@ export function NewSessionSheet() {
242
234
  {/* Ollama Mode Toggle */}
243
235
  {provider === 'ollama' && (
244
236
  <div className="mb-8">
245
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
246
- Mode
247
- </label>
237
+ <SectionLabel>Mode</SectionLabel>
248
238
  <div className="flex p-1 rounded-[14px] bg-surface border border-white/[0.06]">
249
239
  {(['local', 'cloud'] as const).map((mode) => (
250
240
  <button
@@ -267,9 +257,7 @@ export function NewSessionSheet() {
267
257
  {/* Model */}
268
258
  {currentProvider && currentProvider.models.length > 0 && (
269
259
  <div className="mb-8">
270
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
271
- Model
272
- </label>
260
+ <SectionLabel>Model</SectionLabel>
273
261
  <ModelCombobox
274
262
  providerId={currentProvider.id}
275
263
  value={model}
@@ -284,9 +272,7 @@ export function NewSessionSheet() {
284
272
  {/* API Key */}
285
273
  {(currentProvider?.requiresApiKey || (currentProvider?.optionalApiKey && ollamaMode === 'cloud')) && (
286
274
  <div className="mb-8">
287
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
288
- API Key
289
- </label>
275
+ <SectionLabel>API Key</SectionLabel>
290
276
  {providerCredentials.length > 0 && !addingKey ? (
291
277
  <select
292
278
  value={credentialId || ''}
@@ -336,7 +322,7 @@ export function NewSessionSheet() {
336
322
  <button
337
323
  onClick={handleAddKey}
338
324
  disabled={!newKeyValue.trim()}
339
- className="flex-1 py-3 rounded-[14px] border-none bg-[#6366F1] text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
325
+ className="flex-1 py-3 rounded-[14px] border-none bg-accent-bright text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
340
326
  style={{ fontFamily: 'inherit' }}
341
327
  >
342
328
  Save Key
@@ -350,9 +336,7 @@ export function NewSessionSheet() {
350
336
  {/* Endpoint — show for providers that require it (Ollama local, OpenClaw) */}
351
337
  {currentProvider?.requiresEndpoint && (provider === 'openclaw' || (provider === 'ollama' && ollamaMode === 'local')) && (
352
338
  <div className="mb-8">
353
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
354
- {provider === 'openclaw' ? 'OpenClaw Endpoint' : 'Endpoint'}
355
- </label>
339
+ <SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : 'Endpoint'}</SectionLabel>
356
340
  <input
357
341
  type="text"
358
342
  value={endpoint}
@@ -433,9 +417,7 @@ export function NewSessionSheet() {
433
417
 
434
418
  {/* Project */}
435
419
  <div className="mb-10">
436
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
437
- Directory {provider !== 'claude-cli' && <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>}
438
- </label>
420
+ <SectionLabel>Directory {provider !== 'claude-cli' && <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>}</SectionLabel>
439
421
  <DirBrowser
440
422
  value={selectedDir}
441
423
  file={selectedFile}
@@ -452,26 +434,12 @@ export function NewSessionSheet() {
452
434
  </div>
453
435
 
454
436
  {/* Actions */}
455
- <div className="flex gap-3 pt-2 border-t border-white/[0.04]">
456
- <button
457
- onClick={onClose}
458
- className="flex-1 py-3.5 rounded-[14px] border border-white/[0.08] bg-transparent text-text-2 text-[15px] font-600 cursor-pointer
459
- hover:bg-surface-2 transition-all duration-200"
460
- style={{ fontFamily: 'inherit' }}
461
- >
462
- Cancel
463
- </button>
464
- <button
465
- onClick={handleCreate}
466
- disabled={!canCreate()}
467
- className="flex-1 py-3.5 rounded-[14px] border-none bg-[#6366F1] text-white text-[15px] font-600 cursor-pointer
468
- active:scale-[0.97] disabled:opacity-30 transition-all duration-200
469
- shadow-[0_4px_20px_rgba(99,102,241,0.25)] hover:brightness-110"
470
- style={{ fontFamily: 'inherit' }}
471
- >
472
- Create Chat
473
- </button>
474
- </div>
437
+ <SheetFooter
438
+ onCancel={onClose}
439
+ onSave={handleCreate}
440
+ saveLabel="Create Chat"
441
+ saveDisabled={!canCreate()}
442
+ />
475
443
  </BottomSheet>
476
444
  )
477
445
  }
@@ -5,6 +5,7 @@ import { api } from '@/lib/api-client'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { useChatStore } from '@/stores/use-chat-store'
7
7
  import { ConnectorPlatformBadge, getSessionConnector } from '@/components/shared/connector-platform-icon'
8
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
9
 
9
10
  function timeAgo(ts: number): string {
10
11
  if (!ts) return ''
@@ -38,6 +39,9 @@ export function SessionCard({ session, active, onClick }: Props) {
38
39
  const agents = useAppStore((s) => s.agents)
39
40
  const connectors = useAppStore((s) => s.connectors)
40
41
  const streamingSessionId = useChatStore((s) => s.streamingSessionId)
42
+ const streamPhase = useChatStore((s) => s.streamPhase)
43
+ const streamToolName = useChatStore((s) => s.streamToolName)
44
+ const lastReadTimestamps = useAppStore((s) => s.lastReadTimestamps)
41
45
  const isTyping = streamingSessionId === session.id
42
46
 
43
47
  const handleDelete = async (e: React.MouseEvent) => {
@@ -56,14 +60,16 @@ export function SessionCard({ session, active, onClick }: Props) {
56
60
  const agent = session.agentId ? agents[session.agentId] : null
57
61
  const connector = getSessionConnector(session, connectors)
58
62
  const loopIsOngoing = appSettings.loopMode === 'ongoing'
63
+ const explicitOptIn = session.heartbeatEnabled === true || agent?.heartbeatEnabled === true
59
64
  const intervalRaw = session.heartbeatIntervalSec ?? agent?.heartbeatIntervalSec ?? appSettings.heartbeatIntervalSec ?? 120
60
65
  const intervalNum = typeof intervalRaw === 'number' ? intervalRaw : Number.parseInt(String(intervalRaw), 10)
61
66
  const intervalEnabled = Number.isFinite(intervalNum) ? intervalNum > 0 : true
62
67
  const heartbeatEnabled =
63
- loopIsOngoing
68
+ (loopIsOngoing || explicitOptIn)
64
69
  && (session.tools?.length ?? 0) > 0
65
70
  && intervalEnabled
66
- && (session.heartbeatEnabled === true || (session.heartbeatEnabled !== false && agent?.heartbeatEnabled !== false))
71
+ && session.heartbeatEnabled !== false
72
+ && agent?.heartbeatEnabled !== false
67
73
 
68
74
  return (
69
75
  <div
@@ -78,17 +84,13 @@ export function SessionCard({ session, active, onClick }: Props) {
78
84
  <div className="absolute left-0 top-3.5 bottom-3.5 w-[2.5px] rounded-full bg-accent-bright" />
79
85
  )}
80
86
  <div className="flex items-center gap-2.5">
81
- {session.active && (
82
- <span className="inline-block w-[6px] h-[6px] rounded-full bg-success shrink-0"
83
- style={{ animation: 'pulse 2s ease-in-out infinite' }} />
84
- )}
85
- {heartbeatEnabled && (
86
- <span
87
- className="inline-flex items-center justify-center w-[10px] h-[10px] rounded-full bg-emerald-400/15 border border-emerald-400/30 shrink-0"
88
- title="Heartbeat enabled"
89
- >
90
- <span className="w-[4px] h-[4px] rounded-full bg-emerald-400" />
91
- </span>
87
+ {agent && (
88
+ <div className="relative shrink-0">
89
+ <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
90
+ {(heartbeatEnabled || session.active) && (
91
+ <span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-400 ring-2 ring-[#0f0f1a]" />
92
+ )}
93
+ </div>
92
94
  )}
93
95
  {connector && (
94
96
  <ConnectorPlatformBadge
@@ -100,6 +102,20 @@ export function SessionCard({ session, active, onClick }: Props) {
100
102
  />
101
103
  )}
102
104
  <span className="font-display text-[14px] font-600 truncate flex-1 tracking-[-0.01em]">{session.name}</span>
105
+ {session.mainLoopState?.status && session.mainLoopState.status !== 'idle' && (
106
+ <span className={`shrink-0 flex items-center gap-1 text-[9px] font-600 uppercase tracking-wider px-1.5 py-0.5 rounded-[5px] ${
107
+ session.mainLoopState.status === 'progress' ? 'text-blue-400/90 bg-blue-400/[0.08]'
108
+ : session.mainLoopState.status === 'blocked' ? 'text-amber-400/90 bg-amber-400/[0.08]'
109
+ : 'text-emerald-400/90 bg-emerald-400/[0.08]'
110
+ }`}>
111
+ <span className={`w-[5px] h-[5px] rounded-full ${
112
+ session.mainLoopState.status === 'progress' ? 'bg-blue-400'
113
+ : session.mainLoopState.status === 'blocked' ? 'bg-amber-400'
114
+ : 'bg-emerald-400'
115
+ }`} />
116
+ {session.mainLoopState.status}
117
+ </span>
118
+ )}
103
119
  {session.sessionType === 'orchestrated' && (
104
120
  <span className="shrink-0 text-[10px] font-600 uppercase tracking-wider text-amber-400/80 bg-amber-400/[0.08] px-2 py-0.5 rounded-[6px]">
105
121
  AI
@@ -110,6 +126,17 @@ export function SessionCard({ session, active, onClick }: Props) {
110
126
  {providerLabel}
111
127
  </span>
112
128
  )}
129
+ {(() => {
130
+ const lastRead = lastReadTimestamps[session.id] || 0
131
+ const unread = (session.messages || []).filter(
132
+ (m) => m.role === 'assistant' && (m.time || 0) > lastRead,
133
+ ).length
134
+ return unread > 0 ? (
135
+ <span className="shrink-0 min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-accent-bright text-white text-[10px] font-600 px-1">
136
+ {unread > 99 ? '99+' : unread}
137
+ </span>
138
+ ) : null
139
+ })()}
113
140
  <span className="text-[11px] text-text-3/70 shrink-0 tabular-nums font-mono">
114
141
  {timeAgo(session.lastActiveAt)}
115
142
  </span>
@@ -134,7 +161,11 @@ export function SessionCard({ session, active, onClick }: Props) {
134
161
  <span className="w-1 h-1 rounded-full bg-accent-bright/70 animate-bounce [animation-delay:150ms]" />
135
162
  <span className="w-1 h-1 rounded-full bg-accent-bright/70 animate-bounce [animation-delay:300ms]" />
136
163
  </span>
137
- Typing...
164
+ {streamPhase === 'tool' && streamToolName
165
+ ? `Using ${streamToolName}...`
166
+ : streamPhase === 'responding'
167
+ ? 'Responding...'
168
+ : 'Thinking...'}
138
169
  </div>
139
170
  ) : (
140
171
  <div className="text-[13px] text-text-2/50 truncate mt-1 leading-relaxed">{preview}</div>
@@ -5,6 +5,9 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { SessionCard } from './session-card'
7
7
  import { fetchMessages } from '@/lib/sessions'
8
+ import { toast } from 'sonner'
9
+ import { Skeleton } from '@/components/shared/skeleton'
10
+ import { EmptyState } from '@/components/shared/empty-state'
8
11
 
9
12
  interface Props {
10
13
  inSidebar?: boolean
@@ -24,10 +27,16 @@ export function SessionList({ inSidebar, onSelect }: Props) {
24
27
  const setNewSessionOpen = useAppStore((s) => s.setNewSessionOpen)
25
28
  const clearSessions = useAppStore((s) => s.clearSessions)
26
29
  const togglePinSession = useAppStore((s) => s.togglePinSession)
30
+ const markChatRead = useAppStore((s) => s.markChatRead)
27
31
  const setMessages = useChatStore((s) => s.setMessages)
28
32
  const [search, setSearch] = useState('')
29
33
  const [typeFilter, setTypeFilter] = useState<SessionFilter>('all')
30
34
  const [sortMode, setSortMode] = useState<SortMode>('lastActive')
35
+ const [loaded, setLoaded] = useState(Object.keys(sessions).length > 0)
36
+
37
+ useEffect(() => {
38
+ if (Object.keys(sessions).length > 0 && !loaded) setLoaded(true)
39
+ }, [sessions, loaded])
31
40
 
32
41
  useEffect(() => {
33
42
  void loadConnectors()
@@ -67,6 +76,7 @@ export function SessionList({ inSidebar, onSelect }: Props) {
67
76
 
68
77
  const handleSelect = async (id: string) => {
69
78
  setCurrentSession(id)
79
+ markChatRead(id)
70
80
  if (typeof window !== 'undefined') {
71
81
  window.dispatchEvent(new CustomEvent('swarmclaw:scroll-bottom'))
72
82
  }
@@ -82,27 +92,33 @@ export function SessionList({ inSidebar, onSelect }: Props) {
82
92
 
83
93
  // Truly empty — no sessions at all for this user
84
94
  if (!allUserSessions.length) {
95
+ // Show skeleton cards while data is loading
96
+ if (!loaded) {
97
+ return (
98
+ <div className="flex-1 flex flex-col gap-1 px-2 pt-4">
99
+ {Array.from({ length: 3 }).map((_, i) => (
100
+ <div key={i} className="py-3 px-4 rounded-[14px]">
101
+ <div className="flex items-center gap-2.5">
102
+ <Skeleton className="rounded-full" width={28} height={28} />
103
+ <Skeleton className="rounded-[6px]" width={140} height={14} />
104
+ </div>
105
+ <Skeleton className="rounded-[6px] mt-2" width="70%" height={12} />
106
+ </div>
107
+ ))}
108
+ </div>
109
+ )
110
+ }
85
111
  return (
86
- <div className="flex-1 flex flex-col items-center justify-center gap-4 text-text-3 p-8 text-center">
87
- <div className="w-12 h-12 rounded-[14px] bg-accent-soft flex items-center justify-center mb-1">
112
+ <EmptyState
113
+ icon={
88
114
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="text-accent-bright">
89
115
  <path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z" fill="currentColor" />
90
116
  </svg>
91
- </div>
92
- <p className="font-display text-[15px] font-600 text-text-2">No chats yet</p>
93
- <p className="text-[13px] text-text-3/50">Create one to start chatting</p>
94
- {!inSidebar && (
95
- <button
96
- onClick={() => setNewSessionOpen(true)}
97
- className="mt-3 px-8 py-3 rounded-[14px] border-none bg-[#6366F1] text-white
98
- text-[14px] font-600 cursor-pointer active:scale-95 transition-all duration-200
99
- shadow-[0_4px_16px_rgba(99,102,241,0.2)]"
100
- style={{ fontFamily: 'inherit' }}
101
- >
102
- + New Chat
103
- </button>
104
- )}
105
- </div>
117
+ }
118
+ title="No chats yet"
119
+ subtitle="Create one to start chatting"
120
+ action={!inSidebar ? { label: '+ New Chat', onClick: () => setNewSessionOpen(true) } : undefined}
121
+ />
106
122
  )
107
123
  }
108
124
 
@@ -126,6 +142,7 @@ export function SessionList({ inSidebar, onSelect }: Props) {
126
142
  onClick={async () => {
127
143
  if (!window.confirm(`Delete ${filtered.length} chat${filtered.length === 1 ? '' : 's'}?`)) return
128
144
  await clearSessions(filtered.map((s) => s.id))
145
+ toast.success(`${filtered.length} chat${filtered.length === 1 ? '' : 's'} deleted`)
129
146
  }}
130
147
  className="ml-auto p-1.5 rounded-[8px] text-text-3/70 hover:text-red-400 hover:bg-red-400/[0.06]
131
148
  cursor-pointer transition-all bg-transparent border-none"
@@ -175,7 +192,7 @@ export function SessionList({ inSidebar, onSelect }: Props) {
175
192
  onClick={() => handleSelect(s.id)}
176
193
  />
177
194
  <button
178
- onClick={(e) => { e.stopPropagation(); togglePinSession(s.id) }}
195
+ onClick={(e) => { e.stopPropagation(); togglePinSession(s.id); toast.success(s.pinned ? 'Chat unpinned' : 'Chat pinned') }}
179
196
  aria-label={s.pinned ? 'Unpin chat' : 'Pin chat'}
180
197
  className={`absolute top-2 right-2 p-1 rounded-[6px] border-none cursor-pointer transition-all
181
198
  ${s.pinned