@swarmclawai/swarmclaw 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +8 -2
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +5 -0
  4. package/package.json +2 -1
  5. package/src/app/api/agents/[id]/route.ts +23 -5
  6. package/src/app/api/agents/trash/route.ts +44 -0
  7. package/src/app/api/connectors/[id]/route.ts +7 -4
  8. package/src/app/api/connectors/[id]/webhook/route.ts +6 -2
  9. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  10. package/src/app/api/openclaw/approvals/route.ts +46 -0
  11. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  12. package/src/app/api/openclaw/cron/route.ts +52 -0
  13. package/src/app/api/openclaw/directory/route.ts +4 -3
  14. package/src/app/api/openclaw/discover/route.ts +3 -2
  15. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  16. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  17. package/src/app/api/openclaw/gateway/route.ts +72 -0
  18. package/src/app/api/openclaw/history/route.ts +109 -0
  19. package/src/app/api/openclaw/media/route.ts +53 -0
  20. package/src/app/api/openclaw/models/route.ts +12 -0
  21. package/src/app/api/openclaw/permissions/route.ts +39 -0
  22. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  23. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  24. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  25. package/src/app/api/openclaw/skills/route.ts +82 -0
  26. package/src/app/api/openclaw/sync/route.ts +3 -2
  27. package/src/app/api/projects/[id]/route.ts +1 -1
  28. package/src/app/api/projects/route.ts +1 -1
  29. package/src/app/api/secrets/[id]/route.ts +1 -1
  30. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  31. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  32. package/src/app/api/sessions/[id]/messages/route.ts +18 -1
  33. package/src/app/api/sessions/[id]/route.ts +12 -3
  34. package/src/app/api/sessions/route.ts +6 -2
  35. package/src/app/globals.css +14 -0
  36. package/src/app/layout.tsx +5 -20
  37. package/src/cli/index.js +33 -1
  38. package/src/cli/spec.js +40 -0
  39. package/src/components/agents/agent-avatar.tsx +45 -0
  40. package/src/components/agents/agent-card.tsx +19 -5
  41. package/src/components/agents/agent-chat-list.tsx +31 -24
  42. package/src/components/agents/agent-files-editor.tsx +185 -0
  43. package/src/components/agents/agent-list.tsx +82 -3
  44. package/src/components/agents/agent-sheet.tsx +31 -0
  45. package/src/components/agents/cron-job-form.tsx +137 -0
  46. package/src/components/agents/exec-config-panel.tsx +147 -0
  47. package/src/components/agents/inspector-panel.tsx +310 -0
  48. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  49. package/src/components/agents/permission-preset-selector.tsx +79 -0
  50. package/src/components/agents/personality-builder.tsx +111 -0
  51. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  52. package/src/components/agents/skill-install-dialog.tsx +102 -0
  53. package/src/components/agents/trash-list.tsx +109 -0
  54. package/src/components/chat/chat-area.tsx +14 -2
  55. package/src/components/chat/chat-header.tsx +168 -4
  56. package/src/components/chat/chat-preview-panel.tsx +113 -0
  57. package/src/components/chat/exec-approval-card.tsx +89 -0
  58. package/src/components/chat/message-bubble.tsx +218 -36
  59. package/src/components/chat/message-list.tsx +135 -31
  60. package/src/components/chat/streaming-bubble.tsx +59 -10
  61. package/src/components/chat/suggestions-bar.tsx +74 -0
  62. package/src/components/chat/thinking-indicator.tsx +20 -6
  63. package/src/components/chat/tool-call-bubble.tsx +89 -16
  64. package/src/components/chat/tool-request-banner.tsx +20 -2
  65. package/src/components/chat/trace-block.tsx +103 -0
  66. package/src/components/projects/project-list.tsx +1 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  68. package/src/components/shared/avatar.tsx +13 -2
  69. package/src/components/shared/settings/settings-page.tsx +1 -0
  70. package/src/components/tasks/task-board.tsx +1 -1
  71. package/src/components/tasks/task-sheet.tsx +12 -12
  72. package/src/hooks/use-continuous-speech.ts +42 -5
  73. package/src/hooks/use-openclaw-gateway.ts +63 -0
  74. package/src/lib/notification-sounds.ts +58 -0
  75. package/src/lib/personality-parser.ts +97 -0
  76. package/src/lib/providers/openclaw.ts +17 -2
  77. package/src/lib/runtime-loop.ts +2 -2
  78. package/src/lib/server/chat-execution.ts +44 -2
  79. package/src/lib/server/connectors/bluebubbles.test.ts +17 -8
  80. package/src/lib/server/connectors/bluebubbles.ts +5 -2
  81. package/src/lib/server/connectors/googlechat.ts +14 -10
  82. package/src/lib/server/connectors/manager.ts +37 -15
  83. package/src/lib/server/connectors/openclaw.ts +1 -0
  84. package/src/lib/server/daemon-state.ts +11 -0
  85. package/src/lib/server/main-agent-loop.ts +2 -3
  86. package/src/lib/server/main-session.ts +21 -0
  87. package/src/lib/server/openclaw-config-sync.ts +107 -0
  88. package/src/lib/server/openclaw-exec-config.ts +52 -0
  89. package/src/lib/server/openclaw-gateway.ts +291 -0
  90. package/src/lib/server/openclaw-history-merge.ts +36 -0
  91. package/src/lib/server/openclaw-models.ts +56 -0
  92. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  93. package/src/lib/server/openclaw-sync.ts +11 -10
  94. package/src/lib/server/queue.ts +2 -1
  95. package/src/lib/server/session-tools/connector.ts +4 -4
  96. package/src/lib/server/session-tools/delegate.ts +19 -3
  97. package/src/lib/server/session-tools/file.ts +20 -20
  98. package/src/lib/server/session-tools/index.ts +2 -2
  99. package/src/lib/server/session-tools/openclaw-nodes.ts +6 -6
  100. package/src/lib/server/session-tools/sandbox.ts +2 -2
  101. package/src/lib/server/session-tools/search-providers.ts +13 -6
  102. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  103. package/src/lib/server/session-tools/shell.ts +1 -1
  104. package/src/lib/server/session-tools/web.ts +8 -8
  105. package/src/lib/server/storage.ts +62 -11
  106. package/src/lib/server/stream-agent-chat.ts +24 -4
  107. package/src/lib/server/suggestions.ts +20 -0
  108. package/src/lib/server/ws-hub.ts +14 -0
  109. package/src/stores/use-app-store.ts +53 -1
  110. package/src/stores/use-approval-store.ts +78 -0
  111. package/src/stores/use-chat-store.ts +153 -5
  112. package/src/types/index.ts +127 -1
  113. package/tsconfig.json +13 -4
@@ -13,6 +13,7 @@ import {
13
13
  CONNECTOR_PLATFORM_META,
14
14
  getSessionConnector,
15
15
  } from '@/components/shared/connector-platform-icon'
16
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
16
17
 
17
18
  function shortPath(p: string): string {
18
19
  return (p || '').replace(/^\/Users\/\w+/, '~')
@@ -52,9 +53,12 @@ interface Props {
52
53
  export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported }: Props) {
53
54
  const ttsEnabled = useChatStore((s) => s.ttsEnabled)
54
55
  const toggleTts = useChatStore((s) => s.toggleTts)
56
+ const soundEnabled = useChatStore((s) => s.soundEnabled)
57
+ const toggleSound = useChatStore((s) => s.toggleSound)
55
58
  const debugOpen = useChatStore((s) => s.debugOpen)
56
59
  const setDebugOpen = useChatStore((s) => s.setDebugOpen)
57
60
  const lastUsage = useChatStore((s) => s.lastUsage)
61
+ const agentStatus = useChatStore((s) => s.agentStatus)
58
62
  const agents = useAppStore((s) => s.agents)
59
63
  const tasks = useAppStore((s) => s.tasks)
60
64
  const setActiveView = useAppStore((s) => s.setActiveView)
@@ -63,12 +67,15 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
63
67
  const appSettings = useAppStore((s) => s.appSettings)
64
68
  const loadSessions = useAppStore((s) => s.loadSessions)
65
69
  const loadAgents = useAppStore((s) => s.loadAgents)
70
+ const inspectorOpen = useAppStore((s) => s.inspectorOpen)
71
+ const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
66
72
  const connectors = useAppStore((s) => s.connectors)
67
73
  const loadConnectors = useAppStore((s) => s.loadConnectors)
68
74
  const providerLabel = PROVIDER_LABELS[session.provider] || session.provider
69
75
  const agent = session.agentId ? agents[session.agentId] : null
70
76
  const connector = getSessionConnector(session, connectors)
71
77
  const connectorMeta = connector ? CONNECTOR_PLATFORM_META[connector.platform] : null
78
+ const connectorPresence = connector?.presence
72
79
  const modelName = session.model || agent?.model || ''
73
80
  const [copied, setCopied] = useState(false)
74
81
  const [heartbeatSaving, setHeartbeatSaving] = useState(false)
@@ -77,6 +84,8 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
77
84
  const [mainLoopSaving, setMainLoopSaving] = useState(false)
78
85
  const [mainLoopError, setMainLoopError] = useState('')
79
86
  const [mainLoopNotice, setMainLoopNotice] = useState('')
87
+ const [syncingHistory, setSyncingHistory] = useState(false)
88
+ const [syncResult, setSyncResult] = useState('')
80
89
 
81
90
  // Find linked task for this session
82
91
  const linkedTask = useMemo(() => {
@@ -135,7 +144,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
135
144
  const total = (m[1] ? parseInt(m[1]) * 3600 : 0) + (m[2] ? parseInt(m[2]) * 60 : 0) + (m[3] ? parseInt(m[3]) : 0)
136
145
  return Math.max(0, Math.min(86400, total))
137
146
  }
138
- const resolveFrom = (obj: Record<string, any>): number | null => {
147
+ const resolveFrom = (obj: { heartbeatInterval?: string | number | null; heartbeatIntervalSec?: number | null }): number | null => {
139
148
  const dur = parseDur(obj.heartbeatInterval)
140
149
  if (dur !== null) return dur
141
150
  const sec = parseDur(obj.heartbeatIntervalSec)
@@ -143,20 +152,20 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
143
152
  return null
144
153
  }
145
154
  // Global defaults
146
- let sec = resolveFrom(appSettings as Record<string, any>) ?? 1800
155
+ let sec = resolveFrom(appSettings) ?? 1800
147
156
  let enabled = sec > 0
148
157
  let explicitOptIn = false
149
158
  // Agent layer
150
159
  if (agent) {
151
160
  if (agent.heartbeatEnabled === false) enabled = false
152
161
  if (agent.heartbeatEnabled === true) { enabled = true; explicitOptIn = true }
153
- sec = resolveFrom(agent as Record<string, any>) ?? sec
162
+ sec = resolveFrom(agent) ?? sec
154
163
  }
155
164
  // Session layer — only applies for non-agent chats (agent chats save directly to agent)
156
165
  if (!agent) {
157
166
  if (session.heartbeatEnabled === false) enabled = false
158
167
  if (session.heartbeatEnabled === true) { enabled = true; explicitOptIn = true }
159
- sec = resolveFrom(session as Record<string, any>) ?? sec
168
+ sec = resolveFrom(session) ?? sec
160
169
  }
161
170
  return {
162
171
  heartbeatEnabled: enabled && sec > 0,
@@ -268,6 +277,43 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
268
277
  void postMainLoopAction('clear_events')
269
278
  }
270
279
 
280
+ const isOpenClawAgent = agent?.provider === 'openclaw'
281
+ // Derive OpenClaw session key: agent sessions use "agent:<name>:main" convention
282
+ const openclawSessionKey = isOpenClawAgent && agent
283
+ ? `agent:${agent.name.toLowerCase().replace(/\s+/g, '-')}:main`
284
+ : null
285
+
286
+ const handleSyncHistory = async () => {
287
+ if (!openclawSessionKey || syncingHistory) return
288
+ setSyncingHistory(true)
289
+ setSyncResult('')
290
+ try {
291
+ const preview = await api<{ sessionKey: string; epoch: number; messages: Array<{ role: string; content: string; ts: number }> }>(
292
+ 'GET', `/openclaw/history?sessionKey=${encodeURIComponent(openclawSessionKey)}`,
293
+ )
294
+ if (!preview?.messages?.length) {
295
+ setSyncResult('No new messages found.')
296
+ return
297
+ }
298
+ const result = await api<{ ok: boolean; merged: number }>(
299
+ 'POST', '/openclaw/history',
300
+ { sessionKey: openclawSessionKey, epoch: preview.epoch, localSessionId: session.id },
301
+ )
302
+ setSyncResult(result.merged > 0 ? `Synced ${result.merged} message${result.merged !== 1 ? 's' : ''}.` : 'Already up to date.')
303
+ if (result.merged > 0) await loadSessions()
304
+ } catch (err: unknown) {
305
+ setSyncResult(err instanceof Error ? err.message : 'Sync failed.')
306
+ } finally {
307
+ setSyncingHistory(false)
308
+ }
309
+ }
310
+
311
+ useEffect(() => {
312
+ if (!syncResult) return
313
+ const timer = setTimeout(() => setSyncResult(''), 3000)
314
+ return () => clearTimeout(timer)
315
+ }, [syncResult])
316
+
271
317
  useEffect(() => {
272
318
  if (!hbDropdownOpen) return
273
319
  const handler = (e: MouseEvent) => {
@@ -307,6 +353,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
307
353
  )}
308
354
  <div className="flex-1 min-w-0">
309
355
  <div className="flex items-center gap-2.5">
356
+ {agent && <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />}
310
357
  <span className="font-display text-[16px] font-600 block truncate tracking-[-0.02em]">{
311
358
  session.name === '__main__' ? 'Main Chat'
312
359
  : session.name.startsWith('agent-thread:') ? (agent?.name || session.name)
@@ -326,6 +373,27 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
326
373
  {connectorMeta.label}
327
374
  </span>
328
375
  )}
376
+ {connector && connectorPresence && (() => {
377
+ const lastAt = connectorPresence.lastMessageAt
378
+ if (!lastAt) return (
379
+ <span className="shrink-0 inline-flex items-center gap-1 text-[10px] text-text-3/50">
380
+ <span className="w-1.5 h-1.5 rounded-full bg-text-3/40" />
381
+ Inactive
382
+ </span>
383
+ )
384
+ const ago = Date.now() - lastAt
385
+ const isActive = ago < 5 * 60_000
386
+ const isRecent = ago < 30 * 60_000
387
+ const label = isActive ? 'Active' : isRecent ? `${Math.floor(ago / 60_000)}m ago` : 'Inactive'
388
+ const dotColor = isActive ? 'bg-emerald-400' : isRecent ? 'bg-amber-400' : 'bg-text-3/40'
389
+ const textColor = isActive ? 'text-emerald-400' : isRecent ? 'text-amber-300' : 'text-text-3/50'
390
+ return (
391
+ <span className={`shrink-0 inline-flex items-center gap-1 text-[10px] ${textColor}`}>
392
+ <span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
393
+ {label}
394
+ </span>
395
+ )
396
+ })()}
329
397
  {session.provider && session.provider !== 'claude-cli' && (
330
398
  <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">
331
399
  {providerLabel}
@@ -351,6 +419,21 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
351
419
  <>
352
420
  <span className="text-[11px] text-text-3/60">·</span>
353
421
  <span className="text-[11px] text-text-3/50 font-mono truncate shrink-0">{modelName}</span>
422
+ {session.conversationTone && session.conversationTone !== 'neutral' && (() => {
423
+ const toneColors: Record<string, string> = {
424
+ formal: 'bg-[#3B82F6]',
425
+ casual: 'bg-emerald-400',
426
+ empathetic: 'bg-purple-400',
427
+ technical: 'bg-[#F59E0B]',
428
+ }
429
+ const color = toneColors[session.conversationTone] || ''
430
+ return color ? (
431
+ <span
432
+ className={`w-2 h-2 rounded-full shrink-0 ${color}`}
433
+ title={`Tone: ${session.conversationTone}`}
434
+ />
435
+ ) : null
436
+ })()}
354
437
  </>
355
438
  )}
356
439
  {lastUsage && !streaming && (
@@ -360,6 +443,50 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
360
443
  </>
361
444
  )}
362
445
  </div>
446
+ {(() => {
447
+ const liveStatus = agentStatus || (missionState.status ? {
448
+ goal: missionState.goal ?? undefined,
449
+ status: missionState.status ?? undefined,
450
+ summary: missionState.summary ?? undefined,
451
+ nextAction: missionState.nextAction ?? undefined,
452
+ } : null)
453
+ if (!liveStatus) return null
454
+ const statusColors: Record<string, string> = {
455
+ idle: 'bg-text-3/40',
456
+ progress: 'bg-[#3B82F6]',
457
+ blocked: 'bg-amber-400',
458
+ ok: 'bg-emerald-400',
459
+ }
460
+ const dotColor = statusColors[liveStatus.status || ''] || 'bg-text-3/40'
461
+ return (
462
+ <div className="flex items-center gap-2 mt-0.5">
463
+ {liveStatus.goal && (
464
+ <span className="text-[10px] text-text-3/60 font-mono truncate max-w-[240px]" title={liveStatus.goal}>
465
+ {liveStatus.goal}
466
+ </span>
467
+ )}
468
+ {liveStatus.status && (
469
+ <span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-wider ${
470
+ liveStatus.status === 'blocked' ? 'bg-amber-400/15 text-amber-300'
471
+ : liveStatus.status === 'ok' ? 'bg-emerald-400/15 text-emerald-400'
472
+ : liveStatus.status === 'progress' ? 'bg-[#3B82F6]/15 text-[#60A5FA]'
473
+ : 'bg-white/[0.04] text-text-3/60'
474
+ }`}>
475
+ <span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
476
+ {liveStatus.status}
477
+ </span>
478
+ )}
479
+ {liveStatus.nextAction && (
480
+ <>
481
+ <span className="text-[10px] text-text-3/40">→</span>
482
+ <span className="text-[10px] text-text-3/50 font-mono truncate max-w-[200px]" title={liveStatus.nextAction}>
483
+ {liveStatus.nextAction}
484
+ </span>
485
+ </>
486
+ )}
487
+ </div>
488
+ )
489
+ })()}
363
490
  </div>
364
491
  <div className="flex gap-1.5">
365
492
  {streaming && (
@@ -369,6 +496,14 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
369
496
  </svg>
370
497
  </IconButton>
371
498
  )}
499
+ {agent && (
500
+ <IconButton onClick={() => setInspectorOpen(!inspectorOpen)} active={inspectorOpen} aria-label="Toggle inspector panel">
501
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
502
+ <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" />
503
+ <circle cx="12" cy="12" r="3" />
504
+ </svg>
505
+ </IconButton>
506
+ )}
372
507
  <IconButton onClick={() => setDebugOpen(!debugOpen)} active={debugOpen} aria-label="Toggle debug panel">
373
508
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
374
509
  <path d="M12 20V10" />
@@ -376,6 +511,12 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
376
511
  <path d="M6 20v-4" />
377
512
  </svg>
378
513
  </IconButton>
514
+ <IconButton onClick={toggleSound} active={soundEnabled} aria-label="Toggle sound notifications">
515
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
516
+ <path d="M18 8A6 6 0 0 1 18 16" />
517
+ <path d="M13 2L8 7H4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4l5 5V2z" />
518
+ </svg>
519
+ </IconButton>
379
520
  <IconButton onClick={toggleTts} active={ttsEnabled} aria-label="Toggle text-to-speech">
380
521
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
381
522
  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
@@ -539,6 +680,29 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
539
680
  </span>
540
681
  </button>
541
682
  )}
683
+ {isOpenClawAgent && openclawSessionKey && (
684
+ <>
685
+ <button
686
+ onClick={handleSyncHistory}
687
+ disabled={syncingHistory}
688
+ 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"
689
+ title="Sync chat history from OpenClaw gateway"
690
+ >
691
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-indigo-400">
692
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
693
+ <path d="M3 3v5h5" />
694
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
695
+ <path d="M16 16h5v5" />
696
+ </svg>
697
+ <span className="text-[11px] font-600 text-indigo-400">
698
+ {syncingHistory ? 'Syncing...' : 'Sync History'}
699
+ </span>
700
+ </button>
701
+ {syncResult && (
702
+ <span className="text-[10px] text-emerald-300/90">{syncResult}</span>
703
+ )}
704
+ </>
705
+ )}
542
706
  {linkedTask && (
543
707
  <button
544
708
  onClick={() => setActiveView('tasks')}
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react'
4
+ import { CodeBlock } from './code-block'
5
+
6
+ interface PreviewContent {
7
+ type: 'browser' | 'image' | 'code' | 'html'
8
+ url?: string
9
+ content?: string
10
+ title?: string
11
+ }
12
+
13
+ interface Props {
14
+ content: PreviewContent
15
+ onClose: () => void
16
+ }
17
+
18
+ export function ChatPreviewPanel({ content, onClose }: Props) {
19
+ const [width, setWidth] = useState(400)
20
+ const dragging = useRef(false)
21
+ const startX = useRef(0)
22
+ const startWidth = useRef(400)
23
+
24
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
25
+ e.preventDefault()
26
+ dragging.current = true
27
+ startX.current = e.clientX
28
+ startWidth.current = width
29
+
30
+ const handleMouseMove = (ev: MouseEvent) => {
31
+ if (!dragging.current) return
32
+ const diff = startX.current - ev.clientX
33
+ const next = Math.max(300, Math.min(window.innerWidth * 0.5, startWidth.current + diff))
34
+ setWidth(next)
35
+ }
36
+
37
+ const handleMouseUp = () => {
38
+ dragging.current = false
39
+ document.removeEventListener('mousemove', handleMouseMove)
40
+ document.removeEventListener('mouseup', handleMouseUp)
41
+ }
42
+
43
+ document.addEventListener('mousemove', handleMouseMove)
44
+ document.addEventListener('mouseup', handleMouseUp)
45
+ }, [width])
46
+
47
+ return (
48
+ <div
49
+ className="flex flex-col border-l border-white/[0.06] bg-bg shrink-0"
50
+ style={{ width, minWidth: 300, maxWidth: '50%', animation: 'fade-in 0.25s ease' }}
51
+ >
52
+ {/* Resize handle */}
53
+ <div
54
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent-bright/20 transition-colors z-10"
55
+ style={{ position: 'relative', width: 4, minWidth: 4 }}
56
+ onMouseDown={handleMouseDown}
57
+ />
58
+
59
+ {/* Header */}
60
+ <div className="flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.06] shrink-0">
61
+ <span className="text-[12px] font-600 text-text-2 truncate flex-1">
62
+ {content.title || 'Preview'}
63
+ </span>
64
+ <button
65
+ onClick={onClose}
66
+ className="p-1 rounded-[6px] text-text-3 hover:text-text-2 hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors"
67
+ aria-label="Close preview"
68
+ >
69
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
70
+ <line x1="18" y1="6" x2="6" y2="18" />
71
+ <line x1="6" y1="6" x2="18" y2="18" />
72
+ </svg>
73
+ </button>
74
+ </div>
75
+
76
+ {/* Content */}
77
+ <div className="flex-1 overflow-auto min-h-0">
78
+ {content.type === 'browser' && content.url && (
79
+ <iframe
80
+ src={content.url}
81
+ className="w-full h-full border-none"
82
+ title={content.title || 'Browser Preview'}
83
+ sandbox="allow-scripts allow-same-origin"
84
+ />
85
+ )}
86
+ {content.type === 'html' && content.content && (
87
+ <iframe
88
+ srcDoc={content.content}
89
+ className="w-full h-full border-none"
90
+ title={content.title || 'HTML Preview'}
91
+ sandbox="allow-scripts"
92
+ />
93
+ )}
94
+ {content.type === 'image' && content.url && (
95
+ <div className="p-4 flex items-center justify-center h-full">
96
+ <img
97
+ src={content.url}
98
+ alt={content.title || 'Preview'}
99
+ className="max-w-full max-h-full rounded-[8px] object-contain"
100
+ />
101
+ </div>
102
+ )}
103
+ {content.type === 'code' && content.content && (
104
+ <div className="p-2">
105
+ <CodeBlock className={`language-${content.title?.split('.').pop() || 'text'}`}>
106
+ {content.content}
107
+ </CodeBlock>
108
+ </div>
109
+ )}
110
+ </div>
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,89 @@
1
+ 'use client'
2
+
3
+ import type { PendingExecApproval, ExecApprovalDecision } from '@/types'
4
+ import { useApprovalStore } from '@/stores/use-approval-store'
5
+
6
+ interface Props {
7
+ approval: PendingExecApproval
8
+ }
9
+
10
+ export function ExecApprovalCard({ approval }: Props) {
11
+ const resolveApproval = useApprovalStore((s) => s.resolveApproval)
12
+
13
+ const handleResolve = (decision: ExecApprovalDecision) => {
14
+ resolveApproval(approval.id, decision)
15
+ }
16
+
17
+ const expired = approval.expiresAtMs < Date.now()
18
+ const disabled = !!approval.resolving || expired
19
+
20
+ return (
21
+ <div className="my-2 rounded-[12px] border border-amber-500/20 bg-amber-500/[0.04] p-3.5">
22
+ <div className="flex items-center gap-2 mb-2">
23
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-amber-400 shrink-0">
24
+ <path d="M12 9v2m0 4h.01" />
25
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
26
+ </svg>
27
+ <span className="text-[12px] font-600 text-amber-400">Execution Approval Required</span>
28
+ </div>
29
+
30
+ {approval.ask && (
31
+ <p className="text-[13px] text-text-2 mb-2">{approval.ask}</p>
32
+ )}
33
+
34
+ <div className="rounded-[8px] bg-black/20 px-3 py-2 mb-2 overflow-x-auto">
35
+ <code className="text-[12px] text-text font-mono whitespace-pre-wrap break-all">
36
+ {approval.command}
37
+ </code>
38
+ </div>
39
+
40
+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-text-3/60 mb-3">
41
+ {approval.cwd && <span>cwd: {approval.cwd}</span>}
42
+ {approval.host && <span>host: {approval.host}</span>}
43
+ {approval.security && (
44
+ <span className={approval.security === 'high' ? 'text-red-400' : ''}>
45
+ security: {approval.security}
46
+ </span>
47
+ )}
48
+ </div>
49
+
50
+ {approval.error && (
51
+ <p className="text-[12px] text-red-400 mb-2">{approval.error}</p>
52
+ )}
53
+
54
+ {expired ? (
55
+ <p className="text-[12px] text-text-3/50 italic">Approval expired</p>
56
+ ) : (
57
+ <div className="flex items-center gap-2">
58
+ <button
59
+ onClick={() => handleResolve('allow-once')}
60
+ disabled={disabled}
61
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-emerald-500/10 text-[12px] font-600
62
+ text-emerald-400 cursor-pointer hover:bg-emerald-500/20 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
63
+ style={{ fontFamily: 'inherit' }}
64
+ >
65
+ {approval.resolving ? '...' : 'Allow Once'}
66
+ </button>
67
+ <button
68
+ onClick={() => handleResolve('allow-always')}
69
+ disabled={disabled}
70
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[12px] font-600
71
+ text-text-3 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40 disabled:cursor-not-allowed"
72
+ style={{ fontFamily: 'inherit' }}
73
+ >
74
+ Always Allow
75
+ </button>
76
+ <button
77
+ onClick={() => handleResolve('deny')}
78
+ disabled={disabled}
79
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[12px] font-600
80
+ text-red-400 cursor-pointer hover:bg-red-400/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
81
+ style={{ fontFamily: 'inherit' }}
82
+ >
83
+ Deny
84
+ </button>
85
+ </div>
86
+ )}
87
+ </div>
88
+ )
89
+ }