inspector-panel.tsx
1 'use client' 2 3 import { DEFAULT_HEARTBEAT_INTERVAL_SEC } from '@/lib/runtime/heartbeat-defaults' 4 import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' 5 import type { Agent, MemoryEntry, Session } from '@/types' 6 import { useAppStore } from '@/stores/use-app-store' 7 import { useChatStore } from '@/stores/use-chat-store' 8 import { api } from '@/lib/app/api-client' 9 import { sortSessionsNewestFirst } from '@/lib/chat/new-session' 10 import { AgentAvatar } from './agent-avatar' 11 import { AgentFilesEditor } from './agent-files-editor' 12 import { OpenClawSkillsPanel } from './openclaw-skills-panel' 13 import { PermissionPresetSelector } from './permission-preset-selector' 14 import { ExecConfigPanel } from './exec-config-panel' 15 import { SandboxEnvPanel } from './sandbox-env-panel' 16 import { CronJobForm } from './cron-job-form' 17 import { toast } from 'sonner' 18 import { StatusDot } from '@/components/ui/status-dot' 19 import { normalizeAgentExecuteConfig } from '@/lib/agent-execute-defaults' 20 import { normalizeAgentSandboxConfig } from '@/lib/agent-sandbox-defaults' 21 import { getEnabledToolIds, getEnabledExtensionIds, getEnabledCapabilityIds } from '@/lib/capability-selection' 22 import { searchMemory } from '@/lib/memory' 23 import { ConnectorPlatformIcon, getSessionConnector } from '@/components/shared/connector-platform-icon' 24 import { useNavigate } from '@/lib/app/navigation' 25 import { formatDurationSec } from '@/lib/format-display' 26 import { ModelCombobox } from '@/components/shared/model-combobox' 27 import { buildOpenClawMainSessionKey } from '@/lib/openclaw/openclaw-agent-id' 28 import { StructuredSessionLauncher } from '@/components/protocols/structured-session-launcher' 29 import { useWs } from '@/hooks/use-ws' 30 import { buildAgentSelectableProviders } from '@/lib/agent-provider-options' 31 32 interface Props { 33 agent: Agent 34 session: Session 35 onEditAgent?: () => void 36 onDuplicateAgent?: () => void 37 onClearHistory?: () => void 38 onDeleteAgent?: () => void 39 onDeleteChat?: () => void 40 isMainChat?: boolean 41 } 42 43 type InspectorTab = 'dashboard' | 'config' | 'files' 44 45 const TABS: { id: InspectorTab; label: string; openclawOnly?: boolean }[] = [ 46 { id: 'dashboard', label: 'Dashboard' }, 47 { id: 'config', label: 'Config' }, 48 { id: 'files', label: 'Files', openclawOnly: true }, 49 ] 50 51 const PROVIDER_LABELS: Record<string, string> = { 52 'claude-cli': 'Claude CLI', 53 'codex-cli': 'Codex CLI', 54 'opencode-cli': 'OpenCode CLI', 55 'gemini-cli': 'Gemini CLI', 56 'copilot-cli': 'Copilot CLI', 57 'droid-cli': 'Droid CLI', 58 'cursor-cli': 'Cursor CLI', 59 'qwen-code-cli': 'Qwen Code CLI', 60 goose: 'Goose', 61 openai: 'OpenAI', 62 anthropic: 'Anthropic', 63 openclaw: 'OpenClaw', 64 ollama: 'Ollama', 65 } 66 67 // ─── Model Switcher (inline in header) ─────────────────────────── 68 69 function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agent }) { 70 const providers = useAppStore((s) => s.providers) 71 const loadProviders = useAppStore((s) => s.loadProviders) 72 const providerConfigs = useAppStore((s) => s.providerConfigs) 73 const loadProviderConfigs = useAppStore((s) => s.loadProviderConfigs) 74 const refreshSession = useAppStore((s) => s.refreshSession) 75 const streaming = useChatStore((s) => s.streaming) 76 const [expanded, setExpanded] = useState(false) 77 const [selectedProvider, setSelectedProvider] = useState(session.provider || agent.provider) 78 const [saving, setSaving] = useState(false) 79 80 useEffect(() => { 81 void loadProviders() 82 void loadProviderConfigs() 83 }, [loadProviderConfigs, loadProviders]) 84 // Sync selectedProvider when the session's provider changes (e.g. after a successful save) 85 useEffect(() => { setSelectedProvider(session.provider || agent.provider) }, [session.provider, agent.provider]) 86 87 const agentSelectableProviders = useMemo( 88 () => buildAgentSelectableProviders(providers, providerConfigs), 89 [providerConfigs, providers], 90 ) 91 const currentProviderInfo = agentSelectableProviders.find((p) => p.id === selectedProvider) 92 const activeSessionProvider = agentSelectableProviders.find((p) => p.id === (session.provider || agent.provider)) 93 const effectiveProvider = session.provider || agent.provider 94 const providerLabel = PROVIDER_LABELS[effectiveProvider] || activeSessionProvider?.name || effectiveProvider.replace(/-/g, ' ') 95 96 const handleModelChange = async (model: string) => { 97 if (saving) return 98 setSaving(true) 99 try { 100 await api('PUT', `/chats/${session.id}`, { provider: selectedProvider, model }) 101 await refreshSession(session.id) 102 setExpanded(false) 103 toast.success(`Switched to ${model}`) 104 } catch (err: unknown) { 105 toast.error(err instanceof Error ? err.message : 'Failed to switch model') 106 } finally { 107 setSaving(false) 108 } 109 } 110 111 if (!expanded) { 112 return ( 113 <button 114 type="button" 115 onClick={() => !streaming && setExpanded(true)} 116 disabled={streaming} 117 className="mt-2 flex items-center gap-1.5 w-full text-left bg-transparent border-none cursor-pointer disabled:cursor-default disabled:opacity-50 group" 118 > 119 <span className="inline-flex items-center rounded-[8px] border border-white/[0.06] bg-white/[0.03] px-2 py-1 text-[10px] font-600 text-text-3/70 group-hover:border-white/[0.1] group-hover:text-text-2 transition-colors"> 120 {providerLabel} 121 </span> 122 <span className="inline-flex max-w-[180px] items-center rounded-[8px] border border-white/[0.06] bg-white/[0.03] px-2 py-1 text-[10px] font-mono text-text-3/70 truncate group-hover:border-white/[0.1] group-hover:text-text-2 transition-colors"> 123 {session.model || agent.model || 'Default model'} 124 </span> 125 <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/30 group-hover:text-text-3/60 transition-colors ml-auto shrink-0"> 126 <polyline points="6 9 12 15 18 9" /> 127 </svg> 128 </button> 129 ) 130 } 131 132 return ( 133 <div className="mt-2 rounded-[10px] border border-white/[0.08] bg-black/[0.12] p-2.5"> 134 <div className="flex items-center justify-between mb-2"> 135 <span className="text-[10px] font-700 uppercase tracking-[0.12em] text-text-3/45">Switch Model</span> 136 <button 137 type="button" 138 onClick={() => setExpanded(false)} 139 className="text-[10px] text-text-3/50 hover:text-text-3 bg-transparent border-none cursor-pointer" 140 > 141 Cancel 142 </button> 143 </div> 144 <div className="flex flex-wrap gap-1 mb-2"> 145 {agentSelectableProviders.filter((p) => p.models.length > 0).map((p) => ( 146 <button 147 key={p.id} 148 type="button" 149 onClick={() => setSelectedProvider(p.id)} 150 className={`px-2 py-0.5 rounded-[6px] text-[10px] font-600 border cursor-pointer transition-colors ${ 151 p.id === selectedProvider 152 ? 'bg-accent-soft/50 text-accent-bright border-accent-bright/20' 153 : 'bg-white/[0.02] text-text-3/60 border-white/[0.04] hover:bg-white/[0.05]' 154 }`} 155 > 156 {PROVIDER_LABELS[p.id] || p.name} 157 </button> 158 ))} 159 </div> 160 {currentProviderInfo && ( 161 <ModelCombobox 162 providerId={currentProviderInfo.id} 163 value={session.model || agent.model || currentProviderInfo.models[0] || ''} 164 onChange={(m) => void handleModelChange(m)} 165 models={currentProviderInfo.models} 166 defaultModels={currentProviderInfo.defaultModels} 167 supportsDiscovery={currentProviderInfo.supportsModelDiscovery} 168 className="w-full" 169 /> 170 )} 171 </div> 172 ) 173 } 174 175 // ─── Workspace Path ────────────────────────────────────────────── 176 177 function WorkspacePath({ cwd }: { cwd: string }) { 178 const display = cwd.replace(/^\/Users\/[^/]+/, '~') 179 const handleClick = () => { 180 api('POST', '/files/open', { path: cwd }).catch(() => {}) 181 } 182 return ( 183 <button 184 type="button" 185 onClick={handleClick} 186 className="mt-2 flex items-center gap-1.5 w-full text-left bg-transparent border-none cursor-pointer group" 187 > 188 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/40 shrink-0"> 189 <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" /> 190 </svg> 191 <span className="text-[10px] text-text-3/60 font-mono truncate group-hover:text-text-2 transition-colors">{display}</span> 192 </button> 193 ) 194 } 195 196 function panelCardClass(className = '') { 197 return `rounded-[16px] border border-white/[0.06] bg-white/[0.03] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] ${className}`.trim() 198 } 199 200 function SectionLabel({ children }: { children: ReactNode }) { 201 return <label className="block text-[11px] font-700 uppercase tracking-[0.16em] text-text-3/45 mb-2">{children}</label> 202 } 203 204 function ToggleSwitch({ on, onChange, disabled }: { on: boolean; onChange: () => void; disabled?: boolean }) { 205 return ( 206 <button 207 onClick={onChange} 208 disabled={disabled} 209 className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer border-none disabled:opacity-50 ${on ? 'bg-accent-bright/80' : 'bg-white/[0.08]'}`} 210 > 211 <span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${on ? 'translate-x-4' : ''}`} /> 212 </button> 213 ) 214 } 215 216 // --- Main component --- 217 218 export function InspectorPanel({ agent, session, onEditAgent, onDuplicateAgent, onClearHistory, onDeleteAgent, onDeleteChat, isMainChat }: Props) { 219 const inspectorTab = useAppStore((s) => s.inspectorTab) 220 const setInspectorTab = useAppStore((s) => s.setInspectorTab) 221 const setInspectorOpen = useAppStore((s) => s.setInspectorOpen) 222 223 const isOpenClaw = agent.provider === 'openclaw' 224 const visibleTabs = TABS.filter((t) => !t.openclawOnly || isOpenClaw) 225 226 // Reset to dashboard if current tab is not visible 227 useEffect(() => { 228 if (!visibleTabs.find((t) => t.id === inspectorTab)) { 229 setInspectorTab('dashboard') 230 } 231 }, [inspectorTab, setInspectorTab, visibleTabs]) 232 233 // Close on Escape 234 useEffect(() => { 235 const handler = (e: KeyboardEvent) => { 236 if (e.key === 'Escape') setInspectorOpen(false) 237 } 238 window.addEventListener('keydown', handler) 239 return () => window.removeEventListener('keydown', handler) 240 }, [setInspectorOpen]) 241 242 return ( 243 <div className="w-[420px] shrink-0 border-l border-white/[0.06] bg-bg flex flex-col h-full overflow-hidden fade-up-delay" 244 style={{ background: 'radial-gradient(circle at top right, rgba(66, 211, 255, 0.06), transparent 30%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))' }}> 245 {/* Header */} 246 <div className="px-4 pt-4 pb-3 border-b border-white/[0.06] shrink-0 bg-black/[0.12]"> 247 <div className="flex items-start gap-3"> 248 <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={40} /> 249 <div className="min-w-0 flex-1"> 250 <div className="flex items-center gap-2 min-w-0"> 251 <h3 className="font-display text-[16px] font-700 text-text truncate tracking-[-0.02em]">{agent.name}</h3> 252 {agent.disabled === true && ( 253 <span className="inline-flex items-center gap-1 rounded-[7px] border border-amber-400/15 bg-amber-400/[0.1] px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.12em] text-amber-300"> 254 <StatusDot status="warning" size="sm" /> 255 Disabled 256 </span> 257 )} 258 {agent.heartbeatEnabled && ( 259 <span className="inline-flex items-center gap-1 rounded-[7px] border border-emerald-400/15 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.12em] text-emerald-300"> 260 <StatusDot status="online" size="sm" /> 261 Heartbeat 262 </span> 263 )} 264 </div> 265 </div> 266 <button 267 onClick={() => setInspectorOpen(false)} 268 className="p-1.5 rounded-[8px] text-text-3/50 hover:text-text-3 bg-transparent border-none cursor-pointer transition-all hover:bg-white/[0.04]" 269 aria-label="Close inspector" 270 > 271 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 272 <line x1="18" y1="6" x2="6" y2="18" /> 273 <line x1="6" y1="6" x2="18" y2="18" /> 274 </svg> 275 </button> 276 </div> 277 <ModelSwitcherInline session={session} agent={agent} /> 278 {session.cwd && <WorkspacePath cwd={session.cwd} />} 279 </div> 280 281 {/* Tab bar */} 282 <div className="px-4 py-3 shrink-0"> 283 <div className="flex gap-1 rounded-[12px] border border-white/[0.06] bg-black/[0.12] p-1 overflow-x-auto" role="tablist"> 284 {visibleTabs.map((tab) => ( 285 <button 286 key={tab.id} 287 role="tab" 288 onClick={() => setInspectorTab(tab.id)} 289 aria-selected={inspectorTab === tab.id} 290 className={`px-3 py-1.5 rounded-[9px] text-[11px] font-700 cursor-pointer transition-all whitespace-nowrap focus-visible:ring-1 focus-visible:ring-accent-bright/50 291 ${inspectorTab === tab.id 292 ? 'bg-white/[0.08] text-text' 293 : 'bg-transparent text-text-3/65 hover:text-text-2'}`} 294 style={{ fontFamily: 'inherit' }} 295 > 296 {tab.label} 297 </button> 298 ))} 299 </div> 300 </div> 301 302 {/* Tab content */} 303 <div className="flex-1 min-h-0 overflow-y-auto"> 304 {inspectorTab === 'dashboard' && ( 305 <DashboardTab agent={agent} session={session} /> 306 )} 307 {inspectorTab === 'config' && ( 308 <ConfigTab agent={agent} /> 309 )} 310 {inspectorTab === 'files' && isOpenClaw && ( 311 <AgentFilesEditor agentId={agent.id} /> 312 )} 313 </div> 314 315 {/* Sticky footer */} 316 <StickyFooter 317 agent={agent} 318 isMainChat={isMainChat} 319 onEditAgent={onEditAgent} 320 onDuplicateAgent={onDuplicateAgent} 321 onClearHistory={onClearHistory} 322 onDeleteAgent={onDeleteAgent} 323 onDeleteChat={onDeleteChat} 324 /> 325 </div> 326 ) 327 } 328 329 // ─── Dashboard Tab ─────────────────────────────────────────────── 330 331 function DashboardTab({ agent, session }: { agent: Agent; session: Session }) { 332 return ( 333 <div className="p-4 flex flex-col gap-4"> 334 <IdentityCard agent={agent} /> 335 {agent.provider === 'openclaw' && <OpenClawActionsSection agent={agent} />} 336 <HeartbeatSection agent={agent} session={session} /> 337 <ToolsSection agent={agent} session={session} /> 338 <AudioSection /> 339 <MemorySection agentId={agent.id} /> 340 <SessionsSection agent={agent} /> 341 <QuickActionsSection agent={agent} session={session} /> 342 </div> 343 ) 344 } 345 346 // ─── Identity Card ─────────────────────────────────────────────── 347 348 function IdentityCard({ agent }: { agent: Agent }) { 349 const loadAgents = useAppStore((s) => s.loadAgents) 350 const [editing, setEditing] = useState(false) 351 const [draft, setDraft] = useState(agent.description || '') 352 const [saving, setSaving] = useState(false) 353 const [promptExpanded, setPromptExpanded] = useState(false) 354 const textareaRef = useRef<HTMLTextAreaElement>(null) 355 356 const startEdit = () => { 357 setDraft(agent.description || '') 358 setEditing(true) 359 requestAnimationFrame(() => textareaRef.current?.focus()) 360 } 361 362 const cancelEdit = () => { 363 setEditing(false) 364 setDraft(agent.description || '') 365 } 366 367 const saveDescription = async () => { 368 const trimmed = draft.trim() 369 if (trimmed === (agent.description || '').trim()) { 370 setEditing(false) 371 return 372 } 373 setSaving(true) 374 try { 375 await api('PUT', `/agents/${agent.id}`, { description: trimmed }) 376 await loadAgents() 377 setEditing(false) 378 } catch (err: unknown) { 379 toast.error(err instanceof Error ? err.message : 'Failed to update description') 380 } finally { 381 setSaving(false) 382 } 383 } 384 385 return ( 386 <div className={panelCardClass('p-4 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))]')}> 387 {editing ? ( 388 <div> 389 <SectionLabel>Description</SectionLabel> 390 <textarea 391 ref={textareaRef} 392 value={draft} 393 onChange={(e) => setDraft(e.target.value)} 394 onBlur={() => void saveDescription()} 395 onKeyDown={(e) => { 396 if (e.key === 'Escape') cancelEdit() 397 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void saveDescription() 398 }} 399 disabled={saving} 400 placeholder="Add a description..." 401 className="w-full min-h-[60px] rounded-[10px] border border-accent-bright/30 bg-black/[0.14] p-3 text-[13px] text-text-2 leading-relaxed outline-none resize-none font-sans" 402 /> 403 </div> 404 ) : ( 405 <button 406 type="button" 407 onClick={startEdit} 408 className="group w-full text-left bg-transparent border-none cursor-pointer p-0" 409 > 410 <p className="text-[13px] text-text-2 leading-relaxed"> 411 {agent.description || <span className="text-text-3/40 italic">Add a description...</span>} 412 </p> 413 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="mt-1 text-text-3/30 opacity-0 group-hover:opacity-100 transition-opacity"> 414 <path d="M12 20h9" /> 415 <path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /> 416 </svg> 417 </button> 418 )} 419 420 {agent.systemPrompt && ( 421 <div className="mt-3"> 422 <button 423 type="button" 424 onClick={() => setPromptExpanded((v) => !v)} 425 className="flex items-center gap-1.5 text-[11px] font-600 text-text-3/50 hover:text-text-3/70 bg-transparent border-none cursor-pointer transition-colors" 426 > 427 <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className={`transition-transform ${promptExpanded ? 'rotate-90' : ''}`}> 428 <polyline points="9 18 15 12 9 6" /> 429 </svg> 430 System prompt 431 </button> 432 {promptExpanded && ( 433 <p className="mt-2 text-[12px] text-text-3 bg-black/[0.14] rounded-[12px] p-3 border border-white/[0.04] max-h-[220px] overflow-y-auto whitespace-pre-wrap font-mono leading-relaxed"> 434 {agent.systemPrompt} 435 </p> 436 )} 437 </div> 438 )} 439 </div> 440 ) 441 } 442 443 // ─── Heartbeat Section ─────────────────────────────────────────── 444 445 function HeartbeatSection({ agent, session }: { agent: Agent; session: Session }) { 446 const appSettings = useAppStore((s) => s.appSettings) 447 const updateAgentInStore = useAppStore((s) => s.updateAgentInStore) 448 const updateSessionInStore = useAppStore((s) => s.updateSessionInStore) 449 const [heartbeatSaving, setHeartbeatSaving] = useState(false) 450 const [hbDropdownOpen, setHbDropdownOpen] = useState(false) 451 const hbDropdownRef = useRef<HTMLDivElement>(null) 452 453 const heartbeatSupported = getEnabledCapabilityIds(session).length > 0 454 const loopIsOngoing = appSettings.loopMode === 'ongoing' 455 456 const { heartbeatEnabled, heartbeatIntervalSec, heartbeatExplicitOptIn } = useMemo(() => { 457 const parseDur = (v: unknown): number | null => { 458 if (v === null || v === undefined) return null 459 if (typeof v === 'number') return Number.isFinite(v) ? Math.max(0, Math.min(86400, Math.trunc(v))) : null 460 if (typeof v !== 'string') return null 461 const t = v.trim().toLowerCase() 462 if (!t) return null 463 const n = Number(t) 464 if (Number.isFinite(n)) return Math.max(0, Math.min(86400, Math.trunc(n))) 465 const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/) 466 if (!m || (!m[1] && !m[2] && !m[3])) return null 467 const total = (m[1] ? parseInt(m[1]) * 3600 : 0) + (m[2] ? parseInt(m[2]) * 60 : 0) + (m[3] ? parseInt(m[3]) : 0) 468 return Math.max(0, Math.min(86400, total)) 469 } 470 const resolveFrom = (obj: { heartbeatInterval?: string | number | null; heartbeatIntervalSec?: number | null }): number | null => { 471 const dur = parseDur(obj.heartbeatInterval) 472 if (dur !== null) return dur 473 const sec = parseDur(obj.heartbeatIntervalSec) 474 if (sec !== null) return sec 475 return null 476 } 477 let sec = resolveFrom(appSettings) ?? DEFAULT_HEARTBEAT_INTERVAL_SEC 478 let enabled = sec > 0 479 let explicitOptIn = false 480 if (agent) { 481 if (agent.heartbeatEnabled === false) enabled = false 482 if (agent.heartbeatEnabled === true) { enabled = true; explicitOptIn = true } 483 sec = resolveFrom(agent) ?? sec 484 } 485 return { 486 heartbeatEnabled: enabled && sec > 0, 487 heartbeatIntervalSec: sec, 488 heartbeatExplicitOptIn: explicitOptIn, 489 } 490 }, [appSettings, agent]) 491 492 const heartbeatWillRun = heartbeatEnabled && (loopIsOngoing || heartbeatExplicitOptIn) 493 494 // Don't render if heartbeat is not relevant 495 if (!heartbeatSupported) return null 496 497 const handleToggleHeartbeat = async () => { 498 if (heartbeatSaving) return 499 setHeartbeatSaving(true) 500 try { 501 const next = !heartbeatEnabled 502 if (session.agentId) { 503 const updatedAgent = await api<Agent>('PUT', `/agents/${session.agentId}`, { heartbeatEnabled: next }) 504 updateAgentInStore(updatedAgent) 505 const updatedSession = await api<Session>('PUT', `/chats/${session.id}`, { heartbeatEnabled: null }) 506 updateSessionInStore(updatedSession) 507 } else { 508 const updatedSession = await api<Session>('PUT', `/chats/${session.id}`, { heartbeatEnabled: next }) 509 updateSessionInStore(updatedSession) 510 } 511 toast.success(`Heartbeat ${next ? 'enabled' : 'disabled'}`) 512 } finally { 513 setHeartbeatSaving(false) 514 } 515 } 516 517 const handleSelectInterval = async (sec: number) => { 518 if (heartbeatSaving) return 519 setHbDropdownOpen(false) 520 setHeartbeatSaving(true) 521 try { 522 if (session.agentId) { 523 const updatedAgent = await api<Agent>('PUT', `/agents/${session.agentId}`, { 524 heartbeatInterval: formatDurationSec(sec), 525 heartbeatIntervalSec: sec, 526 }) 527 updateAgentInStore(updatedAgent) 528 const updatedSession = await api<Session>('PUT', `/chats/${session.id}`, { heartbeatIntervalSec: null, heartbeatEnabled: null }) 529 updateSessionInStore(updatedSession) 530 } else { 531 const updatedSession = await api<Session>('PUT', `/chats/${session.id}`, { heartbeatIntervalSec: sec }) 532 updateSessionInStore(updatedSession) 533 } 534 } finally { 535 setHeartbeatSaving(false) 536 } 537 } 538 539 const intervalOptions = [1800, 3600, 7200, 21600, 43200] 540 541 return ( 542 <div className={panelCardClass('p-4')}> 543 <div className="flex items-center justify-between mb-2"> 544 <SectionLabel>Heartbeat</SectionLabel> 545 <ToggleSwitch on={heartbeatWillRun} onChange={() => void handleToggleHeartbeat()} disabled={heartbeatSaving} /> 546 </div> 547 {heartbeatWillRun && ( 548 <div className="flex items-center gap-2 text-[12px] text-text-3/70"> 549 <span>Every</span> 550 <div className="relative" ref={hbDropdownRef}> 551 <button 552 onClick={() => setHbDropdownOpen((o) => !o)} 553 disabled={heartbeatSaving} 554 className="px-2 py-0.5 rounded-[6px] bg-white/[0.04] hover:bg-white/[0.08] text-text-2 text-[12px] font-600 cursor-pointer border-none transition-colors" 555 > 556 {formatDurationSec(heartbeatIntervalSec)} 557 <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="inline ml-1 opacity-40"> 558 <polyline points="6 9 12 15 18 9" /> 559 </svg> 560 </button> 561 {hbDropdownOpen && ( 562 <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-[88px]"> 563 {intervalOptions.map((sec) => ( 564 <button 565 key={sec} 566 onClick={() => void handleSelectInterval(sec)} 567 className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none 568 ${sec === heartbeatIntervalSec ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]'}`} 569 > 570 {formatDurationSec(sec)} 571 </button> 572 ))} 573 </div> 574 )} 575 </div> 576 {agent.heartbeatModel && ( 577 <span className="text-text-3/50 text-[11px]">({agent.heartbeatModel})</span> 578 )} 579 </div> 580 )} 581 {heartbeatEnabled && !heartbeatWillRun && ( 582 <p className="text-[11px] text-amber-300/60">Bounded — runs only when loop mode is ongoing</p> 583 )} 584 {heartbeatWillRun && ( 585 <button 586 type="button" 587 onClick={() => { 588 useAppStore.getState().setHeartbeatHistoryOpen(true) 589 useAppStore.getState().setInspectorOpen(false) 590 }} 591 className="mt-2 flex items-center gap-1.5 w-full text-[11px] font-600 text-accent-bright/60 hover:text-accent-bright bg-transparent border-none cursor-pointer transition-colors" 592 > 593 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0"> 594 <circle cx="12" cy="12" r="10" /> 595 <polyline points="12 6 12 12 16 14" /> 596 </svg> 597 View History 598 </button> 599 )} 600 </div> 601 ) 602 } 603 604 // ─── Tools Section ─────────────────────────────────────────────── 605 606 function ToolsSection({ agent, session }: { agent: Agent; session: Session }) { 607 const refreshSession = useAppStore((s) => s.refreshSession) 608 const agentToolIds = getEnabledToolIds(agent) 609 const sessionToolIds = getEnabledToolIds(session) 610 const sessionExtensions = getEnabledExtensionIds(session) 611 const [collapsed, setCollapsed] = useState(agentToolIds.length >= 10) 612 613 if (agentToolIds.length === 0) return null 614 615 const displayTools = collapsed ? agentToolIds.slice(0, 8) : agentToolIds 616 617 const toggleTool = async (toolId: string) => { 618 const updated = sessionToolIds.includes(toolId) 619 ? sessionToolIds.filter((t) => t !== toolId) 620 : [...sessionToolIds, toolId] 621 await api('PUT', `/chats/${session.id}`, { 622 tools: updated, 623 extensions: sessionExtensions, 624 }) 625 await refreshSession(session.id) 626 } 627 628 return ( 629 <div className={panelCardClass('p-4')}> 630 <SectionLabel>Tools ({sessionToolIds.length}/{agentToolIds.length})</SectionLabel> 631 <div className="flex flex-wrap gap-1.5"> 632 {displayTools.map((toolId) => { 633 const enabled = sessionToolIds.includes(toolId) 634 return ( 635 <button 636 key={toolId} 637 type="button" 638 onClick={() => void toggleTool(toolId)} 639 className={`px-2.5 py-1 rounded-[8px] text-[11px] font-700 border cursor-pointer transition-all ${ 640 enabled 641 ? 'bg-sky-400/[0.08] text-sky-300 border-sky-400/[0.08] hover:bg-sky-400/[0.15]' 642 : 'bg-white/[0.02] text-text-3/35 border-white/[0.04] hover:bg-white/[0.05] hover:text-text-3/55' 643 }`} 644 > 645 {toolId} 646 </button> 647 ) 648 })} 649 </div> 650 {agentToolIds.length >= 10 && ( 651 <button 652 type="button" 653 onClick={() => setCollapsed((v) => !v)} 654 className="mt-2 text-[10px] font-600 text-text-3/50 hover:text-text-3/70 bg-transparent border-none cursor-pointer transition-colors" 655 > 656 {collapsed ? `Show all ${agentToolIds.length} tools` : 'Show fewer'} 657 </button> 658 )} 659 </div> 660 ) 661 } 662 663 // ─── Audio Section ─────────────────────────────────────────────── 664 665 function AudioSection() { 666 const ttsEnabled = useChatStore((s) => s.ttsEnabled) 667 const toggleTts = useChatStore((s) => s.toggleTts) 668 const soundEnabled = useChatStore((s) => s.soundEnabled) 669 const toggleSound = useChatStore((s) => s.toggleSound) 670 671 return ( 672 <div className={panelCardClass('p-4')}> 673 <SectionLabel>Audio</SectionLabel> 674 <div className="flex flex-col gap-2.5"> 675 <div className="flex items-center justify-between"> 676 <span className="text-[12px] text-text-2">Read aloud (TTS)</span> 677 <ToggleSwitch on={ttsEnabled} onChange={toggleTts} /> 678 </div> 679 <div className="flex items-center justify-between"> 680 <span className="text-[12px] text-text-2">Notification sounds</span> 681 <ToggleSwitch on={soundEnabled} onChange={toggleSound} /> 682 </div> 683 </div> 684 </div> 685 ) 686 } 687 688 // ─── Memory Section ────────────────────────────────────────────── 689 690 function MemorySection({ agentId }: { agentId: string }) { 691 const setMemoryAgentFilter = useAppStore((s) => s.setMemoryAgentFilter) 692 const memoryRefreshKey = useAppStore((s) => s.memoryRefreshKey) 693 const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) 694 const navigateTo = useNavigate() 695 const [entries, setEntries] = useState<MemoryEntry[]>([]) 696 const [loading, setLoading] = useState(true) 697 698 useEffect(() => { 699 let cancelled = false 700 searchMemory({ agentId, limit: 5 }) 701 .then((data) => { if (!cancelled) { setEntries(Array.isArray(data) ? data : []); setLoading(false) } }) 702 .catch(() => { if (!cancelled) { setEntries([]); setLoading(false) } }) 703 return () => { cancelled = true } 704 }, [agentId, memoryRefreshKey]) 705 706 const handleViewAll = () => { 707 setMemoryAgentFilter(agentId) 708 navigateTo('memory') 709 setSidebarOpen(true) 710 } 711 712 const tierColor = (category: string) => { 713 if (category.includes('working') || category === 'working') return 'text-amber-300 bg-amber-400/10 border-amber-400/15' 714 if (category.includes('durable') || category === 'durable') return 'text-emerald-300 bg-emerald-400/10 border-emerald-400/15' 715 if (category.includes('archive') || category === 'archive') return 'text-blue-300 bg-blue-400/10 border-blue-400/15' 716 return 'text-text-3/60 bg-white/[0.04] border-white/[0.06]' 717 } 718 719 return ( 720 <div className={panelCardClass('p-4')}> 721 <div className="flex items-center justify-between mb-2"> 722 <SectionLabel>Memory</SectionLabel> 723 {entries.length > 0 && ( 724 <button 725 type="button" 726 onClick={handleViewAll} 727 className="text-[10px] font-600 text-accent-bright/60 hover:text-accent-bright bg-transparent border-none cursor-pointer transition-colors" 728 > 729 View all » 730 </button> 731 )} 732 </div> 733 {loading ? ( 734 <div className="flex flex-col gap-2"> 735 {[0, 1, 2].map((i) => <div key={i} className="h-6 rounded-[8px] bg-white/[0.04] animate-pulse" />)} 736 </div> 737 ) : entries.length === 0 ? ( 738 <p className="text-[12px] text-text-3/40 italic">No memories yet</p> 739 ) : ( 740 <div className="flex flex-col gap-1.5"> 741 {entries.map((entry) => ( 742 <div key={entry.id} className="flex items-start gap-2"> 743 <span className={`shrink-0 px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-wider border ${tierColor(entry.category)}`}> 744 {entry.category} 745 </span> 746 <span className="text-[11px] text-text-3/70 truncate flex-1">{entry.title || entry.content}</span> 747 </div> 748 ))} 749 </div> 750 )} 751 </div> 752 ) 753 } 754 755 // ─── OpenClaw Actions Section ──────────────────────────────────── 756 757 function OpenClawActionsSection({ agent }: { agent: Agent }) { 758 const [dashboardUrl, setDashboardUrl] = useState<string | null>(null) 759 const [syncing, setSyncing] = useState(false) 760 const [syncStatus, setSyncStatus] = useState<string | null>(null) 761 762 useEffect(() => { 763 let cancelled = false 764 api<{ url?: string }>('GET', `/openclaw/dashboard-url?agentId=${encodeURIComponent(agent.id)}`) 765 .then((data) => { if (!cancelled && data.url) setDashboardUrl(data.url) }) 766 .catch(() => {}) 767 return () => { cancelled = true } 768 }, [agent.id]) 769 770 const handleSync = async () => { 771 if (syncing) return 772 const sessionKey = buildOpenClawMainSessionKey(agent.name) 773 if (!sessionKey) return 774 setSyncing(true) 775 setSyncStatus(null) 776 try { 777 const history = await api<unknown>('GET', `/openclaw/history?sessionKey=${encodeURIComponent(sessionKey)}`) 778 await api('POST', '/openclaw/history', history) 779 setSyncStatus('Synced') 780 } catch { 781 setSyncStatus('Sync failed') 782 } finally { 783 setSyncing(false) 784 setTimeout(() => setSyncStatus(null), 3000) 785 } 786 } 787 788 return ( 789 <div className={panelCardClass('p-4')}> 790 <SectionLabel>OpenClaw</SectionLabel> 791 <div className="flex flex-col gap-2"> 792 {dashboardUrl && ( 793 <a 794 href={dashboardUrl} 795 target="_blank" 796 rel="noopener noreferrer" 797 className="flex items-center gap-2 text-[12px] text-accent-bright/70 hover:text-accent-bright transition-colors no-underline" 798 > 799 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0"> 800 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 801 <polyline points="15 3 21 3 21 9" /> 802 <line x1="10" y1="14" x2="21" y2="3" /> 803 </svg> 804 Dashboard 805 </a> 806 )} 807 <button 808 type="button" 809 onClick={() => void handleSync()} 810 disabled={syncing} 811 className="flex items-center gap-2 text-[12px] font-600 text-text-2 hover:text-text bg-transparent border-none cursor-pointer transition-colors disabled:opacity-50 text-left" 812 > 813 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className={`shrink-0 ${syncing ? 'animate-spin' : ''}`}> 814 <polyline points="23 4 23 10 17 10" /> 815 <polyline points="1 20 1 14 7 14" /> 816 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /> 817 </svg> 818 {syncStatus || 'Sync History'} 819 </button> 820 </div> 821 </div> 822 ) 823 } 824 825 // ─── Quick Actions Section ─────────────────────────────────────── 826 827 function QuickActionsSection({ agent, session }: { agent: Agent; session: Session }) { 828 const messages = useChatStore((s) => s.messages) 829 const [draftingSkill, setDraftingSkill] = useState(false) 830 const [launcherOpen, setLauncherOpen] = useState(false) 831 const [activeRuns, setActiveRuns] = useState<Array<{ id: string; title?: string; status: string }>>([]) 832 833 const loadRuns = useCallback(() => { 834 api<Array<{ id: string; title?: string; status: string }>>('GET', `/protocols/runs?sessionId=${encodeURIComponent(session.id)}&limit=6`) 835 .then((runs) => { if (Array.isArray(runs)) setActiveRuns(runs.filter((r) => r.status === 'running' || r.status === 'paused')) }) 836 .catch(() => {}) 837 }, [session.id]) 838 839 useEffect(() => { loadRuns() }, [loadRuns]) 840 useWs('protocol_runs', loadRuns) 841 842 const handleDraftSkill = async () => { 843 if (draftingSkill) return 844 setDraftingSkill(true) 845 try { 846 await api('POST', '/skill-suggestions', { sessionId: session.id }) 847 toast.success('Skill draft created') 848 } catch (err: unknown) { 849 toast.error(err instanceof Error ? err.message : 'Failed to draft skill') 850 } finally { 851 setDraftingSkill(false) 852 } 853 } 854 855 return ( 856 <div className={panelCardClass('p-4')}> 857 <SectionLabel>Quick Actions</SectionLabel> 858 <div className="flex flex-col gap-2"> 859 {messages.length > 0 && ( 860 <button 861 type="button" 862 onClick={() => void handleDraftSkill()} 863 disabled={draftingSkill} 864 className="flex items-center gap-2 text-[12px] font-600 text-text-2 hover:text-text bg-transparent border-none cursor-pointer transition-colors disabled:opacity-50 text-left" 865 > 866 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className={`shrink-0 ${draftingSkill ? 'animate-pulse' : ''}`}> 867 <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /> 868 </svg> 869 {draftingSkill ? 'Drafting...' : 'Draft Skill'} 870 </button> 871 )} 872 <button 873 type="button" 874 onClick={() => setLauncherOpen(true)} 875 className="flex items-center gap-2 text-[12px] font-600 text-text-2 hover:text-text bg-transparent border-none cursor-pointer transition-colors text-left" 876 > 877 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0"> 878 <rect x="3" y="3" width="18" height="18" rx="2" /> 879 <path d="M3 9h18" /> 880 <path d="M9 21V9" /> 881 </svg> 882 Start Structured Session 883 </button> 884 {activeRuns.map((run) => ( 885 <span key={run.id} className="inline-flex items-center gap-1.5 px-2 py-1 rounded-[7px] border border-emerald-400/15 bg-emerald-400/10 text-[10px] font-600 text-emerald-300"> 886 <StatusDot status="online" size="sm" /> 887 {run.title || 'Active session'} 888 </span> 889 ))} 890 </div> 891 <StructuredSessionLauncher 892 open={launcherOpen} 893 onClose={() => setLauncherOpen(false)} 894 initialContext={{ sessionId: session.id, participantAgentIds: [agent.id] }} 895 /> 896 </div> 897 ) 898 } 899 900 // ─── Sessions Section ──────────────────────────────────────────── 901 902 function SessionsSection({ agent }: { agent: Agent }) { 903 const sessions = useAppStore((s) => s.sessions) 904 const connectors = useAppStore((s) => s.connectors) 905 const agents = useAppStore((s) => s.agents) 906 const setCurrentAgent = useAppStore((s) => s.setCurrentAgent) 907 const setActiveSessionIdOverride = useAppStore((s) => s.setActiveSessionIdOverride) 908 const setInspectorOpen = useAppStore((s) => s.setInspectorOpen) 909 910 const agentSessions = useMemo(() => { 911 return sortSessionsNewestFirst(Object.values(sessions).filter((s) => s.agentId === agent.id)) 912 }, [sessions, agent.id]) 913 914 if (agentSessions.length === 0) return null 915 916 return ( 917 <div className={panelCardClass('p-4')}> 918 <SectionLabel>Sessions ({agentSessions.length})</SectionLabel> 919 <div className="flex flex-col gap-1.5"> 920 {agentSessions.map((s) => { 921 const connector = getSessionConnector(s, connectors) 922 const delegatedByAgentId = (s as unknown as Record<string, unknown>).delegatedByAgentId as string | undefined 923 const delegatedBy = delegatedByAgentId ? agents[delegatedByAgentId] : null 924 return ( 925 <button 926 key={s.id} 927 type="button" 928 onClick={() => { 929 void setCurrentAgent(agent.id).then(() => { 930 setActiveSessionIdOverride(s.id) 931 setInspectorOpen(false) 932 if (typeof window !== 'undefined') { 933 window.dispatchEvent(new CustomEvent('swarmclaw:scroll-bottom')) 934 } 935 }).catch(() => {}) 936 }} 937 className="flex items-center gap-2 w-full py-1.5 px-2 rounded-[8px] bg-transparent border-none cursor-pointer hover:bg-white/[0.04] transition-colors text-left" 938 > 939 {connector ? ( 940 <ConnectorPlatformIcon platform={connector.platform} size={14} /> 941 ) : ( 942 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/40 shrink-0"> 943 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" /> 944 </svg> 945 )} 946 <span className="text-[12px] text-text-2 truncate flex-1">{s.name}</span> 947 {delegatedBy && ( 948 <span className="text-[9px] text-amber-300/60 font-600 shrink-0">from {delegatedBy.name}</span> 949 )} 950 <StatusDot status={s.active ? 'online' : 'idle'} size="sm" /> 951 </button> 952 ) 953 })} 954 </div> 955 </div> 956 ) 957 } 958 959 // ─── Sticky Footer ─────────────────────────────────────────────── 960 961 function StickyFooter({ agent, isMainChat, onEditAgent, onDuplicateAgent, onClearHistory, onDeleteAgent, onDeleteChat }: { 962 agent: Agent 963 isMainChat?: boolean 964 onEditAgent?: () => void 965 onDuplicateAgent?: () => void 966 onClearHistory?: () => void 967 onDeleteAgent?: () => void 968 onDeleteChat?: () => void 969 }) { 970 const loadAgents = useAppStore((s) => s.loadAgents) 971 const loadSessions = useAppStore((s) => s.loadSessions) 972 const [menuOpen, setMenuOpen] = useState(false) 973 const [availabilitySaving, setAvailabilitySaving] = useState(false) 974 const menuRef = useRef<HTMLDivElement>(null) 975 976 useEffect(() => { 977 if (!menuOpen) return 978 const handler = (e: MouseEvent) => { 979 if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false) 980 } 981 document.addEventListener('mousedown', handler) 982 return () => document.removeEventListener('mousedown', handler) 983 }, [menuOpen]) 984 985 const handleToggleAvailability = async () => { 986 if (availabilitySaving) return 987 setAvailabilitySaving(true) 988 try { 989 const nextDisabled = agent.disabled !== true 990 await api('PUT', `/agents/${agent.id}`, { disabled: nextDisabled }) 991 await Promise.all([loadAgents(), loadSessions()]) 992 toast.success(nextDisabled ? `${agent.name} disabled` : `${agent.name} enabled`) 993 } catch (err: unknown) { 994 toast.error(err instanceof Error ? err.message : 'Failed to update agent availability') 995 } finally { 996 setAvailabilitySaving(false) 997 } 998 } 999 1000 return ( 1001 <div className="shrink-0 border-t border-white/[0.06] px-4 py-3 bg-black/[0.08]"> 1002 <div className="flex items-center gap-2"> 1003 {onEditAgent && ( 1004 <button 1005 onClick={onEditAgent} 1006 className="flex-1 px-3 py-2 rounded-[10px] text-[12px] font-700 text-accent-bright bg-accent-soft/50 border border-accent-bright/10 cursor-pointer transition-all hover:bg-accent-soft text-center" 1007 style={{ fontFamily: 'inherit' }} 1008 > 1009 Edit Agent 1010 </button> 1011 )} 1012 {onDuplicateAgent && ( 1013 <button 1014 onClick={onDuplicateAgent} 1015 className="flex-1 px-3 py-2 rounded-[10px] text-[12px] font-700 text-sky-300 bg-sky-400/[0.06] border border-sky-400/[0.1] cursor-pointer transition-all hover:bg-sky-400/[0.1] text-center" 1016 style={{ fontFamily: 'inherit' }} 1017 > 1018 Duplicate 1019 </button> 1020 )} 1021 <div className="relative" ref={menuRef}> 1022 <button 1023 onClick={() => setMenuOpen((v) => !v)} 1024 className="p-2 rounded-[10px] border border-white/[0.06] bg-white/[0.03] text-text-3/60 hover:text-text-2 hover:bg-white/[0.06] cursor-pointer transition-all" 1025 style={{ fontFamily: 'inherit' }} 1026 > 1027 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 1028 <circle cx="12" cy="6" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="18" r="1" /> 1029 </svg> 1030 </button> 1031 {menuOpen && ( 1032 <div className="absolute bottom-full right-0 mb-1.5 py-1 rounded-[10px] border border-white/[0.08] bg-bg/95 backdrop-blur-md shadow-xl z-50 min-w-[160px]" 1033 style={{ animation: 'fade-in 0.15s ease' }}> 1034 <button 1035 onClick={() => { setMenuOpen(false); void handleToggleAvailability() }} 1036 disabled={availabilitySaving} 1037 className="w-full text-left px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.06] cursor-pointer border-none transition-colors disabled:opacity-50" 1038 > 1039 {agent.disabled === true ? 'Enable Agent' : 'Disable Agent'} 1040 </button> 1041 {onClearHistory && ( 1042 <button 1043 onClick={() => { setMenuOpen(false); onClearHistory() }} 1044 className="w-full text-left px-3 py-2 text-[12px] font-600 text-red-400/80 hover:bg-red-400/[0.06] hover:text-red-400 cursor-pointer border-none transition-colors" 1045 > 1046 Clear History 1047 </button> 1048 )} 1049 {onDeleteAgent && !isMainChat && ( 1050 <button 1051 onClick={() => { setMenuOpen(false); onDeleteAgent() }} 1052 className="w-full text-left px-3 py-2 text-[12px] font-600 text-red-400/80 hover:bg-red-400/[0.06] hover:text-red-400 cursor-pointer border-none transition-colors" 1053 > 1054 Delete Agent 1055 </button> 1056 )} 1057 {onDeleteChat && !isMainChat && ( 1058 <button 1059 onClick={() => { setMenuOpen(false); onDeleteChat() }} 1060 className="w-full text-left px-3 py-2 text-[12px] font-600 text-red-400/80 hover:bg-red-400/[0.06] hover:text-red-400 cursor-pointer border-none transition-colors" 1061 > 1062 Delete Chat 1063 </button> 1064 )} 1065 </div> 1066 )} 1067 </div> 1068 </div> 1069 </div> 1070 ) 1071 } 1072 1073 // ─── Config Tab ────────────────────────────────────────────────── 1074 1075 function ConfigTab({ agent }: { agent: Agent }) { 1076 const isOpenClaw = agent.provider === 'openclaw' 1077 const schedules = useAppStore((s) => s.schedules) 1078 const agentSchedules = Object.values(schedules).filter((s) => s.agentId === agent.id) 1079 const [executeOpen, setExecuteOpen] = useState(false) 1080 const [browserSandboxOpen, setBrowserSandboxOpen] = useState(false) 1081 const [openclawOpen, setOpenclawOpen] = useState(false) 1082 const [detailsOpen, setDetailsOpen] = useState(false) 1083 1084 return ( 1085 <div className="p-4 flex flex-col gap-4"> 1086 {/* Skills section */} 1087 <div className={panelCardClass('p-4')}> 1088 <SectionLabel>Skills</SectionLabel> 1089 {isOpenClaw ? ( 1090 <OpenClawSkillsPanel 1091 agentId={agent.id} 1092 initialMode={agent.openclawSkillMode} 1093 initialAllowed={agent.openclawAllowedSkills} 1094 /> 1095 ) : ( 1096 <p className="text-[12px] text-text-3/50">Skills are configured in the agent editor.</p> 1097 )} 1098 </div> 1099 1100 {/* Automations section */} 1101 <AutomationsSection schedules={agentSchedules} agent={agent} /> 1102 1103 {/* Execute (collapsible) */} 1104 <CollapsibleSection title="Execute" open={executeOpen} onToggle={() => setExecuteOpen((v) => !v)}> 1105 <ExecuteToolConfigSection agent={agent} /> 1106 </CollapsibleSection> 1107 1108 {/* Browser sandbox (collapsible) */} 1109 <CollapsibleSection title="Browser Sandbox" open={browserSandboxOpen} onToggle={() => setBrowserSandboxOpen((v) => !v)}> 1110 <BrowserSandboxSection agent={agent} /> 1111 </CollapsibleSection> 1112 1113 {/* OpenClaw settings (collapsible, OpenClaw only) */} 1114 {isOpenClaw && ( 1115 <CollapsibleSection title="OpenClaw Settings" open={openclawOpen} onToggle={() => setOpenclawOpen((v) => !v)}> 1116 <div className="flex flex-col gap-4"> 1117 <PermissionPresetSelector agentId={agent.id} /> 1118 <div className="border-t border-white/[0.06] pt-4"> 1119 <ExecConfigPanel agentId={agent.id} /> 1120 </div> 1121 <div className="border-t border-white/[0.06] pt-4"> 1122 <SandboxEnvPanel /> 1123 </div> 1124 </div> 1125 </CollapsibleSection> 1126 )} 1127 1128 {/* Details (collapsible) */} 1129 <CollapsibleSection title="Details" open={detailsOpen} onToggle={() => setDetailsOpen((v) => !v)}> 1130 <div className="flex flex-col gap-3"> 1131 {agent.thinkingLevel && ( 1132 <div> 1133 <label className="text-[10px] text-text-3/50 block mb-1">Thinking Level</label> 1134 <p className="text-[12px] text-text-2 capitalize">{agent.thinkingLevel}</p> 1135 </div> 1136 )} 1137 <div> 1138 <label className="text-[10px] text-text-3/50 block mb-1">Agent ID</label> 1139 <p className="text-[12px] text-text-3 font-mono select-all break-all">{agent.id}</p> 1140 </div> 1141 <div> 1142 <label className="text-[10px] text-text-3/50 block mb-1">Created</label> 1143 <p className="text-[12px] text-text-3">{new Date(agent.createdAt).toLocaleString()}</p> 1144 </div> 1145 <div> 1146 <label className="text-[10px] text-text-3/50 block mb-1">Updated</label> 1147 <p className="text-[12px] text-text-3">{new Date(agent.updatedAt).toLocaleString()}</p> 1148 </div> 1149 </div> 1150 </CollapsibleSection> 1151 </div> 1152 ) 1153 } 1154 1155 // ─── Collapsible Section ───────────────────────────────────────── 1156 1157 function CollapsibleSection({ title, open, onToggle, children }: { title: string; open: boolean; onToggle: () => void; children: ReactNode }) { 1158 return ( 1159 <div className={panelCardClass('')}> 1160 <button 1161 type="button" 1162 onClick={onToggle} 1163 className="flex items-center justify-between w-full px-4 py-3 bg-transparent border-none cursor-pointer text-left" 1164 > 1165 <span className="text-[11px] font-700 uppercase tracking-[0.16em] text-text-3/45">{title}</span> 1166 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className={`text-text-3/40 transition-transform ${open ? 'rotate-180' : ''}`}> 1167 <polyline points="6 9 12 15 18 9" /> 1168 </svg> 1169 </button> 1170 {open && ( 1171 <div className="px-4 pb-4 border-t border-white/[0.04]"> 1172 {children} 1173 </div> 1174 )} 1175 </div> 1176 ) 1177 } 1178 1179 // ─── Automations Section ───────────────────────────────────────── 1180 1181 function AutomationsSection({ schedules, agent }: { schedules: Array<{ id: string; name: string; status: string; cron?: string; scheduleType: string }>; agent: Agent }) { 1182 const isOpenClaw = agent.provider === 'openclaw' 1183 const [gatewayCrons, setGatewayCrons] = useState<Array<{ id: string; name: string; enabled: boolean; schedule?: { kind: string; value: string }; state?: { nextRun?: string; lastRun?: string } }>>([]) 1184 const [cronLoading, setCronLoading] = useState(false) 1185 const [showCronForm, setShowCronForm] = useState(false) 1186 1187 const loadCrons = useCallback(async () => { 1188 if (!isOpenClaw) return 1189 setCronLoading(true) 1190 try { 1191 const crons = await api<Array<{ id: string; name: string; enabled: boolean; schedule?: { kind: string; value: string }; state?: { nextRun?: string; lastRun?: string } }>>('GET', '/openclaw/cron') 1192 setGatewayCrons(crons.filter((c) => (c as Record<string, unknown>).agentId === agent.id)) 1193 } catch { /* ignore */ } 1194 finally { setCronLoading(false) } 1195 }, [isOpenClaw, agent.id]) 1196 1197 useEffect(() => { loadCrons() }, [loadCrons]) 1198 1199 const handleRunCron = async (id: string) => { 1200 try { await api('POST', '/openclaw/cron', { action: 'run', id }) } catch { /* ignore */ } 1201 } 1202 1203 const handleRemoveCron = async (id: string) => { 1204 try { 1205 await api('POST', '/openclaw/cron', { action: 'remove', id }) 1206 setGatewayCrons((prev) => prev.filter((c) => c.id !== id)) 1207 } catch { /* ignore */ } 1208 } 1209 1210 return ( 1211 <div className={panelCardClass('p-4')}> 1212 <SectionLabel>Automations</SectionLabel> 1213 <div className="flex flex-col gap-3"> 1214 {schedules.map((s) => ( 1215 <div key={s.id} className="rounded-[10px] border border-white/[0.04] bg-black/[0.08] py-2 px-3"> 1216 <div className="flex items-center gap-2"> 1217 <span className="text-[12px] font-600 text-text truncate flex-1">{s.name}</span> 1218 <span className={`text-[10px] font-600 uppercase tracking-wider px-1.5 py-0.5 rounded-[4px] 1219 ${s.status === 'active' ? 'text-emerald-400 bg-emerald-400/[0.08]' : 'text-text-3/50 bg-white/[0.02]'}`}> 1220 {s.status} 1221 </span> 1222 </div> 1223 <div className="text-[11px] text-text-3/50 mt-1"> 1224 {s.scheduleType}{s.cron ? ` (${s.cron})` : ''} 1225 </div> 1226 </div> 1227 ))} 1228 1229 {isOpenClaw && ( 1230 <> 1231 {cronLoading && <div className="text-[12px] text-text-3/50">Loading gateway crons...</div>} 1232 {gatewayCrons.map((c) => ( 1233 <div key={c.id} className="rounded-[10px] border border-white/[0.04] bg-black/[0.08] py-2 px-3"> 1234 <div className="flex items-center gap-2"> 1235 <span className="text-[12px] font-600 text-text truncate flex-1">{c.name}</span> 1236 <span className={`text-[10px] font-600 uppercase tracking-wider px-1.5 py-0.5 rounded-[4px] 1237 ${c.enabled ? 'text-emerald-400 bg-emerald-400/[0.08]' : 'text-text-3/50 bg-white/[0.02]'}`}> 1238 {c.enabled ? 'active' : 'disabled'} 1239 </span> 1240 </div> 1241 <div className="text-[11px] text-text-3/50 mt-1"> 1242 {c.schedule?.kind} {c.schedule?.value} 1243 {c.state?.nextRun && ` — next: ${c.state.nextRun}`} 1244 </div> 1245 <div className="flex gap-2 mt-2"> 1246 <button onClick={() => void handleRunCron(c.id)} className="text-[10px] text-accent-bright bg-transparent border-none cursor-pointer hover:underline">Run Now</button> 1247 <button onClick={() => void handleRemoveCron(c.id)} className="text-[10px] text-red-400/70 bg-transparent border-none cursor-pointer hover:underline">Delete</button> 1248 </div> 1249 </div> 1250 ))} 1251 {showCronForm ? ( 1252 <CronJobForm agentId={agent.id} onSaved={() => { setShowCronForm(false); void loadCrons() }} onCancel={() => setShowCronForm(false)} /> 1253 ) : ( 1254 <button 1255 onClick={() => setShowCronForm(true)} 1256 className="self-start px-3 py-1.5 rounded-[8px] border border-dashed border-white/[0.08] bg-transparent text-text-3 text-[12px] font-600 cursor-pointer transition-all hover:border-white/[0.15] hover:text-text-2" 1257 style={{ fontFamily: 'inherit' }} 1258 > 1259 + Add Cron Job 1260 </button> 1261 )} 1262 </> 1263 )} 1264 1265 {!schedules.length && !gatewayCrons.length && !cronLoading && !showCronForm && ( 1266 <p className="text-[12px] text-text-3/50">No automations linked to this agent.</p> 1267 )} 1268 </div> 1269 </div> 1270 ) 1271 } 1272 1273 // ─── Execute Config Section ────────────────────────────────────── 1274 1275 function ExecuteToolConfigSection({ agent }: { agent: Agent }) { 1276 const loadAgents = useAppStore((s) => s.loadAgents) 1277 const [saving, setSaving] = useState(false) 1278 const config = normalizeAgentExecuteConfig(agent.executeConfig) 1279 1280 const update = useCallback(async (patch: Partial<NonNullable<typeof agent.executeConfig>>) => { 1281 setSaving(true) 1282 try { 1283 const next = { 1284 ...config, 1285 ...patch, 1286 network: { 1287 ...(config.network || {}), 1288 ...((patch.network as Record<string, unknown> | undefined) || {}), 1289 }, 1290 } 1291 await api('PUT', `/agents/${agent.id}`, { executeConfig: next }) 1292 await loadAgents() 1293 } catch (err: unknown) { 1294 toast.error(err instanceof Error ? err.message : 'Failed to update execute config') 1295 } finally { 1296 setSaving(false) 1297 } 1298 // eslint-disable-next-line react-hooks/exhaustive-deps 1299 }, [agent.id, config]) 1300 1301 return ( 1302 <div className="pt-3 flex flex-col gap-3"> 1303 <div className="text-[11px] text-text-3/60"> 1304 `execute` uses just-bash in sandbox mode by default. Host mode is explicit and required for persistent writes. 1305 </div> 1306 <div> 1307 <label className="text-[10px] text-text-3/50 block mb-1">Backend</label> 1308 <select 1309 value={config.backend || 'sandbox'} 1310 onChange={(e) => void update({ backend: e.target.value as 'sandbox' | 'host' })} 1311 disabled={saving} 1312 className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30" 1313 > 1314 <option value="sandbox">sandbox (just-bash)</option> 1315 <option value="host">host (real bash)</option> 1316 </select> 1317 </div> 1318 <div className="flex items-center justify-between"> 1319 <span className="text-[11px] text-text-3/60">Allow network in sandbox mode</span> 1320 <ToggleSwitch 1321 on={config.network?.enabled !== false} 1322 onChange={() => void update({ network: { ...(config.network || {}), enabled: config.network?.enabled === false } })} 1323 disabled={saving} 1324 /> 1325 </div> 1326 <div> 1327 <label className="text-[10px] text-text-3/50 block mb-1">Timeout (seconds)</label> 1328 <input 1329 type="number" 1330 defaultValue={config.timeout || 30} 1331 min={1} 1332 max={300} 1333 onBlur={(e) => void update({ timeout: Math.max(1, Math.min(300, Number(e.target.value) || 30)) })} 1334 className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text font-mono outline-none focus:border-accent-bright/30" 1335 /> 1336 </div> 1337 <div className="text-[11px] text-text-3/50"> 1338 `shell` remains the host command/process tool. Use `execute` for sandboxed one-shot scripts. 1339 </div> 1340 </div> 1341 ) 1342 } 1343 1344 // ─── Browser Sandbox Section ───────────────────────────────────── 1345 1346 function BrowserSandboxSection({ agent }: { agent: Agent }) { 1347 const loadAgents = useAppStore((s) => s.loadAgents) 1348 const [saving, setSaving] = useState(false) 1349 const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null) 1350 const config = normalizeAgentSandboxConfig(agent.sandboxConfig) 1351 const browserEnabled = config.enabled && config.browser?.enabled !== false 1352 1353 useEffect(() => { 1354 api<{ docker?: { available: boolean; version?: string | null } }>('GET', '/setup/doctor') 1355 .then((data) => setDockerAvailable(data?.docker?.available ?? false)) 1356 .catch(() => setDockerAvailable(false)) 1357 }, []) 1358 1359 const update = useCallback(async (patch: Partial<NonNullable<typeof agent.sandboxConfig>>) => { 1360 setSaving(true) 1361 try { 1362 const next = { 1363 ...config, 1364 ...patch, 1365 browser: patch.browser === null 1366 ? null 1367 : { 1368 ...(config.browser || {}), 1369 ...((patch.browser as Record<string, unknown> | undefined) || {}), 1370 }, 1371 } 1372 await api('PUT', `/agents/${agent.id}`, { sandboxConfig: next }) 1373 await loadAgents() 1374 } catch (err: unknown) { 1375 toast.error(err instanceof Error ? err.message : 'Failed to update sandbox config') 1376 } finally { 1377 setSaving(false) 1378 } 1379 // eslint-disable-next-line react-hooks/exhaustive-deps 1380 }, [agent.id, config]) 1381 1382 return ( 1383 <div className="pt-3"> 1384 <div className="flex items-center justify-between mb-3"> 1385 <span className="text-[12px] text-text-2">Use Docker browser sandbox</span> 1386 <ToggleSwitch 1387 on={browserEnabled} 1388 onChange={() => void update({ 1389 enabled: !browserEnabled, 1390 browser: { 1391 ...(config.browser || {}), 1392 enabled: !browserEnabled, 1393 }, 1394 })} 1395 disabled={saving} 1396 /> 1397 </div> 1398 {dockerAvailable === false && ( 1399 <div className="text-[11px] text-amber-400/80 bg-amber-400/[0.06] rounded-[8px] px-2.5 py-2 mb-3 border border-amber-400/10"> 1400 Docker is not detected. Browser automation will use the host Playwright runtime. 1401 </div> 1402 )} 1403 {dockerAvailable === true && ( 1404 <div className="text-[11px] text-emerald-400/70 mb-3 flex items-center gap-1.5"> 1405 <StatusDot status="online" size="sm" /> Docker available for browser sandboxing 1406 </div> 1407 )} 1408 {browserEnabled && ( 1409 <div className="flex flex-col gap-2.5 mt-1"> 1410 <div> 1411 <label className="text-[10px] text-text-3/50 block mb-1">Scope</label> 1412 <select 1413 defaultValue={config.scope || 'session'} 1414 onChange={(e) => void update({ scope: e.target.value as 'session' | 'agent' })} 1415 className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30" 1416 > 1417 <option value="session">session</option> 1418 <option value="agent">agent</option> 1419 </select> 1420 </div> 1421 <div> 1422 <label className="text-[10px] text-text-3/50 block mb-1">Mode</label> 1423 <select 1424 defaultValue={config.mode === 'non-main' ? 'non-main' : 'all'} 1425 onChange={(e) => void update({ mode: e.target.value as 'all' | 'non-main' })} 1426 className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30" 1427 > 1428 <option value="all">all sessions</option> 1429 <option value="non-main">non-main sessions only</option> 1430 </select> 1431 </div> 1432 <div> 1433 <label className="text-[10px] text-text-3/50 block mb-1">Workspace access</label> 1434 <select 1435 defaultValue={config.workspaceAccess || 'rw'} 1436 onChange={(e) => void update({ workspaceAccess: e.target.value as 'ro' | 'rw' })} 1437 className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30" 1438 > 1439 <option value="rw">read/write</option> 1440 <option value="ro">read-only</option> 1441 </select> 1442 </div> 1443 <div> 1444 <label className="text-[10px] text-text-3/50 block mb-1">Browser network</label> 1445 <select 1446 defaultValue={config.browser?.network || 'bridge'} 1447 onChange={(e) => void update({ browser: { ...(config.browser || {}), network: e.target.value as 'none' | 'bridge' } })} 1448 className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30" 1449 > 1450 <option value="none">none (isolated)</option> 1451 <option value="bridge">bridge (internet access)</option> 1452 </select> 1453 </div> 1454 <div className="flex items-center justify-between"> 1455 <span className="text-[11px] text-text-3/60">Headless browser</span> 1456 <ToggleSwitch 1457 on={config.browser?.headless !== false} 1458 onChange={() => void update({ browser: { ...(config.browser || {}), headless: config.browser?.headless === false } })} 1459 disabled={saving} 1460 /> 1461 </div> 1462 <div className="flex items-center justify-between"> 1463 <span className="text-[11px] text-text-3/60">Enable noVNC observer</span> 1464 <ToggleSwitch 1465 on={config.browser?.enableNoVnc !== false} 1466 onChange={() => void update({ browser: { ...(config.browser || {}), enableNoVnc: config.browser?.enableNoVnc === false } })} 1467 disabled={saving} 1468 /> 1469 </div> 1470 <div className="flex items-center justify-between"> 1471 <span className="text-[11px] text-text-3/60">Mount uploads into sandbox browser</span> 1472 <ToggleSwitch 1473 on={config.browser?.mountUploads !== false} 1474 onChange={() => void update({ browser: { ...(config.browser || {}), mountUploads: config.browser?.mountUploads === false } })} 1475 disabled={saving} 1476 /> 1477 </div> 1478 </div> 1479 )} 1480 </div> 1481 ) 1482 }