@swarmclawai/swarmclaw 0.4.0 → 0.5.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 (209) hide show
  1. package/README.md +21 -4
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +13 -0
  4. package/package.json +3 -1
  5. package/src/app/api/agents/[id]/route.ts +39 -22
  6. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  7. package/src/app/api/agents/route.ts +3 -2
  8. package/src/app/api/agents/trash/route.ts +44 -0
  9. package/src/app/api/clawhub/install/route.ts +2 -2
  10. package/src/app/api/connectors/[id]/route.ts +17 -7
  11. package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
  12. package/src/app/api/connectors/route.ts +6 -3
  13. package/src/app/api/credentials/[id]/route.ts +2 -1
  14. package/src/app/api/credentials/route.ts +2 -2
  15. package/src/app/api/documents/route.ts +2 -2
  16. package/src/app/api/files/serve/route.ts +8 -0
  17. package/src/app/api/knowledge/[id]/route.ts +5 -4
  18. package/src/app/api/knowledge/upload/route.ts +2 -2
  19. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  20. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  21. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  22. package/src/app/api/mcp-servers/route.ts +2 -2
  23. package/src/app/api/memory/[id]/route.ts +9 -8
  24. package/src/app/api/memory/route.ts +2 -2
  25. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  26. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  27. package/src/app/api/openclaw/approvals/route.ts +46 -0
  28. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  29. package/src/app/api/openclaw/cron/route.ts +52 -0
  30. package/src/app/api/openclaw/directory/route.ts +27 -0
  31. package/src/app/api/openclaw/discover/route.ts +62 -0
  32. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  33. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  34. package/src/app/api/openclaw/gateway/route.ts +72 -0
  35. package/src/app/api/openclaw/history/route.ts +109 -0
  36. package/src/app/api/openclaw/media/route.ts +53 -0
  37. package/src/app/api/openclaw/models/route.ts +12 -0
  38. package/src/app/api/openclaw/permissions/route.ts +39 -0
  39. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  40. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  41. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  42. package/src/app/api/openclaw/skills/route.ts +82 -0
  43. package/src/app/api/openclaw/sync/route.ts +31 -0
  44. package/src/app/api/orchestrator/run/route.ts +2 -2
  45. package/src/app/api/projects/[id]/route.ts +55 -0
  46. package/src/app/api/projects/route.ts +27 -0
  47. package/src/app/api/providers/[id]/models/route.ts +2 -1
  48. package/src/app/api/providers/[id]/route.ts +13 -15
  49. package/src/app/api/providers/route.ts +2 -2
  50. package/src/app/api/schedules/[id]/route.ts +16 -18
  51. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  52. package/src/app/api/schedules/route.ts +2 -2
  53. package/src/app/api/secrets/[id]/route.ts +16 -17
  54. package/src/app/api/secrets/route.ts +2 -2
  55. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  56. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  57. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  58. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  59. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  60. package/src/app/api/sessions/[id]/messages/route.ts +20 -2
  61. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  62. package/src/app/api/sessions/[id]/route.ts +14 -4
  63. package/src/app/api/sessions/route.ts +8 -4
  64. package/src/app/api/skills/[id]/route.ts +23 -21
  65. package/src/app/api/skills/import/route.ts +2 -2
  66. package/src/app/api/skills/route.ts +2 -2
  67. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  68. package/src/app/api/tasks/[id]/route.ts +6 -5
  69. package/src/app/api/tasks/route.ts +2 -2
  70. package/src/app/api/tts/stream/route.ts +48 -0
  71. package/src/app/api/upload/route.ts +2 -2
  72. package/src/app/api/uploads/[filename]/route.ts +4 -1
  73. package/src/app/api/webhooks/[id]/route.ts +29 -31
  74. package/src/app/api/webhooks/route.ts +2 -2
  75. package/src/app/globals.css +14 -0
  76. package/src/app/layout.tsx +5 -20
  77. package/src/app/page.tsx +3 -24
  78. package/src/cli/index.js +60 -0
  79. package/src/cli/index.ts +1 -1
  80. package/src/cli/spec.js +42 -0
  81. package/src/components/agents/agent-avatar.tsx +45 -0
  82. package/src/components/agents/agent-card.tsx +19 -5
  83. package/src/components/agents/agent-chat-list.tsx +31 -24
  84. package/src/components/agents/agent-files-editor.tsx +185 -0
  85. package/src/components/agents/agent-list.tsx +84 -3
  86. package/src/components/agents/agent-sheet.tsx +147 -14
  87. package/src/components/agents/cron-job-form.tsx +137 -0
  88. package/src/components/agents/exec-config-panel.tsx +147 -0
  89. package/src/components/agents/inspector-panel.tsx +310 -0
  90. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  91. package/src/components/agents/permission-preset-selector.tsx +79 -0
  92. package/src/components/agents/personality-builder.tsx +111 -0
  93. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  94. package/src/components/agents/skill-install-dialog.tsx +102 -0
  95. package/src/components/agents/trash-list.tsx +109 -0
  96. package/src/components/chat/chat-area.tsx +41 -6
  97. package/src/components/chat/chat-header.tsx +305 -29
  98. package/src/components/chat/chat-preview-panel.tsx +113 -0
  99. package/src/components/chat/exec-approval-card.tsx +89 -0
  100. package/src/components/chat/message-bubble.tsx +218 -36
  101. package/src/components/chat/message-list.tsx +135 -31
  102. package/src/components/chat/streaming-bubble.tsx +59 -10
  103. package/src/components/chat/suggestions-bar.tsx +74 -0
  104. package/src/components/chat/thinking-indicator.tsx +20 -6
  105. package/src/components/chat/tool-call-bubble.tsx +98 -19
  106. package/src/components/chat/tool-request-banner.tsx +20 -2
  107. package/src/components/chat/trace-block.tsx +103 -0
  108. package/src/components/chat/voice-overlay.tsx +80 -0
  109. package/src/components/connectors/connector-list.tsx +6 -2
  110. package/src/components/connectors/connector-sheet.tsx +31 -7
  111. package/src/components/layout/app-layout.tsx +47 -25
  112. package/src/components/projects/project-list.tsx +123 -0
  113. package/src/components/projects/project-sheet.tsx +135 -0
  114. package/src/components/schedules/schedule-list.tsx +3 -1
  115. package/src/components/sessions/new-session-sheet.tsx +6 -6
  116. package/src/components/sessions/session-card.tsx +1 -1
  117. package/src/components/sessions/session-list.tsx +7 -7
  118. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  119. package/src/components/shared/avatar.tsx +13 -2
  120. package/src/components/shared/connector-platform-icon.tsx +4 -0
  121. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  122. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  123. package/src/components/shared/settings/section-web-search.tsx +56 -0
  124. package/src/components/shared/settings/settings-page.tsx +74 -0
  125. package/src/components/skills/skill-list.tsx +2 -1
  126. package/src/components/tasks/task-board.tsx +1 -1
  127. package/src/components/tasks/task-list.tsx +5 -2
  128. package/src/components/tasks/task-sheet.tsx +12 -12
  129. package/src/hooks/use-continuous-speech.ts +181 -0
  130. package/src/hooks/use-openclaw-gateway.ts +63 -0
  131. package/src/hooks/use-view-router.ts +52 -0
  132. package/src/hooks/use-voice-conversation.ts +80 -0
  133. package/src/lib/id.ts +6 -0
  134. package/src/lib/notification-sounds.ts +58 -0
  135. package/src/lib/personality-parser.ts +97 -0
  136. package/src/lib/projects.ts +13 -0
  137. package/src/lib/provider-sets.ts +5 -0
  138. package/src/lib/providers/anthropic.ts +14 -1
  139. package/src/lib/providers/index.ts +6 -0
  140. package/src/lib/providers/ollama.ts +9 -1
  141. package/src/lib/providers/openai.ts +9 -1
  142. package/src/lib/providers/openclaw.ts +28 -2
  143. package/src/lib/runtime-loop.ts +2 -2
  144. package/src/lib/server/api-routes.test.ts +5 -6
  145. package/src/lib/server/build-llm.ts +17 -4
  146. package/src/lib/server/chat-execution.ts +82 -6
  147. package/src/lib/server/collection-helpers.ts +54 -0
  148. package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
  149. package/src/lib/server/connectors/bluebubbles.ts +360 -0
  150. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  151. package/src/lib/server/connectors/googlechat.ts +51 -8
  152. package/src/lib/server/connectors/manager.ts +424 -13
  153. package/src/lib/server/connectors/media.ts +2 -2
  154. package/src/lib/server/connectors/openclaw.ts +65 -0
  155. package/src/lib/server/connectors/pairing.test.ts +99 -0
  156. package/src/lib/server/connectors/pairing.ts +256 -0
  157. package/src/lib/server/connectors/signal.ts +1 -0
  158. package/src/lib/server/connectors/teams.ts +5 -5
  159. package/src/lib/server/connectors/types.ts +10 -0
  160. package/src/lib/server/daemon-state.ts +11 -0
  161. package/src/lib/server/execution-log.ts +3 -3
  162. package/src/lib/server/heartbeat-service.ts +1 -1
  163. package/src/lib/server/knowledge-db.test.ts +2 -33
  164. package/src/lib/server/main-agent-loop.ts +8 -9
  165. package/src/lib/server/main-session.ts +21 -0
  166. package/src/lib/server/memory-db.ts +6 -6
  167. package/src/lib/server/openclaw-approvals.ts +105 -0
  168. package/src/lib/server/openclaw-config-sync.ts +107 -0
  169. package/src/lib/server/openclaw-exec-config.ts +52 -0
  170. package/src/lib/server/openclaw-gateway.ts +291 -0
  171. package/src/lib/server/openclaw-history-merge.ts +36 -0
  172. package/src/lib/server/openclaw-models.ts +56 -0
  173. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  174. package/src/lib/server/openclaw-sync.ts +497 -0
  175. package/src/lib/server/orchestrator-lg.ts +30 -9
  176. package/src/lib/server/orchestrator.ts +4 -4
  177. package/src/lib/server/process-manager.ts +2 -2
  178. package/src/lib/server/queue.ts +24 -11
  179. package/src/lib/server/scheduler.ts +2 -2
  180. package/src/lib/server/session-mailbox.ts +2 -2
  181. package/src/lib/server/session-run-manager.ts +2 -2
  182. package/src/lib/server/session-tools/connector.ts +53 -6
  183. package/src/lib/server/session-tools/crud.ts +3 -3
  184. package/src/lib/server/session-tools/delegate.ts +22 -6
  185. package/src/lib/server/session-tools/file.ts +192 -19
  186. package/src/lib/server/session-tools/index.ts +4 -2
  187. package/src/lib/server/session-tools/memory.ts +2 -2
  188. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  189. package/src/lib/server/session-tools/sandbox.ts +33 -0
  190. package/src/lib/server/session-tools/search-providers.ts +277 -0
  191. package/src/lib/server/session-tools/session-info.ts +2 -2
  192. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  193. package/src/lib/server/session-tools/shell.ts +1 -1
  194. package/src/lib/server/session-tools/web.ts +53 -72
  195. package/src/lib/server/storage.ts +74 -11
  196. package/src/lib/server/stream-agent-chat.ts +53 -4
  197. package/src/lib/server/suggestions.ts +20 -0
  198. package/src/lib/server/task-result.test.ts +44 -0
  199. package/src/lib/server/task-result.ts +14 -0
  200. package/src/lib/server/ws-hub.ts +14 -0
  201. package/src/lib/tool-definitions.ts +5 -3
  202. package/src/lib/tts-stream.ts +130 -0
  203. package/src/lib/view-routes.ts +28 -0
  204. package/src/proxy.ts +3 -0
  205. package/src/stores/use-app-store.ts +80 -1
  206. package/src/stores/use-approval-store.ts +78 -0
  207. package/src/stores/use-chat-store.ts +162 -6
  208. package/src/types/index.ts +154 -3
  209. package/tsconfig.json +13 -4
@@ -0,0 +1,89 @@
1
+ 'use client'
2
+
3
+ import type { PendingExecApproval, ExecApprovalDecision } from '@/types'
4
+ import { useApprovalStore } from '@/stores/use-approval-store'
5
+
6
+ interface Props {
7
+ approval: PendingExecApproval
8
+ }
9
+
10
+ export function ExecApprovalCard({ approval }: Props) {
11
+ const resolveApproval = useApprovalStore((s) => s.resolveApproval)
12
+
13
+ const handleResolve = (decision: ExecApprovalDecision) => {
14
+ resolveApproval(approval.id, decision)
15
+ }
16
+
17
+ const expired = approval.expiresAtMs < Date.now()
18
+ const disabled = !!approval.resolving || expired
19
+
20
+ return (
21
+ <div className="my-2 rounded-[12px] border border-amber-500/20 bg-amber-500/[0.04] p-3.5">
22
+ <div className="flex items-center gap-2 mb-2">
23
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-amber-400 shrink-0">
24
+ <path d="M12 9v2m0 4h.01" />
25
+ <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" />
26
+ </svg>
27
+ <span className="text-[12px] font-600 text-amber-400">Execution Approval Required</span>
28
+ </div>
29
+
30
+ {approval.ask && (
31
+ <p className="text-[13px] text-text-2 mb-2">{approval.ask}</p>
32
+ )}
33
+
34
+ <div className="rounded-[8px] bg-black/20 px-3 py-2 mb-2 overflow-x-auto">
35
+ <code className="text-[12px] text-text font-mono whitespace-pre-wrap break-all">
36
+ {approval.command}
37
+ </code>
38
+ </div>
39
+
40
+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-text-3/60 mb-3">
41
+ {approval.cwd && <span>cwd: {approval.cwd}</span>}
42
+ {approval.host && <span>host: {approval.host}</span>}
43
+ {approval.security && (
44
+ <span className={approval.security === 'high' ? 'text-red-400' : ''}>
45
+ security: {approval.security}
46
+ </span>
47
+ )}
48
+ </div>
49
+
50
+ {approval.error && (
51
+ <p className="text-[12px] text-red-400 mb-2">{approval.error}</p>
52
+ )}
53
+
54
+ {expired ? (
55
+ <p className="text-[12px] text-text-3/50 italic">Approval expired</p>
56
+ ) : (
57
+ <div className="flex items-center gap-2">
58
+ <button
59
+ onClick={() => handleResolve('allow-once')}
60
+ disabled={disabled}
61
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-emerald-500/10 text-[12px] font-600
62
+ text-emerald-400 cursor-pointer hover:bg-emerald-500/20 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
63
+ style={{ fontFamily: 'inherit' }}
64
+ >
65
+ {approval.resolving ? '...' : 'Allow Once'}
66
+ </button>
67
+ <button
68
+ onClick={() => handleResolve('allow-always')}
69
+ disabled={disabled}
70
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[12px] font-600
71
+ text-text-3 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40 disabled:cursor-not-allowed"
72
+ style={{ fontFamily: 'inherit' }}
73
+ >
74
+ Always Allow
75
+ </button>
76
+ <button
77
+ onClick={() => handleResolve('deny')}
78
+ disabled={disabled}
79
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[12px] font-600
80
+ text-red-400 cursor-pointer hover:bg-red-400/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
81
+ style={{ fontFamily: 'inherit' }}
82
+ >
83
+ Deny
84
+ </button>
85
+ </div>
86
+ )}
87
+ </div>
88
+ )
89
+ }
@@ -7,6 +7,7 @@ import rehypeHighlight from 'rehype-highlight'
7
7
  import type { Message } from '@/types'
8
8
  import { useAppStore } from '@/stores/use-app-store'
9
9
  import { AiAvatar } from '@/components/shared/avatar'
10
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
10
11
  import { CodeBlock } from './code-block'
11
12
  import { ToolCallBubble } from './tool-call-bubble'
12
13
  import { ToolRequestBanner } from './tool-request-banner'
@@ -170,6 +171,8 @@ function heartbeatSummary(text: string): string {
170
171
 
171
172
  const IMAGE_ATTACH_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i
172
173
  const PREVIEWABLE_ATTACH_RE = /\.(html?|svg)$/i
174
+ const CODE_ATTACH_RE = /\.(js|jsx|ts|tsx|css|json|md|txt|py|sh|rb|go|rs|c|cpp|h|java|yaml|yml|toml|xml|sql|graphql)$/i
175
+ const PDF_ATTACH_RE = /\.pdf$/i
173
176
  const FILE_TYPE_COLORS: Record<string, string> = {
174
177
  html: 'text-orange-400', htm: 'text-orange-400', svg: 'text-emerald-400',
175
178
  js: 'text-yellow-400', jsx: 'text-yellow-400', ts: 'text-blue-400', tsx: 'text-blue-400',
@@ -186,10 +189,49 @@ function parseAttachmentUrl(filePath?: string, fileUrl?: string) {
186
189
 
187
190
  function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: string; isUserMsg?: boolean }) {
188
191
  const isImage = IMAGE_ATTACH_RE.test(filename)
192
+ const isCode = CODE_ATTACH_RE.test(filename)
193
+ const isPdf = PDF_ATTACH_RE.test(filename)
194
+ const [lightbox, setLightbox] = useState(false)
195
+ const [codePreview, setCodePreview] = useState<string | null>(null)
196
+ const [codeExpanded, setCodeExpanded] = useState(false)
197
+
189
198
  if (isImage) {
190
199
  return (
191
- <img src={url} alt="Attached" className="max-w-[240px] rounded-[12px] mb-2 border border-white/10"
192
- onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
200
+ <>
201
+ <img
202
+ src={url} alt="Attached"
203
+ loading="lazy"
204
+ className="max-w-[240px] rounded-[12px] mb-2 border border-white/10 cursor-pointer hover:border-white/25 transition-colors"
205
+ onClick={() => setLightbox(true)}
206
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
207
+ />
208
+ {lightbox && (
209
+ <div
210
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm cursor-pointer"
211
+ onClick={() => setLightbox(false)}
212
+ >
213
+ <img src={url} alt="Preview" className="max-w-[90vw] max-h-[90vh] rounded-[12px] shadow-2xl" />
214
+ </div>
215
+ )}
216
+ </>
217
+ )
218
+ }
219
+
220
+ if (isPdf) {
221
+ return (
222
+ <div className="mb-2 rounded-[12px] border border-white/[0.08] bg-[rgba(255,255,255,0.02)] overflow-hidden" style={{ maxWidth: 480 }}>
223
+ <div className="flex items-center gap-3 px-4 py-2.5">
224
+ <div className="flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 bg-red-500/10 text-red-400">
225
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
226
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
227
+ <polyline points="14 2 14 8 20 8" />
228
+ </svg>
229
+ </div>
230
+ <span className="text-[13px] font-500 truncate flex-1">{filename}</span>
231
+ <a href={url} download={filename} className="text-[11px] font-600 text-text-3 hover:text-text-2 no-underline">Download</a>
232
+ </div>
233
+ <iframe src={url} loading="lazy" className="w-full h-[300px] border-t border-white/[0.06]" title={filename} />
234
+ </div>
193
235
  )
194
236
  }
195
237
 
@@ -197,7 +239,6 @@ function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: s
197
239
  const colorClass = FILE_TYPE_COLORS[ext] || 'text-text-3'
198
240
  const isPreviewable = PREVIEWABLE_ATTACH_RE.test(filename)
199
241
 
200
- // Solid bg so chip is readable on both user (purple) and assistant bubbles
201
242
  const chipBg = isUserMsg
202
243
  ? 'bg-[rgba(0,0,0,0.25)] border-white/[0.12]'
203
244
  : 'bg-[rgba(255,255,255,0.04)] border-white/[0.08]'
@@ -206,40 +247,85 @@ function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: s
206
247
  ? 'bg-white/[0.12] hover:bg-white/[0.18] text-white/80'
207
248
  : 'bg-white/[0.06] hover:bg-white/[0.10] text-text-3'
208
249
 
250
+ const handleCodePreview = async () => {
251
+ if (codePreview !== null) { setCodeExpanded(!codeExpanded); return }
252
+ try {
253
+ const serveUrl = `/api/files/serve?path=${encodeURIComponent(url.replace('/api/uploads/', ''))}`
254
+ const res = await fetch(url.startsWith('/api/files/') ? url : serveUrl)
255
+ if (!res.ok) return
256
+ const text = await res.text()
257
+ setCodePreview(text)
258
+ setCodeExpanded(true)
259
+ } catch {
260
+ // ignore
261
+ }
262
+ }
263
+
209
264
  return (
210
- <div className={`flex items-center gap-3 px-4 py-2.5 mb-2 rounded-[12px] border ${chipBg}`}>
211
- <div className={`flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 ${iconBg} ${colorClass}`}>
212
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
213
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
214
- <polyline points="14 2 14 8 20 8" />
215
- </svg>
216
- </div>
217
- <div className="flex flex-col flex-1 min-w-0">
218
- <span className={`text-[13px] font-500 truncate ${isUserMsg ? 'text-white' : 'text-text'}`}>{filename}</span>
219
- <span className={`text-[11px] uppercase tracking-wide ${isUserMsg ? 'text-white/50' : 'text-text-3/70'}`}>{ext || 'file'}</span>
220
- </div>
221
- {isPreviewable && (
222
- <a href={url} target="_blank" rel="noopener noreferrer"
223
- className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${
224
- isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
225
- }`}
226
- title="Preview in new tab">
265
+ <div className="mb-2">
266
+ <div className={`flex items-center gap-3 px-4 py-2.5 rounded-[12px] border ${chipBg}`}>
267
+ <div className={`flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 ${iconBg} ${colorClass}`}>
268
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
269
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
270
+ <polyline points="14 2 14 8 20 8" />
271
+ </svg>
272
+ </div>
273
+ <div className="flex flex-col flex-1 min-w-0">
274
+ <span className={`text-[13px] font-500 truncate ${isUserMsg ? 'text-white' : 'text-text'}`}>{filename}</span>
275
+ <span className={`text-[11px] uppercase tracking-wide ${isUserMsg ? 'text-white/50' : 'text-text-3/70'}`}>{ext || 'file'}</span>
276
+ </div>
277
+ {isCode && (
278
+ <button
279
+ onClick={handleCodePreview}
280
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 border-none cursor-pointer ${
281
+ isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
282
+ }`}
283
+ >
284
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
285
+ <polyline points="16 18 22 12 16 6" />
286
+ <polyline points="8 6 2 12 8 18" />
287
+ </svg>
288
+ {codeExpanded ? 'Hide' : 'Preview'}
289
+ </button>
290
+ )}
291
+ {isPreviewable && (
292
+ <a href={url} target="_blank" rel="noopener noreferrer"
293
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${
294
+ isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
295
+ }`}
296
+ title="Preview in new tab">
297
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
298
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
299
+ <circle cx="12" cy="12" r="3" />
300
+ </svg>
301
+ Preview
302
+ </a>
303
+ )}
304
+ <a href={url} download={filename}
305
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${btnBg}`}>
227
306
  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
228
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
229
- <circle cx="12" cy="12" r="3" />
307
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
308
+ <polyline points="7 10 12 15 17 10" />
309
+ <line x1="12" y1="15" x2="12" y2="3" />
230
310
  </svg>
231
- Preview
311
+ Download
232
312
  </a>
313
+ </div>
314
+ {isCode && codeExpanded && codePreview !== null && (
315
+ <div className="mt-1 rounded-[10px] border border-white/[0.06] overflow-hidden" style={{ animation: 'fade-in 0.2s ease' }}>
316
+ <CodeBlock className={`language-${ext}`}>
317
+ {codePreview.split('\n').slice(0, codeExpanded ? undefined : 10).join('\n')}
318
+ </CodeBlock>
319
+ {codePreview.split('\n').length > 10 && (
320
+ <button
321
+ onClick={() => setCodeExpanded((v) => !v)}
322
+ className="w-full px-3 py-1.5 text-[10px] text-text-3 hover:text-text-2 bg-white/[0.02] hover:bg-white/[0.04] border-none border-t border-white/[0.06] cursor-pointer transition-colors"
323
+ >
324
+ {codePreview.split('\n').length > 10 ? `Show all ${codePreview.split('\n').length} lines` : 'Show less'}
325
+ </button>
326
+ )}
327
+ </div>
233
328
  )}
234
- <a href={url} download={filename}
235
- className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${btnBg}`}>
236
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
237
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
238
- <polyline points="7 10 12 15 17 10" />
239
- <line x1="12" y1="15" x2="12" y2="3" />
240
- </svg>
241
- Download
242
- </a>
243
329
  </div>
244
330
  )
245
331
  }
@@ -280,17 +366,25 @@ function renderAttachments(message: Message) {
280
366
  interface Props {
281
367
  message: Message
282
368
  assistantName?: string
369
+ agentAvatarSeed?: string
370
+ agentName?: string
283
371
  isLast?: boolean
284
372
  onRetry?: () => void
373
+ messageIndex?: number
374
+ onToggleBookmark?: (index: number) => void
375
+ onEditResend?: (index: number, newText: string) => void
376
+ onFork?: (index: number) => void
285
377
  }
286
378
 
287
- export const MessageBubble = memo(function MessageBubble({ message, assistantName, isLast, onRetry }: Props) {
379
+ export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork }: Props) {
288
380
  const isUser = message.role === 'user'
289
381
  const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
290
382
  const currentUser = useAppStore((s) => s.currentUser)
291
383
  const [copied, setCopied] = useState(false)
292
384
  const [heartbeatExpanded, setHeartbeatExpanded] = useState(false)
293
385
  const [toolEventsExpanded, setToolEventsExpanded] = useState(false)
386
+ const [editing, setEditing] = useState(false)
387
+ const [editText, setEditText] = useState('')
294
388
  const toolEvents = message.toolEvents || []
295
389
  const hasToolEvents = !isUser && toolEvents.length > 0
296
390
  const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
@@ -309,7 +403,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
309
403
  >
310
404
  {/* Sender label + timestamp */}
311
405
  <div className={`flex items-center gap-2.5 mb-2 px-1 ${isUser ? 'flex-row-reverse' : ''}`}>
312
- {!isUser && <AiAvatar size="sm" />}
406
+ {!isUser && (agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" />)}
313
407
  <span className={`text-[12px] font-600 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
314
408
  {isUser ? (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You') : (assistantName || 'Claude')}
315
409
  </span>
@@ -414,12 +508,12 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
414
508
  const isVideo = /\.(mp4|webm|mov|avi)$/i.test(src)
415
509
  if (isVideo) {
416
510
  return (
417
- <video src={src} controls className="max-w-full rounded-[10px] border border-white/10 my-2" />
511
+ <video src={src} controls preload="none" className="max-w-full rounded-[10px] border border-white/10 my-2" />
418
512
  )
419
513
  }
420
514
  return (
421
515
  <a href={src} download target="_blank" rel="noopener noreferrer" className="block my-2">
422
- <img src={src} alt={alt || 'File'} className="max-w-full rounded-[10px] border border-white/10 hover:border-white/25 transition-colors cursor-pointer" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
516
+ <img src={src} alt={alt || 'File'} loading="lazy" className="max-w-full rounded-[10px] border border-white/10 hover:border-white/25 transition-colors cursor-pointer" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
423
517
  </a>
424
518
  )
425
519
  },
@@ -518,6 +612,16 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
518
612
  toolOutputs={toolEvents.map((e) => e.output || '').filter(Boolean)}
519
613
  />}
520
614
 
615
+ {/* Bookmark indicator */}
616
+ {message.bookmarked && (
617
+ <div className={`flex items-center gap-1 mt-1 px-1 ${isUser ? 'justify-end' : ''}`}>
618
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="#F59E0B" stroke="#F59E0B" strokeWidth="2" className="shrink-0">
619
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
620
+ </svg>
621
+ <span className="text-[10px] text-[#F59E0B]/70 font-600">Bookmarked</span>
622
+ </div>
623
+ )}
624
+
521
625
  {/* Action buttons */}
522
626
  <div className={`flex items-center gap-1 mt-1.5 px-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${isUser ? 'justify-end' : ''}`}>
523
627
  <button
@@ -533,6 +637,53 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
533
637
  </svg>
534
638
  {copied ? 'Copied' : 'Copy'}
535
639
  </button>
640
+ {typeof messageIndex === 'number' && onToggleBookmark && (
641
+ <button
642
+ onClick={() => onToggleBookmark(messageIndex)}
643
+ aria-label={message.bookmarked ? 'Remove bookmark' : 'Bookmark message'}
644
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
645
+ text-[11px] font-500 cursor-pointer hover:bg-white/[0.04] transition-all"
646
+ style={{ fontFamily: 'inherit', color: message.bookmarked ? '#F59E0B' : undefined }}
647
+ >
648
+ <svg width="12" height="12" viewBox="0 0 24 24" fill={message.bookmarked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
649
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
650
+ </svg>
651
+ {message.bookmarked ? 'Unbookmark' : 'Bookmark'}
652
+ </button>
653
+ )}
654
+ {isUser && typeof messageIndex === 'number' && onEditResend && (
655
+ <button
656
+ onClick={() => { setEditText(message.text); setEditing(true) }}
657
+ aria-label="Edit and resend"
658
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
659
+ text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
660
+ style={{ fontFamily: 'inherit' }}
661
+ >
662
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
663
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
664
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
665
+ </svg>
666
+ Edit
667
+ </button>
668
+ )}
669
+ {typeof messageIndex === 'number' && onFork && (
670
+ <button
671
+ onClick={() => onFork(messageIndex)}
672
+ aria-label="Fork conversation from here"
673
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
674
+ text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
675
+ style={{ fontFamily: 'inherit' }}
676
+ >
677
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
678
+ <circle cx="12" cy="18" r="3" />
679
+ <circle cx="6" cy="6" r="3" />
680
+ <circle cx="18" cy="6" r="3" />
681
+ <path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9" />
682
+ <path d="M12 12v3" />
683
+ </svg>
684
+ Fork
685
+ </button>
686
+ )}
536
687
  {!isUser && isLast && onRetry && (
537
688
  <button
538
689
  onClick={onRetry}
@@ -549,6 +700,37 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
549
700
  </button>
550
701
  )}
551
702
  </div>
703
+
704
+ {/* Inline edit mode */}
705
+ {editing && (
706
+ <div className={`max-w-[85%] md:max-w-[72%] mt-2 ${isUser ? 'self-end' : ''}`} style={{ animation: 'fade-in 0.2s ease' }}>
707
+ <textarea
708
+ value={editText}
709
+ onChange={(e) => setEditText(e.target.value)}
710
+ className="w-full min-h-[80px] p-3 rounded-[12px] bg-surface border border-white/[0.08] text-text text-[14px] resize-y outline-none focus:border-accent-bright/30"
711
+ style={{ fontFamily: 'inherit' }}
712
+ />
713
+ <div className="flex gap-2 mt-2 justify-end">
714
+ <button
715
+ onClick={() => setEditing(false)}
716
+ className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 text-text-3 bg-white/[0.04] hover:bg-white/[0.07] border-none cursor-pointer transition-colors"
717
+ >
718
+ Cancel
719
+ </button>
720
+ <button
721
+ onClick={() => {
722
+ if (editText.trim() && typeof messageIndex === 'number' && onEditResend) {
723
+ onEditResend(messageIndex, editText.trim())
724
+ setEditing(false)
725
+ }
726
+ }}
727
+ className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 text-white bg-accent-bright hover:bg-accent-bright/80 border-none cursor-pointer transition-colors"
728
+ >
729
+ Save & Resend
730
+ </button>
731
+ </div>
732
+ </div>
733
+ )}
552
734
  </div>
553
735
  )
554
736
  })