@swarmclawai/swarmclaw 0.6.0 → 0.6.3

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 (118) hide show
  1. package/README.md +56 -42
  2. package/bin/server-cmd.js +1 -0
  3. package/package.json +2 -1
  4. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
  6. package/src/app/api/connectors/[id]/route.ts +1 -0
  7. package/src/app/api/connectors/route.ts +2 -1
  8. package/src/app/api/files/open/route.ts +43 -0
  9. package/src/app/api/search/route.ts +9 -7
  10. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  11. package/src/app/api/sessions/[id]/route.ts +4 -0
  12. package/src/app/api/tasks/metrics/route.ts +101 -0
  13. package/src/app/api/tasks/route.ts +17 -2
  14. package/src/app/api/tts/route.ts +16 -35
  15. package/src/app/api/tts/stream/route.ts +14 -42
  16. package/src/app/api/uploads/[filename]/route.ts +19 -34
  17. package/src/app/api/uploads/route.ts +94 -0
  18. package/src/app/globals.css +5 -0
  19. package/src/cli/index.js +16 -1
  20. package/src/cli/spec.js +26 -0
  21. package/src/components/agents/agent-card.tsx +3 -3
  22. package/src/components/agents/agent-chat-list.tsx +29 -6
  23. package/src/components/agents/agent-sheet.tsx +66 -4
  24. package/src/components/agents/inspector-panel.tsx +81 -6
  25. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  26. package/src/components/agents/personality-builder.tsx +42 -14
  27. package/src/components/agents/soul-library-picker.tsx +89 -0
  28. package/src/components/canvas/canvas-panel.tsx +96 -0
  29. package/src/components/chat/activity-moment.tsx +8 -4
  30. package/src/components/chat/chat-area.tsx +76 -24
  31. package/src/components/chat/chat-header.tsx +522 -286
  32. package/src/components/chat/chat-preview-panel.tsx +1 -2
  33. package/src/components/chat/delegation-banner.tsx +371 -0
  34. package/src/components/chat/file-path-chip.tsx +23 -2
  35. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  36. package/src/components/chat/message-bubble.tsx +315 -25
  37. package/src/components/chat/message-list.tsx +113 -8
  38. package/src/components/chat/streaming-bubble.tsx +68 -1
  39. package/src/components/chat/tool-call-bubble.tsx +45 -3
  40. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  41. package/src/components/chatrooms/chatroom-list.tsx +8 -1
  42. package/src/components/chatrooms/chatroom-message.tsx +8 -3
  43. package/src/components/chatrooms/chatroom-view.tsx +3 -3
  44. package/src/components/connectors/connector-list.tsx +168 -90
  45. package/src/components/connectors/connector-sheet.tsx +84 -17
  46. package/src/components/home/home-view.tsx +1 -1
  47. package/src/components/input/chat-input.tsx +28 -2
  48. package/src/components/layout/app-layout.tsx +19 -2
  49. package/src/components/projects/project-detail.tsx +1 -1
  50. package/src/components/schedules/schedule-sheet.tsx +260 -127
  51. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  54. package/src/components/shared/connector-platform-icon.tsx +51 -4
  55. package/src/components/shared/icon-button.tsx +16 -2
  56. package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
  57. package/src/components/shared/search-dialog.tsx +17 -10
  58. package/src/components/shared/settings/section-embedding.tsx +48 -13
  59. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  60. package/src/components/shared/settings/section-storage.tsx +206 -0
  61. package/src/components/shared/settings/section-user-preferences.tsx +18 -0
  62. package/src/components/shared/settings/section-voice.tsx +42 -21
  63. package/src/components/shared/settings/section-web-search.tsx +30 -6
  64. package/src/components/shared/settings/settings-page.tsx +3 -1
  65. package/src/components/shared/settings/storage-browser.tsx +259 -0
  66. package/src/components/tasks/task-card.tsx +14 -1
  67. package/src/components/tasks/task-sheet.tsx +328 -3
  68. package/src/components/usage/metrics-dashboard.tsx +90 -6
  69. package/src/hooks/use-continuous-speech.ts +10 -4
  70. package/src/hooks/use-voice-conversation.ts +53 -10
  71. package/src/hooks/use-ws.ts +4 -2
  72. package/src/lib/providers/anthropic.ts +13 -7
  73. package/src/lib/providers/index.ts +1 -0
  74. package/src/lib/providers/openai.ts +13 -7
  75. package/src/lib/server/chat-execution.ts +125 -14
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/connector-routing.test.ts +118 -1
  78. package/src/lib/server/connectors/discord.ts +31 -8
  79. package/src/lib/server/connectors/manager.ts +594 -16
  80. package/src/lib/server/connectors/media.ts +5 -0
  81. package/src/lib/server/connectors/telegram.ts +12 -2
  82. package/src/lib/server/connectors/types.ts +2 -0
  83. package/src/lib/server/connectors/whatsapp.ts +28 -2
  84. package/src/lib/server/elevenlabs.test.ts +60 -0
  85. package/src/lib/server/elevenlabs.ts +103 -0
  86. package/src/lib/server/heartbeat-service.ts +8 -1
  87. package/src/lib/server/main-agent-loop.ts +1 -1
  88. package/src/lib/server/memory-consolidation.ts +15 -2
  89. package/src/lib/server/memory-db.ts +134 -6
  90. package/src/lib/server/mime.ts +51 -0
  91. package/src/lib/server/openclaw-gateway.ts +2 -2
  92. package/src/lib/server/orchestrator-lg.ts +2 -0
  93. package/src/lib/server/orchestrator.ts +5 -2
  94. package/src/lib/server/playwright-proxy.mjs +2 -3
  95. package/src/lib/server/prompt-runtime-context.ts +53 -0
  96. package/src/lib/server/queue.ts +182 -8
  97. package/src/lib/server/session-tools/canvas.ts +67 -0
  98. package/src/lib/server/session-tools/connector.ts +583 -63
  99. package/src/lib/server/session-tools/crud.ts +21 -0
  100. package/src/lib/server/session-tools/delegate.ts +68 -4
  101. package/src/lib/server/session-tools/file.ts +26 -7
  102. package/src/lib/server/session-tools/git.ts +71 -0
  103. package/src/lib/server/session-tools/http.ts +57 -0
  104. package/src/lib/server/session-tools/index.ts +8 -0
  105. package/src/lib/server/session-tools/memory.ts +1 -0
  106. package/src/lib/server/session-tools/search-providers.ts +16 -8
  107. package/src/lib/server/session-tools/subagent.ts +106 -0
  108. package/src/lib/server/session-tools/web.ts +118 -8
  109. package/src/lib/server/stream-agent-chat.ts +39 -10
  110. package/src/lib/server/task-mention.ts +41 -0
  111. package/src/lib/sessions.ts +10 -0
  112. package/src/lib/soul-library.ts +103 -0
  113. package/src/lib/task-dedupe.ts +26 -0
  114. package/src/lib/tool-definitions.ts +2 -0
  115. package/src/lib/tts.ts +2 -2
  116. package/src/stores/use-app-store.ts +5 -1
  117. package/src/stores/use-chat-store.ts +65 -2
  118. package/src/types/index.ts +32 -2
@@ -14,6 +14,7 @@ import { ExecApprovalCard } from './exec-approval-card'
14
14
  import { HeartbeatMoment, ActivityMoment, isNotableTool } from './activity-moment'
15
15
  import { useApprovalStore } from '@/stores/use-approval-store'
16
16
  import { useWs } from '@/hooks/use-ws'
17
+ import { GatewayDisconnectOverlay, useGatewayStatus } from '@/components/settings/gateway-disconnect-overlay'
17
18
 
18
19
  const INTRO_GREETINGS = [
19
20
  'What can I help you with?',
@@ -45,9 +46,10 @@ function dateSeparator(ts: number): string {
45
46
  interface Props {
46
47
  messages: Message[]
47
48
  streaming: boolean
49
+ connectorFilter?: string | null
48
50
  }
49
51
 
50
- export function MessageList({ messages, streaming }: Props) {
52
+ export function MessageList({ messages, streaming, connectorFilter = null }: Props) {
51
53
  const scrollRef = useRef<HTMLDivElement>(null)
52
54
  const [showScrollToBottom, setShowScrollToBottom] = useState(false)
53
55
  const snapUntilRef = useRef(0)
@@ -57,6 +59,10 @@ export function MessageList({ messages, streaming }: Props) {
57
59
  const retryLastMessage = useChatStore((s) => s.retryLastMessage)
58
60
  const editAndResend = useChatStore((s) => s.editAndResend)
59
61
  const sendMessage = useChatStore((s) => s.sendMessage)
62
+ const hasMoreMessages = useChatStore((s) => s.hasMoreMessages)
63
+ const loadingMore = useChatStore((s) => s.loadingMore)
64
+ const totalMessages = useChatStore((s) => s.totalMessages)
65
+ const loadMoreMessages = useChatStore((s) => s.loadMoreMessages)
60
66
  const forkSession = useAppStore((s) => s.forkSession)
61
67
  const session = useAppStore((s) => {
62
68
  const id = s.currentSessionId
@@ -73,6 +79,11 @@ export function MessageList({ messages, streaming }: Props) {
73
79
  const showOk = appSettings.heartbeatShowOk ?? false
74
80
  const showAlerts = appSettings.heartbeatShowAlerts ?? true
75
81
 
82
+ // Gateway disconnect overlay for openclaw agents
83
+ const isOpenClaw = agent?.provider === 'openclaw'
84
+ const gatewayStatus = useGatewayStatus()
85
+ const showGatewayOverlay = isOpenClaw && gatewayStatus === 'disconnected'
86
+
76
87
  // Moment overlay for last assistant message (heartbeat or tool events)
77
88
  type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
78
89
  const [currentMoment, setCurrentMoment] = useState<MomentType | null>(null)
@@ -108,6 +119,8 @@ export function MessageList({ messages, streaming }: Props) {
108
119
  // Bookmark filter
109
120
  const [bookmarkFilter, setBookmarkFilter] = useState(false)
110
121
 
122
+ // Connector filtering is handled via connectorFilter prop from chat-area
123
+
111
124
  const toggleBookmark = useCallback(async (index: number) => {
112
125
  if (!sessionId) return
113
126
  const msg = messages[index]
@@ -166,10 +179,13 @@ export function MessageList({ messages, streaming }: Props) {
166
179
  }
167
180
  }
168
181
 
169
- // Apply bookmark filter
170
- const filteredMessages = bookmarkFilter
182
+ // Apply bookmark + connector filter
183
+ let filteredMessages = bookmarkFilter
171
184
  ? displayedMessages.filter((msg) => msg.bookmarked)
172
185
  : displayedMessages
186
+ if (connectorFilter) {
187
+ filteredMessages = filteredMessages.filter((msg) => msg.source?.connectorId === connectorFilter)
188
+ }
173
189
 
174
190
  // Search matches
175
191
  const searchMatches = searchQuery.trim()
@@ -274,6 +290,23 @@ export function MessageList({ messages, streaming }: Props) {
274
290
  return () => window.removeEventListener('swarmclaw:scroll-bottom', handler)
275
291
  }, [handleScrollToBottom])
276
292
 
293
+ // Scroll to a specific message by index (used by search)
294
+ useEffect(() => {
295
+ if (typeof window === 'undefined') return
296
+ const handler = (e: Event) => {
297
+ const idx = (e as CustomEvent).detail?.index
298
+ if (typeof idx !== 'number') return
299
+ const el = scrollRef.current?.querySelector(`[data-message-index="${idx}"]`) as HTMLElement | null
300
+ if (el) {
301
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
302
+ el.classList.add('bg-accent-bright/10')
303
+ setTimeout(() => el.classList.remove('bg-accent-bright/10'), 2000)
304
+ }
305
+ }
306
+ window.addEventListener('swarmclaw:scroll-to-message', handler)
307
+ return () => window.removeEventListener('swarmclaw:scroll-to-message', handler)
308
+ }, [])
309
+
277
310
  // Ctrl+F search toggle
278
311
  useEffect(() => {
279
312
  const handler = (e: KeyboardEvent) => {
@@ -296,7 +329,7 @@ export function MessageList({ messages, streaming }: Props) {
296
329
  }, [searchOpen])
297
330
 
298
331
  return (
299
- <div className="relative flex-1 min-h-0 min-w-0">
332
+ <div className="relative flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden">
300
333
  {/* In-thread search bar */}
301
334
  {searchOpen && (
302
335
  <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]">
@@ -310,6 +343,7 @@ export function MessageList({ messages, streaming }: Props) {
310
343
  value={searchQuery}
311
344
  onChange={(e) => { setSearchQuery(e.target.value); setSearchIdx(0) }}
312
345
  placeholder="Search in conversation..."
346
+ aria-label="Search messages"
313
347
  className="flex-1 bg-transparent text-text text-[13px] outline-none placeholder:text-text-3/50"
314
348
  style={{ fontFamily: 'inherit' }}
315
349
  onKeyDown={(e) => {
@@ -344,7 +378,7 @@ export function MessageList({ messages, streaming }: Props) {
344
378
  <button
345
379
  onClick={() => setBookmarkFilter((v) => !v)}
346
380
  aria-label={bookmarkFilter ? 'Show all messages' : 'Show bookmarked only'}
347
- 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'}`}
381
+ className={`p-1 rounded-[6px] hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors ${bookmarkFilter ? 'text-amber-500' : 'text-text-3 hover:text-text-2'}`}
348
382
  >
349
383
  <svg width="14" height="14" viewBox="0 0 24 24" fill={bookmarkFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
350
384
  <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
@@ -363,11 +397,46 @@ export function MessageList({ messages, streaming }: Props) {
363
397
  <div
364
398
  ref={scrollRef}
365
399
  onScroll={updateScrollState}
366
- className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6 fade-up"
400
+ className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 md:px-12 lg:px-16 pt-6 pb-10 fade-up"
367
401
  >
368
402
  <div className="flex flex-col gap-6 relative">
369
403
  {/* Chat spine — vertical line for assistant messages */}
370
404
  <div className="absolute left-[15px] top-0 bottom-0 w-px bg-white/[0.06] pointer-events-none" />
405
+ {hasMoreMessages && (
406
+ <div className="flex justify-center py-3">
407
+ <button
408
+ onClick={async () => {
409
+ const el = scrollRef.current
410
+ const prevHeight = el?.scrollHeight ?? 0
411
+ await loadMoreMessages()
412
+ // Preserve scroll position after prepending
413
+ if (el) {
414
+ requestAnimationFrame(() => {
415
+ el.scrollTop += el.scrollHeight - prevHeight
416
+ })
417
+ }
418
+ }}
419
+ disabled={loadingMore}
420
+ className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-white/[0.08] bg-surface/80 text-text-3 text-[12px] font-600 hover:bg-surface-2 hover:text-text-2 transition-colors cursor-pointer disabled:opacity-50"
421
+ >
422
+ {loadingMore ? (
423
+ <>
424
+ <span className="w-3 h-3 rounded-full border-2 border-text-3/30 border-t-text-2 animate-spin" />
425
+ Loading...
426
+ </>
427
+ ) : (
428
+ <>
429
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
430
+ <path d="M12 19V5" />
431
+ <path d="m5 12 7-7 7 7" />
432
+ </svg>
433
+ Load earlier messages
434
+ <span className="text-text-3/50">({totalMessages - messages.length} more)</span>
435
+ </>
436
+ )}
437
+ </button>
438
+ </div>
439
+ )}
371
440
  {filteredMessages.length === 0 && !streaming && (
372
441
  <div className="flex flex-col items-center justify-center gap-3 py-20 text-center" style={{ animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) both' }}>
373
442
  <AgentAvatar seed={agent?.avatarSeed || null} name={agent?.name || 'Agent'} size={48} />
@@ -378,6 +447,41 @@ export function MessageList({ messages, streaming }: Props) {
378
447
  </div>
379
448
  )}
380
449
  {filteredMessages.map((msg, i) => {
450
+ // Context-clear divider — render a visual separator instead of a bubble
451
+ if (msg.kind === 'context-clear') {
452
+ const originalIndex = messages.indexOf(msg)
453
+ return (
454
+ <div key={`ctx-clear-${msg.time}-${i}`} className="group/ctx flex items-center gap-4 py-3">
455
+ <div className="flex-1 h-px bg-amber-400/20" />
456
+ <span className="flex items-center gap-1.5 text-[10px] font-600 text-amber-400/60 uppercase tracking-[0.1em]">
457
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="shrink-0">
458
+ <line x1="2" y1="12" x2="22" y2="12" />
459
+ <polyline points="8 8 4 12 8 16" />
460
+ <polyline points="16 8 20 12 16 16" />
461
+ </svg>
462
+ New context
463
+ {msg.time ? ` · ${new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''}
464
+ </span>
465
+ {sessionId && originalIndex >= 0 && (
466
+ <button
467
+ type="button"
468
+ onClick={async () => {
469
+ try {
470
+ await api('DELETE', `/sessions/${sessionId}/messages`, { messageIndex: originalIndex })
471
+ setMessages(messages.filter((_: Message, idx: number) => idx !== originalIndex))
472
+ } catch { /* best-effort */ }
473
+ }}
474
+ className="opacity-0 group-hover/ctx:opacity-100 text-[10px] font-600 text-amber-400/60 hover:text-amber-400 bg-transparent border-none cursor-pointer transition-all px-1.5 py-0.5 rounded-[4px] hover:bg-amber-400/10"
475
+ title="Undo — restore full context"
476
+ >
477
+ Undo
478
+ </button>
479
+ )}
480
+ <div className="flex-1 h-px bg-amber-400/20" />
481
+ </div>
482
+ )
483
+ }
484
+
381
485
  // Find original index in the full messages array for API calls
382
486
  const originalIndex = messages.indexOf(msg)
383
487
  const isLastAssistant = msg.role === 'assistant' && !streaming
@@ -407,7 +511,7 @@ export function MessageList({ messages, streaming }: Props) {
407
511
  }
408
512
 
409
513
  return (
410
- <div key={`${msg.time}-${i}`}>
514
+ <div key={`${msg.time}-${i}`} data-message-index={i}>
411
515
  {showDateSep && (
412
516
  <div className="flex items-center gap-4 py-2 mb-2">
413
517
  <div className="flex-1 h-px bg-white/[0.06]" />
@@ -438,11 +542,12 @@ export function MessageList({ messages, streaming }: Props) {
438
542
  <ApprovalCards agentId={agent?.id} />
439
543
  {streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
440
544
  {streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
441
- {!streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
545
+ {appSettings.suggestionsEnabled !== false && !streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
442
546
  <SuggestionsBar lastMessage={filteredMessages[filteredMessages.length - 1]} onSend={sendMessage} />
443
547
  )}
444
548
  </div>
445
549
  </div>
550
+ {showGatewayOverlay && <GatewayDisconnectOverlay />}
446
551
  {showScrollToBottom && (
447
552
  <button
448
553
  onClick={handleScrollToBottom}
@@ -3,7 +3,7 @@
3
3
  import { useMemo, useState } from 'react'
4
4
  import { AiAvatar } from '@/components/shared/avatar'
5
5
  import { AgentAvatar } from '@/components/agents/agent-avatar'
6
- import { ToolCallBubble } from './tool-call-bubble'
6
+ import { ToolCallBubble, extractMedia } from './tool-call-bubble'
7
7
  import { ActivityMoment, isNotableTool } from './activity-moment'
8
8
  import { useChatStore, type ToolEvent } from '@/stores/use-chat-store'
9
9
  import { isStructuredMarkdown } from './markdown-utils'
@@ -13,6 +13,26 @@ function ToolEventsSection({ toolEvents }: { toolEvents: ToolEvent[] }) {
13
13
  const shouldCollapse = toolEvents.length > 2
14
14
  const latestTool = toolEvents[toolEvents.length - 1]
15
15
 
16
+ // When collapsed, collect deduplicated media from all tool events so files remain visible
17
+ const collapsedMedia = useMemo(() => {
18
+ if (!shouldCollapse || expanded) return null
19
+ const seen = new Set<string>()
20
+ const images: string[] = []
21
+ const videos: string[] = []
22
+ const pdfs: { name: string; url: string }[] = []
23
+ const files: { name: string; url: string }[] = []
24
+ for (const ev of toolEvents) {
25
+ if (!ev.output) continue
26
+ const m = extractMedia(ev.output)
27
+ for (const url of m.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
28
+ for (const url of m.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
29
+ for (const p of m.pdfs) { if (!seen.has(p.url)) { seen.add(p.url); pdfs.push(p) } }
30
+ for (const f of m.files) { if (!seen.has(f.url)) { seen.add(f.url); files.push(f) } }
31
+ }
32
+ if (!images.length && !videos.length && !pdfs.length && !files.length) return null
33
+ return { images, videos, pdfs, files }
34
+ }, [toolEvents, shouldCollapse, expanded])
35
+
16
36
  if (shouldCollapse && !expanded) {
17
37
  return (
18
38
  <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
@@ -31,6 +51,33 @@ function ToolEventsSection({ toolEvents }: { toolEvents: ToolEvent[] }) {
31
51
  latest: {latestTool?.name || 'unknown'}
32
52
  </span>
33
53
  </button>
54
+ {collapsedMedia && (
55
+ <>
56
+ {collapsedMedia.images.map((src, i) => (
57
+ // eslint-disable-next-line @next/next/no-img-element
58
+ <img key={`ci-${i}`} src={src} alt={`Screenshot ${i + 1}`} loading="lazy"
59
+ className="max-w-[400px] rounded-[10px] border border-white/10"
60
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
61
+ ))}
62
+ {collapsedMedia.videos.map((src, i) => (
63
+ <video key={`cv-${i}`} src={src} controls playsInline preload="none" className="max-w-full rounded-[10px] border border-white/10" />
64
+ ))}
65
+ {collapsedMedia.pdfs.map((file, i) => (
66
+ <div key={`cp-${i}`} className="rounded-[10px] border border-white/10 overflow-hidden">
67
+ <iframe src={file.url} loading="lazy" className="w-full h-[400px] bg-white" title={file.name} />
68
+ </div>
69
+ ))}
70
+ {collapsedMedia.files.map((file, i) => (
71
+ <a key={`cf-${i}`} href={file.url} download className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/10 bg-surface/60 text-[13px] text-text-2 no-underline">
72
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
73
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
74
+ <polyline points="14 2 14 8 20 8" />
75
+ </svg>
76
+ {file.name}
77
+ </a>
78
+ ))}
79
+ </>
80
+ )}
34
81
  </div>
35
82
  )
36
83
  }
@@ -64,6 +111,7 @@ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentNam
64
111
  const toolEvents = useChatStore((s) => s.toolEvents)
65
112
  const streamPhase = useChatStore((s) => s.streamPhase)
66
113
  const streamToolName = useChatStore((s) => s.streamToolName)
114
+ const thinkingText = useChatStore((s) => s.thinkingText)
67
115
  const wide = useMemo(() => isStructuredMarkdown(text), [text])
68
116
 
69
117
  // Track which activity moments have been dismissed
@@ -107,6 +155,25 @@ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentNam
107
155
  )}
108
156
  </div>
109
157
 
158
+ {/* Collapsed thinking section (shown when text has started but thinking exists) */}
159
+ {text && thinkingText && (
160
+ <div className="max-w-[85%] md:max-w-[72%] mb-2">
161
+ <details className="group rounded-[12px] border border-purple-500/15 bg-purple-500/[0.04]">
162
+ <summary className="flex items-center gap-2 px-3.5 py-2.5 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
163
+ <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">
164
+ <polyline points="9 18 15 12 9 6" />
165
+ </svg>
166
+ <span className="text-[11px] font-600 text-purple-400/70 uppercase tracking-[0.05em]">Thinking</span>
167
+ </summary>
168
+ <div className="px-3.5 pb-3 pt-1 max-h-[300px] overflow-y-auto">
169
+ <div className="text-[13px] leading-[1.6] text-text-3/70 whitespace-pre-wrap break-words">
170
+ {thinkingText}
171
+ </div>
172
+ </div>
173
+ </details>
174
+ </div>
175
+ )}
176
+
110
177
  {/* Tool call events (collapsible when > 2) */}
111
178
  {toolEvents.length > 0 && (
112
179
  <ToolEventsSection toolEvents={toolEvents} />
@@ -20,6 +20,7 @@ const TOOL_COLORS: Record<string, string> = {
20
20
  web_search: '#3B82F6',
21
21
  web_fetch: '#3B82F6',
22
22
  delegate_to_agent: '#6366F1',
23
+ check_delegation_status: '#6366F1',
23
24
  delegate_to_claude_code: '#6366F1',
24
25
  delegate_to_codex_cli: '#0EA5E9',
25
26
  delegate_to_opencode_cli: '#14B8A6',
@@ -71,6 +72,7 @@ export const TOOL_LABELS: Record<string, string> = {
71
72
  codex_cli: 'Codex CLI',
72
73
  opencode_cli: 'OpenCode CLI',
73
74
  delegate_to_agent: 'Agent Delegation',
75
+ check_delegation_status: 'Check Delegation',
74
76
  delegate_to_claude_code: 'Claude Code',
75
77
  delegate_to_codex_cli: 'Codex CLI',
76
78
  delegate_to_opencode_cli: 'OpenCode CLI',
@@ -107,6 +109,7 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
107
109
  codex_cli: 'Enable delegation to OpenAI Codex CLI',
108
110
  opencode_cli: 'Enable delegation to OpenCode CLI',
109
111
  delegate_to_agent: 'Delegate a task to another agent',
112
+ check_delegation_status: 'Check the status of a delegated task',
110
113
  delegate_to_claude_code: 'Delegate complex coding tasks to Claude Code',
111
114
  delegate_to_codex_cli: 'Delegate complex coding tasks to Codex CLI',
112
115
  delegate_to_opencode_cli: 'Delegate complex coding tasks to OpenCode CLI',
@@ -164,6 +167,45 @@ function formatJson(raw: string): string {
164
167
  }
165
168
  }
166
169
 
170
+ /** Relative time label like "2h ago", "5m ago" */
171
+ function relativeTime(ts: number): string {
172
+ const diff = Date.now() - ts
173
+ if (diff < 60_000) return 'just now'
174
+ if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`
175
+ if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`
176
+ return `${Math.round(diff / 86_400_000)}d ago`
177
+ }
178
+
179
+ /** Format search_history_tool output into human-readable text */
180
+ function formatSearchHistoryOutput(raw: string): string | null {
181
+ try {
182
+ const parsed = JSON.parse(raw)
183
+ const matches = parsed?.matches
184
+ if (!Array.isArray(matches) || matches.length === 0) {
185
+ return parsed?.query ? `No matches found for "${parsed.query}"` : null
186
+ }
187
+ const header = `Found ${matches.length} match${matches.length === 1 ? '' : 'es'}${parsed.query ? ` for "${parsed.query}"` : ''}`
188
+ const lines = matches.slice(0, 10).map((m: Record<string, unknown>, i: number) => {
189
+ const role = String(m.role || 'unknown')
190
+ const kind = m.kind ? ` (${m.kind})` : ''
191
+ const time = typeof m.time === 'number' ? ` · ${relativeTime(m.time)}` : ''
192
+ const text = String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 200)
193
+ return `${i + 1}. [${role}]${kind}${time}\n ${text}${String(m.text || '').length > 200 ? '...' : ''}`
194
+ })
195
+ return `${header}\n\n${lines.join('\n\n')}`
196
+ } catch {
197
+ return null
198
+ }
199
+ }
200
+
201
+ /** Try to produce a human-readable output for known tool types */
202
+ function formatToolOutput(toolName: string, raw: string): string {
203
+ if (toolName === 'search_history_tool') {
204
+ return formatSearchHistoryOutput(raw) || formatJson(raw)
205
+ }
206
+ return formatJson(raw)
207
+ }
208
+
167
209
  /** Extract a human-readable preview from tool input */
168
210
  function getInputPreview(name: string, input: string): string {
169
211
  try {
@@ -218,7 +260,7 @@ function getInputPreview(name: string, input: string): string {
218
260
  }
219
261
 
220
262
  /** Extract embedded images, videos, PDFs, and file links from tool output */
221
- function extractMedia(output: string): { images: string[]; videos: string[]; pdfs: { name: string; url: string }[]; files: { name: string; url: string }[]; cleanText: string } {
263
+ export function extractMedia(output: string): { images: string[]; videos: string[]; pdfs: { name: string; url: string }[]; files: { name: string; url: string }[]; cleanText: string } {
222
264
  const images: string[] = []
223
265
  const videos: string[] = []
224
266
  const pdfs: { name: string; url: string }[] = []
@@ -341,8 +383,8 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
341
383
 
342
384
  const formattedCleanOutput = useMemo(() => {
343
385
  if (!media.cleanText) return ''
344
- return formatJson(media.cleanText)
345
- }, [media.cleanText])
386
+ return formatToolOutput(event.name, media.cleanText)
387
+ }, [event.name, media.cleanText])
346
388
 
347
389
  const hasMedia = media.images.length > 0 || media.videos.length > 0 || media.pdfs.length > 0 || media.files.length > 0
348
390
 
@@ -30,7 +30,7 @@ export function TransferAgentPicker({ excludeIds, filterIds, onSelect, onClose }
30
30
  return (
31
31
  <>
32
32
  <div className="fixed inset-0 z-40" onClick={onClose} />
33
- <div className="absolute left-0 bottom-full mb-2 z-50 w-[220px] rounded-[10px] bg-[#1a1a2e]/95 backdrop-blur-xl border border-white/[0.1] shadow-[0_12px_40px_rgba(0,0,0,0.5)] overflow-hidden">
33
+ <div className="absolute left-0 bottom-full mb-2 z-50 w-[220px] rounded-[10px] bg-surface/95 backdrop-blur-xl border border-white/[0.1] shadow-[0_12px_40px_rgba(0,0,0,0.5)] overflow-hidden">
34
34
  <div className="p-2">
35
35
  <input
36
36
  value={query}
@@ -24,6 +24,13 @@ export function ChatroomList() {
24
24
  useEffect(() => { refresh() }, [refresh])
25
25
  useWs('chatrooms', refresh, 15_000)
26
26
 
27
+ // Auto-select the latest chatroom when none is selected
28
+ useEffect(() => {
29
+ if (currentChatroomId) return
30
+ const latest = Object.values(chatrooms).sort((a, b) => b.updatedAt - a.updatedAt)[0]
31
+ if (latest) setCurrentChatroom(latest.id)
32
+ }, [chatrooms, currentChatroomId, setCurrentChatroom])
33
+
27
34
  const [filter, setFilter] = useState<'all' | 'active' | 'recent'>('all')
28
35
 
29
36
  const sorted = useMemo(() =>
@@ -63,7 +70,7 @@ export function ChatroomList() {
63
70
  type="button"
64
71
  onClick={() => setFilter(f)}
65
72
  data-active={filter === f || undefined}
66
- className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 border-none cursor-pointer transition-all
73
+ className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 border-none cursor-pointer transition-all focus-visible:ring-1 focus-visible:ring-accent-bright/50
67
74
  data-[active]:bg-accent-soft data-[active]:text-accent-bright
68
75
  bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]"
69
76
  >
@@ -14,6 +14,7 @@ import { AgentHoverCard } from './agent-hover-card'
14
14
  import { ChatroomToolRequestBanner } from './chatroom-tool-request-banner'
15
15
  import { isStructuredMarkdown } from '@/components/chat/markdown-utils'
16
16
  import { TransferAgentPicker } from '@/components/chat/transfer-agent-picker'
17
+ import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
17
18
  import type { ChatroomMessage, Agent } from '@/types'
18
19
 
19
20
  interface Props {
@@ -188,13 +189,17 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
188
189
  <div className="flex items-baseline gap-2 mb-0.5">
189
190
  {!isUser && agent ? (
190
191
  <AgentHoverCard agent={agent}>
191
- <span className="text-[13px] font-600 text-accent-bright hover:underline cursor-pointer">
192
+ <span className="text-[13px] font-600 text-accent-bright hover:underline cursor-pointer flex items-center gap-1.5">
193
+ {message.source && <ConnectorPlatformIcon platform={message.source.platform} size={12} />}
192
194
  {message.senderName}
193
195
  </span>
194
196
  </AgentHoverCard>
195
197
  ) : (
196
- <span className="text-[13px] font-600 text-text">
197
- {message.senderName}
198
+ <span className="text-[13px] font-600 text-text flex items-center gap-1.5">
199
+ {message.source && <ConnectorPlatformIcon platform={message.source.platform} size={12} />}
200
+ {isUser && message.source?.senderName
201
+ ? `${message.source.senderName} via ${CONNECTOR_PLATFORM_META[message.source.platform]?.label || message.source.platform}`
202
+ : message.senderName}
198
203
  </span>
199
204
  )}
200
205
  <span className="label-mono" title={new Date(message.time).toLocaleString()}>{formatRelativeTime(message.time)}</span>
@@ -177,7 +177,7 @@ export function ChatroomView() {
177
177
  </svg>
178
178
  </div>
179
179
  <div className="flex-1 min-w-0">
180
- <h3 className="text-[14px] font-600 text-text truncate">{chatroom.name}</h3>
180
+ <h3 className="text-[14px] font-700 text-text truncate">{chatroom.name}</h3>
181
181
  <p className="text-[11px] text-text-3 truncate">
182
182
  {memberAgents.length} agent{memberAgents.length !== 1 ? 's' : ''}
183
183
  {chatroom.description ? ` · ${chatroom.description}` : ''}
@@ -192,7 +192,7 @@ export function ChatroomView() {
192
192
  onClick={() => navigateToAgent(agent.id)}
193
193
  className="relative transition-all duration-200 hover:scale-110 hover:z-10 hover:-translate-y-0.5 cursor-pointer bg-transparent border-none p-0"
194
194
  >
195
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={22} className="ring-1 ring-bg" status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
195
+ <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={22} status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
196
196
  </button>
197
197
  </TooltipTrigger>
198
198
  <TooltipContent side="bottom" sideOffset={6}>
@@ -201,7 +201,7 @@ export function ChatroomView() {
201
201
  </Tooltip>
202
202
  ))}
203
203
  {memberAgents.length > 5 && (
204
- <div className="w-[22px] h-[22px] rounded-full bg-white/[0.08] flex items-center justify-center text-[9px] text-text-3 ring-1 ring-bg">
204
+ <div className="w-[22px] h-[22px] rounded-full bg-white/[0.08] flex items-center justify-center text-[9px] text-text-3">
205
205
  +{memberAgents.length - 5}
206
206
  </div>
207
207
  )}