/ src / components / agents / inspector-panel.tsx
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 &raquo;
 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  }