@flame0510/project-aether 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/README.md +150 -0
  2. package/agent-templates/README.md +37 -0
  3. package/agent-templates/argus/AGENTS.md +52 -0
  4. package/agent-templates/argus/IDENTITY.md +7 -0
  5. package/agent-templates/argus/MEMORY.md +9 -0
  6. package/agent-templates/argus/SOUL.md +24 -0
  7. package/agent-templates/argus/TOOLS.md +8 -0
  8. package/agent-templates/atlas/AGENTS.md +47 -0
  9. package/agent-templates/atlas/HEARTBEAT.md +10 -0
  10. package/agent-templates/atlas/IDENTITY.md +6 -0
  11. package/agent-templates/atlas/MEMORY.md +9 -0
  12. package/agent-templates/atlas/SOUL.md +21 -0
  13. package/agent-templates/atlas/TOOLS.md +4 -0
  14. package/agent-templates/base-image/.trigger +0 -0
  15. package/agent-templates/base-image/Dockerfile +90 -0
  16. package/agent-templates/base-image/entrypoint.sh +37 -0
  17. package/agent-templates/custom/AGENTS.md +29 -0
  18. package/agent-templates/custom/BOOTSTRAP.md +56 -0
  19. package/agent-templates/custom/MEMORY.md +8 -0
  20. package/agent-templates/prometheus/AGENTS.md +37 -0
  21. package/agent-templates/prometheus/IDENTITY.md +6 -0
  22. package/agent-templates/prometheus/MEMORY.md +9 -0
  23. package/agent-templates/prometheus/SOUL.md +21 -0
  24. package/agent-templates/prometheus/TOOLS.md +4 -0
  25. package/app/agents/ChannelManager.tsx +465 -0
  26. package/app/agents/ImageDownloadBanner.tsx +159 -0
  27. package/app/agents/PageClient.tsx +1285 -0
  28. package/app/agents/create/PageClient.tsx +437 -0
  29. package/app/agents/create/page.tsx +126 -0
  30. package/app/agents/loading.tsx +14 -0
  31. package/app/agents/page.tsx +5 -0
  32. package/app/api/agents/[id]/backup/route.ts +169 -0
  33. package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
  34. package/app/api/agents/[id]/channels/route.ts +14 -0
  35. package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
  36. package/app/api/agents/[id]/lifecycle/route.ts +94 -0
  37. package/app/api/agents/[id]/recreate/route.ts +199 -0
  38. package/app/api/agents/[id]/restart/route.ts +33 -0
  39. package/app/api/agents/[id]/restore/route.ts +81 -0
  40. package/app/api/agents/[id]/route.ts +280 -0
  41. package/app/api/agents/channels-summary/route.ts +39 -0
  42. package/app/api/agents/config-json/route.ts +58 -0
  43. package/app/api/agents/create/route.ts +488 -0
  44. package/app/api/agents/download-image/route.ts +32 -0
  45. package/app/api/agents/image-status/route.ts +126 -0
  46. package/app/api/agents/route.ts +213 -0
  47. package/app/api/agents/token/route.ts +94 -0
  48. package/app/api/agents-active/route.ts +190 -0
  49. package/app/api/agents-config/route.ts +629 -0
  50. package/app/api/alerts/smoke/route.ts +26 -0
  51. package/app/api/aliases/add/route.ts +28 -0
  52. package/app/api/aliases/remove/route.ts +27 -0
  53. package/app/api/assistant/route.ts +204 -0
  54. package/app/api/auth/check/route.ts +38 -0
  55. package/app/api/auth/login/route.ts +85 -0
  56. package/app/api/auth/logout/route.ts +7 -0
  57. package/app/api/auth/status/route.ts +33 -0
  58. package/app/api/config/env/route.ts +132 -0
  59. package/app/api/config/restart/route.ts +26 -0
  60. package/app/api/containers/route.ts +75 -0
  61. package/app/api/cost-override/route.ts +46 -0
  62. package/app/api/costs/route.ts +84 -0
  63. package/app/api/credentials/[id]/reveal/route.ts +21 -0
  64. package/app/api/credentials/[id]/route.ts +52 -0
  65. package/app/api/credentials/[id]/sync/route.ts +58 -0
  66. package/app/api/credentials/detect/route.ts +246 -0
  67. package/app/api/credentials/route.ts +33 -0
  68. package/app/api/crons/[id]/route.ts +69 -0
  69. package/app/api/crons/route.ts +12 -0
  70. package/app/api/crons/runs/route.ts +31 -0
  71. package/app/api/debug/route.ts +11 -0
  72. package/app/api/envcheck/route.ts +18 -0
  73. package/app/api/events/route.ts +24 -0
  74. package/app/api/gateway/agent/route.ts +154 -0
  75. package/app/api/gateway/provider/balance/route.ts +38 -0
  76. package/app/api/gateway/provider/keys.ts +38 -0
  77. package/app/api/gateway/provider/oauth/route.ts +304 -0
  78. package/app/api/gateway/provider/route.ts +209 -0
  79. package/app/api/gateway/route.ts +223 -0
  80. package/app/api/gateway/sync.ts +189 -0
  81. package/app/api/lineage/route.ts +54 -0
  82. package/app/api/memory-context/route.ts +15 -0
  83. package/app/api/metrics/route.ts +35 -0
  84. package/app/api/models/route.ts +77 -0
  85. package/app/api/plugins/route.js +37 -0
  86. package/app/api/provider/upstream.ts +142 -0
  87. package/app/api/provider/v1/chat/completions/route.ts +146 -0
  88. package/app/api/provider/v1/models/route.ts +167 -0
  89. package/app/api/provider-usage/route.ts +629 -0
  90. package/app/api/session/route.ts +39 -0
  91. package/app/api/sessions/[id]/route.ts +38 -0
  92. package/app/api/sessions/route.ts +23 -0
  93. package/app/api/setup/agent-image/route.ts +102 -0
  94. package/app/api/setup/password/route.ts +111 -0
  95. package/app/api/setup/restart/route.ts +76 -0
  96. package/app/api/skills/delete/route.js +105 -0
  97. package/app/api/skills/promote/route.js +129 -0
  98. package/app/api/skills/route.js +203 -0
  99. package/app/api/skills/save/route.js +142 -0
  100. package/app/api/stats/route.ts +34 -0
  101. package/app/api/stats-since/route.ts +20 -0
  102. package/app/api/stream/route.ts +70 -0
  103. package/app/api/system-health/route.ts +233 -0
  104. package/app/api/tool-calls/route.ts +24 -0
  105. package/app/api/tools-config/route.ts +163 -0
  106. package/app/api/update-check/route.ts +127 -0
  107. package/app/api/version/route.ts +25 -0
  108. package/app/api/wizard/complete/route.ts +32 -0
  109. package/app/api/wizard/reset/route.ts +28 -0
  110. package/app/api/wizard/status/route.ts +28 -0
  111. package/app/api/workspace/route.ts +392 -0
  112. package/app/api/workspace/stream/route.ts +136 -0
  113. package/app/components/AppShell.tsx +33 -0
  114. package/app/components/AuthGuard.tsx +91 -0
  115. package/app/components/CostBreakdown.tsx +37 -0
  116. package/app/components/DashboardHeader.tsx +73 -0
  117. package/app/components/DashboardLayout.tsx +76 -0
  118. package/app/components/DashboardToolbar.tsx +61 -0
  119. package/app/components/FloatingActions.tsx +33 -0
  120. package/app/components/Icons.tsx +78 -0
  121. package/app/components/LineageGraphPage.tsx +142 -0
  122. package/app/components/LiveFeed.tsx +48 -0
  123. package/app/components/MobileBottomNav.tsx +84 -0
  124. package/app/components/ModelPickerModal.tsx +94 -0
  125. package/app/components/PasswordInput.tsx +86 -0
  126. package/app/components/PulseChat.tsx +362 -0
  127. package/app/components/Rev4aLoader.tsx +48 -0
  128. package/app/components/SessionDrawer.tsx +263 -0
  129. package/app/components/SessionTopology.tsx +289 -0
  130. package/app/components/Sidebar.tsx +294 -0
  131. package/app/components/Skeleton.tsx +133 -0
  132. package/app/components/SystemCockpit.tsx +169 -0
  133. package/app/components/VersionBanner.tsx +105 -0
  134. package/app/components/WizardButton.tsx +30 -0
  135. package/app/components/ui/Badge.tsx +30 -0
  136. package/app/components/ui/Button.tsx +59 -0
  137. package/app/components/ui/ConfirmModal.tsx +46 -0
  138. package/app/components/ui/FilterBar.tsx +33 -0
  139. package/app/components/ui/Input.tsx +25 -0
  140. package/app/components/ui/ItemList.tsx +83 -0
  141. package/app/components/ui/LoadingSpinner.tsx +30 -0
  142. package/app/components/ui/Metric.tsx +22 -0
  143. package/app/components/ui/Modal.tsx +42 -0
  144. package/app/components/ui/Page.tsx +18 -0
  145. package/app/components/ui/Pill.tsx +6 -0
  146. package/app/components/ui/PropertyList.tsx +24 -0
  147. package/app/components/ui/Select.tsx +26 -0
  148. package/app/components/ui/StatusCard.tsx +32 -0
  149. package/app/components/ui/Surface.tsx +14 -0
  150. package/app/components/ui/Tabs.tsx +55 -0
  151. package/app/components/ui/TemplateOption.tsx +59 -0
  152. package/app/components/ui/Toast.tsx +44 -0
  153. package/app/components/ui/index.ts +19 -0
  154. package/app/components/ui/tokens.ts +18 -0
  155. package/app/config/PageClient.tsx +293 -0
  156. package/app/config/loading.tsx +10 -0
  157. package/app/config/page.tsx +7 -0
  158. package/app/containers/ContainersClient.tsx +129 -0
  159. package/app/containers/loading.tsx +21 -0
  160. package/app/containers/page.tsx +5 -0
  161. package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
  162. package/app/containers/terminal/[id]/page.tsx +6 -0
  163. package/app/credentials/PageClient.tsx +598 -0
  164. package/app/credentials/loading.tsx +10 -0
  165. package/app/credentials/page.tsx +7 -0
  166. package/app/crons/PageClient.tsx +538 -0
  167. package/app/crons/loading.tsx +10 -0
  168. package/app/crons/page.tsx +5 -0
  169. package/app/design-system.ts +57 -0
  170. package/app/gateway/PageClient.tsx +1048 -0
  171. package/app/gateway/loading.tsx +24 -0
  172. package/app/gateway/page.tsx +5 -0
  173. package/app/globals.css +1474 -0
  174. package/app/layout.tsx +45 -0
  175. package/app/lib/models-context.tsx +41 -0
  176. package/app/lineage/loading.tsx +10 -0
  177. package/app/lineage/page.tsx +13 -0
  178. package/app/loading.tsx +16 -0
  179. package/app/login/page.tsx +160 -0
  180. package/app/memory/MemoryContextPageClient.tsx +196 -0
  181. package/app/memory/loading.tsx +10 -0
  182. package/app/memory/page.tsx +8 -0
  183. package/app/page.tsx +31 -0
  184. package/app/plugins/PageClient.tsx +296 -0
  185. package/app/plugins/loading.tsx +10 -0
  186. package/app/plugins/page.tsx +5 -0
  187. package/app/setup/PageClient.tsx +227 -0
  188. package/app/setup/loading.tsx +21 -0
  189. package/app/setup/page.tsx +7 -0
  190. package/app/skills/PageClient.tsx +499 -0
  191. package/app/skills/loading.tsx +10 -0
  192. package/app/skills/page.tsx +5 -0
  193. package/app/tools/ToolsPageClient.tsx +308 -0
  194. package/app/tools/loading.tsx +10 -0
  195. package/app/tools/page.tsx +62 -0
  196. package/app/tools/tools-catalog.ts +141 -0
  197. package/app/wizard/PageClient.tsx +485 -0
  198. package/app/wizard/icons.tsx +89 -0
  199. package/app/wizard/loading.tsx +24 -0
  200. package/app/wizard/page.tsx +7 -0
  201. package/app/wizard/storage.ts +33 -0
  202. package/app/wizard/useWizard.ts +101 -0
  203. package/app/workspace/WorkspaceClient.tsx +633 -0
  204. package/app/workspace/loading.tsx +36 -0
  205. package/app/workspace/page.tsx +8 -0
  206. package/bin/docker-entrypoint.sh +21 -0
  207. package/bin/postinstall.js +20 -0
  208. package/bin/rev4a.js +696 -0
  209. package/daemon.js +783 -0
  210. package/docs/ARCHITECTURE.md +557 -0
  211. package/docs/CONTAINER-TERMINAL.md +280 -0
  212. package/docs/DESIGN-SYSTEM.md +99 -0
  213. package/docs/FRONTEND-ARCHITECTURE.md +119 -0
  214. package/docs/REV4A.md +504 -0
  215. package/docs/dev/API-REFERENCE.md +1835 -0
  216. package/docs/dev/DATABASE.md +224 -0
  217. package/docs/dev/GATEWAY.md +300 -0
  218. package/docs/dev/PROVIDERS.md +229 -0
  219. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
  220. package/docs/dev/WORKSPACE.md +199 -0
  221. package/docs/rag/DATA-FRESHNESS.md +109 -0
  222. package/docs/rag/GLOSSARY.md +125 -0
  223. package/docs/rag/REV4A-OVERVIEW.md +135 -0
  224. package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
  225. package/lib/agent-setup.ts +130 -0
  226. package/lib/alerts.ts +115 -0
  227. package/lib/apiFetch.ts +7 -0
  228. package/lib/auth.ts +28 -0
  229. package/lib/billing.ts +100 -0
  230. package/lib/buildAgentImage.ts +346 -0
  231. package/lib/channelManager.ts +386 -0
  232. package/lib/container.ts +60 -0
  233. package/lib/credentials/db.ts +86 -0
  234. package/lib/credentials/delivery.ts +448 -0
  235. package/lib/credentials/detect.ts +149 -0
  236. package/lib/credentials/hash.ts +9 -0
  237. package/lib/credentials/providers.ts +110 -0
  238. package/lib/credentials/vault.ts +195 -0
  239. package/lib/db-bootstrap.d.ts +3 -0
  240. package/lib/db-bootstrap.mjs +144 -0
  241. package/lib/db.ts +25 -0
  242. package/lib/docker-utils.ts +60 -0
  243. package/lib/hooks/useDashboard.ts +264 -0
  244. package/lib/hooks/useRev4aTimezone.ts +28 -0
  245. package/lib/hooks/useScreenshot.ts +30 -0
  246. package/lib/memory-context.ts +385 -0
  247. package/lib/model-catalogue.ts +131 -0
  248. package/lib/model-pricing.ts +63 -0
  249. package/lib/openclaw-cron.ts +350 -0
  250. package/lib/patterns/ApiAdapter.ts +73 -0
  251. package/lib/patterns/ApiClient.ts +65 -0
  252. package/lib/patterns/EventBus.ts +113 -0
  253. package/lib/patterns/FilterStrategy.ts +79 -0
  254. package/lib/patterns/SessionFactory.ts +117 -0
  255. package/lib/patterns/sessionPresentation.ts +117 -0
  256. package/lib/provider-balance.ts +163 -0
  257. package/lib/requireAuth.tsx +38 -0
  258. package/lib/rev4a-auth.d.ts +3 -0
  259. package/lib/rev4a-auth.js +36 -0
  260. package/lib/rev4a-paths.ts +145 -0
  261. package/lib/timezone.ts +46 -0
  262. package/lib/types/index.ts +96 -0
  263. package/lib/utils/format.ts +82 -0
  264. package/lineage.js +56 -0
  265. package/model-pricing.json +482 -0
  266. package/models.config.json +844 -0
  267. package/next-env.d.ts +6 -0
  268. package/next.config.mjs +7 -0
  269. package/package.json +82 -0
  270. package/public/apple-touch-icon.png +0 -0
  271. package/public/favicon-64.png +0 -0
  272. package/public/favicon.svg +58 -0
  273. package/public/icon-192-maskable.png +0 -0
  274. package/public/icon-192.png +0 -0
  275. package/public/icon-512-maskable.png +0 -0
  276. package/public/icon-512.png +0 -0
  277. package/public/manifest.json +38 -0
  278. package/public/rev4a-logo.png +0 -0
  279. package/public/rev4a-logo.svg +58 -0
  280. package/rev4a-rules/AGENTS.md +37 -0
  281. package/scripts/add-auth.cjs +185 -0
  282. package/scripts/backup.sh +66 -0
  283. package/scripts/check-providers.mjs +143 -0
  284. package/scripts/docker/traefik/docker-compose.yml +32 -0
  285. package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
  286. package/scripts/docker/traefik/traefik.yml +37 -0
  287. package/scripts/remove-auth-profile.js +56 -0
  288. package/scripts/restore.sh +82 -0
  289. package/terminal-ws-server.js +139 -0
  290. package/tsconfig.json +42 -0
@@ -0,0 +1,1285 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { SkeletonLines } from '../components/Skeleton';
6
+ import { Button, ListItem, Badge, ConfirmModal, Input, type Tone } from '../components/ui';
7
+ import PasswordInput from '../components/PasswordInput';
8
+ import ImageDownloadBanner from './ImageDownloadBanner';
9
+ import ChannelManager from './ChannelManager';
10
+ import {
11
+ InfoIcon, BracesIcon, KeyIcon, TrashIcon, TerminalIcon,
12
+ LinkIcon, ArchiveIcon, RotateIcon, PackageIcon,
13
+ } from '../components/Icons';
14
+
15
+ /** Section header with an identifying icon. `danger` switches to the red palette. */
16
+ function SectionHeader({ icon, label, danger = false }: { icon: React.ReactNode; label: string; danger?: boolean }) {
17
+ return (
18
+ <div style={{
19
+ display: 'flex', alignItems: 'center', gap: 7,
20
+ padding: '8px 14px',
21
+ borderBottom: `1px solid ${danger ? 'rgba(239,68,68,0.15)' : 'var(--border)'}`,
22
+ color: danger ? '#ef4444' : 'var(--violet)',
23
+ fontSize: 10, letterSpacing: '0.08em',
24
+ }}>
25
+ <span style={{ display: 'inline-flex', flexShrink: 0 }}>{icon}</span>
26
+ {label}
27
+ </div>
28
+ );
29
+ }
30
+
31
+ interface Agent {
32
+ id: string;
33
+ agentId: string;
34
+ name: string;
35
+ displayName?: string;
36
+ image: string;
37
+ imageTag: string;
38
+ template: string | null;
39
+ status: string;
40
+ state: string;
41
+ ports: string;
42
+ ip: string | null;
43
+ created: string | null;
44
+ env: string[];
45
+ traefikUrl: string | null;
46
+ authToken: string | null;
47
+ imageOutdated?: boolean;
48
+ }
49
+
50
+ type FilterState = 'all' | 'running' | 'exited';
51
+
52
+ const STATUS_BULLET: Record<string, string> = {
53
+ running: '#22c55e',
54
+ exited: '#ef4444',
55
+ paused: '#f59e0b',
56
+ restarting: '#3b82f6',
57
+ };
58
+
59
+ function fmtCreated(created: string | null): string {
60
+ if (!created) return '-';
61
+ const ms = Date.now() - new Date(created).getTime();
62
+ const h = Math.floor(ms / 3600000);
63
+ const m = Math.floor((ms % 3600000) / 60000);
64
+ if (h > 24) return `${Math.floor(h / 24)}d ${h % 24}h`;
65
+ if (h > 0) return `${h}h ${m}m`;
66
+ return `${m}m`;
67
+ }
68
+
69
+ /** ToggleSwitch - compact on/off toggle for container lifecycle */
70
+ function ToggleSwitch({ checked, disabled, onChange, ariaLabel }: { checked: boolean; disabled?: boolean; onChange: () => void; ariaLabel: string }) {
71
+ return (
72
+ <button
73
+ type="button"
74
+ disabled={disabled}
75
+ onClick={(e) => { e.stopPropagation(); onChange(); }}
76
+ aria-label={ariaLabel}
77
+ aria-pressed={checked}
78
+ className={`toggle-switch${checked ? ' toggle-switch--on' : ''}`}
79
+ style={{
80
+ position: 'relative',
81
+ width: 38,
82
+ height: 22,
83
+ borderRadius: 11,
84
+ border: '1px solid var(--border)',
85
+ background: checked ? 'var(--violet)' : 'var(--bg)',
86
+ cursor: disabled ? 'not-allowed' : 'pointer',
87
+ transition: 'background 0.15s ease, border-color 0.15s ease',
88
+ flexShrink: 0,
89
+ opacity: disabled ? 0.4 : 1,
90
+ padding: 0,
91
+ outline: 'none',
92
+ }}
93
+ >
94
+ <span
95
+ style={{
96
+ position: 'absolute',
97
+ top: 2,
98
+ left: checked ? 18 : 2,
99
+ width: 16,
100
+ height: 16,
101
+ borderRadius: '50%',
102
+ background: checked ? '#fff' : '#888',
103
+ transition: 'left 0.15s ease, background 0.15s ease',
104
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
105
+ }}
106
+ />
107
+ </button>
108
+ );
109
+ }
110
+
111
+ /** NoProviderBanner - shown when no provider API keys are configured */
112
+ function NoProviderBanner() {
113
+ return (
114
+ <div style={{
115
+ padding: '10px 14px',
116
+ background: 'rgba(251,191,36,0.08)',
117
+ borderBottom: '1px solid rgba(251,191,36,0.2)',
118
+ fontSize: 12,
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ justifyContent: 'space-between',
122
+ flexShrink: 0,
123
+ flexWrap: 'wrap',
124
+ gap: 8,
125
+ }}>
126
+ <div>
127
+ <strong style={{ color: '#fbbf24' }}>⚠ No providers configured.</strong>
128
+ {' '}Agents need model providers to run. Configure them in the&nbsp;
129
+ <a href="/gateway" style={{ color: 'var(--violet)', textDecoration: 'underline' }}>
130
+ Gateway
131
+ </a>.
132
+ </div>
133
+ <a
134
+ href="/gateway"
135
+ style={{
136
+ padding: '4px 10px',
137
+ border: '1px solid var(--violet)',
138
+ color: 'var(--violet)',
139
+ fontSize: 11,
140
+ textDecoration: 'none',
141
+ background: 'transparent',
142
+ whiteSpace: 'nowrap',
143
+ }}
144
+ >
145
+ Open Gateway →
146
+ </a>
147
+ </div>
148
+ );
149
+ }
150
+
151
+
152
+ /** Shared agent list-item used in both mobile and desktop views. */
153
+ function AgentListItem({
154
+ agent,
155
+ loading,
156
+ selected,
157
+ channelSummary,
158
+ onClick,
159
+ onLifecycle,
160
+ }: {
161
+ agent: Agent;
162
+ loading: boolean;
163
+ selected?: boolean;
164
+ channelSummary: Record<string, { telegram: boolean }>;
165
+ onClick: () => void;
166
+ onLifecycle: () => void;
167
+ }) {
168
+ return (
169
+ <ListItem onClick={onClick} active={selected} style={{ padding: '12px 14px' }}>
170
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
171
+ <ToggleSwitch
172
+ checked={agent.state === 'running'}
173
+ disabled={loading || agent.state === 'unknown'}
174
+ onChange={() => { void onLifecycle(); }}
175
+ ariaLabel={agent.state === 'running' ? 'Stop container' : 'Start container'}
176
+ />
177
+ <span style={{ color: selected ? 'var(--violet)' : 'var(--text)', fontSize: 13, fontWeight: 500 }}>{agent.displayName || agent.name}</span>
178
+ {agent.template && (
179
+ <span style={{ fontSize: 10, background: 'rgba(146,91,252,0.12)', color: 'var(--violet)', padding: '1px 6px', borderRadius: 0, border: '1px solid rgba(146,91,252,0.25)' }}>{agent.template}</span>
180
+ )}
181
+ <div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
182
+ {loading && <span style={{ fontSize: 10, color: '#888' }}>...</span>}
183
+ {agent.state === 'running' && agent.traefikUrl && (
184
+ <Button
185
+ variant="primary"
186
+ size="sm"
187
+ onClick={(e) => { e.stopPropagation(); window.open(agent.traefikUrl!, '_blank'); }}
188
+ title="Open agent Control UI"
189
+ >
190
+ Open ↗
191
+ </Button>
192
+ )}
193
+ </div>
194
+ </div>
195
+ <div style={{ fontSize: 11, color: '#666', marginTop: 4 }}>
196
+ {agent.image?.includes('/') ? agent.image.split('/').pop() : agent.image}
197
+ {agent.ports && <span style={{ marginLeft: 8 }}>{agent.ports}</span>}
198
+ </div>
199
+ {channelSummary[agent.agentId]?.telegram && (
200
+ <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
201
+ <Badge tone="success"><span style={{ fontSize: 8, padding: '1px 6px' }}>TG</span></Badge>
202
+ </div>
203
+ )}
204
+ {agent.imageOutdated && (
205
+ <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
206
+ <Badge tone="warning"><span style={{ fontSize: 8, padding: '1px 6px' }}>OUTDATED</span></Badge>
207
+ </div>
208
+ )}
209
+ </ListItem>
210
+ );
211
+ }
212
+
213
+ export default function AgentsPageClient() {
214
+ const router = useRouter();
215
+ const [agents, setAgents] = useState<Agent[]>([]);
216
+ const [loading, setLoading] = useState(true);
217
+ const [error, setError] = useState('');
218
+ const [filter, setFilter] = useState<FilterState>('all');
219
+ const [selected, setSelected] = useState<Agent | null>(null);
220
+ const [isMobile, setIsMobile] = useState(false);
221
+ const [showList, setShowList] = useState(true);
222
+ const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
223
+ const [imageReady, setImageReady] = useState(true);
224
+ const [providersConfigured, setProvidersConfigured] = useState(false);
225
+ const [gatewayToken, setGatewayToken] = useState('');
226
+ const [tokenLoading, setTokenLoading] = useState(true);
227
+ const [tokenSaving, setTokenSaving] = useState(false);
228
+ const [tokenStatus, setTokenStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
229
+ const [lifecycleLoading, setLifecycleLoading] = useState<string | null>(null);
230
+ const skipNextPoll = useRef(false);
231
+ useEffect(() => {
232
+ const check = () => setIsMobile(window.innerWidth < 768);
233
+ check();
234
+ window.addEventListener('resize', check);
235
+ return () => window.removeEventListener('resize', check);
236
+ }, []);
237
+
238
+ const load = useCallback(async () => {
239
+ try {
240
+ const res = await fetch('/api/agents');
241
+ if (!res.ok) throw new Error(`Error ${res.status}`);
242
+ const data = await res.json();
243
+ if (!Array.isArray(data)) throw new Error('Invalid response');
244
+ setAgents(data);
245
+ setError('');
246
+ // Also load channel summary
247
+ try {
248
+ const chRes = await fetch('/api/agents/channels-summary');
249
+ if (chRes.ok) setChSummary(await chRes.json());
250
+ } catch { /* */ }
251
+ } catch (e: any) {
252
+ setError(e.message || 'Loading error');
253
+ } finally {
254
+ setLoading(false);
255
+ }
256
+ }, []);
257
+
258
+ useEffect(() => {
259
+ load();
260
+ const i = setInterval(() => {
261
+ if (skipNextPoll.current) { skipNextPoll.current = false; return; }
262
+ load();
263
+ }, 15000);
264
+ return () => clearInterval(i);
265
+ }, [load]);
266
+
267
+ // Check if agent base image exists (needed for creating agents)
268
+ useEffect(() => {
269
+ fetch('/api/agents/image-status')
270
+ .then((r) => r.json())
271
+ .then((d: { exists: boolean }) => setImageReady(d.exists))
272
+ .catch(() => setImageReady(false));
273
+ }, []);
274
+
275
+ // Check if any provider is configured in the Gateway
276
+ useEffect(() => {
277
+ fetch('/api/gateway/provider')
278
+ .then((r) => r.json())
279
+ .then((d: { providers?: Array<{ configured: boolean }> }) =>
280
+ setProvidersConfigured((d.providers || []).some((p) => p.configured))
281
+ )
282
+ .catch(() => setProvidersConfigured(false));
283
+ }, []);
284
+
285
+ // Load gateway token
286
+ const loadToken = useCallback(async () => {
287
+ try {
288
+ const res = await fetch('/api/agents/token');
289
+ if (!res.ok) throw new Error(`Error ${res.status}`);
290
+ const data = await res.json();
291
+ setGatewayToken(typeof data.token === 'string' ? data.token : '');
292
+ } catch {
293
+ // token not available
294
+ } finally {
295
+ setTokenLoading(false);
296
+ }
297
+ }, []);
298
+
299
+ useEffect(() => { loadToken(); }, [loadToken]);
300
+
301
+ // Auto-clear token status after 4 seconds
302
+ useEffect(() => {
303
+ if (!tokenStatus) return;
304
+ const t = setTimeout(() => setTokenStatus(null), 4000);
305
+ return () => clearTimeout(t);
306
+ }, [tokenStatus]);
307
+
308
+ const handleImageReady = useCallback(() => {
309
+ setImageReady(true);
310
+ }, []);
311
+
312
+ const handleLifecycle = useCallback(async (agent: Agent) => {
313
+ const action = agent.state === 'running' ? 'stop' : 'start';
314
+ setLifecycleLoading(agent.agentId);
315
+ try {
316
+ const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}/lifecycle`, {
317
+ method: 'POST',
318
+ headers: { 'Content-Type': 'application/json' },
319
+ body: JSON.stringify({ action }),
320
+ });
321
+ if (!res.ok) {
322
+ const data = await res.json().catch(() => ({}));
323
+ console.error(`Lifecycle ${action} failed:`, data.error || res.statusText);
324
+ }
325
+ // Refresh agent list to pick up new state; skip the next polling cycle
326
+ skipNextPoll.current = true;
327
+ await load();
328
+ } catch (e: unknown) {
329
+ console.error(`Lifecycle request failed:`, (e as Error).message);
330
+ } finally {
331
+ setLifecycleLoading(null);
332
+ }
333
+ }, [load]);
334
+
335
+ async function saveToken() {
336
+ setTokenSaving(true);
337
+ setTokenStatus(null);
338
+ try {
339
+ const res = await fetch('/api/agents/token', {
340
+ method: 'PUT',
341
+ headers: { 'Content-Type': 'application/json' },
342
+ body: JSON.stringify({ token: gatewayToken }),
343
+ });
344
+ const data = await res.json();
345
+ if (!res.ok || !data.success) throw new Error(data.error || 'Save failed');
346
+ setTokenStatus({ type: 'success', message: `Saved and synced to ${data.containersUpdated} container(s)` });
347
+ } catch (e: any) {
348
+ setTokenStatus({ type: 'error', message: e.message || 'Save failed' });
349
+ } finally {
350
+ setTokenSaving(false);
351
+ }
352
+ }
353
+
354
+ const filtered = useMemo(() => {
355
+ let list = [...agents];
356
+ if (filter === 'running') list = list.filter((a) => a.state === 'running');
357
+ if (filter === 'exited') list = list.filter((a) => a.state === 'exited');
358
+ return list;
359
+ }, [agents, filter]);
360
+
361
+ function selectAgent(agent: Agent) {
362
+ setSelected(agent);
363
+ if (isMobile) setShowList(false);
364
+ }
365
+
366
+ function backToList() {
367
+ setShowList(true);
368
+ }
369
+
370
+ const tokenSection = (
371
+ <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)', flexShrink: 0 }}>
372
+ <div style={{ fontSize: 10, letterSpacing: '0.06em', color: 'var(--violet)', marginBottom: 6, textTransform: 'uppercase' }}>
373
+ Gateway Token
374
+ </div>
375
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 8, lineHeight: 1.4 }}>
376
+ Shared token injected into all agent containers as OPENCLAW_GATEWAY_TOKEN. Agents use it to protect their OpenClaw Gateway.
377
+ </div>
378
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
379
+ <PasswordInput
380
+ value={gatewayToken}
381
+ onChange={setGatewayToken}
382
+ placeholder="Gateway Token"
383
+ disabled={tokenSaving || tokenLoading}
384
+ showGenerate
385
+ style={{ flex: '1 1 280px', minWidth: 180 }}
386
+ />
387
+ <Button variant="primary" size="sm" loading={tokenSaving} disabled={tokenLoading || !gatewayToken.trim()} onClick={saveToken}>
388
+ {tokenSaving ? 'Saving...' : 'Save & Sync'}
389
+ </Button>
390
+ </div>
391
+ {tokenStatus && (
392
+ <div style={{ marginTop: 8, fontSize: 11, color: tokenStatus.type === 'success' ? 'var(--green)' : 'var(--red)' }}>
393
+ {tokenStatus.message}
394
+ </div>
395
+ )}
396
+ </div>
397
+ );
398
+
399
+ if (loading) {
400
+ return (
401
+ <div style={{ padding: 14, display: 'grid', gap: 14 }}>
402
+ {Array.from({ length: 3 }).map((_, i) => (
403
+ <div key={i} style={{ border: '1px solid var(--border)', background: 'var(--bg2)', padding: 12 }}>
404
+ <SkeletonLines count={4} />
405
+ </div>
406
+ ))}
407
+ </div>
408
+ );
409
+ }
410
+
411
+ if (error) {
412
+ return (
413
+ <div style={{ padding: 40, textAlign: 'center' }}>
414
+ <div style={{ color: '#ef4444', fontSize: 14, marginBottom: 10 }}>{error}</div>
415
+ <Button variant="secondary" size="sm" onClick={() => load()}>
416
+ RETRY
417
+ </Button>
418
+ </div>
419
+ );
420
+ }
421
+
422
+ // ─── Mobile: show list or detail ───
423
+ if (isMobile) {
424
+ if (!showList && selected) {
425
+ return (
426
+ <div style={{ height: '100dvh', background: 'var(--bg)', color: 'var(--text)', fontFamily: 'var(--font-mono-stack)', display: 'flex', flexDirection: 'column', }}>
427
+ {/* Mobile header back - fixed top */}
428
+ <div style={{ height: 48, padding: '0 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
429
+ <Button variant="ghost" size="sm" onClick={backToList}>
430
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M9 2L4 7l5 5"/></svg>
431
+ BACK
432
+ </Button>
433
+ <span style={{ fontSize: 12, color: 'var(--violet)' }}>{selected.name}</span>
434
+ </div>
435
+ <ImageDownloadBanner onImageReady={handleImageReady} />
436
+ {!providersConfigured && <NoProviderBanner />}
437
+ {/* Scrollable content */}
438
+ <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
439
+ <DetailPanel agent={selected} />
440
+ </div>
441
+ </div>
442
+ );
443
+ }
444
+
445
+ // Mobile: lista
446
+ return (
447
+ <div style={{ flex: 1, background: 'var(--bg)', color: 'var(--text)', fontFamily: 'var(--font-mono-stack)', display: 'flex', flexDirection: 'column', }}>
448
+ <div style={{ height: 48, padding: '0 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
449
+ <span style={{ fontFamily: 'var(--font-serif-stack)', fontSize: '20px', letterSpacing: '4px', color: 'var(--violet)' }}>AGENTS</span>
450
+ <span style={{ fontSize: 11, color: '#888' }}>{agents.length}</span>
451
+ <Button variant="primary" size="sm" disabled={!imageReady} onClick={() => router.push('/agents/create')} title={!imageReady ? 'Agent base image not built yet' : undefined}>
452
+ + NEW
453
+ </Button>
454
+ </div>
455
+ <div style={{ padding: '8px 14px', display: 'flex', gap: 6, borderBottom: '1px solid var(--border)' }}>
456
+ {(['all', 'running', 'exited'] as const).map((f) => (
457
+ <Button key={f} variant={filter === f ? 'primary' : 'ghost'} size="sm" onClick={() => setFilter(f)}>
458
+ {f}
459
+ </Button>
460
+ ))}
461
+ </div>
462
+ <ImageDownloadBanner onImageReady={handleImageReady} />
463
+ {!providersConfigured && <NoProviderBanner />}
464
+ {tokenSection}
465
+ <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
466
+ {filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
467
+ {filtered.map((agent) => (
468
+ <AgentListItem
469
+ key={agent.id}
470
+ agent={agent}
471
+ loading={lifecycleLoading === agent.agentId}
472
+ channelSummary={chSummary}
473
+ onClick={() => selectAgent(agent)}
474
+ onLifecycle={() => handleLifecycle(agent)}
475
+ />
476
+ ))}
477
+ </div>
478
+ </div>
479
+ );
480
+ }
481
+
482
+ // ─── Desktop: split view ───
483
+ return (
484
+ <div style={{ flex: 1, background: 'var(--bg)', color: 'var(--text)', fontFamily: 'var(--font-mono-stack)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
485
+ <div style={{ height: 48, padding: '0 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
486
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
487
+ <span style={{ fontFamily: 'var(--font-serif-stack)', fontSize: '20px', letterSpacing: '4px', color: 'var(--violet)' }}>AGENTS</span>
488
+ <span style={{ fontSize: 11, color: '#888' }}>{agents.length} agents · {agents.filter((a) => a.state === 'running').length} running</span>
489
+ <Button variant="primary" size="sm" disabled={!imageReady || !providersConfigured} onClick={() => router.push('/agents/create')} title={!imageReady ? 'Agent base image not built yet' : !providersConfigured ? 'No providers configured' : undefined}>
490
+ + NEW
491
+ </Button>
492
+ </div>
493
+ </div>
494
+ <div style={{ padding: '8px 14px', display: 'flex', gap: 6, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
495
+ {(['all', 'running', 'exited'] as const).map((f) => (
496
+ <Button key={f} variant={filter === f ? 'primary' : 'ghost'} size="sm" onClick={() => setFilter(f)}>
497
+ {f}
498
+ </Button>
499
+ ))}
500
+ </div>
501
+ <ImageDownloadBanner onImageReady={handleImageReady} />
502
+ {!providersConfigured && <NoProviderBanner />}
503
+ {tokenSection}
504
+ <div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
505
+ <div style={{ width: '38%', minWidth: 300, borderRight: '1px solid var(--border)', overflow: 'auto' }}>
506
+ {filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
507
+ {filtered.map((agent) => {
508
+ const isSelected = selected?.id === agent.id;
509
+
510
+ return (
511
+ <AgentListItem
512
+ key={agent.id}
513
+ agent={agent}
514
+ loading={lifecycleLoading === agent.agentId}
515
+ channelSummary={chSummary}
516
+ selected={isSelected}
517
+ onClick={() => setSelected(agent)}
518
+ onLifecycle={() => handleLifecycle(agent)}
519
+ />
520
+ );
521
+ })}
522
+ </div>
523
+ <div style={{ flex: 1, overflow: 'auto', background: 'var(--bg2)', padding: 16 }}>
524
+ {!selected ? (
525
+ <div style={{ color: '#666', fontSize: 12, padding: 20 }}>Select an agent for details</div>
526
+ ) : (
527
+ <DetailPanel agent={selected} />
528
+ )}
529
+ </div>
530
+ </div>
531
+ </div>
532
+ );
533
+ }
534
+
535
+ // ─── Edit Agent modal ───
536
+ function EditAgentModal({ agent, onClose }: { agent: Agent; onClose: () => void }) {
537
+ const [displayName, setDisplayName] = useState(agent.displayName || agent.name);
538
+ const [saving, setSaving] = useState(false);
539
+ const [error, setError] = useState('');
540
+
541
+ const handleSave = async () => {
542
+ const trimmed = displayName.trim();
543
+ if (!trimmed) {
544
+ setError('Display name must not be empty');
545
+ return;
546
+ }
547
+ if (trimmed === (agent.displayName || agent.name)) {
548
+ onClose();
549
+ return;
550
+ }
551
+ setSaving(true);
552
+ setError('');
553
+ try {
554
+ const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}`, {
555
+ method: 'PATCH',
556
+ headers: { 'Content-Type': 'application/json' },
557
+ body: JSON.stringify({ displayName: trimmed }),
558
+ });
559
+ const data = await res.json();
560
+ if (!res.ok) throw new Error(data.error || 'Failed to update');
561
+ // Reload to pick up the new display name
562
+ window.location.reload();
563
+ } catch (e: unknown) {
564
+ setError((e as Error).message);
565
+ setSaving(false);
566
+ }
567
+ };
568
+
569
+ return (
570
+ <>
571
+ <div
572
+ style={{
573
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 100,
574
+ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
575
+ }}
576
+ onClick={saving ? undefined : onClose}
577
+ >
578
+ <div
579
+ style={{
580
+ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 0,
581
+ maxWidth: 400, width: '100%', maxHeight: '90vh', overflow: 'auto',
582
+ }}
583
+ onClick={(e) => e.stopPropagation()}
584
+ >
585
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 14px', borderBottom: '1px solid var(--border)', fontSize: 12, fontWeight: 500, color: 'var(--violet)', letterSpacing: '0.05em' }}>
586
+ <span>EDIT AGENT</span>
587
+ <Button variant="ghost" size="sm" onClick={onClose} disabled={saving}>✕</Button>
588
+ </div>
589
+ <div style={{ padding: '16px 14px', display: 'flex', flexDirection: 'column', gap: 12 }}>
590
+ <div>
591
+ <Input
592
+ label="Display Name"
593
+ value={displayName}
594
+ onChange={(e) => { setDisplayName(e.target.value); setError(''); }}
595
+ placeholder="My Agent 🚀"
596
+ error={error}
597
+ disabled={saving}
598
+ />
599
+ <div style={{ fontSize: 10, color: '#666', marginTop: 4 }}>
600
+ Container: {agent.name} · This will recreate the container. Sessions and workspace are preserved.
601
+ </div>
602
+ </div>
603
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
604
+ <Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>Cancel</Button>
605
+ <Button variant="primary" size="sm" onClick={handleSave} disabled={saving}>
606
+ {saving ? 'Saving...' : 'Save & Recreate'}
607
+ </Button>
608
+ </div>
609
+ </div>
610
+ </div>
611
+ </div>
612
+ </>
613
+ );
614
+ }
615
+
616
+ // ─── Config modal ───
617
+ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void }) {
618
+ const [raw, setRaw] = useState<string | null>(null);
619
+ const [loading, setLoading] = useState(true);
620
+ const [err, setErr] = useState('');
621
+
622
+ useEffect(() => {
623
+ (async () => {
624
+ try {
625
+ const res = await fetch(`/api/agents/config-json?name=${agent.name}`);
626
+ const data = await res.json();
627
+ if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
628
+ setRaw(JSON.stringify(data.config, null, 2));
629
+ } catch (e: unknown) {
630
+ setErr((e as Error).message || 'Failed to load config');
631
+ } finally {
632
+ setLoading(false);
633
+ }
634
+ })();
635
+ }, [agent.name]);
636
+
637
+ return (
638
+ <div
639
+ onClick={onClose}
640
+ style={{
641
+ position: 'fixed', inset: 0, zIndex: 9999,
642
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
643
+ background: 'rgba(0,0,0,0.65)', padding: 20,
644
+ }}
645
+ >
646
+ <div
647
+ onClick={(e) => e.stopPropagation()}
648
+ style={{
649
+ maxWidth: 800, width: '100%', maxHeight: '85vh',
650
+ background: '#111', border: '1px solid var(--border)',
651
+ borderRadius: 0, display: 'flex', flexDirection: 'column',
652
+ overflow: 'hidden',
653
+ }}
654
+ >
655
+ <div style={{
656
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
657
+ padding: '10px 14px', borderBottom: '1px solid var(--border)',
658
+ }}>
659
+ <span style={{ fontSize: 12, color: 'var(--violet)' }}>openclaw.json - {agent.displayName || agent.agentId || agent.name}</span>
660
+ <Button variant="ghost" size="sm" onClick={onClose}>
661
+ ✕
662
+ </Button>
663
+ </div>
664
+ <div style={{ flex: 1, overflow: 'auto', padding: 14 }}>
665
+ {loading && (
666
+ <div style={{ textAlign: 'center', color: '#888', fontSize: 12, padding: 20 }}>Loading config...</div>
667
+ )}
668
+ {err && (
669
+ <div style={{ color: '#ef4444', fontSize: 12 }}>{err}</div>
670
+ )}
671
+ {raw && (
672
+ <pre style={{
673
+ margin: 0, fontSize: 11, lineHeight: 1.5, color: '#ccc',
674
+ whiteSpace: 'pre-wrap', wordBreak: 'break-all',
675
+ fontFamily: 'var(--font-mono-stack)',
676
+ }}>{raw}</pre>
677
+ )}
678
+ </div>
679
+ </div>
680
+ </div>
681
+ );
682
+ }
683
+
684
+ // ─── Detail panel (shared between mobile & desktop) ───
685
+ function DetailPanel({ agent }: { agent: Agent }) {
686
+ const [deleting, setDeleting] = useState(false);
687
+ const [deleteError, setDeleteError] = useState('');
688
+ const [showConfig, setShowConfig] = useState(false);
689
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
690
+ const [showEdit, setShowEdit] = useState(false);
691
+
692
+ const handleDelete = async () => {
693
+ setDeleting(true);
694
+ setDeleteError('');
695
+ try {
696
+ const res = await fetch(`/api/agents/${agent.name}`, { method: 'DELETE' });
697
+ const data = await res.json();
698
+ if (!res.ok || !data.success) {
699
+ setDeleteError(data.error || 'Delete failed');
700
+ setDeleting(false);
701
+ return;
702
+ }
703
+ window.location.reload();
704
+ } catch (e: unknown) {
705
+ setDeleteError((e as Error).message || 'Network error');
706
+ setDeleting(false);
707
+ }
708
+ };
709
+
710
+ return (
711
+ <div style={{ display: 'grid', gap: 12, maxWidth: 600, margin: '16px auto', paddingBottom: 24 }}>
712
+ {/* Status */}
713
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', padding: '14px 16px' }}>
714
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
715
+ <span style={{ width: 12, height: 12, borderRadius: '50%', flexShrink: 0, background: STATUS_BULLET[agent.state] || '#888' }} />
716
+ <div>
717
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
718
+ <span style={{ fontSize: 16, fontWeight: 500 }}>{agent.displayName || agent.name}</span>
719
+ <Button variant="primary" size="sm" onClick={() => setShowEdit(true)} title="Edit agent settings">
720
+ ✎
721
+ </Button>
722
+ </div>
723
+ <div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>{agent.name}</div>
724
+ <div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>{agent.status}</div>
725
+ </div>
726
+ {agent.template && (
727
+ <span style={{ marginLeft: 'auto', fontSize: 10, background: 'rgba(146,91,252,0.12)', color: 'var(--violet)', padding: '3px 8px', borderRadius: 0, border: '1px solid rgba(146,91,252,0.25)' }}>
728
+ template: {agent.template}
729
+ </span>
730
+ )}
731
+ </div>
732
+ </div>
733
+
734
+ {/* Properties */}
735
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
736
+ <SectionHeader icon={<InfoIcon size={12} />} label="PROPERTIES" />
737
+ {([
738
+ ['Display Name', agent.displayName || agent.name],
739
+ ['Agent ID', agent.agentId],
740
+ ['Container', agent.name],
741
+ ['Image', agent.image],
742
+ ['Tag', agent.imageTag],
743
+ ['Template', agent.template || '-'],
744
+ ['State', agent.state],
745
+ ['Ports', agent.ports || '-'],
746
+ ['Internal IP', agent.ip || '-'],
747
+ ['Created', fmtCreated(agent.created)],
748
+ ] as const).map(([label, value]) => (
749
+ <div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '7px 14px', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
750
+ <span style={{ color: '#888' }}>{label}</span>
751
+ <span style={{ color: 'var(--text)', textAlign: 'right', maxWidth: '60%', wordBreak: 'break-word' }}>{value}</span>
752
+ </div>
753
+ ))}
754
+ </div>
755
+
756
+ {/* Channels - Telegram */}
757
+ <ChannelSection agentId={agent.agentId} agentName={agent.name} />
758
+
759
+ {/* Config - View openclaw.json */}
760
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
761
+ <SectionHeader icon={<BracesIcon size={12} />} label="CONFIG" />
762
+ <div style={{ padding: '12px 14px' }}>
763
+ <Button variant="secondary" size="sm" onClick={() => setShowConfig(true)}>
764
+ View openclaw.json
765
+ </Button>
766
+ <div style={{ fontSize: 10, color: '#666', marginTop: 6 }}>
767
+ Inspect the full container config - providers, models, auth mode.
768
+ </div>
769
+ </div>
770
+ </div>
771
+
772
+ {/* Integrations - credentials synced to this agent */}
773
+ <IntegrationSection agentName={agent.name} />
774
+
775
+ {/* Restart */}
776
+ <RestartSection agentName={agent.name} />
777
+
778
+ {/* Backup - persistent volume backup & restore */}
779
+ <BackupSection agentName={agent.name} />
780
+
781
+ {/* Access - agent URL (token inline in link) */}
782
+ {agent.traefikUrl && (
783
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
784
+ <SectionHeader icon={<KeyIcon size={12} />} label="ACCESS" />
785
+ <div style={{ padding: '7px 14px', fontSize: 12 }}>
786
+ <div style={{ color: '#888', marginBottom: 2 }}>Control UI</div>
787
+ <a href={agent.traefikUrl} target="_blank" rel="noopener noreferrer"
788
+ style={{ color: '#60a5fa', textDecoration: 'underline', fontSize: 12, wordBreak: 'break-all' }}
789
+ >{agent.traefikUrl}</a>
790
+
791
+ </div>
792
+ </div>
793
+ )}
794
+
795
+ {/* Config modal */}
796
+ {showConfig && <ConfigModal agent={agent} onClose={() => setShowConfig(false)} />}
797
+
798
+ {/* Edit agent modal */}
799
+ {showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} />}
800
+
801
+ {/* Delete confirmation */}
802
+ <ConfirmModal
803
+ open={showDeleteConfirm}
804
+ onClose={() => setShowDeleteConfirm(false)}
805
+ onConfirm={() => { setShowDeleteConfirm(false); handleDelete(); }}
806
+ title={`Delete ${agent.name}?`}
807
+ message="This permanently deletes the container, its persistent volume, and ALL its backups. This cannot be undone."
808
+ confirmLabel="Delete Agent"
809
+ cancelLabel="Cancel"
810
+ tone="danger"
811
+ />
812
+
813
+ {/* Recreate */}
814
+ {agent.imageOutdated && (
815
+ <div style={{ marginBottom: 12, border: '1px solid rgba(234,179,8,0.3)', borderRadius: 0, padding: '10px 14px', background: 'rgba(234,179,8,0.06)' }}>
816
+ <div style={{ fontSize: 11, color: 'var(--yellow)', marginBottom: 4, fontWeight: 500 }}>⚠ Container Outdated</div>
817
+ <div style={{ fontSize: 11, color: '#888' }}>
818
+ This container is running an older image. Recreate it to pick up the latest version.
819
+ </div>
820
+ </div>
821
+ )}
822
+ <RecreateSection agentName={agent.name} />
823
+
824
+ {/* Delete */}
825
+ <div style={{ border: '1px solid rgba(239,68,68,0.3)', borderRadius: 0, background: 'rgba(239,68,68,0.04)', overflow: 'hidden' }}>
826
+ <SectionHeader icon={<TrashIcon size={12} />} label="DANGER ZONE" danger />
827
+ <div style={{ padding: '12px 14px' }}>
828
+ <div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
829
+ Permanently delete this agent: container, persistent volume, and all backups. This cannot be undone.
830
+ </div>
831
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
832
+ <Button variant="danger" size="sm" loading={deleting} onClick={() => setShowDeleteConfirm(true)}>
833
+ {deleting ? 'Deleting...' : 'Delete Agent'}
834
+ </Button>
835
+ </div>
836
+ {deleteError && (
837
+ <div style={{ fontSize: 11, color: '#ef4444', marginTop: 6 }}>{deleteError}</div>
838
+ )}
839
+ </div>
840
+ </div>
841
+
842
+ {/* Env */}
843
+ {agent.env.length > 0 && (
844
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
845
+ <SectionHeader icon={<TerminalIcon size={12} />} label="ENV (AGENT_ / MODEL_)" />
846
+ {agent.env.map((e) => (
847
+ <div key={e} style={{ padding: '6px 14px', borderBottom: '1px solid var(--border)', fontSize: 11, color: '#aaa', fontFamily: 'var(--font-mono-stack)' }}>
848
+ {e.split('=').map((v, i) => i === 1 && v.length > 0 ? `${v.slice(0, 20)}${v.length > 20 ? '...' : ''}` : v).join('=')}
849
+ </div>
850
+ ))}
851
+ </div>
852
+ )}
853
+ </div>
854
+ );
855
+ }
856
+
857
+ /* ─── Integration section - credentials synced to this agent ─── */
858
+
859
+ function IntegrationSection({ agentName }: { agentName: string }) {
860
+ const [profiles, setProfiles] = useState<{ providerId: string; label: string }[]>([]);
861
+ const [loading, setLoading] = useState(false);
862
+
863
+ useEffect(() => {
864
+ setLoading(true);
865
+ setProfiles([]);
866
+ const load = async () => {
867
+ try {
868
+ const res = await fetch(`/api/credentials/detect?agent=${agentName}`);
869
+ const data: {
870
+ agents?: { matchedProfiles: { providerId: string; label: string }[] }[];
871
+ error?: string;
872
+ } = await res.json();
873
+ const matched = data.agents?.[0]?.matchedProfiles ?? [];
874
+ setProfiles(matched);
875
+ } catch { /* ignore */ }
876
+ setLoading(false);
877
+ };
878
+ void load();
879
+ }, [agentName]);
880
+
881
+ const count = profiles.length;
882
+
883
+ return (
884
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
885
+ <SectionHeader icon={<LinkIcon size={12} />} label="INTEGRATIONS" />
886
+ <div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
887
+ {loading ? (
888
+ <div style={{ fontSize: 11, color: '#888' }}>Loading...</div>
889
+ ) : (
890
+ <>
891
+ <div style={{ fontSize: 12, color: 'var(--text)' }}>
892
+ {count > 0
893
+ ? `${count} credential${count !== 1 ? 's' : ''} configured`
894
+ : 'No credentials synced to this agent.'}
895
+ </div>
896
+ {count > 0 && (
897
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
898
+ {profiles.map((p) => (
899
+ <span key={p.providerId} style={{ fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--violet)', background: 'rgba(146,91,252,0.08)', border: '1px solid rgba(146,91,252,0.2)', padding: '2px 8px' }}>
900
+ {p.label}
901
+ </span>
902
+ ))}
903
+ </div>
904
+ )}
905
+ <a
906
+ href="/credentials"
907
+ style={{ fontSize: 11, color: 'var(--violet)', textDecoration: 'none', cursor: 'pointer', marginTop: 4 }}
908
+ >
909
+ Manage credentials →
910
+ </a>
911
+ </>
912
+ )}
913
+ </div>
914
+ </div>
915
+ );
916
+ }
917
+
918
+ /* ─── Backup section - persistent volume backup & restore ─── */
919
+
920
+ function BackupSection({ agentName }: { agentName: string }) {
921
+ const [backups, setBackups] = useState<{ name: string; size: string; date: string }[]>([]);
922
+ const [loading, setLoading] = useState(false);
923
+ const [status, setStatus] = useState<{ type: 'backup' | 'restore'; label: string } | null>(null);
924
+ const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
925
+ const [restoreConfirm, setRestoreConfirm] = useState<string | null>(null);
926
+
927
+ const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
928
+ setToast({ msg, type });
929
+ setTimeout(() => setToast(null), 4000);
930
+ };
931
+
932
+ const loadBackups = useCallback(async () => {
933
+ setLoading(true);
934
+ try {
935
+ const res = await fetch(`/api/agents/${agentName}/backup`);
936
+ if (res.ok) {
937
+ const data: { backups: { name: string; size: string; date: string }[] } = await res.json();
938
+ setBackups(data.backups ?? []);
939
+ }
940
+ } catch { /* ignore */ }
941
+ setLoading(false);
942
+ }, [agentName]);
943
+
944
+ useEffect(() => { void loadBackups(); }, [loadBackups]);
945
+
946
+ const handleBackup = async () => {
947
+ setStatus({ type: 'backup', label: 'Creating backup...' });
948
+ setBackups([]);
949
+ try {
950
+ const res = await fetch(`/api/agents/${agentName}/backup`, { method: 'POST' });
951
+ const data = await res.json();
952
+ if (data.success) {
953
+ showToast('Backup created: ' + data.file);
954
+ await loadBackups();
955
+ } else {
956
+ showToast(data.error || 'Backup failed', 'error');
957
+ }
958
+ } catch (e: unknown) {
959
+ showToast((e as Error).message, 'error');
960
+ }
961
+ setStatus(null);
962
+ };
963
+
964
+ const handleRestore = async (file: string) => {
965
+ setRestoreConfirm(file);
966
+ };
967
+
968
+ const executeRestore = async () => {
969
+ const file = restoreConfirm;
970
+ if (!file) return;
971
+ setRestoreConfirm(null);
972
+ setStatus({ type: 'restore', label: `Restoring from ${file}... (container will restart)` });
973
+ setBackups([]);
974
+ try {
975
+ const res = await fetch(`/api/agents/${agentName}/restore?file=${encodeURIComponent(file)}`, { method: 'POST' });
976
+ const data = await res.json();
977
+ if (data.success) {
978
+ showToast('Restored from ' + file);
979
+ await loadBackups();
980
+ } else {
981
+ showToast(data.error || 'Restore failed', 'error');
982
+ }
983
+ } catch (e: unknown) {
984
+ showToast((e as Error).message, 'error');
985
+ }
986
+ setStatus(null);
987
+ };
988
+
989
+ const handleDeleteBackup = async (file: string) => {
990
+ setStatus({ type: 'backup', label: `Deleting ${file}...` });
991
+ try {
992
+ const res = await fetch(`/api/agents/${agentName}/backup?file=${encodeURIComponent(file)}`, { method: 'DELETE' });
993
+ const data = await res.json().catch(() => ({}));
994
+ if (!res.ok || !data.success) {
995
+ showToast(data.error || 'Delete failed', 'error');
996
+ }
997
+ setBackups([]);
998
+ await loadBackups();
999
+ } catch (e: unknown) {
1000
+ showToast((e as Error).message, 'error');
1001
+ }
1002
+ setStatus(null);
1003
+ };
1004
+
1005
+ return (
1006
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1007
+ <SectionHeader icon={<ArchiveIcon size={12} />} label="BACKUP" />
1008
+ <div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
1009
+ {status && (
1010
+ <div style={{
1011
+ padding: '8px 12px',
1012
+ fontSize: 11,
1013
+ fontFamily: 'var(--font-mono)',
1014
+ color: 'var(--violet)',
1015
+ background: 'rgba(146,91,252,0.08)',
1016
+ border: '1px solid rgba(146,91,252,0.25)',
1017
+ display: 'flex',
1018
+ alignItems: 'center',
1019
+ gap: 8,
1020
+ }}>
1021
+ <span style={{
1022
+ display: 'inline-block',
1023
+ width: 8, height: 8,
1024
+ borderRadius: '50%',
1025
+ background: 'var(--violet)',
1026
+ animation: 'pulse 1s infinite',
1027
+ }} />
1028
+ {status.label}
1029
+ </div>
1030
+ )}
1031
+ {!status && (
1032
+ <>
1033
+ {loading && <div style={{ fontSize: 11, color: '#888' }}>Loading...</div>}
1034
+ {!loading && backups.length === 0 && (
1035
+ <div style={{ fontSize: 11, color: '#888' }}>No backups yet.</div>
1036
+ )}
1037
+ {backups.length > 0 && (
1038
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
1039
+ {backups.map((b) => (
1040
+ <div key={b.name} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, fontFamily: 'var(--font-mono)', padding: '4px 0' }}>
1041
+ <span style={{ flex: 1, color: 'var(--text)', wordBreak: 'break-all' }}>{b.name}</span>
1042
+ <span style={{ color: '#888', whiteSpace: 'nowrap' }}>{b.size}</span>
1043
+ <span style={{ color: '#666', whiteSpace: 'nowrap' }}>{b.date}</span>
1044
+ <button
1045
+ type="button"
1046
+ disabled={!!status}
1047
+ onClick={() => handleRestore(b.name)}
1048
+ style={{ background: 'none', border: '1px solid var(--violet)', color: 'var(--violet)', cursor: 'pointer', fontSize: 10, padding: '2px 6px', opacity: status ? 0.5 : 1 }}
1049
+ >
1050
+ Restore
1051
+ </button>
1052
+ <button
1053
+ type="button"
1054
+ disabled={!!status}
1055
+ onClick={() => handleDeleteBackup(b.name)}
1056
+ style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12, padding: '2px', opacity: status ? 0.5 : 1 }}
1057
+ >
1058
+ ×
1059
+ </button>
1060
+ </div>
1061
+ ))}
1062
+ </div>
1063
+ )}
1064
+ <div style={{ display: 'flex', gap: 8 }}>
1065
+ <Button variant="secondary" size="sm" disabled={!!status} onClick={handleBackup}>
1066
+ Backup Now
1067
+ </Button>
1068
+ </div>
1069
+ </>
1070
+ )}
1071
+ </div>
1072
+ {toast && (
1073
+ <div style={{ padding: '8px 14px', borderTop: '1px solid var(--border)', fontSize: 11, color: toast.type === 'success' ? '#22c55e' : '#ef4444' }}>
1074
+ {toast.msg}
1075
+ </div>
1076
+ )}
1077
+ <ConfirmModal
1078
+ open={restoreConfirm !== null}
1079
+ onClose={() => setRestoreConfirm(null)}
1080
+ onConfirm={executeRestore}
1081
+ title="Restore Backup"
1082
+ message={`Restore from ${restoreConfirm || ''}?\n\nThe agent container will be stopped, restored, and restarted.`}
1083
+ confirmLabel="Restore"
1084
+ cancelLabel="Cancel"
1085
+ tone="warning"
1086
+ />
1087
+ </div>
1088
+ );
1089
+ }
1090
+
1091
+ /* ─── Restart section - simple container restart ─── */
1092
+
1093
+ function RestartSection({ agentName }: { agentName: string }) {
1094
+ const [restarting, setRestarting] = useState(false);
1095
+ const [toast, setToast] = useState<string | null>(null);
1096
+
1097
+ const showToast = (msg: string) => {
1098
+ setToast(msg);
1099
+ setTimeout(() => setToast(null), 5000);
1100
+ };
1101
+
1102
+ const handleRestart = async () => {
1103
+ setRestarting(true);
1104
+ try {
1105
+ const res = await fetch(`/api/agents/${agentName}/restart`, { method: 'POST' });
1106
+ const data = await res.json();
1107
+ if (data.success) {
1108
+ showToast('Container restarted');
1109
+ } else {
1110
+ showToast(data.error || 'Restart failed');
1111
+ }
1112
+ } catch (e: unknown) {
1113
+ showToast((e as Error).message || 'Network error');
1114
+ }
1115
+ setRestarting(false);
1116
+ };
1117
+
1118
+ return (
1119
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1120
+ <SectionHeader icon={<RotateIcon size={12} />} label="RESTART" />
1121
+ <div style={{ padding: '12px 14px' }}>
1122
+ <div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
1123
+ Restart the container without losing any data.
1124
+ </div>
1125
+ <Button variant="secondary" size="sm" loading={restarting} disabled={restarting} onClick={handleRestart}>
1126
+ {restarting ? 'Restarting...' : 'Restart Container'}
1127
+ </Button>
1128
+ </div>
1129
+ {toast && (
1130
+ <div style={{ padding: '8px 14px', borderTop: '1px solid var(--border)', fontSize: 11, color: '#22c55e' }}>
1131
+ {toast}
1132
+ </div>
1133
+ )}
1134
+ </div>
1135
+ );
1136
+ }
1137
+
1138
+ /* ─── Recreate section - rebuild container from same image while keeping volume ─── */
1139
+
1140
+ function RecreateSection({ agentName }: { agentName: string }) {
1141
+ const [recreating, setRecreating] = useState(false);
1142
+ const [error, setError] = useState('');
1143
+ const [toast, setToast] = useState<string | null>(null);
1144
+ const [showConfirm, setShowConfirm] = useState(false);
1145
+
1146
+ const showToast = (msg: string) => {
1147
+ setToast(msg);
1148
+ setTimeout(() => setToast(null), 5000);
1149
+ };
1150
+
1151
+ const handleRecreate = async () => {
1152
+ setShowConfirm(false);
1153
+ setRecreating(true);
1154
+ setError('');
1155
+ try {
1156
+ const res = await fetch(`/api/agents/${agentName}/recreate`, { method: 'POST' });
1157
+ const data = await res.json();
1158
+ if (data.success) {
1159
+ showToast(data.backup ? `Recreated (backup: ${data.backup})` : 'Container recreated successfully');
1160
+ setTimeout(() => window.location.reload(), 2000);
1161
+ } else {
1162
+ setError(data.error || 'Recreate failed');
1163
+ }
1164
+ } catch (e: unknown) {
1165
+ setError((e as Error).message || 'Network error');
1166
+ }
1167
+ setRecreating(false);
1168
+ };
1169
+
1170
+ return (
1171
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1172
+ <SectionHeader icon={<PackageIcon size={12} />} label="RECREATE" />
1173
+ <div style={{ padding: '12px 14px' }}>
1174
+ {recreating && (
1175
+ <div style={{
1176
+ padding: '8px 12px',
1177
+ fontSize: 11,
1178
+ fontFamily: 'var(--font-mono)',
1179
+ color: 'var(--violet)',
1180
+ background: 'rgba(146,91,252,0.08)',
1181
+ border: '1px solid rgba(146,91,252,0.25)',
1182
+ display: 'flex',
1183
+ alignItems: 'center',
1184
+ gap: 8,
1185
+ marginBottom: 8,
1186
+ }}>
1187
+ <span style={{
1188
+ display: 'inline-block',
1189
+ width: 8, height: 8,
1190
+ borderRadius: '50%',
1191
+ background: 'var(--violet)',
1192
+ animation: 'pulse 1s infinite',
1193
+ }} />
1194
+ Recreating container...
1195
+ </div>
1196
+ )}
1197
+ <div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
1198
+ Takes an automatic backup, then rebuilds the container from the latest image - keeping workspace, config, credentials, and state intact. Use this to pick up image/OpenClaw updates.
1199
+ </div>
1200
+ <Button variant="secondary" size="sm" loading={recreating} disabled={recreating} onClick={() => setShowConfirm(true)}>
1201
+ {recreating ? 'Recreating...' : 'Recreate Container'}
1202
+ </Button>
1203
+ {error && <div style={{ fontSize: 11, color: '#ef4444', marginTop: 6 }}>{error}</div>}
1204
+ </div>
1205
+ {toast && (
1206
+ <div style={{ padding: '8px 14px', borderTop: '1px solid var(--border)', fontSize: 11, color: '#22c55e' }}>
1207
+ {toast}
1208
+ </div>
1209
+ )}
1210
+ <ConfirmModal
1211
+ open={showConfirm}
1212
+ onClose={() => setShowConfirm(false)}
1213
+ onConfirm={handleRecreate}
1214
+ title="Recreate Container"
1215
+ message={`Recreate container "${agentName}"?\n\nAn automatic backup is taken first, then the container is rebuilt from the latest image. Workspace, credentials, and state are preserved in the persistent volume. If the backup fails, the recreate is aborted.`}
1216
+ confirmLabel="Recreate"
1217
+ cancelLabel="Cancel"
1218
+ tone="warning"
1219
+ />
1220
+ </div>
1221
+ );
1222
+ }
1223
+
1224
+ /* ─── Channel section - Telegram ─── */
1225
+
1226
+ function ChannelSection({ agentId, agentName }: { agentId: string; agentName: string }) {
1227
+ const [open, setOpen] = useState(false);
1228
+ const [preview, setPreview] = useState<{ telegram: boolean } | null>(null);
1229
+ const previewAbortRef = useRef<AbortController | null>(null);
1230
+
1231
+ const doFetchPreview = useCallback(() => {
1232
+ previewAbortRef.current?.abort();
1233
+ const controller = new AbortController();
1234
+ previewAbortRef.current = controller;
1235
+ fetch(`/api/agents/${encodeURIComponent(agentId)}/channels`, { signal: controller.signal })
1236
+ .then(res => res.ok ? res.json() : null)
1237
+ .then(data => {
1238
+ if (data) {
1239
+ setPreview({
1240
+ telegram: data.telegram?.connected === true,
1241
+ });
1242
+ }
1243
+ })
1244
+ .catch((e: any) => {
1245
+ if (e?.name !== 'AbortError') { /* ignore aborted */ }
1246
+ });
1247
+ }, [agentId]);
1248
+
1249
+ useEffect(() => {
1250
+ setPreview(null);
1251
+ doFetchPreview();
1252
+ return () => { previewAbortRef.current?.abort(); };
1253
+ }, [doFetchPreview]);
1254
+
1255
+ return (
1256
+ <>
1257
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1258
+ <SectionHeader icon={<LinkIcon size={12} />} label="CHANNELS" />
1259
+ <div style={{ padding: '12px 14px' }}>
1260
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
1261
+ {preview === null ? (
1262
+ <span style={{ color: 'var(--text-dim)' }}>Loading...</span>
1263
+ ) : (
1264
+ <>
1265
+ <Badge tone={preview.telegram ? 'success' : 'neutral'}>
1266
+ <span style={{ fontSize: 9 }}>{preview.telegram ? 'TG: Connected' : 'TG: Not Set'}</span>
1267
+ </Badge>
1268
+ </>
1269
+ )}
1270
+ </div>
1271
+ <Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
1272
+ Manage Channels
1273
+ </Button>
1274
+ </div>
1275
+ </div>
1276
+ <ChannelManager
1277
+ agentId={agentId}
1278
+ agentName={agentName}
1279
+ open={open}
1280
+ onClose={() => { setOpen(false); doFetchPreview(); }}
1281
+ />
1282
+ </>
1283
+ );
1284
+
1285
+ }