@swarmclawai/swarmclaw 0.6.0 → 0.6.2

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 (109) hide show
  1. package/README.md +15 -2
  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 +3 -2
  15. package/src/app/api/tts/stream/route.ts +3 -2
  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 +46 -22
  31. package/src/components/chat/chat-header.tsx +455 -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 +180 -7
  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 +68 -16
  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 +51 -11
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/manager.ts +218 -7
  78. package/src/lib/server/heartbeat-service.ts +8 -1
  79. package/src/lib/server/main-agent-loop.ts +1 -1
  80. package/src/lib/server/memory-consolidation.ts +15 -2
  81. package/src/lib/server/memory-db.ts +134 -6
  82. package/src/lib/server/mime.ts +51 -0
  83. package/src/lib/server/openclaw-gateway.ts +2 -2
  84. package/src/lib/server/orchestrator-lg.ts +2 -0
  85. package/src/lib/server/orchestrator.ts +5 -2
  86. package/src/lib/server/playwright-proxy.mjs +2 -3
  87. package/src/lib/server/prompt-runtime-context.ts +53 -0
  88. package/src/lib/server/queue.ts +52 -7
  89. package/src/lib/server/session-tools/canvas.ts +67 -0
  90. package/src/lib/server/session-tools/connector.ts +83 -9
  91. package/src/lib/server/session-tools/crud.ts +21 -0
  92. package/src/lib/server/session-tools/delegate.ts +68 -4
  93. package/src/lib/server/session-tools/git.ts +71 -0
  94. package/src/lib/server/session-tools/http.ts +57 -0
  95. package/src/lib/server/session-tools/index.ts +8 -0
  96. package/src/lib/server/session-tools/memory.ts +1 -0
  97. package/src/lib/server/session-tools/search-providers.ts +16 -8
  98. package/src/lib/server/session-tools/subagent.ts +106 -0
  99. package/src/lib/server/session-tools/web.ts +115 -4
  100. package/src/lib/server/stream-agent-chat.ts +32 -10
  101. package/src/lib/server/task-mention.ts +41 -0
  102. package/src/lib/sessions.ts +10 -0
  103. package/src/lib/soul-library.ts +103 -0
  104. package/src/lib/task-dedupe.ts +26 -0
  105. package/src/lib/tool-definitions.ts +2 -0
  106. package/src/lib/tts.ts +2 -2
  107. package/src/stores/use-app-store.ts +5 -1
  108. package/src/stores/use-chat-store.ts +65 -2
  109. package/src/types/index.ts +32 -2
@@ -6,6 +6,7 @@ import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { api } from '@/lib/api-client'
8
8
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
+ import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
9
10
  import { MessageBubble } from './message-bubble'
10
11
  import { StreamingBubble } from './streaming-bubble'
11
12
  import { ThinkingIndicator } from './thinking-indicator'
@@ -14,6 +15,7 @@ import { ExecApprovalCard } from './exec-approval-card'
14
15
  import { HeartbeatMoment, ActivityMoment, isNotableTool } from './activity-moment'
15
16
  import { useApprovalStore } from '@/stores/use-approval-store'
16
17
  import { useWs } from '@/hooks/use-ws'
18
+ import { GatewayDisconnectOverlay, useGatewayStatus } from '@/components/settings/gateway-disconnect-overlay'
17
19
 
18
20
  const INTRO_GREETINGS = [
19
21
  'What can I help you with?',
@@ -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,10 @@ export function MessageList({ messages, streaming }: Props) {
108
119
  // Bookmark filter
109
120
  const [bookmarkFilter, setBookmarkFilter] = useState(false)
110
121
 
122
+ // Connector source filter
123
+ const [connectorFilter, setConnectorFilter] = useState<string | null>(null)
124
+ const [connectorFilterCollapsed, setConnectorFilterCollapsed] = useState(false)
125
+
111
126
  const toggleBookmark = useCallback(async (index: number) => {
112
127
  if (!sessionId) return
113
128
  const msg = messages[index]
@@ -166,10 +181,24 @@ export function MessageList({ messages, streaming }: Props) {
166
181
  }
167
182
  }
168
183
 
169
- // Apply bookmark filter
170
- const filteredMessages = bookmarkFilter
184
+ // Collect unique connector sources for filter UI
185
+ const connectorSources = new Map<string, { platform: string; connectorName: string }>()
186
+ for (const msg of displayedMessages) {
187
+ if (msg.source?.connectorId && !connectorSources.has(msg.source.connectorId)) {
188
+ connectorSources.set(msg.source.connectorId, {
189
+ platform: msg.source.platform,
190
+ connectorName: msg.source.connectorName,
191
+ })
192
+ }
193
+ }
194
+
195
+ // Apply bookmark + connector filter
196
+ let filteredMessages = bookmarkFilter
171
197
  ? displayedMessages.filter((msg) => msg.bookmarked)
172
198
  : displayedMessages
199
+ if (connectorFilter) {
200
+ filteredMessages = filteredMessages.filter((msg) => msg.source?.connectorId === connectorFilter)
201
+ }
173
202
 
174
203
  // Search matches
175
204
  const searchMatches = searchQuery.trim()
@@ -274,6 +303,23 @@ export function MessageList({ messages, streaming }: Props) {
274
303
  return () => window.removeEventListener('swarmclaw:scroll-bottom', handler)
275
304
  }, [handleScrollToBottom])
276
305
 
306
+ // Scroll to a specific message by index (used by search)
307
+ useEffect(() => {
308
+ if (typeof window === 'undefined') return
309
+ const handler = (e: Event) => {
310
+ const idx = (e as CustomEvent).detail?.index
311
+ if (typeof idx !== 'number') return
312
+ const el = scrollRef.current?.querySelector(`[data-message-index="${idx}"]`) as HTMLElement | null
313
+ if (el) {
314
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
315
+ el.classList.add('bg-accent-bright/10')
316
+ setTimeout(() => el.classList.remove('bg-accent-bright/10'), 2000)
317
+ }
318
+ }
319
+ window.addEventListener('swarmclaw:scroll-to-message', handler)
320
+ return () => window.removeEventListener('swarmclaw:scroll-to-message', handler)
321
+ }, [])
322
+
277
323
  // Ctrl+F search toggle
278
324
  useEffect(() => {
279
325
  const handler = (e: KeyboardEvent) => {
@@ -296,7 +342,7 @@ export function MessageList({ messages, streaming }: Props) {
296
342
  }, [searchOpen])
297
343
 
298
344
  return (
299
- <div className="relative flex-1 min-h-0 min-w-0">
345
+ <div className="relative flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden">
300
346
  {/* In-thread search bar */}
301
347
  {searchOpen && (
302
348
  <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 +356,7 @@ export function MessageList({ messages, streaming }: Props) {
310
356
  value={searchQuery}
311
357
  onChange={(e) => { setSearchQuery(e.target.value); setSearchIdx(0) }}
312
358
  placeholder="Search in conversation..."
359
+ aria-label="Search messages"
313
360
  className="flex-1 bg-transparent text-text text-[13px] outline-none placeholder:text-text-3/50"
314
361
  style={{ fontFamily: 'inherit' }}
315
362
  onKeyDown={(e) => {
@@ -344,7 +391,7 @@ export function MessageList({ messages, streaming }: Props) {
344
391
  <button
345
392
  onClick={() => setBookmarkFilter((v) => !v)}
346
393
  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'}`}
394
+ 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
395
  >
349
396
  <svg width="14" height="14" viewBox="0 0 24 24" fill={bookmarkFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
350
397
  <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
@@ -360,14 +407,104 @@ export function MessageList({ messages, streaming }: Props) {
360
407
  </div>
361
408
  )}
362
409
 
410
+ {/* Connector source filter — shown when connector messages exist */}
411
+ {connectorSources.size > 0 && (
412
+ <div className="flex items-center gap-1.5 px-6 md:px-12 lg:px-16 py-1.5 border-b border-white/[0.04]">
413
+ <button
414
+ onClick={() => setConnectorFilterCollapsed((c) => !c)}
415
+ className="flex items-center gap-1 text-[10px] text-text-3/50 uppercase tracking-wider font-600 mr-1 bg-transparent border-none cursor-pointer hover:text-text-3/70 transition-colors p-0"
416
+ title={connectorFilterCollapsed ? 'Expand source filter' : 'Collapse source filter'}
417
+ >
418
+ <svg
419
+ width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
420
+ className="transition-transform duration-200"
421
+ style={{ transform: connectorFilterCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
422
+ >
423
+ <polyline points="6 9 12 15 18 9" />
424
+ </svg>
425
+ Source
426
+ {connectorFilterCollapsed && connectorFilter && (
427
+ <span className="text-accent-bright/70 normal-case tracking-normal">
428
+ ({connectorSources.get(connectorFilter)?.connectorName || connectorFilter})
429
+ </span>
430
+ )}
431
+ </button>
432
+ {!connectorFilterCollapsed && (
433
+ <>
434
+ <button
435
+ onClick={() => setConnectorFilter(null)}
436
+ className={`px-2 py-1 rounded-[6px] text-[11px] font-600 cursor-pointer border-none transition-all ${
437
+ !connectorFilter ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'
438
+ }`}
439
+ style={{ fontFamily: 'inherit' }}
440
+ >
441
+ All
442
+ </button>
443
+ {Array.from(connectorSources.entries()).map(([cid, info]) => {
444
+ const active = connectorFilter === cid
445
+ const meta = CONNECTOR_PLATFORM_META[info.platform as keyof typeof CONNECTOR_PLATFORM_META]
446
+ return (
447
+ <button
448
+ key={cid}
449
+ onClick={() => setConnectorFilter(active ? null : cid)}
450
+ className={`flex items-center gap-1.5 px-2 py-1 rounded-[6px] text-[11px] font-600 cursor-pointer border-none transition-all ${
451
+ active ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'
452
+ }`}
453
+ style={{ fontFamily: 'inherit' }}
454
+ >
455
+ <ConnectorPlatformIcon platform={info.platform as keyof typeof CONNECTOR_PLATFORM_META} size={12} />
456
+ {info.connectorName || meta?.label || info.platform}
457
+ </button>
458
+ )
459
+ })}
460
+ </>
461
+ )}
462
+ </div>
463
+ )}
464
+
363
465
  <div
364
466
  ref={scrollRef}
365
467
  onScroll={updateScrollState}
366
- className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6 fade-up"
468
+ 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
469
  >
368
470
  <div className="flex flex-col gap-6 relative">
369
471
  {/* Chat spine — vertical line for assistant messages */}
370
472
  <div className="absolute left-[15px] top-0 bottom-0 w-px bg-white/[0.06] pointer-events-none" />
473
+ {hasMoreMessages && (
474
+ <div className="flex justify-center py-3">
475
+ <button
476
+ onClick={async () => {
477
+ const el = scrollRef.current
478
+ const prevHeight = el?.scrollHeight ?? 0
479
+ await loadMoreMessages()
480
+ // Preserve scroll position after prepending
481
+ if (el) {
482
+ requestAnimationFrame(() => {
483
+ el.scrollTop += el.scrollHeight - prevHeight
484
+ })
485
+ }
486
+ }}
487
+ disabled={loadingMore}
488
+ 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"
489
+ >
490
+ {loadingMore ? (
491
+ <>
492
+ <span className="w-3 h-3 rounded-full border-2 border-text-3/30 border-t-text-2 animate-spin" />
493
+ Loading...
494
+ </>
495
+ ) : (
496
+ <>
497
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
498
+ <path d="M12 19V5" />
499
+ <path d="m5 12 7-7 7 7" />
500
+ </svg>
501
+ Load earlier messages
502
+ <span className="text-text-3/50">({totalMessages - messages.length} more)</span>
503
+ </>
504
+ )}
505
+ </button>
506
+ </div>
507
+ )}
371
508
  {filteredMessages.length === 0 && !streaming && (
372
509
  <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
510
  <AgentAvatar seed={agent?.avatarSeed || null} name={agent?.name || 'Agent'} size={48} />
@@ -378,6 +515,41 @@ export function MessageList({ messages, streaming }: Props) {
378
515
  </div>
379
516
  )}
380
517
  {filteredMessages.map((msg, i) => {
518
+ // Context-clear divider — render a visual separator instead of a bubble
519
+ if (msg.kind === 'context-clear') {
520
+ const originalIndex = messages.indexOf(msg)
521
+ return (
522
+ <div key={`ctx-clear-${msg.time}-${i}`} className="group/ctx flex items-center gap-4 py-3">
523
+ <div className="flex-1 h-px bg-amber-400/20" />
524
+ <span className="flex items-center gap-1.5 text-[10px] font-600 text-amber-400/60 uppercase tracking-[0.1em]">
525
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="shrink-0">
526
+ <line x1="2" y1="12" x2="22" y2="12" />
527
+ <polyline points="8 8 4 12 8 16" />
528
+ <polyline points="16 8 20 12 16 16" />
529
+ </svg>
530
+ New context
531
+ {msg.time ? ` · ${new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''}
532
+ </span>
533
+ {sessionId && originalIndex >= 0 && (
534
+ <button
535
+ type="button"
536
+ onClick={async () => {
537
+ try {
538
+ await api('DELETE', `/sessions/${sessionId}/messages`, { messageIndex: originalIndex })
539
+ setMessages(messages.filter((_: Message, idx: number) => idx !== originalIndex))
540
+ } catch { /* best-effort */ }
541
+ }}
542
+ 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"
543
+ title="Undo — restore full context"
544
+ >
545
+ Undo
546
+ </button>
547
+ )}
548
+ <div className="flex-1 h-px bg-amber-400/20" />
549
+ </div>
550
+ )
551
+ }
552
+
381
553
  // Find original index in the full messages array for API calls
382
554
  const originalIndex = messages.indexOf(msg)
383
555
  const isLastAssistant = msg.role === 'assistant' && !streaming
@@ -407,7 +579,7 @@ export function MessageList({ messages, streaming }: Props) {
407
579
  }
408
580
 
409
581
  return (
410
- <div key={`${msg.time}-${i}`}>
582
+ <div key={`${msg.time}-${i}`} data-message-index={i}>
411
583
  {showDateSep && (
412
584
  <div className="flex items-center gap-4 py-2 mb-2">
413
585
  <div className="flex-1 h-px bg-white/[0.06]" />
@@ -438,11 +610,12 @@ export function MessageList({ messages, streaming }: Props) {
438
610
  <ApprovalCards agentId={agent?.id} />
439
611
  {streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
440
612
  {streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
441
- {!streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
613
+ {appSettings.suggestionsEnabled !== false && !streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
442
614
  <SuggestionsBar lastMessage={filteredMessages[filteredMessages.length - 1]} onSend={sendMessage} />
443
615
  )}
444
616
  </div>
445
617
  </div>
618
+ {showGatewayOverlay && <GatewayDisconnectOverlay />}
446
619
  {showScrollToBottom && (
447
620
  <button
448
621
  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
  )}