@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,7 +14,9 @@ import {
14
14
  getSessionConnector,
15
15
  } from '@/components/shared/connector-platform-icon'
16
16
  import { AgentAvatar } from '@/components/agents/agent-avatar'
17
+ import { ModelCombobox } from '@/components/shared/model-combobox'
17
18
  import { toast } from 'sonner'
19
+ import type { ProviderType } from '@/types'
18
20
 
19
21
  function shortPath(p: string): string {
20
22
  return (p || '').replace(/^\/Users\/\w+/, '~')
@@ -49,9 +51,15 @@ interface Props {
49
51
  onVoiceToggle?: () => void
50
52
  voiceActive?: boolean
51
53
  voiceSupported?: boolean
54
+ heartbeatHistoryOpen?: boolean
55
+ onToggleHeartbeatHistory?: () => void
56
+ connectorSources?: Map<string, { platform: string; connectorName: string }>
57
+ connectorFilter?: string | null
58
+ onConnectorFilterChange?: (filter: string | null) => void
59
+ hasMultipleSources?: boolean
52
60
  }
53
61
 
54
- export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported }: Props) {
62
+ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, heartbeatHistoryOpen, onToggleHeartbeatHistory, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources }: Props) {
55
63
  const ttsEnabled = useChatStore((s) => s.ttsEnabled)
56
64
  const toggleTts = useChatStore((s) => s.toggleTts)
57
65
  const soundEnabled = useChatStore((s) => s.soundEnabled)
@@ -72,21 +80,32 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
72
80
  const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
73
81
  const connectors = useAppStore((s) => s.connectors)
74
82
  const loadConnectors = useAppStore((s) => s.loadConnectors)
75
- const providerLabel = PROVIDER_LABELS[session.provider] || session.provider
76
83
  const agent = session.agentId ? agents[session.agentId] : null
77
84
  const connector = getSessionConnector(session, connectors)
78
85
  const connectorMeta = connector ? CONNECTOR_PLATFORM_META[connector.platform] : null
79
86
  const connectorPresence = connector?.presence
87
+ const providers = useAppStore((s) => s.providers)
88
+ const loadProviders = useAppStore((s) => s.loadProviders)
80
89
  const modelName = session.model || agent?.model || ''
90
+ const [modelSwitcherOpen, setModelSwitcherOpen] = useState(false)
91
+ const modelSwitcherRef = useRef<HTMLDivElement>(null)
81
92
  const [copied, setCopied] = useState(false)
82
93
  const [heartbeatSaving, setHeartbeatSaving] = useState(false)
83
94
  const [hbDropdownOpen, setHbDropdownOpen] = useState(false)
84
95
  const hbDropdownRef = useRef<HTMLDivElement>(null)
96
+ const [sourceDropdownOpen, setSourceDropdownOpen] = useState(false)
97
+ const sourceDropdownRef = useRef<HTMLDivElement>(null)
85
98
  const [mainLoopSaving, setMainLoopSaving] = useState(false)
86
99
  const [mainLoopError, setMainLoopError] = useState('')
87
100
  const [mainLoopNotice, setMainLoopNotice] = useState('')
88
101
  const [syncingHistory, setSyncingHistory] = useState(false)
89
102
  const [syncResult, setSyncResult] = useState('')
103
+ const [renaming, setRenaming] = useState(false)
104
+ const [renameDraft, setRenameDraft] = useState('')
105
+ const [renameSaving, setRenameSaving] = useState(false)
106
+ const [renameError, setRenameError] = useState('')
107
+ const renameInputRef = useRef<HTMLInputElement>(null)
108
+ const renameContainerRef = useRef<HTMLSpanElement>(null)
90
109
 
91
110
  // Find linked task for this session
92
111
  const linkedTask = useMemo(() => {
@@ -128,6 +147,19 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
128
147
  setTimeout(() => setCopied(false), 2000)
129
148
  }
130
149
 
150
+ const handleDismissResumeHandle = async (e: React.MouseEvent) => {
151
+ e.stopPropagation()
152
+ try {
153
+ await api('PUT', `/sessions/${session.id}`, {
154
+ claudeSessionId: null,
155
+ codexThreadId: null,
156
+ opencodeSessionId: null,
157
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null },
158
+ })
159
+ await loadSessions()
160
+ } catch { /* best-effort */ }
161
+ }
162
+
131
163
  const heartbeatSupported = (session.tools?.length ?? 0) > 0
132
164
  const loopIsOngoing = appSettings.loopMode === 'ongoing'
133
165
  const { heartbeatEnabled, heartbeatIntervalSec, heartbeatExplicitOptIn } = useMemo(() => {
@@ -316,6 +348,54 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
316
348
  return () => clearTimeout(timer)
317
349
  }, [syncResult])
318
350
 
351
+ const startRename = () => {
352
+ if (!agent) return
353
+ setRenameDraft(agent.name)
354
+ setRenameError('')
355
+ setRenaming(true)
356
+ requestAnimationFrame(() => {
357
+ renameInputRef.current?.focus()
358
+ renameInputRef.current?.select()
359
+ })
360
+ }
361
+
362
+ const cancelRename = () => {
363
+ setRenaming(false)
364
+ setRenameDraft('')
365
+ setRenameError('')
366
+ }
367
+
368
+ const commitRename = async () => {
369
+ if (!agent || renameSaving) return
370
+ const trimmed = renameDraft.trim()
371
+ if (!trimmed || trimmed === agent.name) {
372
+ cancelRename()
373
+ return
374
+ }
375
+ setRenameSaving(true)
376
+ setRenameError('')
377
+ try {
378
+ await api('PUT', `/agents/${agent.id}`, { name: trimmed })
379
+ await loadAgents()
380
+ setRenaming(false)
381
+ } catch (err: unknown) {
382
+ setRenameError(err instanceof Error ? err.message : 'Rename failed')
383
+ } finally {
384
+ setRenameSaving(false)
385
+ }
386
+ }
387
+
388
+ useEffect(() => {
389
+ if (!renaming) return
390
+ const handler = (e: PointerEvent) => {
391
+ if (renameContainerRef.current && !renameContainerRef.current.contains(e.target as Node)) {
392
+ cancelRename()
393
+ }
394
+ }
395
+ document.addEventListener('pointerdown', handler, true)
396
+ return () => document.removeEventListener('pointerdown', handler, true)
397
+ }, [renaming])
398
+
319
399
  useEffect(() => {
320
400
  if (!hbDropdownOpen) return
321
401
  const handler = (e: MouseEvent) => {
@@ -325,6 +405,37 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
325
405
  return () => document.removeEventListener('mousedown', handler)
326
406
  }, [hbDropdownOpen])
327
407
 
408
+ useEffect(() => {
409
+ if (!sourceDropdownOpen) return
410
+ const handler = (e: MouseEvent) => {
411
+ if (sourceDropdownRef.current && !sourceDropdownRef.current.contains(e.target as Node)) setSourceDropdownOpen(false)
412
+ }
413
+ document.addEventListener('mousedown', handler)
414
+ return () => document.removeEventListener('mousedown', handler)
415
+ }, [sourceDropdownOpen])
416
+
417
+ useEffect(() => {
418
+ if (!modelSwitcherOpen) return
419
+ const handler = (e: MouseEvent) => {
420
+ if (modelSwitcherRef.current && !modelSwitcherRef.current.contains(e.target as Node)) setModelSwitcherOpen(false)
421
+ }
422
+ document.addEventListener('mousedown', handler)
423
+ return () => document.removeEventListener('mousedown', handler)
424
+ }, [modelSwitcherOpen])
425
+
426
+ const handleModelSwitch = async (nextProvider: ProviderType, nextModel: string) => {
427
+ setModelSwitcherOpen(false)
428
+ try {
429
+ await api('PUT', `/sessions/${session.id}`, { provider: nextProvider, model: nextModel })
430
+ await loadSessions()
431
+ } catch (err: unknown) {
432
+ toast.error(err instanceof Error ? err.message : 'Failed to switch model')
433
+ }
434
+ }
435
+
436
+ const currentProviderInfo = providers.find((p) => p.id === session.provider)
437
+ const currentModels = currentProviderInfo?.models || []
438
+
328
439
  useEffect(() => {
329
440
  if (session.name.startsWith('connector:')) {
330
441
  void loadConnectors()
@@ -334,6 +445,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
334
445
  useEffect(() => {
335
446
  setMainLoopError('')
336
447
  setMainLoopNotice('')
448
+ setModelSwitcherOpen(false)
337
449
  }, [session.id])
338
450
 
339
451
  useEffect(() => {
@@ -342,53 +454,123 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
342
454
  return () => clearTimeout(timer)
343
455
  }, [mainLoopNotice])
344
456
 
457
+ // Context bar shows for tools, mission controls, memories, source filter, task links, resume handles, browser
458
+ const hasToolToggles = ((agent?.tools?.length ?? 0) > 0) || ((session.tools?.length ?? 0) > 0)
459
+ const hasMemoryLink = !!(agent && session.tools?.includes('memory'))
460
+ const hasSourceFilter = !!hasMultipleSources
461
+ const hasContextBar = !!(hasToolToggles || isMainSession || hasMemoryLink || hasSourceFilter || linkedTask || resumeHandle || (isOpenClawAgent && openclawSessionKey) || browserActive)
462
+
345
463
  return (
346
- <header className="relative z-20 flex flex-col border-b border-white/[0.04] bg-bg/80 backdrop-blur-md shrink-0"
347
- style={mobile ? { paddingTop: 'max(12px, env(safe-area-inset-top))' } : undefined}>
348
- <div className="flex items-center gap-3 px-5 py-3 min-h-[56px]">
464
+ <header
465
+ className="relative z-20 border-b border-white/[0.06] shrink-0"
466
+ style={{
467
+ background: 'linear-gradient(180deg, rgba(var(--rgb-bg, 15,15,26), 0.95) 0%, rgba(var(--rgb-bg, 15,15,26), 0.88) 100%)',
468
+ backdropFilter: 'blur(20px) saturate(1.4)',
469
+ WebkitBackdropFilter: 'blur(20px) saturate(1.4)',
470
+ ...(mobile ? { paddingTop: 'max(12px, env(safe-area-inset-top))' } : {}),
471
+ }}
472
+ >
473
+ {/* Main row */}
474
+ <div className="flex items-center gap-2 px-3.5 py-1.5 min-h-[48px]">
475
+ {/* Back button */}
349
476
  {onBack && (
350
- <IconButton onClick={onBack} aria-label="Go back">
351
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
477
+ <IconButton onClick={onBack} aria-label="Go back" size="sm">
478
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
352
479
  <polyline points="15 18 9 12 15 6" />
353
480
  </svg>
354
481
  </IconButton>
355
482
  )}
356
- <div className="flex-1 min-w-0">
357
- <div className="flex items-center gap-2.5">
358
- {agent && <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />}
359
- <span className="font-display text-[16px] font-600 block truncate tracking-[-0.02em]">{
360
- session.name === '__main__' ? 'Main Chat'
361
- : session.name.startsWith('agent-thread:') ? (agent?.name || session.name)
362
- : session.name
363
- }</span>
483
+
484
+ {/* Avatar */}
485
+ {agent && (
486
+ <div className="relative shrink-0">
487
+ {streaming && (
488
+ <div
489
+ className="absolute -inset-[3px] rounded-full opacity-40"
490
+ style={{
491
+ background: 'conic-gradient(from 0deg, var(--color-accent-bright), transparent 120deg, transparent 240deg, var(--color-accent-bright))',
492
+ animation: 'spin 2.5s linear infinite',
493
+ filter: 'blur(3px)',
494
+ }}
495
+ />
496
+ )}
497
+ <div
498
+ className="relative rounded-full"
499
+ style={{
500
+ padding: 2,
501
+ background: streaming
502
+ ? 'conic-gradient(from 0deg, var(--color-accent-bright), transparent 120deg, transparent 240deg, var(--color-accent-bright))'
503
+ : 'linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.03))',
504
+ animation: streaming ? 'spin 2.5s linear infinite' : undefined,
505
+ }}
506
+ >
507
+ <div className="rounded-full bg-bg">
508
+ <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={hasContextBar ? 44 : 34} />
509
+ </div>
510
+ </div>
511
+ </div>
512
+ )}
513
+
514
+ {/* Identity + metadata — fills center */}
515
+ <div className="flex-1 min-w-0 flex items-center gap-3">
516
+ {/* Name + inline badges */}
517
+ <div className="flex items-center gap-2 min-w-0 shrink">
518
+ {renaming && agent ? (
519
+ <span ref={renameContainerRef} className="inline-flex items-center gap-2">
520
+ <input
521
+ ref={renameInputRef}
522
+ value={renameDraft}
523
+ onChange={(e) => setRenameDraft(e.target.value)}
524
+ onKeyDown={(e) => {
525
+ if (e.key === 'Enter') void commitRename()
526
+ if (e.key === 'Escape') cancelRename()
527
+ }}
528
+ disabled={renameSaving}
529
+ className="font-display text-[15px] font-700 tracking-[-0.02em] bg-transparent border-b border-accent-bright/40 outline-none text-text px-0 py-0 w-[180px]"
530
+ style={{ fontFamily: 'inherit' }}
531
+ />
532
+ {renameSaving && <span className="w-3 h-3 rounded-full border-2 border-text-3/30 border-t-accent-bright animate-spin shrink-0" />}
533
+ {renameError && <span className="text-[10px] text-red-400 shrink-0">{renameError}</span>}
534
+ </span>
535
+ ) : (
536
+ <span
537
+ className={`font-display text-[15px] font-700 truncate tracking-[-0.02em] text-text${agent ? ' cursor-pointer hover:text-accent-bright transition-colors duration-200' : ''}`}
538
+ onClick={agent ? startRename : undefined}
539
+ title={agent ? 'Click to rename' : undefined}
540
+ >{
541
+ session.name === '__main__' ? 'Main Chat'
542
+ : session.name.startsWith('agent-thread:') ? (agent?.name || session.name)
543
+ : session.name
544
+ }</span>
545
+ )}
364
546
  {connector && connectorMeta && (
365
547
  <span
366
- className="shrink-0 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-[7px] border text-[10px] font-700 uppercase tracking-wider"
548
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[5px] border text-[9px] font-700 uppercase tracking-wider shrink-0"
367
549
  style={{
368
550
  color: connectorMeta.color,
369
- backgroundColor: `${connectorMeta.color}1A`,
370
- borderColor: `${connectorMeta.color}33`,
551
+ backgroundColor: `${connectorMeta.color}10`,
552
+ borderColor: `${connectorMeta.color}20`,
371
553
  }}
372
554
  title={`${connector.name} connector`}
373
555
  >
374
- <ConnectorPlatformIcon platform={connector.platform} size={11} />
556
+ <ConnectorPlatformIcon platform={connector.platform} size={10} />
375
557
  {connectorMeta.label}
376
558
  </span>
377
559
  )}
378
560
  {connector && connectorPresence && (() => {
379
561
  const lastAt = connectorPresence.lastMessageAt
380
562
  if (!lastAt) return (
381
- <span className="shrink-0 inline-flex items-center gap-1 text-[10px] text-text-3/50">
382
- <span className="w-1.5 h-1.5 rounded-full bg-text-3/40" />
383
- Inactive
563
+ <span className="shrink-0 inline-flex items-center gap-1 text-[10px] text-text-3/40">
564
+ <span className="w-1.5 h-1.5 rounded-full bg-text-3/30" />
565
+ Idle
384
566
  </span>
385
567
  )
386
568
  const ago = Date.now() - lastAt
387
569
  const isActive = ago < 5 * 60_000
388
570
  const isRecent = ago < 30 * 60_000
389
- const label = isActive ? 'Active' : isRecent ? `${Math.floor(ago / 60_000)}m ago` : 'Inactive'
390
- const dotColor = isActive ? 'bg-emerald-400' : isRecent ? 'bg-amber-400' : 'bg-text-3/40'
391
- const textColor = isActive ? 'text-emerald-400' : isRecent ? 'text-amber-300' : 'text-text-3/50'
571
+ const label = isActive ? 'Active' : isRecent ? `${Math.floor(ago / 60_000)}m ago` : 'Idle'
572
+ const dotColor = isActive ? 'bg-emerald-400' : isRecent ? 'bg-amber-400' : 'bg-text-3/30'
573
+ const textColor = isActive ? 'text-emerald-400' : isRecent ? 'text-amber-300' : 'text-text-3/40'
392
574
  return (
393
575
  <span className={`shrink-0 inline-flex items-center gap-1 text-[10px] ${textColor}`}>
394
576
  <span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
@@ -396,369 +578,423 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
396
578
  </span>
397
579
  )
398
580
  })()}
399
- {session.provider && session.provider !== 'claude-cli' && (
400
- <span className="shrink-0 px-2.5 py-0.5 rounded-[7px] bg-accent-soft text-accent-bright text-[10px] font-700 uppercase tracking-wider">
401
- {providerLabel}
402
- </span>
403
- )}
404
581
  {agent?.isOrchestrator && (
405
- <span className="shrink-0 px-2.5 py-0.5 rounded-[7px] bg-[#F59E0B]/10 text-[#F59E0B] text-[10px] font-700 uppercase tracking-wider">
406
- Orchestrator
407
- </span>
582
+ <span className="px-1.5 py-0.5 rounded-[5px] bg-amber-500/10 text-amber-500 text-[9px] font-700 uppercase tracking-wider shrink-0">Orch</span>
408
583
  )}
409
- {session.tools?.length ? (
410
- <span className="shrink-0 px-2.5 py-0.5 rounded-[7px] bg-emerald-500/10 text-emerald-400 text-[10px] font-700 uppercase tracking-wider">
411
- Tools
412
- </span>
413
- ) : null}
414
584
  {streaming && (
415
585
  <span className="shrink-0 w-2 h-2 rounded-full bg-accent-bright" style={{ animation: 'pulse 1.5s ease infinite' }} />
416
586
  )}
417
587
  </div>
418
- <div className="flex items-center gap-2 mt-0.5">
419
- <span className="text-[11px] text-text-3/60 font-mono block truncate">{shortPath(session.cwd)}</span>
588
+
589
+ {/* Metadata tray: model · usage · path · status */}
590
+ <div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
591
+ <span className="text-text-3/10 text-[10px] select-none shrink-0">/</span>
420
592
  {modelName && (
421
- <>
422
- <span className="text-[11px] text-text-3/60">·</span>
423
- <span className="text-[11px] text-text-3/50 font-mono truncate shrink-0">{modelName}</span>
424
- {session.conversationTone && session.conversationTone !== 'neutral' && (() => {
425
- const toneColors: Record<string, string> = {
426
- formal: 'bg-[#3B82F6]',
427
- casual: 'bg-emerald-400',
428
- empathetic: 'bg-purple-400',
429
- technical: 'bg-[#F59E0B]',
430
- }
431
- const color = toneColors[session.conversationTone] || ''
432
- return color ? (
433
- <span
434
- className={`w-2 h-2 rounded-full shrink-0 ${color}`}
435
- title={`Tone: ${session.conversationTone}`}
593
+ <div className="relative shrink-0" ref={modelSwitcherRef}>
594
+ <button
595
+ type="button"
596
+ onClick={() => {
597
+ if (streaming) return
598
+ setModelSwitcherOpen((o) => { if (!o) void loadProviders(); return !o })
599
+ }}
600
+ disabled={streaming}
601
+ className="inline-flex items-center gap-1 text-[11px] text-text-3/45 font-mono shrink-0 cursor-pointer bg-transparent border-none px-1 py-0.5 rounded-[5px] hover:bg-white/[0.04] hover:text-text-3/70 transition-colors disabled:cursor-default disabled:hover:text-text-3/45"
602
+ title="Switch model"
603
+ >
604
+ {modelName}
605
+ <svg width="7" height="7" viewBox="0 0 16 16" fill="none" className="shrink-0 opacity-30">
606
+ <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
607
+ </svg>
608
+ </button>
609
+ {modelSwitcherOpen && (
610
+ <div className="absolute z-50 top-full left-0 mt-2 w-[280px] rounded-[12px] border border-white/[0.08] bg-surface backdrop-blur-md shadow-xl p-3">
611
+ <div className="text-[10px] font-600 text-text-3/50 uppercase tracking-wider mb-2">Provider</div>
612
+ <div className="flex flex-wrap gap-1.5 mb-3">
613
+ {providers.map((p) => (
614
+ <button
615
+ key={p.id}
616
+ type="button"
617
+ onClick={() => { if (p.id !== session.provider) void handleModelSwitch(p.id, p.models[0] || '') }}
618
+ className={`px-2.5 py-1 rounded-[7px] text-[11px] font-600 border-none cursor-pointer transition-colors
619
+ ${p.id === session.provider ? 'bg-accent-bright/15 text-accent-bright' : 'bg-white/[0.04] text-text-3 hover:bg-white/[0.08]'}`}
620
+ >
621
+ {PROVIDER_LABELS[p.id] || p.id}
622
+ </button>
623
+ ))}
624
+ </div>
625
+ <div className="text-[10px] font-600 text-text-3/50 uppercase tracking-wider mb-2">Model</div>
626
+ <ModelCombobox
627
+ providerId={session.provider}
628
+ value={modelName}
629
+ onChange={(m) => void handleModelSwitch(session.provider, m)}
630
+ models={currentModels}
631
+ defaultModels={currentProviderInfo?.defaultModels}
632
+ className="px-2.5 py-1.5 rounded-[7px] text-[12px] font-mono bg-white/[0.04] hover:bg-white/[0.06] transition-colors"
436
633
  />
437
- ) : null
438
- })()}
439
- </>
634
+ </div>
635
+ )}
636
+ </div>
440
637
  )}
441
638
  {lastUsage && !streaming && (
442
639
  <>
443
- <span className="text-[11px] text-text-3/60">·</span>
640
+ <span className="text-text-3/10 text-[10px] select-none shrink-0">·</span>
444
641
  <UsageBadge {...lastUsage} />
445
642
  </>
446
643
  )}
447
- </div>
448
- {(() => {
449
- const liveStatus = agentStatus || (missionState.status ? {
450
- goal: missionState.goal ?? undefined,
451
- status: missionState.status ?? undefined,
452
- summary: missionState.summary ?? undefined,
453
- nextAction: missionState.nextAction ?? undefined,
454
- } : null)
455
- if (!liveStatus) return null
456
- const statusColors: Record<string, string> = {
457
- idle: 'bg-text-3/40',
458
- progress: 'bg-[#3B82F6]',
459
- blocked: 'bg-amber-400',
460
- ok: 'bg-emerald-400',
461
- }
462
- const dotColor = statusColors[liveStatus.status || ''] || 'bg-text-3/40'
463
- return (
464
- <div className="flex items-center gap-2 mt-0.5">
465
- {liveStatus.goal && (
466
- <span className="text-[10px] text-text-3/60 font-mono truncate max-w-[240px]" title={liveStatus.goal}>
467
- {liveStatus.goal}
468
- </span>
469
- )}
470
- {liveStatus.status && (
471
- <span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-wider ${
472
- liveStatus.status === 'blocked' ? 'bg-amber-400/15 text-amber-300'
473
- : liveStatus.status === 'ok' ? 'bg-emerald-400/15 text-emerald-400'
474
- : liveStatus.status === 'progress' ? 'bg-[#3B82F6]/15 text-[#60A5FA]'
475
- : 'bg-white/[0.04] text-text-3/60'
476
- }`}>
477
- <span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
478
- {liveStatus.status}
479
- </span>
480
- )}
481
- {liveStatus.nextAction && (
482
- <>
483
- <span className="text-[10px] text-text-3/40">→</span>
484
- <span className="text-[10px] text-text-3/50 font-mono truncate max-w-[200px]" title={liveStatus.nextAction}>
485
- {liveStatus.nextAction}
644
+ <button
645
+ type="button"
646
+ onClick={() => { api('POST', '/files/open', { path: session.cwd }).catch(() => {}) }}
647
+ className="inline-flex items-center shrink-0 bg-transparent border-none p-0.5 rounded-[4px] cursor-pointer text-text-3/20 hover:text-text-3/50 hover:bg-white/[0.04] transition-colors"
648
+ title={shortPath(session.cwd)}
649
+ >
650
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
651
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
652
+ </svg>
653
+ </button>
654
+ {/* Live agent status */}
655
+ {(() => {
656
+ const liveStatus = agentStatus || (missionState.status ? {
657
+ goal: missionState.goal ?? undefined,
658
+ status: missionState.status ?? undefined,
659
+ summary: missionState.summary ?? undefined,
660
+ nextAction: missionState.nextAction ?? undefined,
661
+ } : null)
662
+ if (!liveStatus) return null
663
+ const statusColors: Record<string, string> = {
664
+ idle: 'bg-text-3/40', progress: 'bg-blue-500', blocked: 'bg-amber-400', ok: 'bg-emerald-400',
665
+ }
666
+ const dotColor = statusColors[liveStatus.status || ''] || 'bg-text-3/40'
667
+ return (
668
+ <>
669
+ <span className="text-text-3/10 text-[10px] select-none shrink-0">·</span>
670
+ {liveStatus.status && (
671
+ <span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] text-[9px] font-700 uppercase tracking-wider ${
672
+ liveStatus.status === 'blocked' ? 'bg-amber-400/12 text-amber-300'
673
+ : liveStatus.status === 'ok' ? 'bg-emerald-400/12 text-emerald-400'
674
+ : liveStatus.status === 'progress' ? 'bg-blue-500/12 text-blue-400'
675
+ : 'bg-white/[0.03] text-text-3/50'
676
+ }`}>
677
+ <span className={`w-1 h-1 rounded-full ${dotColor}`} />
678
+ {liveStatus.status}
486
679
  </span>
487
- </>
488
- )}
489
- </div>
490
- )
491
- })()}
680
+ )}
681
+ {liveStatus.goal && (
682
+ <span className="text-[10px] text-text-3/40 font-mono truncate max-w-[180px]" title={liveStatus.goal}>
683
+ {liveStatus.goal}
684
+ </span>
685
+ )}
686
+ {liveStatus.nextAction && (
687
+ <>
688
+ <span className="text-[9px] text-text-3/20 shrink-0">→</span>
689
+ <span className="text-[10px] text-text-3/35 font-mono truncate max-w-[140px]" title={liveStatus.nextAction}>
690
+ {liveStatus.nextAction}
691
+ </span>
692
+ </>
693
+ )}
694
+ </>
695
+ )
696
+ })()}
697
+ </div>
492
698
  </div>
493
- <div className="flex gap-1.5">
699
+
700
+ {/* Heartbeat compound control */}
701
+ {heartbeatSupported && (
702
+ <div className="flex items-center rounded-[8px] shrink-0" style={{ background: 'rgba(255,255,255,0.025)' }}>
703
+ <button
704
+ onClick={handleToggleHeartbeat}
705
+ disabled={heartbeatSaving}
706
+ className={`flex items-center gap-1.5 pl-2.5 pr-1.5 py-1 transition-colors cursor-pointer border-none text-[11px] font-600
707
+ ${heartbeatWillRun ? 'text-emerald-400 hover:bg-emerald-500/10' : 'text-text-3/60 hover:bg-white/[0.04]'}`}
708
+ title={heartbeatWillRun ? 'Disable heartbeat' : 'Enable heartbeat'}
709
+ >
710
+ <span className={`w-1.5 h-1.5 rounded-full transition-colors ${heartbeatWillRun ? 'bg-emerald-400' : 'bg-text-3/30'}`} />
711
+ HB
712
+ {heartbeatEnabled && !loopIsOngoing && !heartbeatExplicitOptIn && (
713
+ <span className="text-[9px] text-text-3/40">(bounded)</span>
714
+ )}
715
+ </button>
716
+ <div className="relative" ref={hbDropdownRef}>
717
+ <button
718
+ onClick={() => setHbDropdownOpen((o) => !o)}
719
+ disabled={heartbeatSaving}
720
+ className="flex items-center gap-0.5 pl-1 pr-2 py-1 text-text-3/50 hover:text-text-3/70 hover:bg-white/[0.04] transition-colors cursor-pointer border-none"
721
+ title="Set heartbeat interval"
722
+ >
723
+ <span className="text-[11px] font-600">{formatDuration(heartbeatIntervalSec)}</span>
724
+ <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="opacity-40">
725
+ <polyline points="6 9 12 15 18 9" />
726
+ </svg>
727
+ </button>
728
+ {hbDropdownOpen && (
729
+ <div className="absolute top-full right-0 mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[80px]">
730
+ {[1800, 3600, 7200, 21600, 43200].map((sec) => (
731
+ <button
732
+ key={sec}
733
+ onClick={() => handleSelectHeartbeatInterval(sec)}
734
+ className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none
735
+ ${sec === heartbeatIntervalSec ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]'}`}
736
+ >
737
+ {formatDuration(sec)}
738
+ </button>
739
+ ))}
740
+ </div>
741
+ )}
742
+ </div>
743
+ </div>
744
+ )}
745
+
746
+ {/* Action buttons */}
747
+ <div className="flex items-center shrink-0">
494
748
  {streaming && (
495
- <IconButton onClick={onStop} variant="danger" aria-label="Stop generation">
496
- <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
497
- <rect x="6" y="6" width="12" height="12" rx="2" />
498
- </svg>
499
- </IconButton>
500
- )}
501
- {agent && (
502
- <IconButton onClick={() => setInspectorOpen(!inspectorOpen)} active={inspectorOpen} aria-label="Toggle inspector panel">
503
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
504
- <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
505
- <circle cx="12" cy="12" r="3" />
506
- </svg>
507
- </IconButton>
749
+ <>
750
+ <IconButton onClick={onStop} variant="danger" tooltip="Stop" aria-label="Stop generation" size="sm">
751
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
752
+ <rect x="6" y="6" width="12" height="12" rx="2" />
753
+ </svg>
754
+ </IconButton>
755
+ <div className="w-px h-3.5 bg-white/[0.06] mx-0.5" />
756
+ </>
508
757
  )}
509
- <IconButton onClick={() => setDebugOpen(!debugOpen)} active={debugOpen} aria-label="Toggle debug panel">
510
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
511
- <path d="M12 20V10" />
512
- <path d="M18 20V4" />
513
- <path d="M6 20v-4" />
514
- </svg>
515
- </IconButton>
516
- <IconButton onClick={toggleSound} active={soundEnabled} aria-label="Toggle sound notifications">
517
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
518
- <path d="M18 8A6 6 0 0 1 18 16" />
519
- <path d="M13 2L8 7H4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4l5 5V2z" />
758
+ <IconButton onClick={toggleSound} active={soundEnabled} tooltip="Notifications" aria-label="Toggle sound" size="sm">
759
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
760
+ <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
761
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
520
762
  </svg>
521
763
  </IconButton>
522
- <IconButton onClick={toggleTts} active={ttsEnabled} aria-label="Toggle text-to-speech">
523
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
764
+ <IconButton onClick={toggleTts} active={ttsEnabled} tooltip="Read aloud" aria-label="Toggle TTS" size="sm">
765
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
524
766
  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
525
767
  <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
526
768
  </svg>
527
769
  </IconButton>
528
770
  {voiceSupported && onVoiceToggle && (
529
- <IconButton onClick={onVoiceToggle} active={voiceActive} aria-label="Toggle voice conversation">
530
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
771
+ <IconButton onClick={onVoiceToggle} active={voiceActive} tooltip="Voice mode" aria-label="Toggle voice" size="sm">
772
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
531
773
  <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
532
774
  <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
533
775
  <line x1="12" x2="12" y1="19" y2="22" />
534
776
  </svg>
535
777
  </IconButton>
536
778
  )}
537
- <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} aria-label="Chat menu">
538
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
539
- <circle cx="12" cy="6" r="1" />
540
- <circle cx="12" cy="12" r="1" />
541
- <circle cx="12" cy="18" r="1" />
779
+ {agent?.heartbeatEnabled && onToggleHeartbeatHistory && (
780
+ <IconButton onClick={onToggleHeartbeatHistory} active={heartbeatHistoryOpen} tooltip="Heartbeat history" aria-label="Toggle heartbeat history" size="sm">
781
+ <svg width="14" height="14" viewBox="0 0 24 24" fill={heartbeatHistoryOpen ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
782
+ <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
783
+ </svg>
784
+ </IconButton>
785
+ )}
786
+ <div className="w-px h-3.5 bg-white/[0.06] mx-0.5" />
787
+ <IconButton onClick={() => setDebugOpen(!debugOpen)} active={debugOpen} tooltip="Debug" aria-label="Toggle debug panel" size="sm">
788
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
789
+ <path d="M12 20V10" /><path d="M18 20V4" /><path d="M6 20v-4" />
542
790
  </svg>
543
791
  </IconButton>
792
+ {(!agent || mobile) && (
793
+ <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} tooltip="Menu" aria-label="Chat menu" size="sm">
794
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
795
+ <circle cx="12" cy="6" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="18" r="1" />
796
+ </svg>
797
+ </IconButton>
798
+ )}
799
+ {agent && (
800
+ <IconButton onClick={() => setInspectorOpen(!inspectorOpen)} active={inspectorOpen} tooltip="Settings" aria-label="Toggle inspector" size="sm">
801
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
802
+ <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
803
+ <circle cx="12" cy="12" r="3" />
804
+ </svg>
805
+ </IconButton>
806
+ )}
544
807
  </div>
545
808
  </div>
546
809
 
547
- {/* Sub-bar: tools toggle + agent memories + task link + CLI session ID + browser */}
548
- {(agent || linkedTask || resumeHandle || browserActive || session.tools?.length || isMainSession) && (
549
- <div className="flex items-center gap-3 px-5 pb-2.5 -mt-1">
550
- {(((agent?.tools?.length ?? 0) > 0) || ((session.tools?.length ?? 0) > 0)) && (
551
- <ChatToolToggles session={session} />
552
- )}
553
- {heartbeatSupported && (
554
- <>
555
- <button
556
- onClick={handleToggleHeartbeat}
557
- disabled={heartbeatSaving}
558
- className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] transition-colors cursor-pointer border-none
559
- ${heartbeatWillRun ? 'bg-emerald-500/10 hover:bg-emerald-500/15 text-emerald-400' : 'bg-white/[0.04] hover:bg-white/[0.07] text-text-3'}`}
560
- title={heartbeatWillRun ? 'Toggle heartbeat' : !heartbeatEnabled ? 'Heartbeat disabled — click to enable' : 'Heartbeat enabled but paused (bounded loop mode, no explicit opt-in)'}
561
- >
562
- <span className={`w-1.5 h-1.5 rounded-full ${heartbeatWillRun ? 'bg-emerald-400' : 'bg-text-3/40'}`} />
563
- <span className="text-[11px] font-600">
564
- HB {heartbeatWillRun ? 'On' : 'Off'}
565
- </span>
566
- {heartbeatEnabled && !loopIsOngoing && !heartbeatExplicitOptIn && (
567
- <span className="text-[10px] text-text-3/50">(bounded)</span>
568
- )}
569
- </button>
570
- <div className="relative" ref={hbDropdownRef}>
571
- <button
572
- onClick={() => setHbDropdownOpen((o) => !o)}
573
- disabled={heartbeatSaving}
574
- className="flex items-center gap-1 px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] text-text-3 transition-colors cursor-pointer border-none"
575
- title="Set heartbeat interval"
576
- >
577
- <span className="text-[11px] font-600">{formatDuration(heartbeatIntervalSec)}</span>
578
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/50">
579
- <polyline points="6 9 12 15 18 9" />
580
- </svg>
581
- </button>
582
- {hbDropdownOpen && (
583
- <div className="absolute top-full left-0 mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[80px]">
584
- {[30, 60, 120, 300, 600, 1800, 3600].map((sec) => (
585
- <button
586
- key={sec}
587
- onClick={() => handleSelectHeartbeatInterval(sec)}
588
- className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none
589
- ${sec === heartbeatIntervalSec ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]'}`}
590
- >
591
- {formatDuration(sec)}
592
- </button>
593
- ))}
594
- </div>
595
- )}
596
- </div>
597
- </>
810
+ {/* Context bar: tools, mission controls, links */}
811
+ {hasContextBar && (
812
+ <div className="flex items-center gap-1.5 px-3.5 pb-1.5 overflow-x-auto scrollbar-none">
813
+ {hasToolToggles && <ChatToolToggles session={session} />}
814
+ {hasToolToggles && (hasMemoryLink || isMainSession || linkedTask || resumeHandle || isOpenClawAgent || browserActive) && (
815
+ <div className="w-px h-4 bg-white/[0.05] shrink-0" />
598
816
  )}
599
817
  {isMainSession && (
600
818
  <>
601
819
  <button
602
820
  onClick={handleToggleMissionPause}
603
821
  disabled={mainLoopSaving}
604
- className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] transition-colors cursor-pointer border-none
605
- ${missionPaused ? 'bg-amber-500/12 hover:bg-amber-500/20 text-amber-300' : 'bg-emerald-500/10 hover:bg-emerald-500/15 text-emerald-400'}`}
606
- title={missionPaused ? 'Resume autonomous mission loop' : 'Pause autonomous mission loop'}
822
+ className={`flex items-center gap-1.5 px-2 py-1 rounded-[7px] transition-colors cursor-pointer border-none text-[10px] font-600
823
+ ${missionPaused ? 'bg-amber-500/10 hover:bg-amber-500/18 text-amber-300' : 'bg-emerald-500/8 hover:bg-emerald-500/12 text-emerald-400'}`}
824
+ title={missionPaused ? 'Resume mission' : 'Pause mission'}
607
825
  >
608
826
  <span className={`w-1.5 h-1.5 rounded-full ${missionPaused ? 'bg-amber-300' : 'bg-emerald-400'}`} />
609
- <span className="text-[11px] font-600">
610
- Mission {missionPaused ? 'Paused' : 'Live'}
611
- </span>
827
+ {missionPaused ? 'Paused' : 'Live'}
612
828
  </button>
613
829
  <button
614
830
  onClick={handleToggleMissionMode}
615
831
  disabled={mainLoopSaving}
616
- className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] transition-colors cursor-pointer border-none
617
- ${missionMode === 'autonomous'
618
- ? 'bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-200'
619
- : 'bg-white/[0.04] hover:bg-white/[0.07] text-text-3'
620
- }`}
621
- title="Toggle mission autonomy mode"
832
+ className={`flex items-center gap-1 px-2 py-1 rounded-[7px] transition-colors cursor-pointer border-none text-[10px] font-600
833
+ ${missionMode === 'autonomous' ? 'bg-indigo-500/12 hover:bg-indigo-500/20 text-indigo-300' : 'bg-white/[0.03] hover:bg-white/[0.06] text-text-3/60'}`}
834
+ title="Toggle autonomy mode"
622
835
  >
623
- <span className="text-[11px] font-600">
624
- Mode {missionMode === 'autonomous' ? 'Auto' : 'Assist'}
625
- </span>
836
+ {missionMode === 'autonomous' ? 'Auto' : 'Assist'}
626
837
  </button>
627
838
  <button
628
839
  onClick={handleNudgeMission}
629
840
  disabled={mainLoopSaving || missionPaused}
630
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-[#3B82F6]/10 hover:bg-[#3B82F6]/18 text-[#60A5FA] transition-colors cursor-pointer border-none disabled:opacity-60"
631
- title="Run one immediate main-loop mission tick"
841
+ className="px-2 py-1 rounded-[7px] bg-blue-500/8 hover:bg-blue-500/15 text-blue-400 transition-colors cursor-pointer border-none disabled:opacity-50 text-[10px] font-600"
842
+ title="Run one tick"
632
843
  >
633
- <span className="text-[11px] font-600">Nudge</span>
844
+ Nudge
634
845
  </button>
635
846
  <button
636
847
  onClick={handleSetMissionGoal}
637
848
  disabled={mainLoopSaving}
638
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-fuchsia-500/10 hover:bg-fuchsia-500/18 text-fuchsia-300 transition-colors cursor-pointer border-none"
639
- title="Set an explicit mission goal"
849
+ className="px-2 py-1 rounded-[7px] bg-fuchsia-500/8 hover:bg-fuchsia-500/15 text-fuchsia-300 transition-colors cursor-pointer border-none text-[10px] font-600"
850
+ title="Set mission goal"
640
851
  >
641
- <span className="text-[11px] font-600">Set Goal</span>
852
+ Goal
642
853
  </button>
643
854
  {missionEventsCount > 0 && (
644
855
  <button
645
856
  onClick={handleClearMissionEvents}
646
857
  disabled={mainLoopSaving}
647
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] text-text-3 transition-colors cursor-pointer border-none"
648
- title="Clear pending mission events"
858
+ className="px-2 py-1 rounded-[7px] bg-white/[0.03] hover:bg-white/[0.06] text-text-3/60 transition-colors cursor-pointer border-none text-[10px] font-600"
859
+ title="Clear pending events"
649
860
  >
650
- <span className="text-[11px] font-600">Events {missionEventsCount}</span>
861
+ Events {missionEventsCount}
651
862
  </button>
652
863
  )}
653
- <span className="text-[10px] text-text-3/50 uppercase tracking-wider">
654
- {`State ${missionStatus}${missionMomentum !== null ? ` · ${missionMomentum}` : ''}`}
864
+ <span className="text-[9px] text-text-3/40 uppercase tracking-wider shrink-0">
865
+ {missionStatus}{missionMomentum !== null ? ` · ${missionMomentum}` : ''}
655
866
  </span>
656
- {mainLoopError && (
657
- <span className="text-[10px] text-red-300/90 truncate max-w-[280px]" title={mainLoopError}>
658
- {mainLoopError}
659
- </span>
660
- )}
661
- {mainLoopNotice && (
662
- <span className="text-[10px] text-emerald-300/90 truncate max-w-[220px]" title={mainLoopNotice}>
663
- {mainLoopNotice}
664
- </span>
665
- )}
867
+ {mainLoopError && <span className="text-[9px] text-red-300/80 truncate max-w-[240px]" title={mainLoopError}>{mainLoopError}</span>}
868
+ {mainLoopNotice && <span className="text-[9px] text-emerald-300/80 truncate max-w-[200px]" title={mainLoopNotice}>{mainLoopNotice}</span>}
666
869
  </>
667
870
  )}
668
- {agent && session.tools?.includes('memory') && (
871
+ {hasMemoryLink && (
669
872
  <button
670
- onClick={() => {
671
- setMemoryAgentFilter(session.agentId!)
672
- setActiveView('memory')
673
- setSidebarOpen(true)
674
- }}
675
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-accent-soft/50 hover:bg-accent-soft transition-colors cursor-pointer"
873
+ onClick={() => { setMemoryAgentFilter(session.agentId!); setActiveView('memory'); setSidebarOpen(true) }}
874
+ className="flex items-center gap-1 px-2 py-1 rounded-[7px] bg-accent-soft/40 hover:bg-accent-soft/70 transition-colors cursor-pointer text-[10px] font-600 text-accent-bright/55 hover:text-accent-bright/80 shrink-0"
676
875
  >
677
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-accent-bright/60">
876
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
678
877
  <ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" /><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
679
878
  </svg>
680
- <span className="text-[11px] font-600 text-accent-bright/60">
681
- {agent.name} Memories
682
- </span>
879
+ Memories
683
880
  </button>
684
881
  )}
882
+ {hasSourceFilter && onConnectorFilterChange && connectorSources && (
883
+ <div className="relative shrink-0" ref={sourceDropdownRef}>
884
+ <button
885
+ onClick={() => setSourceDropdownOpen((o) => !o)}
886
+ className={`flex items-center gap-1 px-2 py-1 rounded-[7px] transition-colors cursor-pointer border-none text-[10px] font-600 shrink-0 ${
887
+ connectorFilter
888
+ ? 'bg-accent-soft/60 text-accent-bright/80 hover:bg-accent-soft'
889
+ : 'bg-white/[0.03] text-text-3/50 hover:bg-white/[0.06] hover:text-text-3/70'
890
+ }`}
891
+ title="Filter by message source"
892
+ >
893
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
894
+ <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
895
+ </svg>
896
+ {connectorFilter
897
+ ? (connectorSources.get(connectorFilter)?.connectorName || 'Source')
898
+ : 'Source'}
899
+ <svg width="7" height="7" viewBox="0 0 16 16" fill="none" className="opacity-40">
900
+ <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
901
+ </svg>
902
+ </button>
903
+ {sourceDropdownOpen && (
904
+ <div className="absolute top-full left-0 mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[140px]">
905
+ <button
906
+ onClick={() => { onConnectorFilterChange(null); setSourceDropdownOpen(false) }}
907
+ className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none flex items-center gap-2 ${
908
+ !connectorFilter ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]'
909
+ }`}
910
+ >
911
+ All Sources
912
+ </button>
913
+ {Array.from(connectorSources.entries()).map(([cid, info]) => {
914
+ const active = connectorFilter === cid
915
+ const meta = CONNECTOR_PLATFORM_META[info.platform as keyof typeof CONNECTOR_PLATFORM_META]
916
+ return (
917
+ <button
918
+ key={cid}
919
+ onClick={() => { onConnectorFilterChange(active ? null : cid); setSourceDropdownOpen(false) }}
920
+ className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none flex items-center gap-2 ${
921
+ active ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]'
922
+ }`}
923
+ >
924
+ <ConnectorPlatformIcon platform={info.platform as keyof typeof CONNECTOR_PLATFORM_META} size={12} />
925
+ {info.connectorName || meta?.label || info.platform}
926
+ </button>
927
+ )
928
+ })}
929
+ </div>
930
+ )}
931
+ </div>
932
+ )}
685
933
  {isOpenClawAgent && openclawSessionKey && (
686
934
  <>
687
935
  <button
688
936
  onClick={handleSyncHistory}
689
937
  disabled={syncingHistory}
690
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-indigo-500/10 hover:bg-indigo-500/15 transition-colors cursor-pointer border-none disabled:opacity-50"
691
- title="Sync chat history from OpenClaw gateway"
938
+ className="flex items-center gap-1 px-2 py-1 rounded-[7px] bg-indigo-500/8 hover:bg-indigo-500/12 transition-colors cursor-pointer border-none disabled:opacity-50 text-[10px] font-600 text-indigo-400 shrink-0"
939
+ title="Sync from gateway"
692
940
  >
693
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-indigo-400">
694
- <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
695
- <path d="M3 3v5h5" />
696
- <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
697
- <path d="M16 16h5v5" />
941
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
942
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" />
943
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" /><path d="M16 16h5v5" />
698
944
  </svg>
699
- <span className="text-[11px] font-600 text-indigo-400">
700
- {syncingHistory ? 'Syncing...' : 'Sync History'}
701
- </span>
945
+ {syncingHistory ? 'Syncing...' : 'Sync'}
702
946
  </button>
703
- {syncResult && (
704
- <span className="text-[10px] text-emerald-300/90">{syncResult}</span>
705
- )}
947
+ {syncResult && <span className="text-[9px] text-emerald-300/80 shrink-0">{syncResult}</span>}
706
948
  </>
707
949
  )}
708
950
  {linkedTask && (
709
951
  <button
710
952
  onClick={() => setActiveView('tasks')}
711
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-[#F59E0B]/10 hover:bg-[#F59E0B]/15 transition-colors cursor-pointer"
953
+ className="flex items-center gap-1 px-2 py-1 rounded-[7px] bg-amber-500/8 hover:bg-amber-500/12 transition-colors cursor-pointer text-[10px] font-600 text-amber-500 shrink-0"
712
954
  >
713
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2.5" strokeLinecap="round">
714
- <path d="M9 11l3 3L22 4" />
715
- <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
955
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
956
+ <path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
716
957
  </svg>
717
- <span className="text-[11px] font-600 text-[#F59E0B] truncate max-w-[200px]">
718
- Task: {linkedTask.title}
719
- </span>
958
+ <span className="truncate max-w-[160px]">{linkedTask.title}</span>
720
959
  </button>
721
960
  )}
722
961
  {resumeHandle && (
723
- <button
724
- onClick={handleCopySessionId}
725
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] transition-colors cursor-pointer group"
726
- title="Copy resume handle/command to clipboard"
727
- >
728
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/50">
729
- <path d="M4 17l6 0l0 -6" />
730
- <path d="M20 7l-6 0l0 6" />
731
- <path d="M4 17l10 -10" />
732
- </svg>
733
- <span className="text-[11px] font-mono text-text-3/50 group-hover:text-text-3/70 truncate max-w-[220px]">
734
- {copied ? 'Copied!' : `${resumeHandle.label}: ${resumeHandle.id}`}
735
- </span>
736
- {!copied && (
737
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/60 shrink-0">
738
- <rect x="9" y="9" width="13" height="13" rx="2" />
739
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
962
+ <div className="flex items-center rounded-[7px] bg-white/[0.03] group/resume shrink-0">
963
+ <button
964
+ onClick={handleCopySessionId}
965
+ className="flex items-center gap-1 px-2 py-1 rounded-l-[7px] hover:bg-white/[0.06] transition-colors cursor-pointer"
966
+ title="Copy resume command"
967
+ >
968
+ <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/40 shrink-0">
969
+ <path d="M4 17l6 0l0 -6" /><path d="M20 7l-6 0l0 6" /><path d="M4 17l10 -10" />
740
970
  </svg>
741
- )}
742
- </button>
971
+ <span className="text-[10px] font-mono text-text-3/40 group-hover/resume:text-text-3/60 truncate max-w-[180px]">
972
+ {copied ? 'Copied!' : `${resumeHandle.label}: ${resumeHandle.id}`}
973
+ </span>
974
+ </button>
975
+ <button
976
+ onClick={handleDismissResumeHandle}
977
+ className="px-1 py-1 rounded-r-[7px] hover:bg-white/[0.06] transition-colors cursor-pointer opacity-0 group-hover/resume:opacity-100"
978
+ title="Dismiss"
979
+ >
980
+ <svg width="8" height="8" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/40 hover:text-text-3">
981
+ <path d="M4 4l8 8M12 4l-8 8" />
982
+ </svg>
983
+ </button>
984
+ </div>
743
985
  )}
744
986
  {browserActive && (
745
987
  <button
746
988
  onClick={onStopBrowser}
747
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-[#3B82F6]/10 hover:bg-[#F43F5E]/15 transition-colors cursor-pointer group"
989
+ className="flex items-center gap-1 px-2 py-1 rounded-[7px] bg-accent-bright/8 hover:bg-red-500/12 transition-colors cursor-pointer group text-[10px] font-600 shrink-0"
748
990
  title="Stop browser"
749
991
  >
750
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-[#3B82F6] group-hover:text-[#F43F5E]">
751
- <rect x="3" y="3" width="18" height="14" rx="2" />
752
- <path d="M3 9h18" />
753
- <circle cx="7" cy="6" r="0.5" fill="currentColor" />
754
- <circle cx="10" cy="6" r="0.5" fill="currentColor" />
992
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-accent-bright group-hover:text-red-400">
993
+ <rect x="3" y="3" width="18" height="14" rx="2" /><path d="M3 9h18" />
755
994
  </svg>
756
- <span className="text-[11px] font-600 text-[#3B82F6] group-hover:text-[#F43F5E]">
757
- Browser Active
758
- </span>
759
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/60 group-hover:text-[#F43F5E] shrink-0">
760
- <line x1="18" y1="6" x2="6" y2="18" />
761
- <line x1="6" y1="6" x2="18" y2="18" />
995
+ <span className="text-accent-bright group-hover:text-red-400">Browser</span>
996
+ <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/40 group-hover:text-red-400">
997
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
762
998
  </svg>
763
999
  </button>
764
1000
  )}