@swarmclawai/swarmclaw 0.6.8 → 0.7.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 (166) hide show
  1. package/README.md +70 -45
  2. package/next.config.ts +31 -6
  3. package/package.json +3 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +1 -0
  5. package/src/app/api/agents/route.ts +18 -5
  6. package/src/app/api/approvals/route.ts +22 -0
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
  9. package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
  10. package/src/app/api/memory/route.ts +36 -5
  11. package/src/app/api/notifications/route.ts +3 -0
  12. package/src/app/api/plugins/install/route.ts +57 -5
  13. package/src/app/api/plugins/marketplace/route.ts +73 -22
  14. package/src/app/api/plugins/route.ts +61 -1
  15. package/src/app/api/plugins/ui/route.ts +34 -0
  16. package/src/app/api/settings/route.ts +62 -0
  17. package/src/app/api/setup/doctor/route.ts +22 -5
  18. package/src/app/api/tasks/[id]/approve/route.ts +4 -3
  19. package/src/app/api/tasks/[id]/route.ts +11 -3
  20. package/src/app/api/tasks/route.ts +8 -2
  21. package/src/app/globals.css +27 -0
  22. package/src/app/page.tsx +10 -5
  23. package/src/cli/index.js +13 -0
  24. package/src/components/activity/activity-feed.tsx +9 -2
  25. package/src/components/agents/agent-avatar.tsx +5 -1
  26. package/src/components/agents/agent-card.tsx +55 -9
  27. package/src/components/agents/agent-sheet.tsx +86 -29
  28. package/src/components/agents/inspector-panel.tsx +1 -1
  29. package/src/components/auth/access-key-gate.tsx +63 -54
  30. package/src/components/auth/user-picker.tsx +37 -32
  31. package/src/components/chat/chat-area.tsx +11 -0
  32. package/src/components/chat/chat-header.tsx +69 -25
  33. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  34. package/src/components/chat/code-block.tsx +3 -1
  35. package/src/components/chat/exec-approval-card.tsx +8 -1
  36. package/src/components/chat/message-bubble.tsx +164 -4
  37. package/src/components/chat/message-list.tsx +30 -4
  38. package/src/components/chat/session-approval-card.tsx +80 -0
  39. package/src/components/chat/streaming-bubble.tsx +6 -5
  40. package/src/components/chat/thinking-indicator.tsx +48 -12
  41. package/src/components/chat/tool-request-banner.tsx +39 -20
  42. package/src/components/chatrooms/chatroom-list.tsx +11 -4
  43. package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
  44. package/src/components/connectors/connector-list.tsx +33 -11
  45. package/src/components/connectors/connector-sheet.tsx +29 -6
  46. package/src/components/home/home-view.tsx +20 -14
  47. package/src/components/input/chat-input.tsx +22 -1
  48. package/src/components/knowledge/knowledge-list.tsx +17 -18
  49. package/src/components/knowledge/knowledge-sheet.tsx +9 -5
  50. package/src/components/layout/app-layout.tsx +73 -21
  51. package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
  52. package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
  53. package/src/components/memory/memory-list.tsx +20 -13
  54. package/src/components/plugins/plugin-list.tsx +213 -59
  55. package/src/components/plugins/plugin-sheet.tsx +119 -24
  56. package/src/components/projects/project-list.tsx +17 -9
  57. package/src/components/providers/provider-list.tsx +21 -6
  58. package/src/components/providers/provider-sheet.tsx +42 -25
  59. package/src/components/runs/run-list.tsx +17 -13
  60. package/src/components/schedules/schedule-card.tsx +10 -3
  61. package/src/components/schedules/schedule-list.tsx +2 -2
  62. package/src/components/schedules/schedule-sheet.tsx +19 -7
  63. package/src/components/secrets/secret-sheet.tsx +7 -2
  64. package/src/components/secrets/secrets-list.tsx +18 -5
  65. package/src/components/sessions/new-session-sheet.tsx +183 -376
  66. package/src/components/sessions/session-card.tsx +10 -2
  67. package/src/components/settings/gateway-connection-panel.tsx +9 -8
  68. package/src/components/shared/command-palette.tsx +13 -5
  69. package/src/components/shared/empty-state.tsx +20 -8
  70. package/src/components/shared/notification-center.tsx +134 -86
  71. package/src/components/shared/profile-sheet.tsx +4 -0
  72. package/src/components/shared/settings/plugin-manager.tsx +360 -135
  73. package/src/components/shared/settings/section-capability-policy.tsx +3 -3
  74. package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
  75. package/src/components/skills/clawhub-browser.tsx +1 -0
  76. package/src/components/skills/skill-list.tsx +31 -12
  77. package/src/components/skills/skill-sheet.tsx +20 -7
  78. package/src/components/tasks/approvals-panel.tsx +170 -66
  79. package/src/components/tasks/task-board.tsx +20 -12
  80. package/src/components/tasks/task-card.tsx +21 -7
  81. package/src/components/tasks/task-column.tsx +4 -3
  82. package/src/components/tasks/task-list.tsx +1 -1
  83. package/src/components/tasks/task-sheet.tsx +130 -1
  84. package/src/components/ui/dialog.tsx +1 -0
  85. package/src/components/ui/sheet.tsx +1 -0
  86. package/src/components/usage/metrics-dashboard.tsx +66 -64
  87. package/src/components/wallets/wallet-panel.tsx +65 -41
  88. package/src/components/wallets/wallet-section.tsx +9 -3
  89. package/src/components/webhooks/webhook-list.tsx +21 -12
  90. package/src/components/webhooks/webhook-sheet.tsx +13 -3
  91. package/src/lib/approval-display.test.ts +45 -0
  92. package/src/lib/approval-display.ts +62 -0
  93. package/src/lib/clipboard.ts +38 -0
  94. package/src/lib/memory.ts +8 -0
  95. package/src/lib/providers/claude-cli.ts +5 -3
  96. package/src/lib/providers/index.ts +67 -21
  97. package/src/lib/runtime-loop.ts +3 -2
  98. package/src/lib/server/approvals.ts +150 -0
  99. package/src/lib/server/chat-execution.ts +223 -62
  100. package/src/lib/server/clawhub-client.ts +82 -6
  101. package/src/lib/server/connectors/manager.ts +27 -1
  102. package/src/lib/server/cost.test.ts +73 -0
  103. package/src/lib/server/cost.ts +165 -34
  104. package/src/lib/server/daemon-state.ts +42 -0
  105. package/src/lib/server/data-dir.ts +18 -1
  106. package/src/lib/server/integrity-monitor.ts +208 -0
  107. package/src/lib/server/llm-response-cache.test.ts +102 -0
  108. package/src/lib/server/llm-response-cache.ts +227 -0
  109. package/src/lib/server/main-agent-loop.ts +1 -1
  110. package/src/lib/server/main-session.ts +6 -3
  111. package/src/lib/server/mcp-conformance.test.ts +18 -0
  112. package/src/lib/server/mcp-conformance.ts +233 -0
  113. package/src/lib/server/memory-db.ts +180 -17
  114. package/src/lib/server/memory-retrieval.test.ts +56 -0
  115. package/src/lib/server/orchestrator-lg.ts +4 -1
  116. package/src/lib/server/orchestrator.ts +4 -3
  117. package/src/lib/server/plugins.ts +650 -142
  118. package/src/lib/server/process-manager.ts +18 -0
  119. package/src/lib/server/queue.ts +253 -11
  120. package/src/lib/server/runtime-settings.ts +9 -0
  121. package/src/lib/server/session-run-manager.test.ts +23 -0
  122. package/src/lib/server/session-run-manager.ts +11 -1
  123. package/src/lib/server/session-tools/canvas.ts +85 -50
  124. package/src/lib/server/session-tools/chatroom.ts +130 -127
  125. package/src/lib/server/session-tools/connector.ts +233 -454
  126. package/src/lib/server/session-tools/context-mgmt.ts +87 -105
  127. package/src/lib/server/session-tools/crud.ts +84 -7
  128. package/src/lib/server/session-tools/delegate.ts +351 -752
  129. package/src/lib/server/session-tools/discovery.ts +198 -0
  130. package/src/lib/server/session-tools/edit_file.ts +82 -0
  131. package/src/lib/server/session-tools/file-send.test.ts +39 -0
  132. package/src/lib/server/session-tools/file.ts +257 -425
  133. package/src/lib/server/session-tools/git.ts +87 -47
  134. package/src/lib/server/session-tools/http.ts +85 -33
  135. package/src/lib/server/session-tools/index.ts +205 -160
  136. package/src/lib/server/session-tools/memory.ts +152 -265
  137. package/src/lib/server/session-tools/monitor.ts +126 -0
  138. package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
  139. package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
  140. package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
  141. package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
  142. package/src/lib/server/session-tools/platform.ts +86 -0
  143. package/src/lib/server/session-tools/plugin-creator.ts +239 -0
  144. package/src/lib/server/session-tools/sample-ui.ts +97 -0
  145. package/src/lib/server/session-tools/sandbox.ts +175 -148
  146. package/src/lib/server/session-tools/schedule.ts +66 -31
  147. package/src/lib/server/session-tools/session-info.ts +104 -410
  148. package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
  149. package/src/lib/server/session-tools/shell.ts +171 -143
  150. package/src/lib/server/session-tools/subagent.ts +77 -77
  151. package/src/lib/server/session-tools/wallet.ts +182 -106
  152. package/src/lib/server/session-tools/web.ts +179 -349
  153. package/src/lib/server/storage.ts +24 -0
  154. package/src/lib/server/stream-agent-chat.ts +301 -244
  155. package/src/lib/server/task-quality-gate.test.ts +44 -0
  156. package/src/lib/server/task-quality-gate.ts +67 -0
  157. package/src/lib/server/task-validation.test.ts +78 -0
  158. package/src/lib/server/task-validation.ts +67 -2
  159. package/src/lib/server/tool-aliases.ts +68 -0
  160. package/src/lib/server/tool-capability-policy.ts +23 -5
  161. package/src/lib/tasks.ts +7 -1
  162. package/src/lib/tool-definitions.ts +23 -23
  163. package/src/lib/validation/schemas.ts +12 -0
  164. package/src/lib/view-routes.ts +2 -24
  165. package/src/stores/use-app-store.ts +23 -1
  166. package/src/types/index.ts +121 -7
@@ -5,7 +5,6 @@ import type { Session } from '@/types'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { useChatStore } from '@/stores/use-chat-store'
7
7
  import { IconButton } from '@/components/shared/icon-button'
8
- import { UsageBadge } from '@/components/shared/usage-badge'
9
8
  import { ChatToolToggles } from './chat-tool-toggles'
10
9
  import { api } from '@/lib/api-client'
11
10
  import {
@@ -17,6 +16,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
17
16
  import { ModelCombobox } from '@/components/shared/model-combobox'
18
17
  import { toast } from 'sonner'
19
18
  import type { ProviderType } from '@/types'
19
+ import { copyTextToClipboard } from '@/lib/clipboard'
20
20
  import { useWs } from '@/hooks/use-ws'
21
21
 
22
22
  function shortPath(p: string): string {
@@ -67,7 +67,6 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
67
67
  const toggleSound = useChatStore((s) => s.toggleSound)
68
68
  const debugOpen = useChatStore((s) => s.debugOpen)
69
69
  const setDebugOpen = useChatStore((s) => s.setDebugOpen)
70
- const lastUsage = useChatStore((s) => s.lastUsage)
71
70
  const agentStatus = useChatStore((s) => s.agentStatus)
72
71
  const agents = useAppStore((s) => s.agents)
73
72
  const tasks = useAppStore((s) => s.tasks)
@@ -109,18 +108,50 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
109
108
  const renameContainerRef = useRef<HTMLSpanElement>(null)
110
109
  const setWalletPanelAgentId = useAppStore((s) => s.setWalletPanelAgentId)
111
110
  const [walletBalance, setWalletBalance] = useState<number | null>(null)
111
+ const [headerWidgets, setHeaderWidgets] = useState<Array<{ id: string; label: string; icon?: string }>>([])
112
+
113
+ useEffect(() => {
114
+ api<Array<{ id: string; label: string; icon?: string }>>('GET', `/plugins/ui?type=header&sessionId=${session.id}`).then(widgets => {
115
+ if (Array.isArray(widgets)) setHeaderWidgets(widgets)
116
+ }).catch(() => {})
117
+ }, [session.id])
112
118
 
113
119
  const fetchWalletBalance = useCallback(async () => {
114
- if (!agent?.walletId) { setWalletBalance(null); return }
120
+ if (!agent?.walletId) {
121
+ setWalletBalance(null)
122
+ return
123
+ }
115
124
  try {
116
125
  const data = await api<{ balanceSol?: number }>('GET', `/wallets/${agent.walletId}`)
117
126
  setWalletBalance(data.balanceSol ?? null)
118
- } catch { setWalletBalance(null) }
127
+ } catch {
128
+ setWalletBalance(null)
129
+ }
119
130
  }, [agent?.walletId])
120
131
 
121
- useEffect(() => { fetchWalletBalance() }, [fetchWalletBalance])
132
+ useEffect(() => {
133
+ void fetchWalletBalance()
134
+ }, [fetchWalletBalance])
122
135
  useWs('wallets', fetchWalletBalance)
123
136
 
137
+
138
+ const visibleHeaderWidgets = useMemo(() => {
139
+ const seen = new Set<string>()
140
+ return headerWidgets.filter((widget) => {
141
+ const key = widget.id || widget.label
142
+ if (seen.has(key)) return false
143
+ seen.add(key)
144
+ return true
145
+ })
146
+ }, [headerWidgets])
147
+
148
+ const handleHeaderWidgetClick = (widgetId: string) => {
149
+ if (widgetId === 'wallet-status') {
150
+ if (agent?.id) setWalletPanelAgentId(agent.id)
151
+ setActiveView('wallets')
152
+ }
153
+ }
154
+
124
155
  // Find linked task for this session
125
156
  const linkedTask = useMemo(() => {
126
157
  return Object.values(tasks).find((t) => t.sessionId === session.id)
@@ -156,9 +187,11 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
156
187
 
157
188
  const handleCopySessionId = () => {
158
189
  if (!resumeHandle) return
159
- navigator.clipboard.writeText(resumeHandle.command)
160
- setCopied(true)
161
- setTimeout(() => setCopied(false), 2000)
190
+ void copyTextToClipboard(resumeHandle.command).then((copiedCommand) => {
191
+ if (!copiedCommand) return
192
+ setCopied(true)
193
+ setTimeout(() => setCopied(false), 2000)
194
+ })
162
195
  }
163
196
 
164
197
  const handleDismissResumeHandle = async (e: React.MouseEvent) => {
@@ -606,21 +639,37 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
606
639
  {/* Metadata tray: wallet · model · path · status */}
607
640
  <div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
608
641
  <span className="text-text-3/10 text-[10px] select-none shrink-0">/</span>
609
- {walletBalance !== null && (
610
- <>
642
+ {visibleHeaderWidgets.map((widget) => {
643
+ const actionable = widget.id === 'wallet-status'
644
+ const walletLabel = walletBalance !== null
645
+ ? `${walletBalance.toFixed(3)} SOL`
646
+ : (widget.label || 'Wallet')
647
+ return (
611
648
  <button
649
+ key={widget.id}
612
650
  type="button"
613
- onClick={() => { setWalletPanelAgentId(agent!.id); setActiveView('wallets') }}
614
- className="inline-flex items-center gap-1 shrink-0 bg-transparent border-none p-0.5 rounded-[4px] cursor-pointer text-[11px] text-text-3/45 font-mono hover:text-text-3/70 hover:bg-white/[0.04] transition-colors"
615
- title="View wallet"
651
+ onClick={actionable ? () => handleHeaderWidgetClick(widget.id) : undefined}
652
+ className={`inline-flex items-center gap-1 shrink-0 bg-transparent border-none p-0.5 rounded-[4px] text-[11px] font-mono transition-colors ${
653
+ actionable ? 'cursor-pointer text-text-3/45 hover:text-text-3/70 hover:bg-white/[0.04]' : 'cursor-default text-text-3/55'
654
+ }`}
655
+ title={actionable ? 'View wallet' : widget.label}
616
656
  >
617
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
618
- <rect x="2" y="6" width="20" height="14" rx="2" /><path d="M22 10H18a2 2 0 0 0 0 4h4" />
619
- </svg>
620
- {walletBalance.toFixed(3)} SOL
657
+ {actionable ? (
658
+ <>
659
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
660
+ <rect x="2" y="6" width="20" height="14" rx="2" />
661
+ <path d="M22 10H18a2 2 0 0 0 0 4h4" />
662
+ </svg>
663
+ {walletLabel}
664
+ </>
665
+ ) : (
666
+ widget.label
667
+ )}
621
668
  </button>
622
- <span className="text-text-3/10 text-[10px] select-none shrink-0">·</span>
623
- </>
669
+ )
670
+ })}
671
+ {visibleHeaderWidgets.length > 0 && (
672
+ <span className="text-text-3/10 text-[10px] select-none shrink-0">·</span>
624
673
  )}
625
674
  {modelName && (
626
675
  <div className="relative shrink-0" ref={modelSwitcherRef}>
@@ -668,12 +717,6 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
668
717
  )}
669
718
  </div>
670
719
  )}
671
- {lastUsage && !streaming && (
672
- <>
673
- <span className="text-text-3/10 text-[10px] select-none shrink-0">·</span>
674
- <UsageBadge {...lastUsage} />
675
- </>
676
- )}
677
720
  <button
678
721
  type="button"
679
722
  onClick={() => { api('POST', '/files/open', { path: session.cwd }).catch(() => {}) }}
@@ -855,6 +898,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
855
898
  <span className={`w-1.5 h-1.5 rounded-full ${missionPaused ? 'bg-amber-300' : 'bg-emerald-400'}`} />
856
899
  {missionPaused ? 'Paused' : 'Live'}
857
900
  </button>
901
+
858
902
  <button
859
903
  onClick={handleToggleMissionMode}
860
904
  disabled={mainLoopSaving}
@@ -8,8 +8,8 @@ import type { ToolDefinition } from '@/lib/tool-definitions'
8
8
  import type { Session } from '@/types'
9
9
 
10
10
  const TOOL_GROUPS: { label: string; tools: ToolDefinition[] }[] = [
11
- { label: 'Tools', tools: AVAILABLE_TOOLS },
12
- { label: 'Platform', tools: PLATFORM_TOOLS },
11
+ { label: 'Plugins', tools: AVAILABLE_TOOLS },
12
+ { label: 'Platform Plugins', tools: PLATFORM_TOOLS },
13
13
  ]
14
14
 
15
15
  const TOTAL_TOOL_COUNT = AVAILABLE_TOOLS.length + PLATFORM_TOOLS.length
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useCallback, useState, type ReactNode } from 'react'
4
+ import { copyTextToClipboard } from '@/lib/clipboard'
4
5
 
5
6
  function extractText(node: ReactNode): string {
6
7
  if (typeof node === 'string') return node
@@ -29,7 +30,8 @@ export function CodeBlock({ children, className }: Props) {
29
30
  const getText = useCallback(() => extractText(children), [children])
30
31
 
31
32
  const handleCopy = useCallback(() => {
32
- navigator.clipboard.writeText(getText()).then(() => {
33
+ void copyTextToClipboard(getText()).then((copiedText) => {
34
+ if (!copiedText) return
33
35
  setCopied(true)
34
36
  setTimeout(() => setCopied(false), 2000)
35
37
  })
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import { useState, useEffect } from 'react'
3
4
  import type { PendingExecApproval, ExecApprovalDecision } from '@/types'
4
5
  import { useApprovalStore } from '@/stores/use-approval-store'
5
6
 
@@ -9,13 +10,19 @@ interface Props {
9
10
 
10
11
  export function ExecApprovalCard({ approval }: Props) {
11
12
  const resolveApproval = useApprovalStore((s) => s.resolveApproval)
13
+ const [now, setNow] = useState(() => Date.now())
14
+
15
+ useEffect(() => {
16
+ const timer = setInterval(() => setNow(Date.now()), 5000)
17
+ return () => clearInterval(timer)
18
+ }, [])
12
19
 
13
20
  const handleResolve = (decision: ExecApprovalDecision) => {
14
21
  resolveApproval(approval.id, decision)
15
22
  }
16
23
 
17
24
  const alreadyResolved = approval.error?.includes('Already resolved') ?? false
18
- const expired = approval.expiresAtMs < Date.now()
25
+ const expired = approval.expiresAtMs < now
19
26
  const disabled = !!approval.resolving || expired || alreadyResolved
20
27
 
21
28
  return (
@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm'
6
6
  import rehypeHighlight from 'rehype-highlight'
7
7
  import type { Message } from '@/types'
8
8
  import { useAppStore } from '@/stores/use-app-store'
9
+ import { useChatStore } from '@/stores/use-chat-store'
9
10
  import { AiAvatar } from '@/components/shared/avatar'
10
11
  import { AgentAvatar } from '@/components/agents/agent-avatar'
11
12
  import { CodeBlock } from './code-block'
@@ -17,6 +18,7 @@ import { FilePathChip, FILE_PATH_RE, DIR_PATH_RE } from './file-path-chip'
17
18
  import { TransferAgentPicker } from './transfer-agent-picker'
18
19
  import { DelegationBanner, DelegationSourceBanner, TaskCompletionCard, parseTaskCompletion } from './delegation-banner'
19
20
  import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
21
+ import { copyTextToClipboard } from '@/lib/clipboard'
20
22
 
21
23
  /** Parse delegation-source metadata prefix from system messages */
22
24
  const DELEGATION_SOURCE_RE = /^\[delegation-source:([^:]*):([^:]*):([^\]]*)\]/
@@ -151,6 +153,33 @@ interface Props {
151
153
  export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentAvatarUrl, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork, onTransferToAgent, momentOverlay }: Props) {
152
154
  const isUser = message.role === 'user'
153
155
  const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
156
+ const isPluginUI = !isUser && message.kind === 'plugin-ui'
157
+ const scaffoldRequest = useMemo(() => {
158
+ if (isUser) return null
159
+ try {
160
+ const data = JSON.parse(message.text)
161
+ if (data.type === 'plugin_scaffold_request') return data
162
+ } catch { /* ignore */ }
163
+ return null
164
+ }, [message.text, isUser])
165
+
166
+ const installRequest = useMemo(() => {
167
+ if (isUser) return null
168
+ try {
169
+ const data = JSON.parse(message.text)
170
+ if (data.type === 'plugin_install_request') return data
171
+ } catch { /* ignore */ }
172
+ return null
173
+ }, [message.text, isUser])
174
+
175
+ const walletRequest = useMemo(() => {
176
+ if (isUser) return null
177
+ try {
178
+ const data = JSON.parse(message.text)
179
+ if (data.type === 'plugin_wallet_transfer_request') return data
180
+ } catch { /* ignore */ }
181
+ return null
182
+ }, [message.text, isUser])
154
183
  const currentUser = useAppStore((s) => s.currentUser)
155
184
  const [copied, setCopied] = useState(false)
156
185
  const [heartbeatExpanded, setHeartbeatExpanded] = useState(false)
@@ -217,7 +246,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
217
246
  const displayText = delegationSource ? delegationSource.rest : message.text
218
247
 
219
248
  const handleCopy = useCallback(() => {
220
- navigator.clipboard.writeText(message.text).then(() => {
249
+ void copyTextToClipboard(message.text).then((copiedText) => {
250
+ if (!copiedText) return
221
251
  setCopied(true)
222
252
  setTimeout(() => setCopied(false), 2000)
223
253
  })
@@ -226,7 +256,6 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
226
256
  return (
227
257
  <div
228
258
  className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start relative pl-[44px]'}`}
229
- style={{ animation: `${isUser ? 'msg-in-right' : 'msg-in-left'} 0.35s cubic-bezier(0.16, 1, 0.3, 1)` }}
230
259
  >
231
260
  {/* Avatar on spine (assistant) */}
232
261
  {!isUser && (
@@ -447,7 +476,138 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
447
476
  <div className={`${isStructured ? 'max-w-[92%] md:max-w-[85%]' : 'max-w-[85%] md:max-w-[72%]'} ${isUser ? 'bubble-user px-5 py-3.5' : isHeartbeat ? 'bubble-ai px-4 py-3' : 'bubble-ai px-5 py-3.5'}`}>
448
477
  {renderAttachments(message)}
449
478
 
450
- {isHeartbeat ? (
479
+ {walletRequest ? (
480
+ <div className="flex flex-col gap-3 p-4 rounded-[18px] bg-sky-500/[0.03] border border-sky-500/20 shadow-[0_0_20px_rgba(14,165,233,0.05)]">
481
+ <div className="flex items-center gap-2 mb-1">
482
+ <div className="w-5 h-5 rounded-full bg-sky-500/20 flex items-center justify-center text-sky-400">
483
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
484
+ <path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
485
+ </svg>
486
+ </div>
487
+ <span className="text-[11px] font-700 uppercase tracking-wider text-sky-400/80">Wallet Transfer Request</span>
488
+ </div>
489
+ <p className="text-[13px] text-text-2/90 leading-relaxed">{walletRequest.message}</p>
490
+ <div className="p-3 rounded-[12px] bg-black/40 border border-white/5 flex flex-col gap-2">
491
+ <div className="flex justify-between items-center">
492
+ <span className="text-[11px] text-text-3/60 font-600 uppercase">Amount</span>
493
+ <span className="text-[13px] font-700 text-sky-400">{walletRequest.amountSol} SOL</span>
494
+ </div>
495
+ <div className="flex flex-col gap-1">
496
+ <span className="text-[11px] text-text-3/60 font-600 uppercase">To Address</span>
497
+ <span className="text-[11px] font-mono text-text-2/70 break-all">{walletRequest.toAddress}</span>
498
+ </div>
499
+ {walletRequest.memo && (
500
+ <div className="flex flex-col gap-1 border-t border-white/5 pt-2">
501
+ <span className="text-[11px] text-text-3/60 font-600 uppercase">Memo</span>
502
+ <span className="text-[12px] text-text-3/80 italic">&quot;{walletRequest.memo}&quot;</span>
503
+ </div>
504
+ )}
505
+ </div>
506
+ <div className="flex gap-2 mt-1">
507
+ <button
508
+ onClick={() => useChatStore.getState().sendMessage(`I approve this transfer of ${walletRequest.amountSol} SOL to ${walletRequest.toAddress}. Proceed with wallet_tool and set approved=true.`)}
509
+ className="px-4 py-2 rounded-[12px] bg-sky-500 text-black text-[13px] font-700 hover:bg-sky-400 transition-all active:scale-[0.98]"
510
+ >
511
+ Approve & Send
512
+ </button>
513
+ <button
514
+ onClick={() => useChatStore.getState().sendMessage(`I do not approve this transaction. Cancel it.`)}
515
+ className="px-4 py-2 rounded-[12px] bg-white/[0.05] hover:bg-white/[0.1] text-text-2 text-[13px] font-600 transition-all border border-white/10"
516
+ >
517
+ Reject
518
+ </button>
519
+ </div>
520
+ </div>
521
+ ) : installRequest ? (
522
+ <div className="flex flex-col gap-3 p-4 rounded-[18px] bg-emerald-500/[0.03] border border-emerald-500/20 shadow-[0_0_20px_rgba(16,185,129,0.05)]">
523
+ <div className="flex items-center gap-2 mb-1">
524
+ <div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center text-emerald-400">
525
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
526
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
527
+ <polyline points="17 8 12 3 7 8" />
528
+ <line x1="12" y1="3" x2="12" y2="15" />
529
+ </svg>
530
+ </div>
531
+ <span className="text-[11px] font-700 uppercase tracking-wider text-emerald-400/80">Plugin Install Request</span>
532
+ </div>
533
+ <p className="text-[13px] text-text-2/90 leading-relaxed">{installRequest.message}</p>
534
+ <div className="p-3 rounded-[12px] bg-black/40 border border-white/5 flex flex-col gap-1">
535
+ <div className="text-[11px] text-text-3/60 font-600 uppercase tracking-tight">Source URL</div>
536
+ <div className="text-[12px] font-mono text-emerald-200/70 truncate">{installRequest.url}</div>
537
+ </div>
538
+ <div className="flex gap-2 mt-1">
539
+ <button
540
+ onClick={() => useChatStore.getState().sendMessage(`I approve the installation of the plugin from ${installRequest.url}. Proceed with install_request and set approved=true.`)}
541
+ className="px-4 py-2 rounded-[12px] bg-emerald-500 text-black text-[13px] font-700 hover:bg-emerald-400 transition-all active:scale-[0.98]"
542
+ >
543
+ Approve & Install
544
+ </button>
545
+ <button
546
+ onClick={() => useChatStore.getState().sendMessage(`I do not approve this plugin installation. Please stop.`)}
547
+ className="px-4 py-2 rounded-[12px] bg-white/[0.05] hover:bg-white/[0.1] text-text-2 text-[13px] font-600 transition-all border border-white/10"
548
+ >
549
+ Reject
550
+ </button>
551
+ </div>
552
+ </div>
553
+ ) : scaffoldRequest ? (
554
+ <div className="flex flex-col gap-3 p-4 rounded-[18px] bg-amber-500/[0.03] border border-amber-500/20 shadow-[0_0_20px_rgba(245,158,11,0.05)]">
555
+ <div className="flex items-center gap-2 mb-1">
556
+ <div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center text-amber-400">
557
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
558
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
559
+ </svg>
560
+ </div>
561
+ <span className="text-[11px] font-700 uppercase tracking-wider text-amber-400/80">Plugin Creation Request</span>
562
+ </div>
563
+ <p className="text-[13px] text-text-2/90 leading-relaxed">{scaffoldRequest.message}</p>
564
+ <div className="p-3 rounded-[12px] bg-black/40 border border-white/5">
565
+ <div className="text-[11px] font-mono text-text-3/60 mb-2 border-b border-white/5 pb-1">filename: {scaffoldRequest.filename}</div>
566
+ <div className="text-[12px] font-mono text-amber-200/70 max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all">
567
+ {scaffoldRequest.code}
568
+ </div>
569
+ </div>
570
+ <div className="flex gap-2 mt-1">
571
+ <button
572
+ onClick={() => useChatStore.getState().sendMessage(`I approve the creation of the plugin ${scaffoldRequest.filename}. Proceed with scaffold and set approved=true.`)}
573
+ className="px-4 py-2 rounded-[12px] bg-amber-500 text-black text-[13px] font-700 hover:bg-amber-400 transition-all active:scale-[0.98]"
574
+ >
575
+ Approve & Install
576
+ </button>
577
+ <button
578
+ onClick={() => useChatStore.getState().sendMessage(`I do not approve the creation of this plugin. Please find another way or stop.`)}
579
+ className="px-4 py-2 rounded-[12px] bg-white/[0.05] hover:bg-white/[0.1] text-text-2 text-[13px] font-600 transition-all border border-white/10"
580
+ >
581
+ Reject
582
+ </button>
583
+ </div>
584
+ </div>
585
+ ) : isPluginUI ? (
586
+ <div className="flex flex-col gap-2 p-4 rounded-[18px] bg-emerald-500/[0.03] border border-emerald-500/10 shadow-[0_0_20px_rgba(16,185,129,0.05)]">
587
+ <div className="flex items-center gap-2 mb-2">
588
+ <div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center">
589
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="2.5">
590
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
591
+ </svg>
592
+ </div>
593
+ <span className="text-[11px] font-700 uppercase tracking-wider text-emerald-400/80">Plugin UI Extension</span>
594
+ </div>
595
+ <div className="text-[14px] text-text-2/90 leading-relaxed">
596
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.text}</ReactMarkdown>
597
+ </div>
598
+ <div className="flex gap-2 mt-2">
599
+ {tryParseJson(message.text)?.actions ? (tryParseJson(message.text)!.actions as Array<{ id: string; href: string; label: string }>).map((action) => (
600
+ <button
601
+ key={action.id}
602
+ onClick={() => window.open(action.href, '_blank')}
603
+ className="px-3 py-1.5 rounded-[10px] bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-[11px] font-600 transition-all border border-emerald-500/10"
604
+ >
605
+ {action.label}
606
+ </button>
607
+ )) : null}
608
+ </div>
609
+ </div>
610
+ ) : isHeartbeat ? (
451
611
  <div className="flex flex-col gap-2">
452
612
  <button
453
613
  type="button"
@@ -660,7 +820,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
660
820
  )}
661
821
 
662
822
  {/* Action buttons */}
663
- <div className={`flex items-center gap-1 mt-1.5 px-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 ${isUser ? 'justify-end' : ''}`}>
823
+ <div className={`flex items-center gap-1 mt-1.5 px-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all duration-200 translate-y-2 md:group-hover:translate-y-0 ${isUser ? 'justify-end' : ''}`}>
664
824
  <button
665
825
  onClick={handleCopy}
666
826
  aria-label="Copy message"
@@ -12,6 +12,7 @@ import { ThinkingIndicator } from './thinking-indicator'
12
12
  import { SuggestionsBar } from './suggestions-bar'
13
13
  import { ExecApprovalCard } from './exec-approval-card'
14
14
  import { TaskApprovalCard } from './task-approval-card'
15
+ import { SessionApprovalCard } from './session-approval-card'
15
16
  import { HeartbeatMoment, ActivityMoment, isNotableTool } from './activity-moment'
16
17
  import { useApprovalStore } from '@/stores/use-approval-store'
17
18
  import { useWs } from '@/hooks/use-ws'
@@ -512,7 +513,14 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
512
513
  }
513
514
 
514
515
  return (
515
- <div key={`${msg.time}-${i}`} data-message-index={i}>
516
+ <div
517
+ key={`${sessionId}-${msg.time}-${i}`}
518
+ data-message-index={i}
519
+ style={{
520
+ animation: `${msg.role === 'user' ? 'msg-in-right' : 'msg-in-left'} 0.4s var(--ease-spring) both`,
521
+ animationDelay: `${Math.min(i * 0.05, 0.4)}s`
522
+ }}
523
+ >
516
524
  {showDateSep && (
517
525
  <div className="flex items-center gap-4 py-2 mb-2">
518
526
  <div className="flex-1 h-px bg-white/[0.06]" />
@@ -576,17 +584,32 @@ function ApprovalCards({ agentId }: { agentId?: string | null }) {
576
584
  const approvals = useApprovalStore((s) => s.approvals)
577
585
  const tasks = useAppStore((s) => s.tasks)
578
586
  const sessionId = useAppStore((s) => s.currentSessionId)
587
+ const serverApprovals = useAppStore((s) => s.approvals)
579
588
 
580
589
  const cards = Object.values(approvals).filter((a) => !agentId || a.agentId === agentId)
581
-
590
+
582
591
  // Find tasks associated with this session that need approval
583
592
  const pendingTasks = Object.values(tasks).filter((t) => {
584
593
  if (!t.pendingApproval) return false
585
- // Show if matches the current session OR the current agent
586
594
  return t.sessionId === sessionId || (agentId && t.agentId === agentId)
587
595
  })
588
596
 
589
- if (!cards.length && !pendingTasks.length) return null
597
+ // Server-side approvals (tool_access, wallet, plugin) matching this session/agent
598
+ // Exclude any that overlap with task-based approvals to prevent duplicates
599
+ const pendingTaskIds = new Set(pendingTasks.map((t) => t.id))
600
+ const sessionApprovalCards = Object.values(serverApprovals).filter((a) => {
601
+ if (a.status !== 'pending') return false
602
+ if (a.category === 'task_tool') return false // Already shown via TaskApprovalCard
603
+ if (a.category === 'tool_access') return false // Handled inline by ToolRequestBanner (with auto-continue)
604
+ if (a.taskId && pendingTaskIds.has(a.taskId)) return false // Dedupe with task card
605
+ return a.sessionId === sessionId || (agentId && a.agentId === agentId)
606
+ })
607
+
608
+ if (!cards.length && !pendingTasks.length && !sessionApprovalCards.length) return null
609
+
610
+ const handleSessionApprovalResolved = () => {
611
+ useAppStore.getState().loadApprovals()
612
+ }
590
613
 
591
614
  return (
592
615
  <div className="flex flex-col gap-2">
@@ -596,6 +619,9 @@ function ApprovalCards({ agentId }: { agentId?: string | null }) {
596
619
  {pendingTasks.map((t) => (
597
620
  <TaskApprovalCard key={t.id} task={t} />
598
621
  ))}
622
+ {sessionApprovalCards.map((a) => (
623
+ <SessionApprovalCard key={a.id} approval={a} onResolved={handleSessionApprovalResolved} />
624
+ ))}
599
625
  </div>
600
626
  )
601
627
  }
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { ApprovalRequest } from '@/types'
5
+ import { api } from '@/lib/api-client'
6
+ import { useAppStore } from '@/stores/use-app-store'
7
+ import { toast } from 'sonner'
8
+ import { getApprovalPayload, getApprovalTitle } from '@/lib/approval-display'
9
+
10
+ interface Props {
11
+ approval: ApprovalRequest
12
+ onResolved?: () => void
13
+ }
14
+
15
+ export function SessionApprovalCard({ approval, onResolved }: Props) {
16
+ const [resolving, setResolving] = useState(false)
17
+ const loadApprovals = useAppStore((s) => s.loadApprovals)
18
+ const loadSessions = useAppStore((s) => s.loadSessions)
19
+
20
+ const handleResolve = async (approved: boolean) => {
21
+ setResolving(true)
22
+ try {
23
+ await api('POST', '/approvals', { id: approval.id, approved })
24
+ toast.success(approved ? 'Action approved' : 'Action rejected')
25
+ await Promise.all([loadApprovals(), loadSessions()])
26
+ onResolved?.()
27
+ } catch (err: unknown) {
28
+ toast.error(err instanceof Error ? err.message : 'Failed to submit decision')
29
+ } finally {
30
+ setResolving(false)
31
+ }
32
+ }
33
+
34
+ const payload = getApprovalPayload(approval)
35
+ const title = getApprovalTitle(approval)
36
+
37
+ return (
38
+ <div className="my-2 rounded-[12px] border border-amber-500/20 bg-amber-500/[0.04] p-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
39
+ <div className="flex items-center gap-2 mb-3">
40
+ <div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center">
41
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-amber-400">
42
+ <path d="M12 9v2m0 4h.01" />
43
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
44
+ </svg>
45
+ </div>
46
+ <span className="text-[12px] font-700 text-amber-400 uppercase tracking-wider">Approval Required</span>
47
+ </div>
48
+
49
+ <p className="text-[13px] text-text-2 mb-2 font-600">{title}</p>
50
+ {approval.description && (
51
+ <p className="text-[12px] text-text-3/90 mb-3">{approval.description}</p>
52
+ )}
53
+
54
+ <div className="bg-black/30 rounded-[10px] border border-white/[0.04] p-3 mb-4 overflow-x-auto max-h-[200px] overflow-y-auto">
55
+ <pre className="text-[11px] font-mono text-text-2/80 leading-relaxed whitespace-pre-wrap break-all">
56
+ {JSON.stringify(payload, null, 2)}
57
+ </pre>
58
+ </div>
59
+
60
+ <div className="flex items-center gap-2">
61
+ <button
62
+ onClick={() => handleResolve(true)}
63
+ disabled={resolving}
64
+ className="flex-1 px-4 py-2 rounded-[10px] bg-emerald-500 text-[#000] text-[12px] font-700 hover:brightness-110 active:scale-[0.98] transition-all disabled:opacity-50"
65
+ style={{ fontFamily: 'inherit' }}
66
+ >
67
+ {resolving ? 'Applying...' : 'Approve'}
68
+ </button>
69
+ <button
70
+ onClick={() => handleResolve(false)}
71
+ disabled={resolving}
72
+ className="px-4 py-2 rounded-[10px] bg-white/[0.04] border border-white/[0.08] text-text-3 text-[12px] font-600 hover:bg-white/[0.08] active:scale-[0.98] transition-all disabled:opacity-50"
73
+ style={{ fontFamily: 'inherit' }}
74
+ >
75
+ Reject
76
+ </button>
77
+ </div>
78
+ </div>
79
+ )
80
+ }
@@ -135,7 +135,7 @@ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentAva
135
135
  return (
136
136
  <div
137
137
  className="flex flex-col items-start relative pl-[44px]"
138
- style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}
138
+ style={{ animation: 'msg-in-left 0.4s var(--ease-spring) both' }}
139
139
  >
140
140
  <div className="absolute left-[4px] top-0 relative">
141
141
  {agentName ? <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
@@ -160,13 +160,14 @@ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentAva
160
160
  {text && thinkingText && (
161
161
  <div className="max-w-[85%] md:max-w-[72%] mb-2">
162
162
  <details className="group rounded-[12px] border border-purple-500/15 bg-purple-500/[0.04]">
163
- <summary className="flex items-center gap-2 px-3.5 py-2.5 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
164
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-purple-400/60 shrink-0 transition-transform group-open:rotate-90">
163
+ <summary className="flex items-center gap-2 px-3.5 py-2.5 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden relative overflow-hidden group-open:rounded-b-none">
164
+ <div className="absolute inset-0 bg-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity" style={{ animation: 'pulse-subtle 3s ease-in-out infinite' }} />
165
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-purple-400/60 shrink-0 transition-transform group-open:rotate-90 relative z-10">
165
166
  <polyline points="9 18 15 12 9 6" />
166
167
  </svg>
167
- <span className="text-[11px] font-600 text-purple-400/70 uppercase tracking-[0.05em]">Thinking</span>
168
+ <span className="text-[11px] font-600 text-purple-400/70 uppercase tracking-[0.05em] relative z-10">Thinking</span>
168
169
  </summary>
169
- <div className="px-3.5 pb-3 pt-1 max-h-[300px] overflow-y-auto">
170
+ <div className="px-3.5 pb-3 pt-1 max-h-[300px] overflow-y-auto border-t border-white/[0.04]">
170
171
  <div className="text-[13px] leading-[1.6] text-text-3/70 whitespace-pre-wrap break-words">
171
172
  {thinkingText}
172
173
  </div>