@swarmclawai/swarmclaw 0.5.3 → 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 (163) hide show
  1. package/README.md +39 -8
  2. package/bin/swarmclaw.js +76 -16
  3. package/next.config.ts +11 -1
  4. package/package.json +4 -2
  5. package/scripts/postinstall.mjs +18 -0
  6. package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
  7. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  8. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  9. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  10. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  11. package/src/app/api/chatrooms/route.ts +50 -0
  12. package/src/app/api/credentials/route.ts +2 -3
  13. package/src/app/api/knowledge/[id]/route.ts +13 -2
  14. package/src/app/api/knowledge/route.ts +8 -1
  15. package/src/app/api/memory/route.ts +8 -0
  16. package/src/app/api/notifications/route.ts +4 -0
  17. package/src/app/api/orchestrator/run/route.ts +1 -1
  18. package/src/app/api/plugins/install/route.ts +2 -2
  19. package/src/app/api/search/route.ts +51 -1
  20. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  21. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  22. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  23. package/src/app/api/sessions/route.ts +3 -3
  24. package/src/app/api/settings/route.ts +9 -0
  25. package/src/app/api/setup/check-provider/route.ts +3 -16
  26. package/src/app/api/skills/[id]/route.ts +6 -0
  27. package/src/app/api/skills/route.ts +6 -0
  28. package/src/app/api/tasks/[id]/route.ts +12 -0
  29. package/src/app/api/tasks/bulk/route.ts +100 -0
  30. package/src/app/api/tasks/route.ts +1 -0
  31. package/src/app/api/webhooks/[id]/route.ts +15 -1
  32. package/src/app/globals.css +58 -15
  33. package/src/app/page.tsx +142 -13
  34. package/src/cli/index.js +24 -0
  35. package/src/cli/index.test.js +30 -0
  36. package/src/cli/spec.js +16 -0
  37. package/src/components/agents/agent-avatar.tsx +57 -10
  38. package/src/components/agents/agent-card.tsx +48 -15
  39. package/src/components/agents/agent-chat-list.tsx +123 -10
  40. package/src/components/agents/agent-list.tsx +50 -19
  41. package/src/components/agents/agent-sheet.tsx +56 -63
  42. package/src/components/auth/access-key-gate.tsx +10 -3
  43. package/src/components/auth/setup-wizard.tsx +2 -2
  44. package/src/components/auth/user-picker.tsx +31 -3
  45. package/src/components/chat/activity-moment.tsx +169 -0
  46. package/src/components/chat/chat-header.tsx +2 -0
  47. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  48. package/src/components/chat/file-path-chip.tsx +125 -0
  49. package/src/components/chat/markdown-utils.ts +9 -0
  50. package/src/components/chat/message-bubble.tsx +46 -295
  51. package/src/components/chat/message-list.tsx +50 -1
  52. package/src/components/chat/streaming-bubble.tsx +36 -46
  53. package/src/components/chat/suggestions-bar.tsx +1 -1
  54. package/src/components/chat/thinking-indicator.tsx +72 -10
  55. package/src/components/chat/tool-call-bubble.tsx +66 -70
  56. package/src/components/chat/tool-request-banner.tsx +31 -7
  57. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  58. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  59. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  60. package/src/components/chatrooms/chatroom-list.tsx +123 -0
  61. package/src/components/chatrooms/chatroom-message.tsx +427 -0
  62. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  63. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  64. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  65. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  66. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  67. package/src/components/connectors/connector-sheet.tsx +34 -47
  68. package/src/components/home/home-view.tsx +501 -0
  69. package/src/components/input/chat-input.tsx +79 -41
  70. package/src/components/knowledge/knowledge-list.tsx +31 -1
  71. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  72. package/src/components/layout/app-layout.tsx +175 -95
  73. package/src/components/layout/update-banner.tsx +2 -2
  74. package/src/components/logs/log-list.tsx +2 -2
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  76. package/src/components/memory/memory-agent-list.tsx +143 -0
  77. package/src/components/memory/memory-browser.tsx +205 -0
  78. package/src/components/memory/memory-card.tsx +34 -7
  79. package/src/components/memory/memory-detail.tsx +359 -120
  80. package/src/components/memory/memory-sheet.tsx +157 -23
  81. package/src/components/plugins/plugin-list.tsx +1 -1
  82. package/src/components/plugins/plugin-sheet.tsx +1 -1
  83. package/src/components/projects/project-detail.tsx +509 -0
  84. package/src/components/projects/project-list.tsx +195 -59
  85. package/src/components/providers/provider-list.tsx +2 -2
  86. package/src/components/providers/provider-sheet.tsx +3 -3
  87. package/src/components/schedules/schedule-card.tsx +1 -1
  88. package/src/components/schedules/schedule-list.tsx +1 -1
  89. package/src/components/schedules/schedule-sheet.tsx +25 -25
  90. package/src/components/secrets/secret-sheet.tsx +47 -24
  91. package/src/components/secrets/secrets-list.tsx +18 -8
  92. package/src/components/sessions/new-session-sheet.tsx +33 -65
  93. package/src/components/sessions/session-card.tsx +45 -14
  94. package/src/components/sessions/session-list.tsx +35 -18
  95. package/src/components/shared/agent-picker-list.tsx +90 -0
  96. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  97. package/src/components/shared/attachment-chip.tsx +165 -0
  98. package/src/components/shared/avatar.tsx +10 -1
  99. package/src/components/shared/check-icon.tsx +12 -0
  100. package/src/components/shared/confirm-dialog.tsx +1 -1
  101. package/src/components/shared/empty-state.tsx +32 -0
  102. package/src/components/shared/file-preview.tsx +34 -0
  103. package/src/components/shared/form-styles.ts +2 -0
  104. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  105. package/src/components/shared/notification-center.tsx +44 -6
  106. package/src/components/shared/profile-sheet.tsx +115 -0
  107. package/src/components/shared/reply-quote.tsx +26 -0
  108. package/src/components/shared/search-dialog.tsx +14 -5
  109. package/src/components/shared/section-label.tsx +12 -0
  110. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  111. package/src/components/shared/settings/section-providers.tsx +1 -1
  112. package/src/components/shared/settings/section-secrets.tsx +1 -1
  113. package/src/components/shared/settings/section-theme.tsx +95 -0
  114. package/src/components/shared/settings/section-user-preferences.tsx +39 -0
  115. package/src/components/shared/settings/settings-page.tsx +180 -27
  116. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  117. package/src/components/shared/sheet-footer.tsx +33 -0
  118. package/src/components/skills/skill-list.tsx +61 -30
  119. package/src/components/skills/skill-sheet.tsx +81 -2
  120. package/src/components/tasks/task-board.tsx +448 -26
  121. package/src/components/tasks/task-card.tsx +46 -9
  122. package/src/components/tasks/task-column.tsx +62 -3
  123. package/src/components/tasks/task-list.tsx +12 -4
  124. package/src/components/tasks/task-sheet.tsx +89 -72
  125. package/src/components/ui/hover-card.tsx +52 -0
  126. package/src/components/usage/usage-list.tsx +1 -1
  127. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  128. package/src/hooks/use-view-router.ts +69 -19
  129. package/src/instrumentation.ts +15 -1
  130. package/src/lib/chat.ts +2 -0
  131. package/src/lib/memory.ts +3 -0
  132. package/src/lib/server/chat-execution.ts +24 -4
  133. package/src/lib/server/connectors/manager.ts +11 -0
  134. package/src/lib/server/context-manager.ts +225 -13
  135. package/src/lib/server/create-notification.ts +14 -2
  136. package/src/lib/server/daemon-state.ts +157 -10
  137. package/src/lib/server/execution-log.ts +1 -0
  138. package/src/lib/server/heartbeat-service.ts +40 -5
  139. package/src/lib/server/heartbeat-wake.ts +110 -0
  140. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  141. package/src/lib/server/memory-consolidation.ts +92 -0
  142. package/src/lib/server/memory-db.ts +51 -6
  143. package/src/lib/server/openclaw-gateway.ts +9 -1
  144. package/src/lib/server/provider-health.ts +125 -0
  145. package/src/lib/server/queue.ts +5 -4
  146. package/src/lib/server/scheduler.ts +8 -0
  147. package/src/lib/server/session-run-manager.ts +4 -0
  148. package/src/lib/server/session-tools/chatroom.ts +136 -0
  149. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  150. package/src/lib/server/session-tools/index.ts +2 -0
  151. package/src/lib/server/session-tools/memory.ts +6 -1
  152. package/src/lib/server/storage.ts +53 -29
  153. package/src/lib/server/stream-agent-chat.ts +153 -47
  154. package/src/lib/server/system-events.ts +49 -0
  155. package/src/lib/server/ws-hub.ts +11 -0
  156. package/src/lib/soul-suggestions.ts +109 -0
  157. package/src/lib/tasks.ts +4 -1
  158. package/src/lib/view-routes.ts +36 -1
  159. package/src/lib/ws-client.ts +14 -4
  160. package/src/stores/use-app-store.ts +36 -2
  161. package/src/stores/use-chat-store.ts +48 -3
  162. package/src/stores/use-chatroom-store.ts +276 -0
  163. package/src/types/index.ts +56 -2
package/src/lib/tasks.ts CHANGED
@@ -4,7 +4,7 @@ import type { BoardTask } from '../types'
4
4
  export const fetchTasks = (includeArchived = false) =>
5
5
  api<Record<string, BoardTask>>('GET', `/tasks${includeArchived ? '?includeArchived=true' : ''}`)
6
6
 
7
- export const createTask = (data: { title: string; description: string; agentId: string }) =>
7
+ export const createTask = (data: { title: string; description: string; agentId: string; status?: string }) =>
8
8
  api<BoardTask>('POST', '/tasks', data)
9
9
 
10
10
  export const updateTask = (id: string, data: Partial<BoardTask>) =>
@@ -18,3 +18,6 @@ export const archiveTask = (id: string) =>
18
18
 
19
19
  export const unarchiveTask = (id: string) =>
20
20
  api<BoardTask>('PUT', `/tasks/${id}`, { status: 'backlog' })
21
+
22
+ export const bulkUpdateTasks = (ids: string[], data: { status?: string; agentId?: string | null; projectId?: string | null }) =>
23
+ api<{ updated: number; ids: string[] }>('POST', '/tasks/bulk', { ids, ...data })
@@ -1,9 +1,11 @@
1
1
  import type { AppView } from '@/types'
2
2
 
3
- export const DEFAULT_VIEW: AppView = 'agents'
3
+ export const DEFAULT_VIEW: AppView = 'home'
4
4
 
5
5
  export const VIEW_TO_PATH: Record<AppView, string> = {
6
+ home: '/',
6
7
  agents: '/agents',
8
+ chatrooms: '/chatrooms',
7
9
  schedules: '/schedules',
8
10
  memory: '/memory',
9
11
  tasks: '/tasks',
@@ -27,3 +29,36 @@ const entries = Object.entries(VIEW_TO_PATH) as [AppView, string][]
27
29
  export const PATH_TO_VIEW: Record<string, AppView> = Object.fromEntries(
28
30
  entries.map(([view, path]) => [path, view]),
29
31
  ) as Record<string, AppView>
32
+
33
+ /** Views that support deep-linking to a specific entity by ID */
34
+ const VIEWS_WITH_ID = new Set<AppView>(['agents', 'chatrooms'])
35
+
36
+ // Sorted longest-first so "/mcp-servers" matches before "/" etc.
37
+ const sortedPaths = entries
38
+ .map(([view, path]) => ({ view, path }))
39
+ .sort((a, b) => b.path.length - a.path.length)
40
+
41
+ /** Parse a pathname into { view, id }. Returns null for unknown paths. */
42
+ export function parsePath(pathname: string): { view: AppView; id: string | null } | null {
43
+ // Exact match first (no trailing ID)
44
+ const exact = PATH_TO_VIEW[pathname]
45
+ if (exact) return { view: exact, id: null }
46
+
47
+ // Prefix match: "/agents/abc123" → view=agents, id=abc123
48
+ for (const { view, path } of sortedPaths) {
49
+ if (pathname.startsWith(path + '/')) {
50
+ const rest = pathname.slice(path.length + 1)
51
+ if (rest && !rest.includes('/') && VIEWS_WITH_ID.has(view)) {
52
+ return { view, id: decodeURIComponent(rest) }
53
+ }
54
+ }
55
+ }
56
+ return null
57
+ }
58
+
59
+ /** Build a URL path for a view, optionally with an entity ID. */
60
+ export function buildPath(view: AppView, id?: string | null): string {
61
+ const base = VIEW_TO_PATH[view]
62
+ if (id && VIEWS_WITH_ID.has(view)) return `${base}/${encodeURIComponent(id)}`
63
+ return base
64
+ }
@@ -9,10 +9,20 @@ const listeners = new Map<string, Set<WsCallback>>()
9
9
  let connected = false
10
10
 
11
11
  function getWsUrl(key: string): string {
12
- const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost'
13
- const port = process.env.NEXT_PUBLIC_WS_PORT || '3457'
14
- const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss' : 'ws'
15
- return `${protocol}://${host}:${port}/ws?key=${encodeURIComponent(key)}`
12
+ if (typeof window === 'undefined') return `ws://localhost:3457/ws?key=${encodeURIComponent(key)}`
13
+
14
+ const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
15
+ const pagePort = window.location.port
16
+ const buildPort = process.env.NEXT_PUBLIC_WS_PORT || '3457'
17
+
18
+ // If the page was loaded on a standard HTTP port (80/443/empty) or a port
19
+ // that doesn't match the expected app port, we're likely behind a reverse
20
+ // proxy. Use the page's host directly so the proxy can route /ws traffic.
21
+ const appPort = String((Number(buildPort) || 3457) - 1) // e.g. 3456
22
+ const behindProxy = !pagePort || pagePort === '80' || pagePort === '443' || pagePort !== appPort
23
+ const wsHost = behindProxy ? window.location.host : `${window.location.hostname}:${buildPort}`
24
+
25
+ return `${protocol}://${wsHost}/ws?key=${encodeURIComponent(key)}`
16
26
  }
17
27
 
18
28
  function handleMessage(event: MessageEvent) {
@@ -53,6 +53,7 @@ interface AppState {
53
53
 
54
54
  agents: Record<string, Agent>
55
55
  loadAgents: () => Promise<void>
56
+ togglePinAgent: (id: string) => void
56
57
 
57
58
  schedules: Record<string, Schedule>
58
59
  loadSchedules: () => Promise<void>
@@ -180,10 +181,18 @@ interface AppState {
180
181
  fleetFilter: FleetFilter
181
182
  setFleetFilter: (filter: FleetFilter) => void
182
183
 
184
+ // Chat list filter
185
+ chatFilter: 'all' | 'active' | 'recent'
186
+ setChatFilter: (filter: 'all' | 'active' | 'recent') => void
187
+
183
188
  // Activity / Audit Trail
184
189
  activityEntries: ActivityEntry[]
185
190
  loadActivity: (filters?: { entityType?: string; limit?: number }) => Promise<void>
186
191
 
192
+ // Unread tracking (localStorage-backed)
193
+ lastReadTimestamps: Record<string, number>
194
+ markChatRead: (id: string) => void
195
+
187
196
  // Notifications
188
197
  notifications: AppNotification[]
189
198
  unreadNotificationCount: number
@@ -200,7 +209,8 @@ export const useAppStore = create<AppState>((set, get) => ({
200
209
  hydrate: () => {
201
210
  if (typeof window === 'undefined') return
202
211
  const user = localStorage.getItem('sc_user')
203
- set({ currentUser: user, _hydrated: true })
212
+ const savedAgentId = localStorage.getItem('sc_agent')
213
+ set({ currentUser: user, currentAgentId: savedAgentId, _hydrated: true })
204
214
  },
205
215
  setUser: (user) => {
206
216
  if (user) localStorage.setItem('sc_user', user)
@@ -305,16 +315,18 @@ export const useAppStore = create<AppState>((set, get) => ({
305
315
  newSessionOpen: false,
306
316
  setNewSessionOpen: (open) => set({ newSessionOpen: open }),
307
317
 
308
- activeView: 'agents',
318
+ activeView: 'home',
309
319
  setActiveView: (view) => set({ activeView: view }),
310
320
 
311
321
  currentAgentId: null,
312
322
  setCurrentAgent: async (id) => {
313
323
  if (!id) {
314
324
  set({ currentAgentId: null })
325
+ if (typeof window !== 'undefined') localStorage.removeItem('sc_agent')
315
326
  return
316
327
  }
317
328
  set({ currentAgentId: id })
329
+ if (typeof window !== 'undefined') localStorage.setItem('sc_agent', id)
318
330
  try {
319
331
  const user = get().currentUser || 'default'
320
332
  const session = await api<Session>('POST', `/agents/${id}/thread`, { user })
@@ -336,6 +348,14 @@ export const useAppStore = create<AppState>((set, get) => ({
336
348
  // ignore
337
349
  }
338
350
  },
351
+ togglePinAgent: (id) => {
352
+ const agents = { ...get().agents }
353
+ if (agents[id]) {
354
+ agents[id] = { ...agents[id], pinned: !agents[id].pinned }
355
+ set({ agents })
356
+ void api('PUT', `/agents/${id}`, { pinned: agents[id].pinned })
357
+ }
358
+ },
339
359
 
340
360
  schedules: {},
341
361
  loadSchedules: async () => {
@@ -584,6 +604,10 @@ export const useAppStore = create<AppState>((set, get) => ({
584
604
  fleetFilter: 'all',
585
605
  setFleetFilter: (filter) => set({ fleetFilter: filter }),
586
606
 
607
+ // Chat list filter
608
+ chatFilter: 'all' as const,
609
+ setChatFilter: (filter) => set({ chatFilter: filter }),
610
+
587
611
  // Activity / Audit Trail
588
612
  activityEntries: [],
589
613
  loadActivity: async (filters) => {
@@ -599,6 +623,16 @@ export const useAppStore = create<AppState>((set, get) => ({
599
623
  }
600
624
  },
601
625
 
626
+ // Unread tracking
627
+ lastReadTimestamps: typeof window !== 'undefined'
628
+ ? (() => { try { return JSON.parse(localStorage.getItem('sc_last_read') || '{}') } catch { return {} } })()
629
+ : {},
630
+ markChatRead: (id) => {
631
+ const ts = { ...get().lastReadTimestamps, [id]: Date.now() }
632
+ set({ lastReadTimestamps: ts })
633
+ try { localStorage.setItem('sc_last_read', JSON.stringify(ts)) } catch { /* ignore */ }
634
+ },
635
+
602
636
  // Notifications
603
637
  notifications: [],
604
638
  unreadNotificationCount: 0,
@@ -68,6 +68,10 @@ interface ChatState {
68
68
  pendingImage: PendingFile | null
69
69
  setPendingImage: (img: PendingFile | null) => void
70
70
 
71
+ // Reply-to
72
+ replyingTo: { message: Message; index: number } | null
73
+ setReplyingTo: (reply: { message: Message; index: number } | null) => void
74
+
71
75
  devServer: DevServerStatus | null
72
76
  setDevServer: (ds: DevServerStatus | null) => void
73
77
 
@@ -83,12 +87,22 @@ interface ChatState {
83
87
  sendHeartbeat: (sessionId: string) => Promise<void>
84
88
  stopStreaming: () => void
85
89
 
90
+ // Thinking/reasoning text during streaming
91
+ thinkingText: string
92
+ thinkingStartTime: number
93
+
86
94
  // Rich trace blocks during streaming (F13)
87
95
  streamTraces: ChatTraceBlock[]
88
96
 
89
97
  // Voice conversation
90
98
  voiceConversationActive: boolean
91
99
  onStreamEvent: ((event: { t: string; text?: string }) => void) | null
100
+
101
+ // Message queue (send while streaming)
102
+ queuedMessages: string[]
103
+ addQueuedMessage: (text: string) => void
104
+ removeQueuedMessage: (index: number) => void
105
+ shiftQueuedMessage: () => string | undefined
92
106
  }
93
107
 
94
108
  // Module-level cadence interval (not in state to avoid re-renders)
@@ -128,9 +142,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
128
142
  setSoundEnabled(next)
129
143
  set({ soundEnabled: next })
130
144
  },
145
+ thinkingText: '',
146
+ thinkingStartTime: 0,
131
147
  streamTraces: [],
132
148
  voiceConversationActive: false,
133
149
  onStreamEvent: null,
150
+ queuedMessages: [],
151
+ addQueuedMessage: (text) => set((s) => ({ queuedMessages: [...s.queuedMessages, text] })),
152
+ removeQueuedMessage: (index) => set((s) => ({ queuedMessages: s.queuedMessages.filter((_, i) => i !== index) })),
153
+ shiftQueuedMessage: () => {
154
+ const q = get().queuedMessages
155
+ if (!q.length) return undefined
156
+ const next = q[0]
157
+ set({ queuedMessages: q.slice(1) })
158
+ return next
159
+ },
134
160
 
135
161
  pendingFiles: [],
136
162
  addPendingFile: (f) => set((s) => ({ pendingFiles: [...s.pendingFiles, f] })),
@@ -141,6 +167,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
141
167
  get pendingImage() { const files = get().pendingFiles; return files.length ? files[0] : null },
142
168
  setPendingImage: (img) => set({ pendingFiles: img ? [img] : [] }),
143
169
 
170
+ // Reply-to
171
+ replyingTo: null,
172
+ setReplyingTo: (reply) => set({ replyingTo: reply }),
173
+
144
174
  previewContent: null,
145
175
  setPreviewContent: (content) => set({ previewContent: content }),
146
176
 
@@ -150,7 +180,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
150
180
  setDebugOpen: (open) => set({ debugOpen: open }),
151
181
 
152
182
  sendMessage: async (text: string) => {
153
- const { pendingFiles } = get()
183
+ const { pendingFiles, replyingTo } = get()
154
184
  if ((!text.trim() && !pendingFiles.length) || get().streaming) return
155
185
  const sessionId = useAppStore.getState().currentSessionId
156
186
  if (!sessionId) return
@@ -162,6 +192,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
162
192
  const attachedFiles = pendingFiles.length > 1
163
193
  ? pendingFiles.map((f) => f.path)
164
194
  : undefined
195
+ const replyToId = replyingTo?.message?.replyToId ? undefined : replyingTo?.message ? `msg-${replyingTo.index}` : undefined
165
196
 
166
197
  const userMsg: Message = {
167
198
  role: 'user',
@@ -170,6 +201,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
170
201
  imagePath,
171
202
  imageUrl,
172
203
  attachedFiles,
204
+ ...(replyToId ? { replyToId } : {}),
173
205
  }
174
206
  clearCadence()
175
207
  set((s) => ({
@@ -180,8 +212,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
180
212
  streamToolName: '',
181
213
  displayText: '',
182
214
  agentStatus: null,
215
+ thinkingText: '',
216
+ thinkingStartTime: Date.now(),
183
217
  messages: [...s.messages, userMsg],
184
218
  pendingFiles: [],
219
+ replyingTo: null,
185
220
  toolEvents: [],
186
221
  lastUsage: null,
187
222
  }))
@@ -296,6 +331,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
296
331
  set({ streamText: fullText })
297
332
  if (get().soundEnabled) playError()
298
333
  }
334
+ } else if (event.t === 'thinking') {
335
+ set((s) => ({ thinkingText: s.thinkingText + (event.text || '') }))
299
336
  } else if (event.t === 'status') {
300
337
  try {
301
338
  const parsed = JSON.parse(event.text || '{}')
@@ -306,7 +343,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
306
343
  } else if (event.t === 'done') {
307
344
  // done
308
345
  }
309
- }, attachedFiles)
346
+ }, attachedFiles, { replyToId })
310
347
 
311
348
  clearCadence()
312
349
  if (get().soundEnabled && soundFiredStart) playStreamEnd()
@@ -333,13 +370,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
333
370
  displayText: '',
334
371
  streamPhase: 'thinking' as const,
335
372
  streamToolName: '',
373
+ thinkingText: '',
374
+ thinkingStartTime: 0,
336
375
  }))
337
376
  if (get().ttsEnabled && !get().voiceConversationActive) speak(fullText)
338
377
  } else {
339
- set({ streaming: false, streamingSessionId: null, streamText: '', displayText: '', streamPhase: 'thinking' as const, streamToolName: '' })
378
+ set({ streaming: false, streamingSessionId: null, streamText: '', displayText: '', streamPhase: 'thinking' as const, streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
340
379
  }
341
380
 
342
381
  useAppStore.getState().loadSessions()
382
+
383
+ // Auto-dequeue: if there are queued messages, send the next one
384
+ const nextQueued = get().shiftQueuedMessage()
385
+ if (nextQueued) {
386
+ setTimeout(() => get().sendMessage(nextQueued), 100)
387
+ }
343
388
  },
344
389
 
345
390
  editAndResend: async (messageIndex: number, newText: string) => {
@@ -0,0 +1,276 @@
1
+ 'use client'
2
+
3
+ import { create } from 'zustand'
4
+ import { api, getStoredAccessKey } from '@/lib/api-client'
5
+ import type { Chatroom, ChatroomMessage, SSEEvent } from '@/types'
6
+ import type { PendingFile } from '@/stores/use-chat-store'
7
+
8
+ interface ToolEvent {
9
+ name: string
10
+ input: string
11
+ output?: string
12
+ }
13
+
14
+ interface StreamingAgent {
15
+ text: string
16
+ name: string
17
+ error?: string
18
+ toolEvents: ToolEvent[]
19
+ }
20
+
21
+ interface ChatroomState {
22
+ chatrooms: Record<string, Chatroom>
23
+ currentChatroomId: string | null
24
+ streaming: boolean
25
+ streamingAgents: Map<string, StreamingAgent>
26
+ chatroomSheetOpen: boolean
27
+ editingChatroomId: string | null
28
+
29
+ // File uploads
30
+ pendingFiles: PendingFile[]
31
+ addPendingFile: (f: PendingFile) => void
32
+ removePendingFile: (index: number) => void
33
+ clearPendingFiles: () => void
34
+
35
+ // Reply-to
36
+ replyingTo: ChatroomMessage | null
37
+ setReplyingTo: (msg: ChatroomMessage | null) => void
38
+
39
+ loadChatrooms: () => Promise<void>
40
+ createChatroom: (data: { name: string; description?: string; agentIds?: string[]; chatMode?: 'sequential' | 'parallel'; autoAddress?: boolean }) => Promise<Chatroom>
41
+ updateChatroom: (id: string, data: Partial<Chatroom>) => Promise<void>
42
+ deleteChatroom: (id: string) => Promise<void>
43
+ setCurrentChatroom: (id: string | null) => void
44
+ sendMessage: (text: string) => Promise<void>
45
+ toggleReaction: (messageId: string, emoji: string) => Promise<void>
46
+ togglePin: (messageId: string) => Promise<void>
47
+ addMember: (agentId: string) => Promise<void>
48
+ removeMember: (agentId: string) => Promise<void>
49
+ setChatroomSheetOpen: (open: boolean) => void
50
+ setEditingChatroomId: (id: string | null) => void
51
+ }
52
+
53
+ export const useChatroomStore = create<ChatroomState>((set, get) => ({
54
+ chatrooms: {},
55
+ currentChatroomId: null,
56
+ streaming: false,
57
+ streamingAgents: new Map(),
58
+ chatroomSheetOpen: false,
59
+ editingChatroomId: null,
60
+
61
+ // File uploads
62
+ pendingFiles: [],
63
+ addPendingFile: (f) => set((s) => ({ pendingFiles: [...s.pendingFiles, f] })),
64
+ removePendingFile: (index) => set((s) => ({ pendingFiles: s.pendingFiles.filter((_, i) => i !== index) })),
65
+ clearPendingFiles: () => set({ pendingFiles: [] }),
66
+
67
+ // Reply-to
68
+ replyingTo: null,
69
+ setReplyingTo: (msg) => set({ replyingTo: msg }),
70
+
71
+ loadChatrooms: async () => {
72
+ const chatrooms = await api<Record<string, Chatroom>>('GET', '/chatrooms')
73
+ set({ chatrooms })
74
+ },
75
+
76
+ createChatroom: async (data) => {
77
+ const chatroom = await api<Chatroom>('POST', '/chatrooms', data)
78
+ set((s) => ({ chatrooms: { ...s.chatrooms, [chatroom.id]: chatroom } }))
79
+ return chatroom
80
+ },
81
+
82
+ updateChatroom: async (id, data) => {
83
+ const chatroom = await api<Chatroom>('PUT', `/chatrooms/${id}`, data)
84
+ set((s) => ({ chatrooms: { ...s.chatrooms, [id]: chatroom } }))
85
+ },
86
+
87
+ deleteChatroom: async (id) => {
88
+ await api('DELETE', `/chatrooms/${id}`)
89
+ set((s) => {
90
+ const chatrooms = { ...s.chatrooms }
91
+ delete chatrooms[id]
92
+ return {
93
+ chatrooms,
94
+ currentChatroomId: s.currentChatroomId === id ? null : s.currentChatroomId,
95
+ }
96
+ })
97
+ },
98
+
99
+ setCurrentChatroom: (id) => set({ currentChatroomId: id }),
100
+
101
+ sendMessage: async (text) => {
102
+ const { currentChatroomId, streaming, pendingFiles, replyingTo } = get()
103
+ if (!currentChatroomId || streaming || (!text.trim() && !pendingFiles.length)) return
104
+
105
+ set({ streaming: true, streamingAgents: new Map(), pendingFiles: [], replyingTo: null })
106
+
107
+ const imagePath = pendingFiles.length > 0 && pendingFiles[0].file.type.startsWith('image/')
108
+ ? pendingFiles[0].path
109
+ : undefined
110
+ const attachedFiles = pendingFiles.length > 0
111
+ ? pendingFiles.map((f) => f.path)
112
+ : undefined
113
+
114
+ const key = getStoredAccessKey()
115
+ try {
116
+ const res = await fetch(`/api/chatrooms/${currentChatroomId}/chat`, {
117
+ method: 'POST',
118
+ headers: {
119
+ 'Content-Type': 'application/json',
120
+ ...(key ? { 'X-Access-Key': key } : {}),
121
+ },
122
+ body: JSON.stringify({
123
+ text,
124
+ ...(imagePath ? { imagePath } : {}),
125
+ ...(attachedFiles ? { attachedFiles } : {}),
126
+ ...(replyingTo ? { replyToId: replyingTo.id } : {}),
127
+ }),
128
+ })
129
+
130
+ if (!res.ok || !res.body) {
131
+ set({ streaming: false })
132
+ return
133
+ }
134
+
135
+ const reader = res.body.getReader()
136
+ const decoder = new TextDecoder()
137
+ let buf = ''
138
+
139
+ while (true) {
140
+ const { done, value } = await reader.read()
141
+ if (done) break
142
+ buf += decoder.decode(value, { stream: true })
143
+ const lines = buf.split('\n')
144
+ buf = lines.pop() || ''
145
+ for (const line of lines) {
146
+ if (!line.startsWith('data: ')) continue
147
+ try {
148
+ const event = JSON.parse(line.slice(6)) as SSEEvent
149
+ const agentId = event.agentId
150
+ const agentName = event.agentName
151
+
152
+ if (event.t === 'cr_agent_start' && agentId && agentName) {
153
+ set((s) => {
154
+ const agents = new Map(s.streamingAgents)
155
+ agents.set(agentId, { text: '', name: agentName, toolEvents: [] })
156
+ return { streamingAgents: agents }
157
+ })
158
+ } else if (event.t === 'tool_call' && agentId && event.toolName) {
159
+ set((s) => {
160
+ const agents = new Map(s.streamingAgents)
161
+ const existing = agents.get(agentId)
162
+ if (existing) {
163
+ agents.set(agentId, {
164
+ ...existing,
165
+ toolEvents: [...existing.toolEvents, { name: event.toolName!, input: event.toolInput || '' }],
166
+ })
167
+ }
168
+ return { streamingAgents: agents }
169
+ })
170
+ } else if (event.t === 'tool_result' && agentId) {
171
+ set((s) => {
172
+ const agents = new Map(s.streamingAgents)
173
+ const existing = agents.get(agentId)
174
+ if (existing && existing.toolEvents.length > 0) {
175
+ const updatedEvents = [...existing.toolEvents]
176
+ const last = updatedEvents[updatedEvents.length - 1]
177
+ updatedEvents[updatedEvents.length - 1] = { ...last, output: event.toolOutput || event.text || '' }
178
+ agents.set(agentId, { ...existing, toolEvents: updatedEvents })
179
+ }
180
+ return { streamingAgents: agents }
181
+ })
182
+ } else if (event.t === 'd' && agentId && event.text) {
183
+ set((s) => {
184
+ const agents = new Map(s.streamingAgents)
185
+ const existing = agents.get(agentId)
186
+ if (existing) {
187
+ agents.set(agentId, { ...existing, text: existing.text + event.text })
188
+ }
189
+ return { streamingAgents: agents }
190
+ })
191
+ } else if (event.t === 'err' && agentId && event.text) {
192
+ set((s) => {
193
+ const agents = new Map(s.streamingAgents)
194
+ const existing = agents.get(agentId)
195
+ if (existing) {
196
+ agents.set(agentId, { ...existing, error: event.text })
197
+ }
198
+ return { streamingAgents: agents }
199
+ })
200
+ } else if (event.t === 'cr_agent_done' && agentId) {
201
+ const currentAgent = get().streamingAgents.get(agentId)
202
+ if (currentAgent?.error) {
203
+ setTimeout(() => {
204
+ set((s) => {
205
+ const agents = new Map(s.streamingAgents)
206
+ agents.delete(agentId)
207
+ return { streamingAgents: agents }
208
+ })
209
+ }, 4000)
210
+ } else {
211
+ set((s) => {
212
+ const agents = new Map(s.streamingAgents)
213
+ agents.delete(agentId)
214
+ return { streamingAgents: agents }
215
+ })
216
+ }
217
+ try {
218
+ const { currentChatroomId: cid } = get()
219
+ if (cid) {
220
+ const chatroom = await api<Chatroom>('GET', `/chatrooms/${cid}`)
221
+ set((s) => ({ chatrooms: { ...s.chatrooms, [cid]: chatroom } }))
222
+ }
223
+ } catch { /* will catch on next WS push */ }
224
+ } else if (event.t === 'done') {
225
+ break
226
+ }
227
+ } catch {
228
+ // skip malformed
229
+ }
230
+ }
231
+ }
232
+ } finally {
233
+ set({ streaming: false, streamingAgents: new Map() })
234
+ try {
235
+ const { currentChatroomId: cid } = get()
236
+ if (cid) {
237
+ const chatroom = await api<Chatroom>('GET', `/chatrooms/${cid}`)
238
+ set((s) => ({ chatrooms: { ...s.chatrooms, [cid]: chatroom } }))
239
+ }
240
+ } catch { /* ignore */ }
241
+ }
242
+ },
243
+
244
+ toggleReaction: async (messageId, emoji) => {
245
+ const { currentChatroomId } = get()
246
+ if (!currentChatroomId) return
247
+ await api('POST', `/chatrooms/${currentChatroomId}/reactions`, { messageId, emoji })
248
+ const chatroom = await api<Chatroom>('GET', `/chatrooms/${currentChatroomId}`)
249
+ set((s) => ({ chatrooms: { ...s.chatrooms, [currentChatroomId]: chatroom } }))
250
+ },
251
+
252
+ togglePin: async (messageId) => {
253
+ const { currentChatroomId } = get()
254
+ if (!currentChatroomId) return
255
+ await api('POST', `/chatrooms/${currentChatroomId}/pins`, { messageId })
256
+ const chatroom = await api<Chatroom>('GET', `/chatrooms/${currentChatroomId}`)
257
+ set((s) => ({ chatrooms: { ...s.chatrooms, [currentChatroomId]: chatroom } }))
258
+ },
259
+
260
+ addMember: async (agentId) => {
261
+ const { currentChatroomId } = get()
262
+ if (!currentChatroomId) return
263
+ const chatroom = await api<Chatroom>('POST', `/chatrooms/${currentChatroomId}/members`, { agentId })
264
+ set((s) => ({ chatrooms: { ...s.chatrooms, [currentChatroomId]: chatroom } }))
265
+ },
266
+
267
+ removeMember: async (agentId) => {
268
+ const { currentChatroomId } = get()
269
+ if (!currentChatroomId) return
270
+ const chatroom = await api<Chatroom>('DELETE', `/chatrooms/${currentChatroomId}/members`, { agentId })
271
+ set((s) => ({ chatrooms: { ...s.chatrooms, [currentChatroomId]: chatroom } }))
272
+ },
273
+
274
+ setChatroomSheetOpen: (open) => set({ chatroomSheetOpen: open }),
275
+ setEditingChatroomId: (id) => set({ editingChatroomId: id }),
276
+ }))