@swarmclawai/swarmclaw 0.5.3 → 0.6.2

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 (224) hide show
  1. package/README.md +53 -9
  2. package/bin/server-cmd.js +1 -0
  3. package/bin/swarmclaw.js +76 -16
  4. package/next.config.ts +11 -1
  5. package/package.json +5 -2
  6. package/scripts/postinstall.mjs +18 -0
  7. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  8. package/src/app/api/chatrooms/[id]/chat/route.ts +284 -0
  9. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  10. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  11. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  12. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  13. package/src/app/api/chatrooms/route.ts +50 -0
  14. package/src/app/api/connectors/[id]/route.ts +1 -0
  15. package/src/app/api/connectors/route.ts +2 -1
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/files/open/route.ts +43 -0
  18. package/src/app/api/knowledge/[id]/route.ts +13 -2
  19. package/src/app/api/knowledge/route.ts +8 -1
  20. package/src/app/api/memory/route.ts +8 -0
  21. package/src/app/api/notifications/route.ts +4 -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 +53 -1
  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/[id]/messages/route.ts +70 -2
  29. package/src/app/api/sessions/[id]/route.ts +4 -0
  30. package/src/app/api/sessions/route.ts +3 -3
  31. package/src/app/api/settings/route.ts +9 -0
  32. package/src/app/api/setup/check-provider/route.ts +3 -16
  33. package/src/app/api/skills/[id]/route.ts +6 -0
  34. package/src/app/api/skills/route.ts +6 -0
  35. package/src/app/api/tasks/[id]/route.ts +12 -0
  36. package/src/app/api/tasks/bulk/route.ts +100 -0
  37. package/src/app/api/tasks/metrics/route.ts +101 -0
  38. package/src/app/api/tasks/route.ts +18 -2
  39. package/src/app/api/tts/route.ts +3 -2
  40. package/src/app/api/tts/stream/route.ts +3 -2
  41. package/src/app/api/uploads/[filename]/route.ts +19 -34
  42. package/src/app/api/uploads/route.ts +94 -0
  43. package/src/app/api/webhooks/[id]/route.ts +15 -1
  44. package/src/app/globals.css +63 -15
  45. package/src/app/page.tsx +142 -13
  46. package/src/cli/index.js +40 -1
  47. package/src/cli/index.test.js +30 -0
  48. package/src/cli/spec.js +42 -0
  49. package/src/components/agents/agent-avatar.tsx +57 -10
  50. package/src/components/agents/agent-card.tsx +50 -17
  51. package/src/components/agents/agent-chat-list.tsx +148 -12
  52. package/src/components/agents/agent-list.tsx +50 -19
  53. package/src/components/agents/agent-sheet.tsx +120 -65
  54. package/src/components/agents/inspector-panel.tsx +81 -6
  55. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  56. package/src/components/agents/personality-builder.tsx +42 -14
  57. package/src/components/agents/soul-library-picker.tsx +89 -0
  58. package/src/components/auth/access-key-gate.tsx +10 -3
  59. package/src/components/auth/setup-wizard.tsx +2 -2
  60. package/src/components/auth/user-picker.tsx +31 -3
  61. package/src/components/canvas/canvas-panel.tsx +96 -0
  62. package/src/components/chat/activity-moment.tsx +173 -0
  63. package/src/components/chat/chat-area.tsx +46 -22
  64. package/src/components/chat/chat-header.tsx +457 -286
  65. package/src/components/chat/chat-preview-panel.tsx +1 -2
  66. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  67. package/src/components/chat/delegation-banner.tsx +371 -0
  68. package/src/components/chat/file-path-chip.tsx +146 -0
  69. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  70. package/src/components/chat/markdown-utils.ts +9 -0
  71. package/src/components/chat/message-bubble.tsx +356 -315
  72. package/src/components/chat/message-list.tsx +230 -8
  73. package/src/components/chat/streaming-bubble.tsx +104 -47
  74. package/src/components/chat/suggestions-bar.tsx +1 -1
  75. package/src/components/chat/thinking-indicator.tsx +72 -10
  76. package/src/components/chat/tool-call-bubble.tsx +111 -73
  77. package/src/components/chat/tool-request-banner.tsx +31 -7
  78. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  79. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  80. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  81. package/src/components/chatrooms/chatroom-list.tsx +130 -0
  82. package/src/components/chatrooms/chatroom-message.tsx +432 -0
  83. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  84. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  85. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  86. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  87. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  88. package/src/components/connectors/connector-list.tsx +168 -90
  89. package/src/components/connectors/connector-sheet.tsx +95 -56
  90. package/src/components/home/home-view.tsx +501 -0
  91. package/src/components/input/chat-input.tsx +107 -43
  92. package/src/components/knowledge/knowledge-list.tsx +31 -1
  93. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  94. package/src/components/layout/app-layout.tsx +194 -97
  95. package/src/components/layout/update-banner.tsx +2 -2
  96. package/src/components/logs/log-list.tsx +2 -2
  97. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  98. package/src/components/memory/memory-agent-list.tsx +143 -0
  99. package/src/components/memory/memory-browser.tsx +205 -0
  100. package/src/components/memory/memory-card.tsx +34 -7
  101. package/src/components/memory/memory-detail.tsx +359 -120
  102. package/src/components/memory/memory-sheet.tsx +157 -23
  103. package/src/components/plugins/plugin-list.tsx +1 -1
  104. package/src/components/plugins/plugin-sheet.tsx +1 -1
  105. package/src/components/projects/project-detail.tsx +509 -0
  106. package/src/components/projects/project-list.tsx +195 -59
  107. package/src/components/providers/provider-list.tsx +2 -2
  108. package/src/components/providers/provider-sheet.tsx +3 -3
  109. package/src/components/schedules/schedule-card.tsx +1 -1
  110. package/src/components/schedules/schedule-list.tsx +1 -1
  111. package/src/components/schedules/schedule-sheet.tsx +259 -126
  112. package/src/components/secrets/secret-sheet.tsx +47 -24
  113. package/src/components/secrets/secrets-list.tsx +18 -8
  114. package/src/components/sessions/new-session-sheet.tsx +33 -65
  115. package/src/components/sessions/session-card.tsx +45 -14
  116. package/src/components/sessions/session-list.tsx +35 -18
  117. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  118. package/src/components/shared/agent-picker-list.tsx +90 -0
  119. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  120. package/src/components/shared/attachment-chip.tsx +165 -0
  121. package/src/components/shared/avatar.tsx +10 -1
  122. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  123. package/src/components/shared/check-icon.tsx +12 -0
  124. package/src/components/shared/confirm-dialog.tsx +1 -1
  125. package/src/components/shared/connector-platform-icon.tsx +51 -4
  126. package/src/components/shared/empty-state.tsx +32 -0
  127. package/src/components/shared/file-preview.tsx +34 -0
  128. package/src/components/shared/form-styles.ts +2 -0
  129. package/src/components/shared/icon-button.tsx +16 -2
  130. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  131. package/src/components/shared/notification-center.tsx +44 -6
  132. package/src/components/shared/profile-sheet.tsx +115 -0
  133. package/src/components/shared/reply-quote.tsx +26 -0
  134. package/src/components/shared/search-dialog.tsx +31 -15
  135. package/src/components/shared/section-label.tsx +12 -0
  136. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  137. package/src/components/shared/settings/section-embedding.tsx +48 -13
  138. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  139. package/src/components/shared/settings/section-providers.tsx +1 -1
  140. package/src/components/shared/settings/section-secrets.tsx +1 -1
  141. package/src/components/shared/settings/section-storage.tsx +206 -0
  142. package/src/components/shared/settings/section-theme.tsx +95 -0
  143. package/src/components/shared/settings/section-user-preferences.tsx +57 -0
  144. package/src/components/shared/settings/section-voice.tsx +42 -21
  145. package/src/components/shared/settings/section-web-search.tsx +30 -6
  146. package/src/components/shared/settings/settings-page.tsx +182 -27
  147. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  148. package/src/components/shared/settings/storage-browser.tsx +259 -0
  149. package/src/components/shared/sheet-footer.tsx +33 -0
  150. package/src/components/skills/skill-list.tsx +61 -30
  151. package/src/components/skills/skill-sheet.tsx +81 -2
  152. package/src/components/tasks/task-board.tsx +448 -26
  153. package/src/components/tasks/task-card.tsx +59 -9
  154. package/src/components/tasks/task-column.tsx +62 -3
  155. package/src/components/tasks/task-list.tsx +12 -4
  156. package/src/components/tasks/task-sheet.tsx +416 -74
  157. package/src/components/ui/hover-card.tsx +52 -0
  158. package/src/components/usage/metrics-dashboard.tsx +90 -6
  159. package/src/components/usage/usage-list.tsx +1 -1
  160. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  161. package/src/hooks/use-continuous-speech.ts +10 -4
  162. package/src/hooks/use-view-router.ts +69 -19
  163. package/src/hooks/use-voice-conversation.ts +53 -10
  164. package/src/hooks/use-ws.ts +4 -2
  165. package/src/instrumentation.ts +15 -1
  166. package/src/lib/chat.ts +2 -0
  167. package/src/lib/memory.ts +3 -0
  168. package/src/lib/providers/anthropic.ts +13 -7
  169. package/src/lib/providers/index.ts +1 -0
  170. package/src/lib/providers/openai.ts +13 -7
  171. package/src/lib/server/chat-execution.ts +75 -15
  172. package/src/lib/server/chatroom-helpers.ts +146 -0
  173. package/src/lib/server/connectors/manager.ts +229 -7
  174. package/src/lib/server/context-manager.ts +225 -13
  175. package/src/lib/server/create-notification.ts +14 -2
  176. package/src/lib/server/daemon-state.ts +157 -10
  177. package/src/lib/server/execution-log.ts +1 -0
  178. package/src/lib/server/heartbeat-service.ts +48 -6
  179. package/src/lib/server/heartbeat-wake.ts +110 -0
  180. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  181. package/src/lib/server/main-agent-loop.ts +1 -1
  182. package/src/lib/server/memory-consolidation.ts +105 -0
  183. package/src/lib/server/memory-db.ts +183 -10
  184. package/src/lib/server/mime.ts +51 -0
  185. package/src/lib/server/openclaw-gateway.ts +9 -1
  186. package/src/lib/server/orchestrator-lg.ts +2 -0
  187. package/src/lib/server/orchestrator.ts +5 -2
  188. package/src/lib/server/playwright-proxy.mjs +2 -3
  189. package/src/lib/server/prompt-runtime-context.ts +53 -0
  190. package/src/lib/server/provider-health.ts +125 -0
  191. package/src/lib/server/queue.ts +56 -10
  192. package/src/lib/server/scheduler.ts +8 -0
  193. package/src/lib/server/session-run-manager.ts +4 -0
  194. package/src/lib/server/session-tools/canvas.ts +67 -0
  195. package/src/lib/server/session-tools/chatroom.ts +136 -0
  196. package/src/lib/server/session-tools/connector.ts +83 -9
  197. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  198. package/src/lib/server/session-tools/crud.ts +21 -0
  199. package/src/lib/server/session-tools/delegate.ts +68 -4
  200. package/src/lib/server/session-tools/git.ts +71 -0
  201. package/src/lib/server/session-tools/http.ts +57 -0
  202. package/src/lib/server/session-tools/index.ts +10 -0
  203. package/src/lib/server/session-tools/memory.ts +7 -1
  204. package/src/lib/server/session-tools/search-providers.ts +16 -8
  205. package/src/lib/server/session-tools/subagent.ts +106 -0
  206. package/src/lib/server/session-tools/web.ts +115 -4
  207. package/src/lib/server/storage.ts +53 -29
  208. package/src/lib/server/stream-agent-chat.ts +185 -57
  209. package/src/lib/server/system-events.ts +49 -0
  210. package/src/lib/server/task-mention.ts +41 -0
  211. package/src/lib/server/ws-hub.ts +11 -0
  212. package/src/lib/sessions.ts +10 -0
  213. package/src/lib/soul-library.ts +103 -0
  214. package/src/lib/soul-suggestions.ts +109 -0
  215. package/src/lib/task-dedupe.ts +26 -0
  216. package/src/lib/tasks.ts +4 -1
  217. package/src/lib/tool-definitions.ts +2 -0
  218. package/src/lib/tts.ts +2 -2
  219. package/src/lib/view-routes.ts +36 -1
  220. package/src/lib/ws-client.ts +14 -4
  221. package/src/stores/use-app-store.ts +41 -3
  222. package/src/stores/use-chat-store.ts +113 -5
  223. package/src/stores/use-chatroom-store.ts +276 -0
  224. package/src/types/index.ts +88 -4
@@ -1,5 +1,8 @@
1
1
  'use client'
2
2
 
3
+ import { useEffect, useState } from 'react'
4
+ import ReactMarkdown from 'react-markdown'
5
+ import remarkGfm from 'remark-gfm'
3
6
  import { AiAvatar } from '@/components/shared/avatar'
4
7
  import { AgentAvatar } from '@/components/agents/agent-avatar'
5
8
  import { useChatStore } from '@/stores/use-chat-store'
@@ -10,31 +13,90 @@ interface Props {
10
13
  agentName?: string
11
14
  }
12
15
 
16
+ function ElapsedTimer({ startTime }: { startTime: number }) {
17
+ const [elapsed, setElapsed] = useState(0)
18
+
19
+ useEffect(() => {
20
+ if (!startTime) return
21
+ const tick = () => setElapsed(Math.floor((Date.now() - startTime) / 1000))
22
+ tick()
23
+ const id = setInterval(tick, 250)
24
+ return () => clearInterval(id)
25
+ }, [startTime])
26
+
27
+ if (!elapsed) return null
28
+ const mins = Math.floor(elapsed / 60)
29
+ const secs = elapsed % 60
30
+ return (
31
+ <span className="text-[10px] text-text-3/50 font-mono tabular-nums">
32
+ {mins > 0 ? `${mins}m ${secs}s` : `${secs}s`}
33
+ </span>
34
+ )
35
+ }
36
+
13
37
  export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentName }: Props) {
14
38
  const streamPhase = useChatStore((s) => s.streamPhase)
15
39
  const streamToolName = useChatStore((s) => s.streamToolName)
40
+ const thinkingText = useChatStore((s) => s.thinkingText)
41
+ const thinkingStartTime = useChatStore((s) => s.thinkingStartTime)
16
42
 
17
43
  const statusText = streamPhase === 'tool' && streamToolName
18
44
  ? `Using ${streamToolName}...`
19
45
  : 'Thinking...'
20
46
 
47
+ const hasThinkingContent = thinkingText.trim().length > 0
48
+
21
49
  return (
22
- <div className="flex flex-col items-start"
50
+ <div className="flex flex-col items-start relative pl-[44px]"
23
51
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}>
52
+ <div className="absolute left-[4px] top-0">
53
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
54
+ </div>
24
55
  <div className="flex items-center gap-2.5 mb-2 px-1">
25
- {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
26
56
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
27
57
  </div>
28
- <div className="bubble-ai px-6 py-5">
29
- <div className="flex items-center gap-3">
30
- <div className="flex gap-2">
31
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite' }} />
32
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.15s' }} />
33
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.3s' }} />
58
+
59
+ {hasThinkingContent ? (
60
+ <details className="group/think w-full max-w-[85%] md:max-w-[72%]">
61
+ <summary className="bubble-ai px-5 py-3.5 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
62
+ <div className="flex items-center gap-3">
63
+ <div className="flex gap-2">
64
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite' }} />
65
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.15s' }} />
66
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.3s' }} />
67
+ </div>
68
+ <span className="text-[12px] text-text-3/60 font-mono">{statusText}</span>
69
+ <ElapsedTimer startTime={thinkingStartTime} />
70
+ <svg
71
+ width="12" height="12" viewBox="0 0 24 24" fill="none"
72
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round"
73
+ className="shrink-0 text-text-3/50 transition-transform duration-200 group-open/think:rotate-180 ml-auto"
74
+ >
75
+ <polyline points="6 9 12 15 18 9" />
76
+ </svg>
77
+ </div>
78
+ </summary>
79
+ <div className="mt-2 px-4 py-3 rounded-[12px] bg-bg/60 border border-white/[0.04] max-h-[300px] overflow-y-auto">
80
+ <div className="msg-content text-[13px] leading-[1.6] text-text-3/80">
81
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
82
+ {thinkingText}
83
+ </ReactMarkdown>
84
+ </div>
85
+ </div>
86
+ </details>
87
+ ) : (
88
+ <div className="bubble-ai px-6 py-5">
89
+ <div className="flex items-center gap-3">
90
+ <div className="flex gap-2">
91
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite' }} />
92
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.15s' }} />
93
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.3s' }} />
94
+ </div>
95
+ <span className="text-[12px] text-text-3/60 font-mono">{statusText}</span>
96
+ <ElapsedTimer startTime={thinkingStartTime} />
34
97
  </div>
35
- <span className="text-[12px] text-text-3/60 font-mono">{statusText}</span>
36
98
  </div>
37
- </div>
99
+ )}
38
100
  </div>
39
101
  )
40
102
  }
@@ -20,6 +20,7 @@ const TOOL_COLORS: Record<string, string> = {
20
20
  web_search: '#3B82F6',
21
21
  web_fetch: '#3B82F6',
22
22
  delegate_to_agent: '#6366F1',
23
+ check_delegation_status: '#6366F1',
23
24
  delegate_to_claude_code: '#6366F1',
24
25
  delegate_to_codex_cli: '#0EA5E9',
25
26
  delegate_to_opencode_cli: '#14B8A6',
@@ -71,6 +72,7 @@ export const TOOL_LABELS: Record<string, string> = {
71
72
  codex_cli: 'Codex CLI',
72
73
  opencode_cli: 'OpenCode CLI',
73
74
  delegate_to_agent: 'Agent Delegation',
75
+ check_delegation_status: 'Check Delegation',
74
76
  delegate_to_claude_code: 'Claude Code',
75
77
  delegate_to_codex_cli: 'Codex CLI',
76
78
  delegate_to_opencode_cli: 'OpenCode CLI',
@@ -107,6 +109,7 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
107
109
  codex_cli: 'Enable delegation to OpenAI Codex CLI',
108
110
  opencode_cli: 'Enable delegation to OpenCode CLI',
109
111
  delegate_to_agent: 'Delegate a task to another agent',
112
+ check_delegation_status: 'Check the status of a delegated task',
110
113
  delegate_to_claude_code: 'Delegate complex coding tasks to Claude Code',
111
114
  delegate_to_codex_cli: 'Delegate complex coding tasks to Codex CLI',
112
115
  delegate_to_opencode_cli: 'Delegate complex coding tasks to OpenCode CLI',
@@ -164,6 +167,45 @@ function formatJson(raw: string): string {
164
167
  }
165
168
  }
166
169
 
170
+ /** Relative time label like "2h ago", "5m ago" */
171
+ function relativeTime(ts: number): string {
172
+ const diff = Date.now() - ts
173
+ if (diff < 60_000) return 'just now'
174
+ if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`
175
+ if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`
176
+ return `${Math.round(diff / 86_400_000)}d ago`
177
+ }
178
+
179
+ /** Format search_history_tool output into human-readable text */
180
+ function formatSearchHistoryOutput(raw: string): string | null {
181
+ try {
182
+ const parsed = JSON.parse(raw)
183
+ const matches = parsed?.matches
184
+ if (!Array.isArray(matches) || matches.length === 0) {
185
+ return parsed?.query ? `No matches found for "${parsed.query}"` : null
186
+ }
187
+ const header = `Found ${matches.length} match${matches.length === 1 ? '' : 'es'}${parsed.query ? ` for "${parsed.query}"` : ''}`
188
+ const lines = matches.slice(0, 10).map((m: Record<string, unknown>, i: number) => {
189
+ const role = String(m.role || 'unknown')
190
+ const kind = m.kind ? ` (${m.kind})` : ''
191
+ const time = typeof m.time === 'number' ? ` · ${relativeTime(m.time)}` : ''
192
+ const text = String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 200)
193
+ return `${i + 1}. [${role}]${kind}${time}\n ${text}${String(m.text || '').length > 200 ? '...' : ''}`
194
+ })
195
+ return `${header}\n\n${lines.join('\n\n')}`
196
+ } catch {
197
+ return null
198
+ }
199
+ }
200
+
201
+ /** Try to produce a human-readable output for known tool types */
202
+ function formatToolOutput(toolName: string, raw: string): string {
203
+ if (toolName === 'search_history_tool') {
204
+ return formatSearchHistoryOutput(raw) || formatJson(raw)
205
+ }
206
+ return formatJson(raw)
207
+ }
208
+
167
209
  /** Extract a human-readable preview from tool input */
168
210
  function getInputPreview(name: string, input: string): string {
169
211
  try {
@@ -218,7 +260,7 @@ function getInputPreview(name: string, input: string): string {
218
260
  }
219
261
 
220
262
  /** Extract embedded images, videos, PDFs, and file links from tool output */
221
- function extractMedia(output: string): { images: string[]; videos: string[]; pdfs: { name: string; url: string }[]; files: { name: string; url: string }[]; cleanText: string } {
263
+ export function extractMedia(output: string): { images: string[]; videos: string[]; pdfs: { name: string; url: string }[]; files: { name: string; url: string }[]; cleanText: string } {
222
264
  const images: string[] = []
223
265
  const videos: string[] = []
224
266
  const pdfs: { name: string; url: string }[] = []
@@ -308,7 +350,6 @@ function TimeoutQuickFix({ event }: { event: ToolEvent }) {
308
350
  }
309
351
 
310
352
  export function ToolCallBubble({ event }: { event: ToolEvent }) {
311
- const [expanded, setExpanded] = useState(false)
312
353
  const [imgExpanded, setImgExpanded] = useState(false)
313
354
  const isError = event.status === 'error'
314
355
  const color = isError ? '#F43F5E' : (TOOL_COLORS[event.name] || '#6366F1')
@@ -342,8 +383,8 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
342
383
 
343
384
  const formattedCleanOutput = useMemo(() => {
344
385
  if (!media.cleanText) return ''
345
- return formatJson(media.cleanText)
346
- }, [media.cleanText])
386
+ return formatToolOutput(event.name, media.cleanText)
387
+ }, [event.name, media.cleanText])
347
388
 
348
389
  const hasMedia = media.images.length > 0 || media.videos.length > 0 || media.pdfs.length > 0 || media.files.length > 0
349
390
 
@@ -367,84 +408,81 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
367
408
 
368
409
  return (
369
410
  <div className="w-full text-left">
370
- <button
371
- onClick={() => isError && setExpanded(!expanded)}
372
- className={`w-full text-left rounded-[12px] border bg-surface/80 backdrop-blur-sm transition-all duration-200 ${isError ? 'hover:bg-surface-2 cursor-pointer' : ''}`}
373
- style={{ borderLeft: `3px solid ${color}`, borderColor: `${color}33` }}
374
- >
375
- <div className="flex items-center gap-2.5 px-3.5 py-2.5">
376
- {isRunning ? (
377
- <span className="w-3.5 h-3.5 shrink-0 rounded-full border-2 border-current animate-spin" style={{ color, borderTopColor: 'transparent' }} />
378
- ) : isError ? (
379
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" className="shrink-0">
380
- <line x1="18" y1="6" x2="6" y2="18" />
381
- <line x1="6" y1="6" x2="18" y2="18" />
382
- </svg>
383
- ) : (
384
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" className="shrink-0">
385
- <polyline points="20 6 9 17 4 12" />
386
- </svg>
387
- )}
388
- <span className="text-[12px] font-700 uppercase tracking-wider shrink-0" style={{ color }}>
389
- {label}
390
- </span>
391
- {delegationInfo ? (
392
- <span className="text-[12px] text-text-2 font-mono truncate flex-1">
393
- <span
394
- role="link"
395
- tabIndex={0}
396
- onClick={handleAgentClick}
397
- onKeyDown={(e) => e.key === 'Enter' && handleAgentClick(e as unknown as React.MouseEvent)}
398
- className="text-accent-bright hover:underline cursor-pointer font-600"
399
- >
400
- {delegationInfo.agentName}
401
- </span>
402
- {delegationInfo.task && <span className="text-text-3">: {delegationInfo.task.slice(0, 80)}</span>}
403
- </span>
404
- ) : (
405
- <span className="text-[12px] text-text-2 font-mono truncate flex-1">
406
- {inputPreview}
407
- </span>
408
- )}
409
- {hasMedia && !expanded && (
410
- <span className="text-[10px] text-text-3/50 font-500 shrink-0">
411
- {media.images.length > 0 && `${media.images.length} image${media.images.length > 1 ? 's' : ''}`}
412
- {media.videos.length > 0 && `${(media.images.length > 0) ? ' · ' : ''}${media.videos.length} video${media.videos.length > 1 ? 's' : ''}`}
413
- {media.pdfs.length > 0 && `${(media.images.length > 0 || media.videos.length > 0) ? ' · ' : ''}${media.pdfs.length} PDF${media.pdfs.length > 1 ? 's' : ''}`}
414
- {media.files.length > 0 && `${(media.images.length > 0 || media.videos.length > 0 || media.pdfs.length > 0) ? ' · ' : ''}${media.files.length} file${media.files.length > 1 ? 's' : ''}`}
411
+ <details open={isError || isRunning || undefined} className="group/tool">
412
+ <summary
413
+ className="w-full text-left rounded-[12px] border bg-surface/80 backdrop-blur-sm transition-all duration-200 hover:bg-surface-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden"
414
+ style={{ borderLeft: `3px solid ${color}`, borderColor: `${color}33` }}
415
+ >
416
+ <div className="flex items-center gap-2.5 px-3.5 py-2.5">
417
+ {isRunning ? (
418
+ <span className="w-3.5 h-3.5 shrink-0 rounded-full border-2 border-current animate-spin" style={{ color, borderTopColor: 'transparent' }} />
419
+ ) : isError ? (
420
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" className="shrink-0">
421
+ <line x1="18" y1="6" x2="6" y2="18" />
422
+ <line x1="6" y1="6" x2="18" y2="18" />
423
+ </svg>
424
+ ) : (
425
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" className="shrink-0">
426
+ <polyline points="20 6 9 17 4 12" />
427
+ </svg>
428
+ )}
429
+ <span className="label-mono shrink-0" style={{ color }}>
430
+ {label}
415
431
  </span>
416
- )}
417
- {isError && (
432
+ {delegationInfo ? (
433
+ <span className="text-[12px] text-text-2 font-mono truncate flex-1">
434
+ <span
435
+ role="link"
436
+ tabIndex={0}
437
+ onClick={handleAgentClick}
438
+ onKeyDown={(e) => e.key === 'Enter' && handleAgentClick(e as unknown as React.MouseEvent)}
439
+ className="text-accent-bright hover:underline cursor-pointer font-600"
440
+ >
441
+ {delegationInfo.agentName}
442
+ </span>
443
+ {delegationInfo.task && <span className="text-text-3">: {delegationInfo.task.slice(0, 80)}</span>}
444
+ </span>
445
+ ) : (
446
+ <span className="text-[12px] text-text-2 font-mono truncate flex-1">
447
+ {inputPreview}
448
+ </span>
449
+ )}
450
+ {hasMedia && (
451
+ <span className="text-[10px] text-text-3/50 font-500 shrink-0 group-open/tool:hidden">
452
+ {media.images.length > 0 && `${media.images.length} image${media.images.length > 1 ? 's' : ''}`}
453
+ {media.videos.length > 0 && `${(media.images.length > 0) ? ' · ' : ''}${media.videos.length} video${media.videos.length > 1 ? 's' : ''}`}
454
+ {media.pdfs.length > 0 && `${(media.images.length > 0 || media.videos.length > 0) ? ' · ' : ''}${media.pdfs.length} PDF${media.pdfs.length > 1 ? 's' : ''}`}
455
+ {media.files.length > 0 && `${(media.images.length > 0 || media.videos.length > 0 || media.pdfs.length > 0) ? ' · ' : ''}${media.files.length} file${media.files.length > 1 ? 's' : ''}`}
456
+ </span>
457
+ )}
418
458
  <svg
419
459
  width="12" height="12" viewBox="0 0 24 24" fill="none"
420
460
  stroke="currentColor" strokeWidth="2" strokeLinecap="round"
421
- className={`shrink-0 text-text-3/70 transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
461
+ className="shrink-0 text-text-3/70 transition-transform duration-200 group-open/tool:rotate-180"
422
462
  >
423
463
  <polyline points="6 9 12 15 18 9" />
424
464
  </svg>
465
+ </div>
466
+ </summary>
467
+
468
+ <div className="px-3.5 pb-3 pt-1 space-y-2 border-t border-white/[0.04] mt-0" onClick={(e) => e.stopPropagation()}>
469
+ <div className="label-mono">Input</div>
470
+ <pre className="text-[12px] text-text-2 font-mono whitespace-pre-wrap break-all bg-bg/50 rounded-[8px] px-3 py-2 max-h-[200px] overflow-y-auto">
471
+ {formattedInput}
472
+ </pre>
473
+ {event.output && (
474
+ <>
475
+ <div className="label-mono mt-2">{isError ? 'Error' : 'Output'}</div>
476
+ {formattedCleanOutput && (
477
+ <pre className="text-[12px] text-text-2 font-mono whitespace-pre-wrap break-all bg-bg/50 rounded-[8px] px-3 py-2 max-h-[300px] overflow-y-auto">
478
+ {formattedCleanOutput}
479
+ </pre>
480
+ )}
481
+ {isError && <TimeoutQuickFix event={event} />}
482
+ </>
425
483
  )}
426
484
  </div>
427
-
428
- {expanded && isError && (
429
- <div className="px-3.5 pb-3 space-y-2" onClick={(e) => e.stopPropagation()}>
430
- <div className="text-[11px] text-text-3/60 uppercase tracking-wider font-600">Input</div>
431
- <pre className="text-[12px] text-text-2 font-mono whitespace-pre-wrap break-all bg-bg/50 rounded-[8px] px-3 py-2 max-h-[200px] overflow-y-auto">
432
- {formattedInput}
433
- </pre>
434
- {event.output && (
435
- <>
436
- <div className="text-[11px] text-text-3/60 uppercase tracking-wider font-600 mt-2">Error</div>
437
- {formattedCleanOutput && (
438
- <pre className="text-[12px] text-text-2 font-mono whitespace-pre-wrap break-all bg-bg/50 rounded-[8px] px-3 py-2 max-h-[300px] overflow-y-auto">
439
- {formattedCleanOutput}
440
- </pre>
441
- )}
442
- <TimeoutQuickFix event={event} />
443
- </>
444
- )}
445
- </div>
446
- )}
447
- </button>
485
+ </details>
448
486
 
449
487
  {/* Render images below the tool call bubble (always visible when present) */}
450
488
  {media.images.length > 0 && (
@@ -16,6 +16,7 @@ export function ToolRequestBanner({ text, toolOutputs = [] }: Props) {
16
16
  const currentSessionId = useAppStore((s) => s.currentSessionId)
17
17
  const sessions = useAppStore((s) => s.sessions)
18
18
  const [granted, setGranted] = useState<Set<string>>(new Set())
19
+ const [denied, setDenied] = useState<Set<string>>(new Set())
19
20
  const continueSentRef = useRef(false)
20
21
 
21
22
  const toolRequests: { toolId: string; reason: string }[] = []
@@ -74,10 +75,22 @@ export function ToolRequestBanner({ text, toolOutputs = [] }: Props) {
74
75
  }
75
76
  }
76
77
 
78
+ const handleDeny = (toolId: string) => {
79
+ setDenied((prev) => new Set(prev).add(toolId))
80
+ const label = TOOL_LABELS[toolId] || toolId
81
+ setTimeout(() => {
82
+ const { streaming, sendMessage } = useChatStore.getState()
83
+ if (!streaming) {
84
+ sendMessage(`Tool access denied for ${label} — proceed without it.`)
85
+ }
86
+ }, 200)
87
+ }
88
+
77
89
  return (
78
90
  <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mt-2">
79
91
  {toolRequests.map(({ toolId, reason }) => {
80
92
  const isGranted = granted.has(toolId) || (session?.tools || []).includes(toolId)
93
+ const isDenied = denied.has(toolId)
81
94
  const label = TOOL_LABELS[toolId] || toolId
82
95
  return (
83
96
  <div
@@ -96,14 +109,25 @@ export function ToolRequestBanner({ text, toolOutputs = [] }: Props) {
96
109
  </div>
97
110
  {isGranted ? (
98
111
  <span className="text-[11px] text-emerald-400 font-600 shrink-0">Granted</span>
112
+ ) : isDenied ? (
113
+ <span className="text-[11px] text-red-400 font-600 shrink-0">Denied</span>
99
114
  ) : (
100
- <button
101
- onClick={() => handleGrant(toolId)}
102
- className="px-3 py-1.5 rounded-[8px] bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 text-[11px] font-600 border-none cursor-pointer transition-colors shrink-0"
103
- style={{ fontFamily: 'inherit' }}
104
- >
105
- Grant
106
- </button>
115
+ <div className="flex gap-1.5 shrink-0">
116
+ <button
117
+ onClick={() => handleGrant(toolId)}
118
+ className="px-3 py-1.5 rounded-[8px] bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 text-[11px] font-600 border-none cursor-pointer transition-colors"
119
+ style={{ fontFamily: 'inherit' }}
120
+ >
121
+ Grant
122
+ </button>
123
+ <button
124
+ onClick={() => handleDeny(toolId)}
125
+ className="px-3 py-1.5 rounded-[8px] bg-red-500/15 hover:bg-red-500/25 text-red-400 text-[11px] font-600 border-none cursor-pointer transition-colors"
126
+ style={{ fontFamily: 'inherit' }}
127
+ >
128
+ Deny
129
+ </button>
130
+ </div>
107
131
  )}
108
132
  </div>
109
133
  )
@@ -0,0 +1,63 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
6
+
7
+ interface TransferAgentPickerProps {
8
+ /** Agent IDs to exclude from the list (e.g. current agent) */
9
+ excludeIds?: string[]
10
+ /** Restrict to these agent IDs only (e.g. chatroom members) */
11
+ filterIds?: string[]
12
+ onSelect: (agentId: string) => void
13
+ onClose: () => void
14
+ }
15
+
16
+ export function TransferAgentPicker({ excludeIds, filterIds, onSelect, onClose }: TransferAgentPickerProps) {
17
+ const agents = useAppStore((s) => s.agents)
18
+ const [query, setQuery] = useState('')
19
+
20
+ const excludeSet = new Set(excludeIds || [])
21
+ const filterSet = filterIds ? new Set(filterIds) : null
22
+
23
+ const filtered = Object.values(agents).filter((a) =>
24
+ !a.trashedAt
25
+ && !excludeSet.has(a.id)
26
+ && (!filterSet || filterSet.has(a.id))
27
+ && (!query || a.name.toLowerCase().includes(query.toLowerCase())),
28
+ )
29
+
30
+ return (
31
+ <>
32
+ <div className="fixed inset-0 z-40" onClick={onClose} />
33
+ <div className="absolute left-0 bottom-full mb-2 z-50 w-[220px] rounded-[10px] bg-surface/95 backdrop-blur-xl border border-white/[0.1] shadow-[0_12px_40px_rgba(0,0,0,0.5)] overflow-hidden">
34
+ <div className="p-2">
35
+ <input
36
+ value={query}
37
+ onChange={(e) => setQuery(e.target.value)}
38
+ placeholder="Search agents..."
39
+ autoFocus
40
+ className="w-full px-2 py-1.5 text-[12px] bg-white/[0.06] rounded-[6px] border border-white/[0.08] text-text placeholder:text-text-3/50 outline-none"
41
+ style={{ fontFamily: 'inherit' }}
42
+ />
43
+ </div>
44
+ <div className="max-h-[200px] overflow-y-auto">
45
+ {filtered.length === 0 && (
46
+ <div className="px-3 py-2 text-[11px] text-text-3/60 text-center">No agents</div>
47
+ )}
48
+ {filtered.map((a) => (
49
+ <button
50
+ key={a.id}
51
+ onClick={() => onSelect(a.id)}
52
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none"
53
+ style={{ fontFamily: 'inherit' }}
54
+ >
55
+ <AgentAvatar seed={a.avatarSeed} name={a.name} size={20} />
56
+ <span className="text-[12px] text-text truncate">{a.name}</span>
57
+ </button>
58
+ ))}
59
+ </div>
60
+ </div>
61
+ </>
62
+ )
63
+ }
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
5
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
6
+ import { useAppStore } from '@/stores/use-app-store'
7
+ import { api } from '@/lib/api-client'
8
+ import { AVAILABLE_TOOLS, PLATFORM_TOOLS, TOOL_LABELS } from '@/lib/tool-definitions'
9
+ import type { Agent } from '@/types'
10
+
11
+ interface Props {
12
+ agent: Agent
13
+ children: React.ReactNode
14
+ status?: 'idle' | 'busy' | 'online'
15
+ }
16
+
17
+ const ALL_TOOL_IDS = [...AVAILABLE_TOOLS, ...PLATFORM_TOOLS].map((t) => t.id)
18
+
19
+ export function AgentHoverCard({ agent, children, status }: Props) {
20
+ const [showAll, setShowAll] = useState(false)
21
+ const [busy, setBusy] = useState(false)
22
+ const tools = agent.tools ?? []
23
+
24
+ const displayTools = showAll ? ALL_TOOL_IDS : tools
25
+
26
+ const toggleTool = async (toolId: string) => {
27
+ if (busy) return
28
+ setBusy(true)
29
+ try {
30
+ const current = agent.tools || []
31
+ const updated = current.includes(toolId)
32
+ ? current.filter((t) => t !== toolId)
33
+ : [...current, toolId]
34
+ await api('PUT', `/agents/${agent.id}`, { tools: updated })
35
+ useAppStore.getState().loadAgents()
36
+ } finally {
37
+ setBusy(false)
38
+ }
39
+ }
40
+
41
+ return (
42
+ <HoverCard>
43
+ <HoverCardTrigger asChild>
44
+ {children}
45
+ </HoverCardTrigger>
46
+ <HoverCardContent align="start" className="w-[280px]">
47
+ {/* Header: avatar + name + model */}
48
+ <div className="flex items-center gap-2">
49
+ <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={28} status={status} />
50
+ <div className="min-w-0 flex-1">
51
+ <div className="text-[13px] font-600 text-text truncate">{agent.name}</div>
52
+ <div className="label-mono truncate">{agent.model}</div>
53
+ </div>
54
+ </div>
55
+
56
+ {/* Description */}
57
+ {agent.description && (
58
+ <p className="text-[12px] text-text-3 mt-1.5 line-clamp-1">{agent.description}</p>
59
+ )}
60
+
61
+ {/* Tools toggles */}
62
+ <div className="mt-2">
63
+ <div className="flex items-center justify-between mb-1.5">
64
+ <span className="text-[10px] font-600 text-text-3/60 uppercase tracking-wider">Tools</span>
65
+ <button
66
+ onClick={() => setShowAll(!showAll)}
67
+ className="text-[10px] text-accent-bright/70 hover:text-accent-bright font-500 bg-transparent border-none cursor-pointer"
68
+ >
69
+ {showAll ? 'Show enabled' : 'Show all'}
70
+ </button>
71
+ </div>
72
+ <div className="max-h-[200px] overflow-y-auto -mx-1 px-1">
73
+ {displayTools.length === 0 && (
74
+ <p className="text-[11px] text-text-3/50 py-1">No tools enabled</p>
75
+ )}
76
+ {displayTools.map((toolId) => {
77
+ const enabled = tools.includes(toolId)
78
+ return (
79
+ <label key={toolId} className="flex items-center gap-2 py-1 cursor-pointer">
80
+ <div
81
+ onClick={(e) => { e.preventDefault(); toggleTool(toolId) }}
82
+ className={`w-7 h-[16px] rounded-full transition-all duration-200 relative cursor-pointer shrink-0
83
+ ${enabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
84
+ >
85
+ <div className={`absolute top-[2px] w-[12px] h-[12px] rounded-full bg-white transition-all duration-200
86
+ ${enabled ? 'left-[13px]' : 'left-[2px]'}`} />
87
+ </div>
88
+ <span className={`text-[11px] ${enabled ? 'text-text-2' : 'text-text-3/70'}`}>
89
+ {TOOL_LABELS[toolId] || toolId}
90
+ </span>
91
+ </label>
92
+ )
93
+ })}
94
+ </div>
95
+ </div>
96
+
97
+ {/* Divider */}
98
+ <div className="border-t border-white/[0.06] my-2" />
99
+
100
+ {/* Actions */}
101
+ <div className="flex gap-2">
102
+ <button
103
+ onClick={() => {
104
+ useAppStore.getState().setActiveView('agents')
105
+ useAppStore.getState().setCurrentAgent(agent.id)
106
+ }}
107
+ className="flex-1 text-[12px] font-500 text-text-2 hover:text-text py-1 rounded-[6px] bg-white/[0.04] hover:bg-white/[0.08] transition-colors cursor-pointer"
108
+ >
109
+ Chat
110
+ </button>
111
+ <button
112
+ onClick={() => {
113
+ useAppStore.getState().setEditingAgentId(agent.id)
114
+ useAppStore.getState().setAgentSheetOpen(true)
115
+ }}
116
+ className="flex-1 text-[12px] font-500 text-text-2 hover:text-text py-1 rounded-[6px] bg-white/[0.04] hover:bg-white/[0.08] transition-colors cursor-pointer"
117
+ >
118
+ Edit
119
+ </button>
120
+ </div>
121
+ </HoverCardContent>
122
+ </HoverCard>
123
+ )
124
+ }