@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
@@ -0,0 +1,501 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { useChatStore } from '@/stores/use-chat-store'
6
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
7
+ import { api } from '@/lib/api-client'
8
+ import type { Agent, Session, ActivityEntry, BoardTask, AppNotification } from '@/types'
9
+
10
+ function timeAgo(ts: number): string {
11
+ const diff = Date.now() - ts
12
+ const mins = Math.floor(diff / 60000)
13
+ if (mins < 1) return 'just now'
14
+ if (mins < 60) return `${mins}m ago`
15
+ const hours = Math.floor(mins / 60)
16
+ if (hours < 24) return `${hours}h ago`
17
+ const days = Math.floor(hours / 24)
18
+ return `${days}d ago`
19
+ }
20
+
21
+ function timeUntil(ts: number): string {
22
+ const diff = ts - Date.now()
23
+ if (diff <= 0) return 'now'
24
+ const mins = Math.floor(diff / 60000)
25
+ if (mins < 60) return `in ${mins}m`
26
+ const hours = Math.floor(mins / 60)
27
+ if (hours < 24) return `in ${hours}h`
28
+ const days = Math.floor(hours / 24)
29
+ return `in ${days}d`
30
+ }
31
+
32
+ const ACTIVITY_ICONS: Record<ActivityEntry['action'], string> = {
33
+ created: 'M12 5v14m-7-7h14',
34
+ updated: 'M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z',
35
+ deleted: 'M3 6h18m-2 0v14a2 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',
36
+ started: 'M5 3l14 9-14 9V3z',
37
+ stopped: 'M6 4h4v16H6zm8 0h4v16h-4z',
38
+ queued: 'M12 6v6l4 2',
39
+ completed: 'M20 6L9 17l-5-5',
40
+ failed: 'M18 6L6 18M6 6l12 12',
41
+ approved: 'M22 11.08V12a10 10 0 1 1-5.93-9.14',
42
+ rejected: 'M10 15l5-5m0 5l-5-5',
43
+ }
44
+
45
+ const ACTIVITY_COLORS: Record<ActivityEntry['action'], string> = {
46
+ created: 'text-emerald-400',
47
+ updated: 'text-sky-400',
48
+ deleted: 'text-red-400',
49
+ started: 'text-emerald-400',
50
+ stopped: 'text-text-3',
51
+ queued: 'text-amber-400',
52
+ completed: 'text-emerald-400',
53
+ failed: 'text-red-400',
54
+ approved: 'text-emerald-400',
55
+ rejected: 'text-red-400',
56
+ }
57
+
58
+ const PLATFORM_LABELS: Record<string, string> = {
59
+ discord: 'Discord',
60
+ telegram: 'Telegram',
61
+ slack: 'Slack',
62
+ whatsapp: 'WhatsApp',
63
+ openclaw: 'OpenClaw',
64
+ }
65
+
66
+ export function HomeView() {
67
+ const agents = useAppStore((s) => s.agents)
68
+ const sessions = useAppStore((s) => s.sessions)
69
+ const tasks = useAppStore((s) => s.tasks)
70
+ const connectors = useAppStore((s) => s.connectors)
71
+ const schedules = useAppStore((s) => s.schedules)
72
+ const activityEntries = useAppStore((s) => s.activityEntries)
73
+ const notifications = useAppStore((s) => s.notifications)
74
+ const unreadNotificationCount = useAppStore((s) => s.unreadNotificationCount)
75
+ const streamingSessionId = useChatStore((s) => s.streamingSessionId)
76
+ const loadActivity = useAppStore((s) => s.loadActivity)
77
+ const loadSchedules = useAppStore((s) => s.loadSchedules)
78
+ const loadNotifications = useAppStore((s) => s.loadNotifications)
79
+ const loadConnectors = useAppStore((s) => s.loadConnectors)
80
+ const markNotificationRead = useAppStore((s) => s.markNotificationRead)
81
+ const setActiveView = useAppStore((s) => s.setActiveView)
82
+ const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
83
+ const setCurrentSession = useAppStore((s) => s.setCurrentSession)
84
+ const setEditingTaskId = useAppStore((s) => s.setEditingTaskId)
85
+ const setTaskSheetOpen = useAppStore((s) => s.setTaskSheetOpen)
86
+ const setMessages = useChatStore((s) => s.setMessages)
87
+ const [todayCost, setTodayCost] = useState(0)
88
+
89
+ const allAgents = Object.values(agents).filter((a) => !a.trashedAt)
90
+ const pinnedAgents = allAgents.filter((a) => a.pinned)
91
+
92
+ const recentChats = useMemo(
93
+ () =>
94
+ Object.values(sessions)
95
+ .sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0))
96
+ .slice(0, 5),
97
+ [sessions],
98
+ )
99
+
100
+ // Quick stats
101
+ const agentCount = allAgents.length
102
+ const allTasks = Object.values(tasks)
103
+ const activeTaskCount = allTasks.filter((t) => t.status === 'running' || t.status === 'queued').length
104
+ const allConnectors = Object.values(connectors)
105
+ const activeConnectorCount = allConnectors.filter((c) => c.status === 'running').length
106
+
107
+ // Agents with running tasks
108
+ const runningAgentIds = useMemo(() => {
109
+ const set = new Set<string>()
110
+ for (const task of allTasks) {
111
+ if (task.status === 'running' && task.agentId) set.add(task.agentId)
112
+ }
113
+ return set
114
+ }, [allTasks])
115
+
116
+ // Running tasks for the running tasks section
117
+ const runningTasks = useMemo(
118
+ () => allTasks.filter((t) => t.status === 'running' || t.status === 'queued').slice(0, 5),
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ [tasks],
121
+ )
122
+
123
+ // Upcoming schedules
124
+ const upcomingSchedules = useMemo(() => {
125
+ const now = Date.now()
126
+ return Object.values(schedules)
127
+ .filter((s) => s.status === 'active' && s.nextRunAt && s.nextRunAt > now)
128
+ .sort((a, b) => (a.nextRunAt || 0) - (b.nextRunAt || 0))
129
+ .slice(0, 5)
130
+ }, [schedules])
131
+
132
+ // Unread notifications
133
+ const unreadNotifications = useMemo(
134
+ () => notifications.filter((n) => !n.read).slice(0, 5),
135
+ [notifications],
136
+ )
137
+
138
+ // Recent activity (last 8)
139
+ const recentActivity = useMemo(() => activityEntries.slice(0, 8), [activityEntries])
140
+
141
+ // Load data on mount
142
+ useEffect(() => {
143
+ void loadActivity({ limit: 8 })
144
+ void loadSchedules()
145
+ void loadNotifications()
146
+ void loadConnectors()
147
+ api<{ records: Array<{ estimatedCost: number }> }>('GET', '/usage?range=24h')
148
+ .then((data) => {
149
+ const total = (data.records || []).reduce((s, r) => s + (r.estimatedCost || 0), 0)
150
+ setTodayCost(total)
151
+ })
152
+ .catch(() => {})
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, [])
155
+
156
+ const handleAgentClick = (agent: Agent) => {
157
+ setMessages([])
158
+ void setCurrentAgent(agent.id)
159
+ setActiveView('agents')
160
+ }
161
+
162
+ const handleChatClick = (session: Session) => {
163
+ setCurrentSession(session.id)
164
+ setActiveView('agents')
165
+ }
166
+
167
+ const handleTaskClick = (task: BoardTask) => {
168
+ setEditingTaskId(task.id)
169
+ setTaskSheetOpen(true)
170
+ setActiveView('tasks')
171
+ }
172
+
173
+ const handleNotificationClick = (n: AppNotification) => {
174
+ if (!n.read) void markNotificationRead(n.id)
175
+ if (n.entityType === 'agent' && n.entityId) {
176
+ void setCurrentAgent(n.entityId)
177
+ setActiveView('agents')
178
+ } else if (n.entityType === 'task' && n.entityId) {
179
+ setEditingTaskId(n.entityId)
180
+ setTaskSheetOpen(true)
181
+ setActiveView('tasks')
182
+ }
183
+ }
184
+
185
+ return (
186
+ <div className="flex-1 overflow-y-auto">
187
+ <div className="max-w-[800px] mx-auto px-6 py-10">
188
+ {/* Header */}
189
+ <div className="mb-10">
190
+ <h1 className="font-display text-[28px] font-700 text-text tracking-[-0.03em]">
191
+ SwarmClaw
192
+ </h1>
193
+ <p className="text-[14px] text-text-3 mt-1">
194
+ Your AI agent orchestration dashboard
195
+ </p>
196
+ </div>
197
+
198
+ {/* Quick Stats */}
199
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-10">
200
+ <StatCard label="Agents" value={String(agentCount)} />
201
+ <StatCard label="Active Tasks" value={String(activeTaskCount)} accent={activeTaskCount > 0} />
202
+ <StatCard label="Today's Cost" value={`$${todayCost.toFixed(2)}`} />
203
+ <StatCard label="Connectors" value={`${activeConnectorCount}/${allConnectors.length}`} accent={activeConnectorCount > 0} />
204
+ </div>
205
+
206
+ {/* Notifications banner */}
207
+ {unreadNotifications.length > 0 && (
208
+ <section className="mb-8">
209
+ <div className="rounded-[14px] border border-amber-400/20 bg-amber-400/[0.04] overflow-hidden">
210
+ <div className="flex items-center gap-2 px-4 py-2.5 border-b border-amber-400/10">
211
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-amber-400">
212
+ <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
213
+ <path d="M13.73 21a2 2 0 0 1-3.46 0" />
214
+ </svg>
215
+ <span className="text-[12px] font-600 text-amber-400">
216
+ {unreadNotificationCount} unread notification{unreadNotificationCount !== 1 ? 's' : ''}
217
+ </span>
218
+ </div>
219
+ <div className="flex flex-col">
220
+ {unreadNotifications.map((n) => (
221
+ <button
222
+ key={n.id}
223
+ onClick={() => handleNotificationClick(n)}
224
+ className="flex items-start gap-3 px-4 py-2.5 text-left bg-transparent border-none cursor-pointer
225
+ hover:bg-white/[0.03] transition-colors w-full"
226
+ style={{ fontFamily: 'inherit' }}
227
+ >
228
+ <div className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${
229
+ n.type === 'error' ? 'bg-red-400' : n.type === 'warning' ? 'bg-amber-400' : n.type === 'success' ? 'bg-emerald-400' : 'bg-sky-400'
230
+ }`} />
231
+ <div className="flex-1 min-w-0">
232
+ <span className="text-[13px] font-500 text-text">{n.title}</span>
233
+ {n.message && <p className="text-[11px] text-text-3/60 truncate mt-0.5 m-0">{n.message}</p>}
234
+ </div>
235
+ <span className="text-[10px] text-text-3/40 shrink-0 mt-0.5">{timeAgo(n.createdAt)}</span>
236
+ </button>
237
+ ))}
238
+ </div>
239
+ </div>
240
+ </section>
241
+ )}
242
+
243
+ {/* Connector Status */}
244
+ <section className="mb-8">
245
+ <SectionHeader label="Connectors" onViewAll={allConnectors.length > 0 ? () => setActiveView('connectors') : undefined} />
246
+ {allConnectors.length > 0 ? (
247
+ <div className="flex gap-2 flex-wrap">
248
+ {allConnectors.map((c) => (
249
+ <div
250
+ key={c.id}
251
+ className="flex items-center gap-2 px-3 py-2 rounded-[10px] bg-white/[0.03] border border-white/[0.06]"
252
+ >
253
+ <div className={`w-2 h-2 rounded-full ${
254
+ c.status === 'running' ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)]'
255
+ : c.status === 'error' ? 'bg-red-400' : 'bg-text-3/30'
256
+ }`} />
257
+ <span className="text-[12px] font-500 text-text">{c.name}</span>
258
+ <span className="text-[10px] text-text-3/50">{PLATFORM_LABELS[c.platform] || c.platform}</span>
259
+ </div>
260
+ ))}
261
+ </div>
262
+ ) : (
263
+ <EmptySection text="No connectors configured — bridge agents to Discord, Slack, Telegram, or WhatsApp" />
264
+ )}
265
+ </section>
266
+
267
+ {/* Two-column layout: Running Tasks + Upcoming Schedules */}
268
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
269
+ {/* Running Tasks */}
270
+ <section>
271
+ <SectionHeader label="Running Tasks" onViewAll={runningTasks.length > 0 ? () => setActiveView('tasks') : undefined} />
272
+ {runningTasks.length > 0 ? (
273
+ <div className="flex flex-col gap-1">
274
+ {runningTasks.map((task) => {
275
+ const agent = task.agentId ? agents[task.agentId] : null
276
+ return (
277
+ <button
278
+ key={task.id}
279
+ onClick={() => handleTaskClick(task)}
280
+ className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] bg-transparent border-none
281
+ hover:bg-white/[0.04] transition-colors cursor-pointer w-full text-left"
282
+ style={{ fontFamily: 'inherit' }}
283
+ >
284
+ <div className={`w-2 h-2 rounded-full shrink-0 ${
285
+ task.status === 'running' ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
286
+ }`} />
287
+ <div className="flex-1 min-w-0">
288
+ <span className="text-[13px] font-500 text-text truncate block">{task.title}</span>
289
+ <span className="text-[11px] text-text-3/50">
290
+ {agent?.name || 'Unassigned'} · {task.status === 'running' ? 'running' : 'queued'}{task.startedAt ? ` · ${timeAgo(task.startedAt)}` : ''}
291
+ </span>
292
+ </div>
293
+ </button>
294
+ )
295
+ })}
296
+ </div>
297
+ ) : (
298
+ <div className="py-4 px-3 text-[12px] text-text-3/40">No tasks running</div>
299
+ )}
300
+ </section>
301
+
302
+ {/* Upcoming Schedules */}
303
+ <section>
304
+ <SectionHeader label="Upcoming Schedules" onViewAll={upcomingSchedules.length > 0 ? () => setActiveView('schedules') : undefined} />
305
+ {upcomingSchedules.length > 0 ? (
306
+ <div className="flex flex-col gap-1">
307
+ {upcomingSchedules.map((sched) => {
308
+ const agent = sched.agentId ? agents[sched.agentId] : null
309
+ return (
310
+ <div
311
+ key={sched.id}
312
+ className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px]"
313
+ >
314
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/50 shrink-0">
315
+ <circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" />
316
+ </svg>
317
+ <div className="flex-1 min-w-0">
318
+ <span className="text-[13px] font-500 text-text truncate block">{sched.name}</span>
319
+ <span className="text-[11px] text-text-3/50">
320
+ {agent?.name || 'No agent'} · {sched.nextRunAt ? timeUntil(sched.nextRunAt) : '—'}
321
+ </span>
322
+ </div>
323
+ </div>
324
+ )
325
+ })}
326
+ </div>
327
+ ) : (
328
+ <div className="py-4 px-3 text-[12px] text-text-3/40">No upcoming schedules</div>
329
+ )}
330
+ </section>
331
+ </div>
332
+
333
+ {/* Pinned Agents */}
334
+ <section className="mb-8">
335
+ <SectionHeader label="Pinned Agents" />
336
+ {pinnedAgents.length > 0 ? (
337
+ <div className="flex gap-3 overflow-x-auto pb-2">
338
+ {pinnedAgents.map((agent) => {
339
+ const threadSession = agent.threadSessionId ? sessions[agent.threadSessionId] as Session | undefined : undefined
340
+ const heartbeatOn = agent.heartbeatEnabled === true && (agent.tools?.length ?? 0) > 0
341
+ const recentlyActive = (threadSession?.lastActiveAt ?? 0) > Date.now() - 30 * 60 * 1000
342
+ const isOnline = runningAgentIds.has(agent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive
343
+ const isTyping = streamingSessionId === agent.threadSessionId
344
+ const lastActive = threadSession?.lastActiveAt || agent.lastUsedAt || agent.updatedAt
345
+ const modelLabel = agent.model ? agent.model.split('/').pop()?.split(':')[0] : agent.provider
346
+
347
+ return (
348
+ <button
349
+ key={agent.id}
350
+ onClick={() => handleAgentClick(agent)}
351
+ className="flex flex-col items-center gap-1.5 px-4 py-3.5 rounded-[14px] bg-white/[0.03] border border-white/[0.06]
352
+ hover:bg-white/[0.06] hover:border-white/[0.1] transition-all cursor-pointer min-w-[130px] shrink-0"
353
+ style={{ fontFamily: 'inherit' }}
354
+ >
355
+ <div className="relative">
356
+ <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={36} />
357
+ <div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-[#1a1a2e] ${
358
+ isTyping ? 'bg-accent-bright animate-pulse'
359
+ : isOnline ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)]'
360
+ : 'bg-text-3/30'
361
+ }`} />
362
+ </div>
363
+ <span className="font-display text-[13px] font-600 text-text truncate max-w-[110px]">
364
+ {agent.name}
365
+ </span>
366
+ {isTyping ? (
367
+ <span className="text-[10px] text-accent-bright/70 flex items-center gap-1">
368
+ <span className="flex gap-0.5">
369
+ <span className="w-1 h-1 rounded-full bg-accent-bright/70 animate-bounce [animation-delay:0ms]" />
370
+ <span className="w-1 h-1 rounded-full bg-accent-bright/70 animate-bounce [animation-delay:150ms]" />
371
+ <span className="w-1 h-1 rounded-full bg-accent-bright/70 animate-bounce [animation-delay:300ms]" />
372
+ </span>
373
+ typing
374
+ </span>
375
+ ) : (
376
+ <span className={`text-[10px] ${isOnline ? 'text-emerald-400/80' : 'text-text-3/50'}`}>
377
+ {isOnline ? 'Online' : lastActive ? timeAgo(lastActive) : 'Idle'}
378
+ </span>
379
+ )}
380
+ {modelLabel && (
381
+ <span className="text-[9px] text-text-3/40 font-mono truncate max-w-[110px]">
382
+ {modelLabel}
383
+ </span>
384
+ )}
385
+ </button>
386
+ )
387
+ })}
388
+ </div>
389
+ ) : (
390
+ <div className="py-6 px-4 rounded-[14px] bg-white/[0.02] border border-dashed border-white/[0.06] text-center">
391
+ <p className="text-[13px] text-text-3/60">
392
+ Star agents from the chat list for quick access
393
+ </p>
394
+ </div>
395
+ )}
396
+ </section>
397
+
398
+ {/* Recent Chats */}
399
+ <section className="mb-8">
400
+ <SectionHeader label="Recent Chats" />
401
+ {recentChats.length > 0 ? (
402
+ <div className="flex flex-col gap-1">
403
+ {recentChats.map((session) => {
404
+ const agent = session.agentId ? agents[session.agentId] : null
405
+ const lastMsg = session.messages?.[session.messages.length - 1]
406
+ const displayName = agent?.name || 'Chat'
407
+ return (
408
+ <button
409
+ key={session.id}
410
+ onClick={() => handleChatClick(session)}
411
+ className="flex items-center gap-3 px-4 py-3 rounded-[12px] bg-transparent border-none
412
+ hover:bg-white/[0.04] transition-all cursor-pointer w-full text-left"
413
+ style={{ fontFamily: 'inherit' }}
414
+ >
415
+ <AgentAvatar
416
+ seed={agent?.avatarSeed}
417
+ name={displayName}
418
+ size={28}
419
+ />
420
+ <div className="flex-1 min-w-0">
421
+ <div className="flex items-center gap-2">
422
+ <span className="text-[13px] font-600 text-text truncate">
423
+ {displayName}
424
+ </span>
425
+ <span className="text-[11px] text-text-3/50 shrink-0">
426
+ {timeAgo(session.lastActiveAt || session.createdAt)}
427
+ </span>
428
+ </div>
429
+ {lastMsg && (
430
+ <p className="text-[12px] text-text-3/60 truncate mt-0.5 m-0">
431
+ {lastMsg.text.slice(0, 80)}
432
+ </p>
433
+ )}
434
+ </div>
435
+ </button>
436
+ )
437
+ })}
438
+ </div>
439
+ ) : (
440
+ <EmptySection text="No chats yet — start by clicking an agent" />
441
+ )}
442
+ </section>
443
+
444
+ {/* Activity Feed */}
445
+ {recentActivity.length > 0 && (
446
+ <section className="mb-10">
447
+ <SectionHeader label="Recent Activity" />
448
+ <div className="flex flex-col gap-0.5">
449
+ {recentActivity.map((entry) => (
450
+ <div key={entry.id} className="flex items-center gap-2.5 px-3 py-2 rounded-[10px]">
451
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
452
+ className={`shrink-0 ${ACTIVITY_COLORS[entry.action] || 'text-text-3'}`}>
453
+ <path d={ACTIVITY_ICONS[entry.action] || ACTIVITY_ICONS.updated} />
454
+ </svg>
455
+ <span className="text-[12px] text-text-3/80 flex-1 truncate">{entry.summary}</span>
456
+ <span className="text-[10px] text-text-3/40 shrink-0">{timeAgo(entry.timestamp)}</span>
457
+ </div>
458
+ ))}
459
+ </div>
460
+ </section>
461
+ )}
462
+ </div>
463
+ </div>
464
+ )
465
+ }
466
+
467
+ function SectionHeader({ label, onViewAll }: { label: string; onViewAll?: () => void }) {
468
+ return (
469
+ <div className="flex items-center justify-between mb-3">
470
+ <h2 className="font-display text-[13px] font-600 text-text-2 uppercase tracking-[0.08em]">
471
+ {label}
472
+ </h2>
473
+ {onViewAll && (
474
+ <button
475
+ onClick={onViewAll}
476
+ className="text-[11px] text-text-3/50 hover:text-text-3 transition-colors bg-transparent border-none cursor-pointer"
477
+ style={{ fontFamily: 'inherit' }}
478
+ >
479
+ View all →
480
+ </button>
481
+ )}
482
+ </div>
483
+ )
484
+ }
485
+
486
+ function StatCard({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
487
+ return (
488
+ <div className="px-4 py-3 rounded-[12px] bg-white/[0.03] border border-white/[0.06]">
489
+ <p className="text-[11px] font-600 text-text-3/60 uppercase tracking-wider mb-1">{label}</p>
490
+ <p className={`font-display text-[20px] font-700 tracking-[-0.02em] ${accent ? 'text-accent-bright' : 'text-text'}`}>{value}</p>
491
+ </div>
492
+ )
493
+ }
494
+
495
+ function EmptySection({ text }: { text: string }) {
496
+ return (
497
+ <div className="py-6 px-4 rounded-[14px] bg-white/[0.02] border border-dashed border-white/[0.06] text-center">
498
+ <p className="text-[13px] text-text-3/60">{text}</p>
499
+ </div>
500
+ )
501
+ }
@@ -1,11 +1,12 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useRef, useState } from 'react'
4
- import { useChatStore, type PendingFile } from '@/stores/use-chat-store'
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { useChatStore } from '@/stores/use-chat-store'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { uploadImage } from '@/lib/upload'
7
7
  import { useAutoResize } from '@/hooks/use-auto-resize'
8
8
  import { useSpeechRecognition } from '@/hooks/use-speech-recognition'
9
+ import { FilePreview } from '@/components/shared/file-preview'
9
10
 
10
11
  interface Props {
11
12
  streaming: boolean
@@ -13,36 +14,7 @@ interface Props {
13
14
  onStop: () => void
14
15
  }
15
16
 
16
- function FilePreview({ file, onRemove }: { file: PendingFile; onRemove: () => void }) {
17
- const isImage = file.file.type.startsWith('image/')
18
- return (
19
- <div className="relative">
20
- {isImage ? (
21
- <img
22
- src={URL.createObjectURL(file.file)}
23
- alt="Preview"
24
- className="h-16 rounded-[10px] object-cover border border-white/[0.06]"
25
- />
26
- ) : (
27
- <div className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border border-white/[0.06] bg-white/[0.03]">
28
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3 shrink-0">
29
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
30
- <polyline points="14 2 14 8 20 8" />
31
- </svg>
32
- <span className="text-[13px] text-text-2 font-500 truncate max-w-[180px]">{file.file.name}</span>
33
- </div>
34
- )}
35
- <button
36
- onClick={onRemove}
37
- className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full border border-white/10 bg-raised
38
- text-text-2 text-[10px] cursor-pointer flex items-center justify-center
39
- hover:bg-danger-soft hover:text-danger hover:border-danger/20 transition-colors"
40
- >
41
- &times;
42
- </button>
43
- </div>
44
- )
45
- }
17
+ // FilePreview is now imported from @/components/shared/file-preview
46
18
 
47
19
  export function ChatInput({ streaming, onSend, onStop }: Props) {
48
20
  const [value, setValue] = useState('')
@@ -53,16 +25,51 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
53
25
  const addPendingFile = useChatStore((s) => s.addPendingFile)
54
26
  const removePendingFile = useChatStore((s) => s.removePendingFile)
55
27
  const speechRecognitionLang = useAppStore((s) => s.appSettings.speechRecognitionLang)
28
+ const sessionId = useAppStore((s) => s.currentSessionId)
29
+
30
+ const queuedMessages = useChatStore((s) => s.queuedMessages)
31
+ const addQueuedMessage = useChatStore((s) => s.addQueuedMessage)
32
+ const removeQueuedMessage = useChatStore((s) => s.removeQueuedMessage)
33
+
34
+ // Draft persistence: restore on session change
35
+ const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
36
+ useEffect(() => {
37
+ if (!sessionId) return
38
+ const draft = localStorage.getItem(`sc_draft_${sessionId}`)
39
+ setValue(draft || '')
40
+ }, [sessionId])
41
+
42
+ // Debounced save to localStorage
43
+ useEffect(() => {
44
+ if (!sessionId) return
45
+ if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
46
+ draftTimerRef.current = setTimeout(() => {
47
+ if (value) localStorage.setItem(`sc_draft_${sessionId}`, value)
48
+ else localStorage.removeItem(`sc_draft_${sessionId}`)
49
+ }, 300)
50
+ return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current) }
51
+ }, [value, sessionId])
56
52
 
57
53
  const handleSend = useCallback(() => {
58
54
  const text = value.trim()
59
- if ((!text && !pendingFiles.length) || streaming) return
55
+ if (!text && !pendingFiles.length) return
56
+ // If streaming, queue the message instead of blocking
57
+ if (streaming) {
58
+ if (text) {
59
+ addQueuedMessage(text)
60
+ setValue('')
61
+ if (textareaRef.current) textareaRef.current.style.height = 'auto'
62
+ }
63
+ return
64
+ }
60
65
  onSend(text || 'See attached file(s).')
61
66
  setValue('')
67
+ if (sessionId) localStorage.removeItem(`sc_draft_${sessionId}`)
62
68
  if (textareaRef.current) {
63
69
  textareaRef.current.style.height = 'auto'
64
70
  }
65
- }, [value, streaming, onSend, pendingFiles.length])
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, [value, streaming, onSend, pendingFiles.length, sessionId])
66
73
 
67
74
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
68
75
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -131,6 +138,27 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
131
138
  </div>
132
139
  )}
133
140
 
141
+ {queuedMessages.length > 0 && (
142
+ <div className="flex flex-wrap items-center gap-1.5 mb-2">
143
+ <span className="label-mono text-amber-400/70">Queued</span>
144
+ {queuedMessages.map((msg, i) => (
145
+ <span key={i} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-amber-500/10 border border-amber-500/15 text-[12px] text-amber-300 font-mono max-w-[200px]">
146
+ <span className="truncate">{msg}</span>
147
+ <button
148
+ type="button"
149
+ onClick={() => removeQueuedMessage(i)}
150
+ className="shrink-0 text-amber-400/60 hover:text-amber-300 border-none bg-transparent cursor-pointer p-0"
151
+ >
152
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
153
+ <line x1="18" y1="6" x2="6" y2="18" />
154
+ <line x1="6" y1="6" x2="18" y2="18" />
155
+ </svg>
156
+ </button>
157
+ </span>
158
+ ))}
159
+ </div>
160
+ )}
161
+
134
162
  <div className="glass rounded-[20px] overflow-hidden
135
163
  shadow-[0_4px_32px_rgba(0,0,0,0.3)] focus-within:border-border-focus focus-within:shadow-[0_4px_32px_rgba(99,102,241,0.08)] transition-all duration-300">
136
164
 
@@ -206,17 +234,27 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
206
234
 
207
235
  <button
208
236
  onClick={handleSend}
209
- disabled={!hasContent || streaming}
237
+ disabled={!hasContent}
210
238
  className={`w-9 h-9 rounded-[11px] border-none flex items-center justify-center
211
239
  shrink-0 cursor-pointer transition-all duration-250
212
- ${hasContent && !streaming
213
- ? 'bg-[#6366F1] text-white active:scale-90 shadow-[0_4px_16px_rgba(99,102,241,0.3)]'
240
+ ${hasContent
241
+ ? streaming
242
+ ? 'bg-amber-500/20 text-amber-400 active:scale-90 border border-amber-500/30'
243
+ : 'bg-accent-bright text-white active:scale-90 shadow-[0_4px_16px_rgba(99,102,241,0.3)]'
214
244
  : 'bg-white/[0.04] text-text-3 pointer-events-none'}`}
245
+ title={streaming ? 'Queue message' : 'Send message'}
215
246
  >
216
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
217
- <line x1="12" y1="19" x2="12" y2="5" />
218
- <polyline points="5 12 12 5 19 12" />
219
- </svg>
247
+ {streaming && hasContent ? (
248
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
249
+ <line x1="12" y1="5" x2="12" y2="19" />
250
+ <line x1="5" y1="12" x2="19" y2="12" />
251
+ </svg>
252
+ ) : (
253
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
254
+ <line x1="12" y1="19" x2="12" y2="5" />
255
+ <polyline points="5 12 12 5 19 12" />
256
+ </svg>
257
+ )}
220
258
  </button>
221
259
  </div>
222
260
  </div>