@swarmclawai/swarmclaw 0.4.5 → 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 (113) hide show
  1. package/README.md +8 -2
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +5 -0
  4. package/package.json +2 -1
  5. package/src/app/api/agents/[id]/route.ts +23 -5
  6. package/src/app/api/agents/trash/route.ts +44 -0
  7. package/src/app/api/connectors/[id]/route.ts +7 -4
  8. package/src/app/api/connectors/[id]/webhook/route.ts +6 -2
  9. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  10. package/src/app/api/openclaw/approvals/route.ts +46 -0
  11. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  12. package/src/app/api/openclaw/cron/route.ts +52 -0
  13. package/src/app/api/openclaw/directory/route.ts +4 -3
  14. package/src/app/api/openclaw/discover/route.ts +3 -2
  15. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  16. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  17. package/src/app/api/openclaw/gateway/route.ts +72 -0
  18. package/src/app/api/openclaw/history/route.ts +109 -0
  19. package/src/app/api/openclaw/media/route.ts +53 -0
  20. package/src/app/api/openclaw/models/route.ts +12 -0
  21. package/src/app/api/openclaw/permissions/route.ts +39 -0
  22. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  23. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  24. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  25. package/src/app/api/openclaw/skills/route.ts +82 -0
  26. package/src/app/api/openclaw/sync/route.ts +3 -2
  27. package/src/app/api/projects/[id]/route.ts +1 -1
  28. package/src/app/api/projects/route.ts +1 -1
  29. package/src/app/api/secrets/[id]/route.ts +1 -1
  30. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  31. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  32. package/src/app/api/sessions/[id]/messages/route.ts +18 -1
  33. package/src/app/api/sessions/[id]/route.ts +12 -3
  34. package/src/app/api/sessions/route.ts +6 -2
  35. package/src/app/globals.css +14 -0
  36. package/src/app/layout.tsx +5 -20
  37. package/src/cli/index.js +33 -1
  38. package/src/cli/spec.js +40 -0
  39. package/src/components/agents/agent-avatar.tsx +45 -0
  40. package/src/components/agents/agent-card.tsx +19 -5
  41. package/src/components/agents/agent-chat-list.tsx +31 -24
  42. package/src/components/agents/agent-files-editor.tsx +185 -0
  43. package/src/components/agents/agent-list.tsx +82 -3
  44. package/src/components/agents/agent-sheet.tsx +31 -0
  45. package/src/components/agents/cron-job-form.tsx +137 -0
  46. package/src/components/agents/exec-config-panel.tsx +147 -0
  47. package/src/components/agents/inspector-panel.tsx +310 -0
  48. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  49. package/src/components/agents/permission-preset-selector.tsx +79 -0
  50. package/src/components/agents/personality-builder.tsx +111 -0
  51. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  52. package/src/components/agents/skill-install-dialog.tsx +102 -0
  53. package/src/components/agents/trash-list.tsx +109 -0
  54. package/src/components/chat/chat-area.tsx +14 -2
  55. package/src/components/chat/chat-header.tsx +168 -4
  56. package/src/components/chat/chat-preview-panel.tsx +113 -0
  57. package/src/components/chat/exec-approval-card.tsx +89 -0
  58. package/src/components/chat/message-bubble.tsx +218 -36
  59. package/src/components/chat/message-list.tsx +135 -31
  60. package/src/components/chat/streaming-bubble.tsx +59 -10
  61. package/src/components/chat/suggestions-bar.tsx +74 -0
  62. package/src/components/chat/thinking-indicator.tsx +20 -6
  63. package/src/components/chat/tool-call-bubble.tsx +89 -16
  64. package/src/components/chat/tool-request-banner.tsx +20 -2
  65. package/src/components/chat/trace-block.tsx +103 -0
  66. package/src/components/projects/project-list.tsx +1 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  68. package/src/components/shared/avatar.tsx +13 -2
  69. package/src/components/shared/settings/settings-page.tsx +1 -0
  70. package/src/components/tasks/task-board.tsx +1 -1
  71. package/src/components/tasks/task-sheet.tsx +12 -12
  72. package/src/hooks/use-continuous-speech.ts +42 -5
  73. package/src/hooks/use-openclaw-gateway.ts +63 -0
  74. package/src/lib/notification-sounds.ts +58 -0
  75. package/src/lib/personality-parser.ts +97 -0
  76. package/src/lib/providers/openclaw.ts +17 -2
  77. package/src/lib/runtime-loop.ts +2 -2
  78. package/src/lib/server/chat-execution.ts +44 -2
  79. package/src/lib/server/connectors/bluebubbles.test.ts +17 -8
  80. package/src/lib/server/connectors/bluebubbles.ts +5 -2
  81. package/src/lib/server/connectors/googlechat.ts +14 -10
  82. package/src/lib/server/connectors/manager.ts +37 -15
  83. package/src/lib/server/connectors/openclaw.ts +1 -0
  84. package/src/lib/server/daemon-state.ts +11 -0
  85. package/src/lib/server/main-agent-loop.ts +2 -3
  86. package/src/lib/server/main-session.ts +21 -0
  87. package/src/lib/server/openclaw-config-sync.ts +107 -0
  88. package/src/lib/server/openclaw-exec-config.ts +52 -0
  89. package/src/lib/server/openclaw-gateway.ts +291 -0
  90. package/src/lib/server/openclaw-history-merge.ts +36 -0
  91. package/src/lib/server/openclaw-models.ts +56 -0
  92. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  93. package/src/lib/server/openclaw-sync.ts +11 -10
  94. package/src/lib/server/queue.ts +2 -1
  95. package/src/lib/server/session-tools/connector.ts +4 -4
  96. package/src/lib/server/session-tools/delegate.ts +19 -3
  97. package/src/lib/server/session-tools/file.ts +20 -20
  98. package/src/lib/server/session-tools/index.ts +2 -2
  99. package/src/lib/server/session-tools/openclaw-nodes.ts +6 -6
  100. package/src/lib/server/session-tools/sandbox.ts +2 -2
  101. package/src/lib/server/session-tools/search-providers.ts +13 -6
  102. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  103. package/src/lib/server/session-tools/shell.ts +1 -1
  104. package/src/lib/server/session-tools/web.ts +8 -8
  105. package/src/lib/server/storage.ts +62 -11
  106. package/src/lib/server/stream-agent-chat.ts +24 -4
  107. package/src/lib/server/suggestions.ts +20 -0
  108. package/src/lib/server/ws-hub.ts +14 -0
  109. package/src/stores/use-app-store.ts +53 -1
  110. package/src/stores/use-approval-store.ts +78 -0
  111. package/src/stores/use-chat-store.ts +153 -5
  112. package/src/types/index.ts +127 -1
  113. package/tsconfig.json +13 -4
@@ -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
  })
@@ -1,12 +1,16 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
4
4
  import type { Message } from '@/types'
5
5
  import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
+ import { api } from '@/lib/api-client'
7
8
  import { MessageBubble } from './message-bubble'
8
9
  import { StreamingBubble } from './streaming-bubble'
9
10
  import { ThinkingIndicator } from './thinking-indicator'
11
+ import { SuggestionsBar } from './suggestions-bar'
12
+ import { ExecApprovalCard } from './exec-approval-card'
13
+ import { useApprovalStore } from '@/stores/use-approval-store'
10
14
 
11
15
  function dateSeparator(ts: number): string {
12
16
  const d = new Date(ts)
@@ -26,14 +30,19 @@ interface Props {
26
30
  export function MessageList({ messages, streaming }: Props) {
27
31
  const scrollRef = useRef<HTMLDivElement>(null)
28
32
  const [showScrollToBottom, setShowScrollToBottom] = useState(false)
29
- const needsSnapRef = useRef(true)
33
+ const snapUntilRef = useRef(0)
30
34
  const prevSessionIdRef = useRef<string | null>(null)
31
- const streamText = useChatStore((s) => s.streamText)
35
+ const displayText = useChatStore((s) => s.displayText)
36
+ const setMessages = useChatStore((s) => s.setMessages)
32
37
  const retryLastMessage = useChatStore((s) => s.retryLastMessage)
38
+ const editAndResend = useChatStore((s) => s.editAndResend)
39
+ const sendMessage = useChatStore((s) => s.sendMessage)
40
+ const forkSession = useAppStore((s) => s.forkSession)
33
41
  const session = useAppStore((s) => {
34
42
  const id = s.currentSessionId
35
43
  return id ? s.sessions[id] : null
36
44
  })
45
+ const sessionId = session?.id ?? null
37
46
  const agents = useAppStore((s) => s.agents)
38
47
  const agent = session?.agentId ? agents[session.agentId] : null
39
48
  const appSettings = useAppStore((s) => s.appSettings)
@@ -49,6 +58,37 @@ export function MessageList({ messages, streaming }: Props) {
49
58
  const [unreadCount, setUnreadCount] = useState(0)
50
59
  const prevMsgCountRef = useRef(messages.length)
51
60
 
61
+ // Bookmark filter
62
+ const [bookmarkFilter, setBookmarkFilter] = useState(false)
63
+
64
+ const toggleBookmark = useCallback(async (index: number) => {
65
+ if (!sessionId) return
66
+ const msg = messages[index]
67
+ if (!msg) return
68
+ const next = !msg.bookmarked
69
+ try {
70
+ await api('PUT', `/sessions/${sessionId}/messages`, { messageIndex: index, bookmarked: next })
71
+ const updated = [...messages]
72
+ updated[index] = { ...updated[index], bookmarked: next }
73
+ setMessages(updated)
74
+ } catch (err: unknown) {
75
+ console.error('Failed to toggle bookmark:', err instanceof Error ? err.message : String(err))
76
+ }
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [sessionId, messages])
79
+
80
+ const handleEditResend = useCallback(async (index: number, newText: string) => {
81
+ if (!sessionId || !editAndResend) return
82
+ await editAndResend(index, newText)
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [sessionId])
85
+
86
+ const handleFork = useCallback(async (index: number) => {
87
+ if (!sessionId || !forkSession) return
88
+ await forkSession(sessionId, index)
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, [sessionId])
91
+
52
92
  // In-thread search
53
93
  const [searchOpen, setSearchOpen] = useState(false)
54
94
  const [searchQuery, setSearchQuery] = useState('')
@@ -56,9 +96,9 @@ export function MessageList({ messages, streaming }: Props) {
56
96
  const searchInputRef = useRef<HTMLInputElement>(null)
57
97
 
58
98
  const isHeartbeatMessage = (msg: Message) =>
59
- msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || ''))
99
+ msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || '') || /^\s*NO_MESSAGE\b/i.test(msg.text || ''))
60
100
  const isHeartbeatOk = (msg: Message) =>
61
- msg.suppressed === true || (msg.kind === 'heartbeat' && /^\s*HEARTBEAT_OK\b/i.test(msg.text || ''))
101
+ msg.suppressed === true || (msg.kind === 'heartbeat' && (/^\s*HEARTBEAT_OK\b/i.test(msg.text || '') || /^\s*NO_MESSAGE\b/i.test(msg.text || '')))
62
102
 
63
103
  const displayedMessages: Message[] = []
64
104
  for (const msg of messages) {
@@ -79,9 +119,14 @@ export function MessageList({ messages, streaming }: Props) {
79
119
  }
80
120
  }
81
121
 
122
+ // Apply bookmark filter
123
+ const filteredMessages = bookmarkFilter
124
+ ? displayedMessages.filter((msg) => msg.bookmarked)
125
+ : displayedMessages
126
+
82
127
  // Search matches
83
128
  const searchMatches = searchQuery.trim()
84
- ? displayedMessages
129
+ ? filteredMessages
85
130
  .map((msg, i) => ({ msg, i }))
86
131
  .filter(({ msg }) => msg.text.toLowerCase().includes(searchQuery.toLowerCase()))
87
132
  : []
@@ -99,6 +144,10 @@ export function MessageList({ messages, streaming }: Props) {
99
144
  const nearBottom = isNearBottom(el)
100
145
  wasAtBottomRef.current = nearBottom
101
146
  setShowScrollToBottom(!nearBottom)
147
+ // Cancel snap window if user manually scrolls away
148
+ if (!nearBottom && Date.now() < snapUntilRef.current) {
149
+ snapUntilRef.current = 0
150
+ }
102
151
  if (nearBottom && unreadRef.current > 0) {
103
152
  unreadRef.current = 0
104
153
  setUnreadCount(0)
@@ -115,31 +164,52 @@ export function MessageList({ messages, streaming }: Props) {
115
164
  }
116
165
  }, [messages.length, isNearBottom])
117
166
 
118
- // Detect session switch during render (no extra useEffect, no dep-array mismatch)
119
- const sessionId = session?.id ?? null
120
- if (sessionId !== prevSessionIdRef.current) {
121
- prevSessionIdRef.current = sessionId
122
- needsSnapRef.current = true
123
- wasAtBottomRef.current = true
124
- }
167
+ // Detect session switch set snap window and reset scroll state.
168
+ // Must fire before the scroll positioning layoutEffect below.
169
+ useLayoutEffect(() => {
170
+ if (sessionId !== prevSessionIdRef.current) {
171
+ prevSessionIdRef.current = sessionId
172
+ wasAtBottomRef.current = true
173
+ snapUntilRef.current = Date.now() + 2000
174
+ }
175
+ }, [sessionId])
125
176
 
126
- useEffect(() => {
177
+ // Position scroll before paint — no setState here to avoid cascading renders.
178
+ // The onScroll handler and the state-update effect below handle UI state.
179
+ useLayoutEffect(() => {
127
180
  const el = scrollRef.current
128
- if (!el) return
129
- if (needsSnapRef.current && messages.length > 0) {
130
- // First render after session switch — snap instantly, no visible scroll
131
- needsSnapRef.current = false
181
+ if (!el || messages.length === 0) return
182
+
183
+ const snapping = Date.now() < snapUntilRef.current
184
+
185
+ if (snapping || wasAtBottomRef.current) {
132
186
  el.scrollTop = el.scrollHeight
133
- setShowScrollToBottom(false)
134
187
  wasAtBottomRef.current = true
135
- return
136
- }
137
- // Auto-scroll if user was at bottom before new content arrived
138
- if (wasAtBottomRef.current) {
139
- el.scrollTop = el.scrollHeight
140
188
  }
189
+ }, [messages.length, displayText])
190
+
191
+ // Update scroll-related UI state after render (separate from layoutEffect to avoid cascading)
192
+ useEffect(() => {
193
+ const el = scrollRef.current
194
+ if (!el || messages.length === 0) return
141
195
  updateScrollState()
142
- }, [messages.length, streamText, isNearBottom, updateScrollState])
196
+ }, [messages.length, displayText, updateScrollState])
197
+
198
+ // Re-snap when content resizes during snap window (lazy images increasing scrollHeight)
199
+ useEffect(() => {
200
+ const el = scrollRef.current
201
+ if (!el) return
202
+ const content = el.firstElementChild as HTMLElement | null
203
+ if (!content) return
204
+
205
+ const observer = new ResizeObserver(() => {
206
+ if (Date.now() < snapUntilRef.current || wasAtBottomRef.current) {
207
+ el.scrollTop = el.scrollHeight
208
+ }
209
+ })
210
+ observer.observe(content)
211
+ return () => observer.disconnect()
212
+ }, [sessionId])
143
213
 
144
214
  const handleScrollToBottom = useCallback(() => {
145
215
  const el = scrollRef.current
@@ -179,7 +249,7 @@ export function MessageList({ messages, streaming }: Props) {
179
249
  }, [searchOpen])
180
250
 
181
251
  return (
182
- <div className="relative flex-1 min-h-0">
252
+ <div className="relative flex-1 min-h-0 min-w-0">
183
253
  {/* In-thread search bar */}
184
254
  {searchOpen && (
185
255
  <div className="absolute top-0 left-0 right-0 z-20 flex items-center gap-2 px-6 md:px-12 lg:px-16 py-2 bg-surface/95 backdrop-blur-sm border-b border-white/[0.06]">
@@ -224,6 +294,15 @@ export function MessageList({ messages, streaming }: Props) {
224
294
  >
225
295
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="m6 9 6 6 6-6" /></svg>
226
296
  </button>
297
+ <button
298
+ onClick={() => setBookmarkFilter((v) => !v)}
299
+ aria-label={bookmarkFilter ? 'Show all messages' : 'Show bookmarked only'}
300
+ className={`p-1 rounded-[6px] hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors ${bookmarkFilter ? 'text-[#F59E0B]' : 'text-text-3 hover:text-text-2'}`}
301
+ >
302
+ <svg width="14" height="14" viewBox="0 0 24 24" fill={bookmarkFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
303
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
304
+ </svg>
305
+ </button>
227
306
  <button
228
307
  onClick={() => { setSearchOpen(false); setSearchQuery(''); setSearchIdx(0) }}
229
308
  aria-label="Close search"
@@ -240,14 +319,16 @@ export function MessageList({ messages, streaming }: Props) {
240
319
  className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6"
241
320
  >
242
321
  <div className="flex flex-col gap-6">
243
- {displayedMessages.map((msg, i) => {
322
+ {filteredMessages.map((msg, i) => {
323
+ // Find original index in the full messages array for API calls
324
+ const originalIndex = messages.indexOf(msg)
244
325
  const isLastAssistant = msg.role === 'assistant' && !streaming
245
- && displayedMessages.slice(i + 1).every((m) => m.role !== 'assistant')
326
+ && filteredMessages.slice(i + 1).every((m) => m.role !== 'assistant')
246
327
  const isSearchMatch = searchQuery && searchMatches.some((m) => m.i === i)
247
328
  const isCurrentMatch = searchQuery && searchMatches[searchIdx]?.i === i
248
329
 
249
330
  // Date separator
250
- const prevMsg = i > 0 ? displayedMessages[i - 1] : null
331
+ const prevMsg = i > 0 ? filteredMessages[i - 1] : null
251
332
  const showDateSep = msg.time && (!prevMsg?.time || new Date(msg.time).toDateString() !== new Date(prevMsg.time).toDateString())
252
333
 
253
334
  return (
@@ -265,15 +346,25 @@ export function MessageList({ messages, streaming }: Props) {
265
346
  <MessageBubble
266
347
  message={msg}
267
348
  assistantName={assistantName}
349
+ agentAvatarSeed={agent?.avatarSeed}
350
+ agentName={agent?.name}
268
351
  isLast={isLastAssistant}
269
352
  onRetry={isLastAssistant ? retryLastMessage : undefined}
353
+ messageIndex={originalIndex >= 0 ? originalIndex : undefined}
354
+ onToggleBookmark={toggleBookmark}
355
+ onEditResend={handleEditResend}
356
+ onFork={handleFork}
270
357
  />
271
358
  </div>
272
359
  </div>
273
360
  )
274
361
  })}
275
- {streaming && !streamText && <ThinkingIndicator assistantName={assistantName} />}
276
- {streaming && streamText && <StreamingBubble text={streamText} assistantName={assistantName} />}
362
+ <ApprovalCards agentId={agent?.id} />
363
+ {streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
364
+ {streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
365
+ {!streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
366
+ <SuggestionsBar lastMessage={filteredMessages[filteredMessages.length - 1]} onSend={sendMessage} />
367
+ )}
277
368
  </div>
278
369
  </div>
279
370
  {showScrollToBottom && (
@@ -297,3 +388,16 @@ export function MessageList({ messages, streaming }: Props) {
297
388
  </div>
298
389
  )
299
390
  }
391
+
392
+ function ApprovalCards({ agentId }: { agentId?: string | null }) {
393
+ const approvals = useApprovalStore((s) => s.approvals)
394
+ const cards = Object.values(approvals).filter((a) => !agentId || a.agentId === agentId)
395
+ if (!cards.length) return null
396
+ return (
397
+ <>
398
+ {cards.map((a) => (
399
+ <ExecApprovalCard key={a.id} approval={a} />
400
+ ))}
401
+ </>
402
+ )
403
+ }