@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
@@ -1,14 +1,12 @@
1
1
  'use client'
2
2
 
3
3
  import { useMemo, useState } from 'react'
4
- import ReactMarkdown from 'react-markdown'
5
- import remarkGfm from 'remark-gfm'
6
- import rehypeHighlight from 'rehype-highlight'
7
4
  import { AiAvatar } from '@/components/shared/avatar'
8
5
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
- import { CodeBlock } from './code-block'
10
6
  import { ToolCallBubble } from './tool-call-bubble'
7
+ import { ActivityMoment, isNotableTool } from './activity-moment'
11
8
  import { useChatStore, type ToolEvent } from '@/stores/use-chat-store'
9
+ import { isStructuredMarkdown } from './markdown-utils'
12
10
 
13
11
  function ToolEventsSection({ toolEvents }: { toolEvents: ToolEvent[] }) {
14
12
  const [expanded, setExpanded] = useState(false)
@@ -63,18 +61,45 @@ interface Props {
63
61
  }
64
62
 
65
63
  export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentName }: Props) {
66
- const rendered = useMemo(() => text, [text])
67
64
  const toolEvents = useChatStore((s) => s.toolEvents)
68
65
  const streamPhase = useChatStore((s) => s.streamPhase)
69
66
  const streamToolName = useChatStore((s) => s.streamToolName)
67
+ const wide = useMemo(() => isStructuredMarkdown(text), [text])
68
+
69
+ // Track which activity moments have been dismissed
70
+ const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set())
71
+
72
+ // Find the latest completed notable tool event that hasn't been dismissed
73
+ let currentMoment: { id: string; name: string; input: string } | null = null
74
+ for (let i = toolEvents.length - 1; i >= 0; i--) {
75
+ const event = toolEvents[i]
76
+ if (event.status === 'done' && isNotableTool(event.name) && !dismissedIds.has(event.id)) {
77
+ currentMoment = { id: event.id, name: event.name, input: event.input }
78
+ break
79
+ }
80
+ }
81
+
82
+ const handleDismiss = (momentId: string) => {
83
+ setDismissedIds((prev) => new Set(prev).add(momentId))
84
+ }
70
85
 
71
86
  return (
72
87
  <div
73
- className="flex flex-col items-start"
88
+ className="flex flex-col items-start relative pl-[44px]"
74
89
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}
75
90
  >
91
+ <div className="absolute left-[4px] top-0 relative">
92
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
93
+ {currentMoment && (
94
+ <ActivityMoment
95
+ key={currentMoment.id}
96
+ toolName={currentMoment.name}
97
+ toolInput={currentMoment.input}
98
+ onDismiss={() => handleDismiss(currentMoment!.id)}
99
+ />
100
+ )}
101
+ </div>
76
102
  <div className="flex items-center gap-2.5 mb-2 px-1">
77
- {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
78
103
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
79
104
  <span className="w-2 h-2 rounded-full bg-accent-bright" style={{ animation: 'pulse 1.5s ease infinite' }} />
80
105
  {streamPhase === 'tool' && streamToolName && (
@@ -87,45 +112,10 @@ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentNam
87
112
  <ToolEventsSection toolEvents={toolEvents} />
88
113
  )}
89
114
 
90
- {rendered && (
91
- <div className="max-w-[85%] md:max-w-[72%] bubble-ai px-5 py-3.5">
92
- <div className="msg-content streaming-cursor text-[15px] leading-[1.7] break-words text-text">
93
- <ReactMarkdown
94
- remarkPlugins={[remarkGfm]}
95
- rehypePlugins={[rehypeHighlight]}
96
- components={{
97
- pre({ children }) {
98
- return <pre>{children}</pre>
99
- },
100
- code({ className, children }) {
101
- const isBlock = className?.startsWith('language-') || className?.startsWith('hljs')
102
- if (isBlock) {
103
- return <CodeBlock className={className}>{children}</CodeBlock>
104
- }
105
- return <code className={className}>{children}</code>
106
- },
107
- a({ href, children }) {
108
- if (!href) return <>{children}</>
109
- const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/)
110
- if (ytMatch) {
111
- return (
112
- <div className="my-2">
113
- <iframe
114
- src={`https://www.youtube-nocookie.com/embed/${ytMatch[1]}`}
115
- className="w-full aspect-video rounded-[10px] border border-white/10"
116
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
117
- allowFullScreen
118
- title="YouTube video"
119
- />
120
- </div>
121
- )
122
- }
123
- return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
124
- },
125
- }}
126
- >
127
- {rendered}
128
- </ReactMarkdown>
115
+ {text && (
116
+ <div className={`${wide ? 'max-w-[92%] md:max-w-[85%]' : 'max-w-[85%] md:max-w-[72%]'} bubble-ai px-5 py-3.5`}>
117
+ <div className="streaming-cursor text-[15px] leading-[1.7] break-words text-text whitespace-pre-wrap">
118
+ {text}
129
119
  </div>
130
120
  </div>
131
121
  )}
@@ -53,7 +53,7 @@ export function SuggestionsBar({ lastMessage, onSend }: Props) {
53
53
 
54
54
  return (
55
55
  <div
56
- className="flex flex-wrap gap-2 px-1 pt-2"
56
+ className="flex flex-wrap gap-2 px-1 pt-2 ml-10"
57
57
  style={{ animation: 'fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1)' }}
58
58
  >
59
59
  {suggestions.map((text) => (
@@ -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
  }
@@ -308,7 +308,6 @@ function TimeoutQuickFix({ event }: { event: ToolEvent }) {
308
308
  }
309
309
 
310
310
  export function ToolCallBubble({ event }: { event: ToolEvent }) {
311
- const [expanded, setExpanded] = useState(false)
312
311
  const [imgExpanded, setImgExpanded] = useState(false)
313
312
  const isError = event.status === 'error'
314
313
  const color = isError ? '#F43F5E' : (TOOL_COLORS[event.name] || '#6366F1')
@@ -367,84 +366,81 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
367
366
 
368
367
  return (
369
368
  <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' : ''}`}
369
+ <details open={isError || isRunning || undefined} className="group/tool">
370
+ <summary
371
+ 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"
372
+ style={{ borderLeft: `3px solid ${color}`, borderColor: `${color}33` }}
373
+ >
374
+ <div className="flex items-center gap-2.5 px-3.5 py-2.5">
375
+ {isRunning ? (
376
+ <span className="w-3.5 h-3.5 shrink-0 rounded-full border-2 border-current animate-spin" style={{ color, borderTopColor: 'transparent' }} />
377
+ ) : isError ? (
378
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" className="shrink-0">
379
+ <line x1="18" y1="6" x2="6" y2="18" />
380
+ <line x1="6" y1="6" x2="18" y2="18" />
381
+ </svg>
382
+ ) : (
383
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" className="shrink-0">
384
+ <polyline points="20 6 9 17 4 12" />
385
+ </svg>
386
+ )}
387
+ <span className="label-mono shrink-0" style={{ color }}>
388
+ {label}
415
389
  </span>
416
- )}
417
- {isError && (
390
+ {delegationInfo ? (
391
+ <span className="text-[12px] text-text-2 font-mono truncate flex-1">
392
+ <span
393
+ role="link"
394
+ tabIndex={0}
395
+ onClick={handleAgentClick}
396
+ onKeyDown={(e) => e.key === 'Enter' && handleAgentClick(e as unknown as React.MouseEvent)}
397
+ className="text-accent-bright hover:underline cursor-pointer font-600"
398
+ >
399
+ {delegationInfo.agentName}
400
+ </span>
401
+ {delegationInfo.task && <span className="text-text-3">: {delegationInfo.task.slice(0, 80)}</span>}
402
+ </span>
403
+ ) : (
404
+ <span className="text-[12px] text-text-2 font-mono truncate flex-1">
405
+ {inputPreview}
406
+ </span>
407
+ )}
408
+ {hasMedia && (
409
+ <span className="text-[10px] text-text-3/50 font-500 shrink-0 group-open/tool:hidden">
410
+ {media.images.length > 0 && `${media.images.length} image${media.images.length > 1 ? 's' : ''}`}
411
+ {media.videos.length > 0 && `${(media.images.length > 0) ? ' · ' : ''}${media.videos.length} video${media.videos.length > 1 ? 's' : ''}`}
412
+ {media.pdfs.length > 0 && `${(media.images.length > 0 || media.videos.length > 0) ? ' · ' : ''}${media.pdfs.length} PDF${media.pdfs.length > 1 ? 's' : ''}`}
413
+ {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' : ''}`}
414
+ </span>
415
+ )}
418
416
  <svg
419
417
  width="12" height="12" viewBox="0 0 24 24" fill="none"
420
418
  stroke="currentColor" strokeWidth="2" strokeLinecap="round"
421
- className={`shrink-0 text-text-3/70 transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
419
+ className="shrink-0 text-text-3/70 transition-transform duration-200 group-open/tool:rotate-180"
422
420
  >
423
421
  <polyline points="6 9 12 15 18 9" />
424
422
  </svg>
423
+ </div>
424
+ </summary>
425
+
426
+ <div className="px-3.5 pb-3 pt-1 space-y-2 border-t border-white/[0.04] mt-0" onClick={(e) => e.stopPropagation()}>
427
+ <div className="label-mono">Input</div>
428
+ <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">
429
+ {formattedInput}
430
+ </pre>
431
+ {event.output && (
432
+ <>
433
+ <div className="label-mono mt-2">{isError ? 'Error' : 'Output'}</div>
434
+ {formattedCleanOutput && (
435
+ <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">
436
+ {formattedCleanOutput}
437
+ </pre>
438
+ )}
439
+ {isError && <TimeoutQuickFix event={event} />}
440
+ </>
425
441
  )}
426
442
  </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>
443
+ </details>
448
444
 
449
445
  {/* Render images below the tool call bubble (always visible when present) */}
450
446
  {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-[#1a1a2e]/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
+ }