agent-relay-server 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -1,501 +1,1133 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-bs-theme="dark">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Agent Relay</title>
7
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230d1117'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='%2358a6ff'/%3E%3Ccircle cx='6' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='6' cy='24' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='24' r='2.5' fill='%233fb950'/%3E%3Cline x1='8' y1='9.5' x2='13' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='9.5' x2='19' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='8' y1='22.5' x2='13' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='22.5' x2='19' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3C/svg%3E">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css">
9
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
7
10
  <style>
8
- * { margin: 0; padding: 0; box-sizing: border-box; }
9
- :root {
10
- --bg: #0d1117; --surface: #161b22; --border: #30363d;
11
- --text: #e6edf3; --text-dim: #7d8590; --accent: #58a6ff;
12
- --green: #3fb950; --yellow: #d29922; --red: #f85149; --blue: #58a6ff;
11
+ [x-cloak] { display: none !important; }
12
+
13
+ .ar-sidebar {
14
+ width: 250px; min-width: 250px; height: 100vh; position: sticky; top: 0;
15
+ background: var(--tblr-bg-surface); border-right: 1px solid var(--tblr-border-color);
16
+ display: flex; flex-direction: column;
13
17
  }
14
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
15
- .container { max-width: 1200px; margin: 0 auto; padding: 16px; overflow-x: hidden; }
16
-
17
- header { display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px; flex-wrap: wrap; gap: 8px; }
18
- header h1 { font-size: 20px; font-weight: 600; }
19
- .stats { display: flex; gap: 12px; font-size: 13px; color: var(--text-dim); flex-wrap: wrap; }
20
- .stats .num { color: var(--accent); font-weight: 600; font-size: 15px; }
21
-
22
- .grid { display: grid; grid-template-columns: 340px 1fr; gap: 24px; }
23
- @media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
24
-
25
- .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
26
- .panel-head { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); display: flex; justify-content: space-between; align-items: center; }
27
- .panel-body { padding: 8px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; }
28
-
29
- .agent { padding: 10px 12px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; transition: background 0.15s; }
30
- .agent:hover { background: #1c2128; }
31
- .agent.selected { background: #1c2128; border-left: 3px solid var(--accent); }
32
- .agent-name { font-weight: 600; font-size: 14px; }
33
- .agent-meta { font-size: 12px; color: var(--text-dim); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; }
34
- .agent-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
35
- .tag { background: #1c2128; border: 1px solid var(--border); border-radius: 12px; padding: 1px 8px; font-size: 11px; color: var(--text-dim); }
36
-
37
- .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
38
- .dot.online { background: var(--green); }
39
- .dot.idle { background: var(--yellow); }
40
- .dot.busy { background: var(--red); }
41
- .dot.offline { background: var(--text-dim); }
42
-
43
- .msg { padding: 10px 14px; border-radius: 6px; margin-bottom: 6px; background: #1c2128; position: relative; overflow: hidden; }
44
- .msg.thread-child { border-left: 2px solid var(--border); margin-left: 16px; }
45
- .msg-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 4px; flex-wrap: wrap; gap: 4px; }
46
- .msg-from { font-weight: 600; font-size: 13px; color: var(--accent); overflow: hidden; text-overflow: ellipsis; }
47
- .msg-to { font-size: 12px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; }
48
- .msg-time { font-size: 11px; color: var(--text-dim); }
49
- .msg-subject { font-size: 13px; font-weight: 600; margin-bottom: 2px; }
50
- .msg-body { font-size: 13px; white-space: pre-wrap; word-break: break-word; }
51
- .msg-channel { font-size: 11px; color: var(--yellow); margin-top: 4px; }
52
- .msg-footer { display: flex; gap: 12px; align-items: center; margin-top: 6px; }
53
- .msg-thread-info { font-size: 11px; color: var(--text-dim); }
54
- .msg-reply-btn, .msg-claim-btn, .msg-delete-btn { background: none; border: 1px solid var(--border); border-radius: 4px; color: var(--text-dim); font-size: 11px; padding: 1px 8px; cursor: pointer; }
55
- .msg-reply-btn:hover { color: var(--accent); border-color: var(--accent); }
56
- .msg-claim-btn:hover { color: var(--green); border-color: var(--green); }
57
- .msg-delete-btn:hover { color: var(--red); border-color: var(--red); }
58
- .agent-actions { float: right; display: flex; gap: 4px; opacity: 0; transition: opacity 0.15s; }
59
- .agent:hover .agent-actions { opacity: 1; }
60
- .agent-actions button { background: none; border: none; color: var(--text-dim); font-size: 13px; cursor: pointer; padding: 2px 4px; }
61
- .agent-actions button.delete:hover { color: var(--red); }
62
- .agent-actions button.rename:hover { color: var(--accent); }
63
- .agent-label { color: var(--yellow); font-weight: 600; }
64
- .msg-claimed { font-size: 11px; color: var(--green); }
65
- .msg-claimable { font-size: 11px; color: var(--yellow); }
66
- .msg.claimed { border-left: 2px solid var(--green); }
67
- .msg-thread-link { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: underline; }
68
- .reply-banner { display: flex; align-items: center; gap: 8px; padding: 6px 16px; background: var(--bg); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-dim); }
69
- .reply-banner .cancel { cursor: pointer; color: var(--red); margin-left: auto; }
70
-
71
- .thread-overlay { position: fixed; top: 0; right: 0; width: 450px; max-width: 100vw; height: 100vh; background: var(--surface); border-left: 1px solid var(--border); z-index: 100; display: flex; flex-direction: column; box-shadow: -4px 0 24px rgba(0,0,0,0.4); }
72
- .thread-overlay .panel-head { flex-shrink: 0; }
73
- .thread-overlay .panel-body { flex: 1; overflow-y: auto; }
74
- .thread-close { cursor: pointer; color: var(--text-dim); font-size: 18px; }
75
- .thread-close:hover { color: var(--text); }
76
-
77
- .compose { padding: 16px; border-top: 1px solid var(--border); }
78
- .compose-row { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
79
- .compose input, .compose textarea, .compose select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 6px 10px; font-size: 13px; font-family: inherit; }
80
- .compose input, .compose select { flex: 1; min-width: 0; }
81
- .compose textarea { width: 100%; min-height: 60px; resize: vertical; }
82
- .compose button { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 6px 16px; font-size: 13px; cursor: pointer; font-weight: 600; }
83
- .compose button:hover { opacity: 0.9; }
84
-
85
- .empty { text-align: center; padding: 40px 16px; color: var(--text-dim); font-size: 14px; }
86
- .filter-row { display: flex; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
87
- .filter-row select, .filter-row input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 4px 8px; font-size: 12px; flex: 1; min-width: 0; }
88
-
89
- @media (max-width: 500px) {
90
- .container { padding: 8px; }
91
- header { margin-bottom: 12px; }
92
- .grid { gap: 12px; }
93
- .compose-row { flex-direction: column; }
94
- .compose-row input, .compose-row select { width: 100%; }
95
- .msg-footer { flex-wrap: wrap; }
96
- .agent-meta { word-break: break-all; }
97
- .stats { gap: 8px; }
18
+ .ar-sidebar .nav-link { color: var(--tblr-body-color); border-radius: 6px; margin: 2px 8px; padding: 8px 12px; }
19
+ .ar-sidebar .nav-link:hover { background: var(--tblr-bg-surface-secondary); }
20
+ .ar-sidebar .nav-link.active { background: var(--tblr-primary); color: #fff; }
21
+ .ar-sidebar .nav-link .ti { font-size: 18px; margin-right: 8px; vertical-align: -2px; }
22
+
23
+ .ar-logo { padding: 16px 20px; font-weight: 700; font-size: 16px; border-bottom: 1px solid var(--tblr-border-color); }
24
+ .ar-logo .ti { color: var(--tblr-primary); margin-right: 6px; }
25
+
26
+ .ar-sidebar-footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid var(--tblr-border-color); font-size: 12px; }
27
+
28
+ .view-fit { display: flex; flex-direction: column; height: calc(100vh - 2rem); }
29
+
30
+ .status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
31
+ .status-dot.online { background: var(--tblr-success); box-shadow: 0 0 6px var(--tblr-success); }
32
+ .status-dot.online.not-ready { animation: pulse-dot 1.5s ease-in-out infinite; }
33
+ .status-dot.idle { background: var(--tblr-warning); }
34
+ .status-dot.busy { background: var(--tblr-danger); }
35
+ .status-dot.offline { background: var(--tblr-secondary); opacity: 0.5; }
36
+
37
+ @keyframes pulse-dot {
38
+ 0%, 100% { opacity: 1; box-shadow: 0 0 6px var(--tblr-success); }
39
+ 50% { opacity: 0.4; box-shadow: none; }
40
+ }
41
+
42
+ .agent-card { transition: background 0.15s; cursor: pointer; }
43
+ .agent-card:hover { background: var(--tblr-bg-surface-secondary); }
44
+ .agent-card.selected { border-left: 3px solid var(--tblr-primary); }
45
+ .agent-card .agent-actions { opacity: 0; transition: opacity 0.15s; }
46
+ .agent-card:hover .agent-actions { opacity: 1; }
47
+
48
+ .agent-label { color: var(--tblr-warning); font-weight: 600; }
49
+
50
+ .msg-card { border-left: 3px solid transparent; }
51
+ .msg-card.claimed { border-left-color: var(--tblr-success); }
52
+ .msg-card.thread-child { margin-left: 24px; border-left-color: var(--tblr-border-color); }
53
+
54
+ .msg-body { white-space: pre-wrap; word-break: break-word; font-size: 13px; }
55
+
56
+ .fade-in { animation: fadeIn 0.2s ease-in; }
57
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
58
+
59
+ .stat-card .ti { font-size: 32px; opacity: 0.3; }
60
+
61
+ @media (max-width: 768px) {
62
+ .ar-sidebar { display: none; }
63
+ .mobile-nav { display: flex !important; }
98
64
  }
99
65
  </style>
100
66
  </head>
101
67
  <body>
102
- <div class="container">
103
- <header>
104
- <h1>Agent Relay</h1>
105
- <div class="stats">
106
- <span>Agents: <span class="num" id="stat-agents">-</span></span>
107
- <span>Online: <span class="num" id="stat-online">-</span></span>
108
- <span>Messages (24h): <span class="num" id="stat-msgs24">-</span></span>
109
- <span>Total: <span class="num" id="stat-msgs">-</span></span>
110
- </div>
111
- </header>
112
-
113
- <div class="grid">
114
- <div class="panel">
115
- <div class="panel-head">
116
- Agents
117
- <label style="font-size:11px;font-weight:400;text-transform:none;">
118
- <input type="checkbox" id="show-offline" onchange="renderAgents(); updateComposeDropdowns();"> Show offline
119
- </label>
68
+ <div x-data="relay" x-init="init()" class="d-flex">
69
+
70
+ <!-- Sidebar -->
71
+ <aside class="ar-sidebar d-none d-md-flex">
72
+ <div class="ar-logo"><i class="ti ti-vector-triangle"></i>Agent Relay</div>
73
+ <nav class="nav flex-column py-2">
74
+ <a href="#" class="nav-link" :class="{ active: view === 'overview' }" @click.prevent="switchView('overview')">
75
+ <i class="ti ti-dashboard"></i>Overview
76
+ </a>
77
+ <a href="#" class="nav-link" :class="{ active: view === 'agents' }" @click.prevent="switchView('agents')">
78
+ <i class="ti ti-robot"></i>Agents
79
+ <span class="badge bg-success text-white ms-auto" x-text="onlineCount"></span>
80
+ </a>
81
+ <a href="#" class="nav-link" :class="{ active: view === 'messages' }" @click.prevent="switchView('messages')">
82
+ <i class="ti ti-messages"></i>Messages
83
+ </a>
84
+ <a href="#" class="nav-link" :class="{ active: view === 'analytics' }" @click.prevent="switchView('analytics')">
85
+ <i class="ti ti-chart-area-line"></i>Analytics
86
+ </a>
87
+ </nav>
88
+ <div class="ar-sidebar-footer">
89
+ <div class="d-flex align-items-center gap-2 mb-2">
90
+ <span class="status-dot" :class="connected ? 'online' : 'offline'"></span>
91
+ <span class="small" x-text="connected ? 'Live' : 'Reconnecting…'"></span>
120
92
  </div>
121
- <div class="panel-body" id="agent-list">
122
- <div class="empty">No agents registered</div>
93
+ <div class="d-flex align-items-center gap-2 mb-2">
94
+ <label class="form-check form-switch mb-0">
95
+ <input type="checkbox" class="form-check-input" x-model="showOffline">
96
+ <span class="form-check-label small">Show offline</span>
97
+ </label>
123
98
  </div>
99
+ <div class="text-muted small" x-show="stats.version" x-text="'v' + stats.version"></div>
124
100
  </div>
101
+ </aside>
125
102
 
126
- <div class="panel" style="display:flex;flex-direction:column;">
127
- <div class="panel-head">
128
- Messages
129
- <label style="font-size:11px;font-weight:400;text-transform:none;">
130
- <input type="checkbox" id="auto-refresh" checked> Auto-refresh
131
- </label>
103
+ <!-- Mobile nav -->
104
+ <div class="mobile-nav d-none border-bottom p-2 gap-1 position-fixed top-0 w-100 bg-dark" style="z-index:50">
105
+ <template x-for="v in ['overview','agents','messages','analytics']">
106
+ <button class="btn btn-sm" :class="view === v ? 'btn-primary' : 'btn-ghost-secondary'" @click="switchView(v)" x-text="v.charAt(0).toUpperCase() + v.slice(1)"></button>
107
+ </template>
108
+ </div>
109
+
110
+ <!-- Main content -->
111
+ <main class="flex-grow-1 overflow-auto" style="height: 100vh">
112
+ <div class="container-xl py-3">
113
+
114
+ <!-- ==================== OVERVIEW ==================== -->
115
+ <div x-show="view === 'overview'" x-cloak class="fade-in">
116
+
117
+ <!-- Stats row -->
118
+ <div class="row g-3 mb-4">
119
+ <div class="col-sm-6 col-lg-3">
120
+ <div class="card">
121
+ <div class="card-body">
122
+ <div class="d-flex align-items-center">
123
+ <div>
124
+ <div class="text-secondary small">Total Agents</div>
125
+ <div class="h1 mb-0" x-text="stats.agents ?? 0"></div>
126
+ </div>
127
+ <i class="ti ti-robot ms-auto stat-card"></i>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ <div class="col-sm-6 col-lg-3">
133
+ <div class="card">
134
+ <div class="card-body">
135
+ <div class="d-flex align-items-center">
136
+ <div>
137
+ <div class="text-secondary small">Online</div>
138
+ <div class="h1 mb-0 text-success" x-text="onlineCount"></div>
139
+ </div>
140
+ <i class="ti ti-circle-check ms-auto stat-card"></i>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ <div class="col-sm-6 col-lg-3">
146
+ <div class="card">
147
+ <div class="card-body">
148
+ <div class="d-flex align-items-center">
149
+ <div>
150
+ <div class="text-secondary small">Messages (24h)</div>
151
+ <div class="h1 mb-0 text-info" x-text="stats.messages24h ?? 0"></div>
152
+ </div>
153
+ <i class="ti ti-mail ms-auto stat-card"></i>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ <div class="col-sm-6 col-lg-3">
159
+ <div class="card">
160
+ <div class="card-body">
161
+ <div class="d-flex align-items-center">
162
+ <div>
163
+ <div class="text-secondary small">Total Messages</div>
164
+ <div class="h1 mb-0" x-text="stats.messages ?? 0"></div>
165
+ </div>
166
+ <i class="ti ti-database ms-auto stat-card"></i>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Two-column: Agents + Recent messages -->
174
+ <div class="row g-3">
175
+ <div class="col-lg-5">
176
+ <div class="card">
177
+ <div class="card-header d-flex align-items-center">
178
+ <h3 class="card-title">Agents</h3>
179
+ <span class="badge bg-success text-white ms-auto" x-text="onlineCount + ' online'"></span>
180
+ </div>
181
+ <div class="list-group list-group-flush" style="max-height: 60vh; overflow-y: auto">
182
+ <template x-for="a in sortedAgents.slice(0, 20)" :key="a.id">
183
+ <div class="list-group-item d-flex align-items-center gap-2" style="cursor:pointer" @click="selectedAgent = a.id; switchView('messages')">
184
+ <span class="status-dot" :class="[a.status, a.status !== 'offline' && !a.ready ? 'not-ready' : '']"></span>
185
+ <div class="flex-grow-1 min-width-0">
186
+ <div class="text-truncate">
187
+ <template x-if="a.label">
188
+ <span><span class="agent-label" x-text="a.label"></span> <span class="text-secondary small" x-text="a.name || a.id.slice(-8)"></span></span>
189
+ </template>
190
+ <template x-if="!a.label">
191
+ <span x-text="a.name || a.id.slice(-12)"></span>
192
+ </template>
193
+ </div>
194
+ <div class="text-secondary small text-truncate" x-text="a.id"></div>
195
+ </div>
196
+ <span class="text-secondary small" x-text="timeAgo(a.lastSeen)"></span>
197
+ </div>
198
+ </template>
199
+ <template x-if="sortedAgents.length === 0">
200
+ <div class="list-group-item text-secondary text-center">No agents</div>
201
+ </template>
202
+ </div>
203
+ </div>
204
+ </div>
205
+
206
+ <div class="col-lg-7">
207
+ <div class="card">
208
+ <div class="card-header d-flex align-items-center">
209
+ <h3 class="card-title">Recent Messages</h3>
210
+ <span class="badge bg-info text-white ms-auto" x-text="messages.length + ' loaded'"></span>
211
+ </div>
212
+ <div class="card-body p-0" style="max-height: 60vh; overflow-y: auto">
213
+ <template x-for="m in messages.slice(0, 15)" :key="m.id">
214
+ <div class="p-3 border-bottom">
215
+ <div class="d-flex align-items-center gap-2 mb-1">
216
+ <span class="fw-bold small" x-text="displayTarget(m.from)"></span>
217
+ <i class="ti ti-arrow-right text-secondary" style="font-size:12px"></i>
218
+ <span class="small" x-text="displayTarget(m.to)"></span>
219
+ <span class="text-secondary small ms-auto" x-text="'#' + m.id"></span>
220
+ </div>
221
+ <div class="msg-body text-secondary" x-text="(m.body || '').slice(0, 200) + (m.body?.length > 200 ? '…' : '')"></div>
222
+ <div class="d-flex gap-2 mt-1">
223
+ <span class="text-secondary small" x-text="timeAgo(m.createdAt)"></span>
224
+ <template x-if="m.channel">
225
+ <span class="badge bg-warning-lt small" x-text="'#' + m.channel"></span>
226
+ </template>
227
+ </div>
228
+ </div>
229
+ </template>
230
+ <template x-if="messages.length === 0">
231
+ <div class="p-3 text-secondary text-center">No messages</div>
232
+ </template>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
132
237
  </div>
133
- <div class="filter-row">
134
- <select id="filter-agent"><option value="">All agents</option></select>
135
- <input id="filter-channel" placeholder="Channel filter..." />
238
+
239
+ <!-- ==================== AGENTS ==================== -->
240
+ <div x-show="view === 'agents'" x-cloak class="fade-in">
241
+
242
+ <div class="d-flex align-items-center mb-3 gap-3 flex-wrap">
243
+ <h2 class="page-title mb-0">Agents</h2>
244
+ <div class="ms-auto d-flex gap-2 align-items-center">
245
+ <select class="form-select form-select-sm" style="width:auto" x-model="agentSort">
246
+ <option value="status">Sort: Status</option>
247
+ <option value="name">Sort: Name</option>
248
+ <option value="lastSeen">Sort: Last seen</option>
249
+ <option value="created">Sort: Created</option>
250
+ </select>
251
+ <button class="btn btn-sm btn-ghost-secondary" @click="agentSortDir = agentSortDir === 'asc' ? 'desc' : 'asc'">
252
+ <i class="ti" :class="agentSortDir === 'asc' ? 'ti-sort-ascending' : 'ti-sort-descending'"></i>
253
+ </button>
254
+ </div>
255
+ </div>
256
+
257
+ <div class="row g-3">
258
+ <template x-for="a in sortedAgents" :key="a.id">
259
+ <div class="col-md-6 col-xl-4">
260
+ <div class="card agent-card" :class="{ selected: selectedAgent === a.id }">
261
+ <div class="card-body">
262
+ <div class="d-flex align-items-start gap-2">
263
+ <span class="status-dot mt-1" :class="[a.status, a.status !== 'offline' && !a.ready ? 'not-ready' : '']" :title="a.status !== 'offline' && !a.ready ? 'Starting up…' : a.status"></span>
264
+ <div class="flex-grow-1 min-width-0">
265
+ <div class="d-flex align-items-center gap-2">
266
+ <template x-if="a.label">
267
+ <span class="agent-label text-truncate" x-text="a.label"></span>
268
+ </template>
269
+ <span class="text-truncate" :class="a.label ? 'text-secondary small' : 'fw-bold'" x-text="a.name || a.id.slice(-12)"></span>
270
+ </div>
271
+ <div class="text-secondary small text-truncate mt-1" x-text="a.id"></div>
272
+ <div class="d-flex gap-1 mt-1 flex-wrap">
273
+ <template x-if="a.machine">
274
+ <span class="badge bg-secondary-lt" x-text="a.machine"></span>
275
+ </template>
276
+ <template x-if="a.rig">
277
+ <span class="badge bg-primary-lt" x-text="a.rig"></span>
278
+ </template>
279
+ <template x-for="tag in (a.tags || [])" :key="tag">
280
+ <span class="badge bg-cyan-lt" x-text="tag"></span>
281
+ </template>
282
+ </div>
283
+ <template x-if="a.capabilities && a.capabilities.length">
284
+ <div class="d-flex gap-1 mt-1 flex-wrap">
285
+ <template x-for="cap in a.capabilities" :key="cap">
286
+ <span class="badge bg-purple-lt" x-text="'⚡' + cap"></span>
287
+ </template>
288
+ </div>
289
+ </template>
290
+ <div class="text-secondary small mt-2">
291
+ <span x-text="'Last seen: ' + timeAgo(a.lastSeen)"></span>
292
+ <span class="ms-2" x-text="'Created: ' + fmtTime(a.createdAt)"></span>
293
+ </div>
294
+ </div>
295
+ <div class="agent-actions d-flex gap-1">
296
+ <button class="btn btn-sm btn-ghost-secondary p-1" title="Send message" @click.stop="openComposeToAgent(a)">
297
+ <i class="ti ti-send"></i>
298
+ </button>
299
+ <button class="btn btn-sm btn-ghost-secondary p-1" title="Rename" @click.stop="openRename(a)">
300
+ <i class="ti ti-pencil"></i>
301
+ </button>
302
+ <button class="btn btn-sm btn-ghost-secondary p-1" title="Filter messages" @click.stop="selectedAgent = a.id; switchView('messages')">
303
+ <i class="ti ti-filter"></i>
304
+ </button>
305
+ <template x-if="a.status === 'offline'">
306
+ <button class="btn btn-sm btn-ghost-danger p-1" title="Delete" @click.stop="openConfirm('Delete Agent', 'Delete agent ' + displayName(a) + '? This cannot be undone.', () => doDeleteAgent(a.id))">
307
+ <i class="ti ti-trash"></i>
308
+ </button>
309
+ </template>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ </div>
314
+ </div>
315
+ </template>
316
+ </div>
317
+
318
+ <template x-if="sortedAgents.length === 0">
319
+ <div class="card">
320
+ <div class="card-body text-center text-secondary py-5">
321
+ <i class="ti ti-robot-off" style="font-size:48px; opacity:0.3"></i>
322
+ <p class="mt-2" x-text="showOffline ? 'No agents registered' : 'No agents online — enable Show Offline'"></p>
323
+ </div>
324
+ </div>
325
+ </template>
136
326
  </div>
137
- <div class="panel-body" id="msg-list" style="flex:1;">
138
- <div class="empty">No messages</div>
327
+
328
+ <!-- ==================== MESSAGES ==================== -->
329
+ <div x-show="view === 'messages'" x-cloak class="fade-in">
330
+
331
+ <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
332
+ <h2 class="page-title mb-0">Messages</h2>
333
+ <div class="ms-auto d-flex gap-2 align-items-center flex-wrap">
334
+ <select class="form-select form-select-sm" style="width: auto; min-width: 160px" x-model="selectedAgent" @change="fetchMessages()">
335
+ <option value="">All agents</option>
336
+ <template x-for="a in agents" :key="a.id">
337
+ <option :value="a.id" x-text="displayName(a) + ' [' + a.id.slice(-6) + ']'"></option>
338
+ </template>
339
+ </select>
340
+ <select class="form-select form-select-sm" style="width: auto; min-width: 120px" x-model="tagFilter">
341
+ <option value="">All tags</option>
342
+ <template x-for="tag in uniqueTags" :key="tag">
343
+ <option :value="tag" x-text="'# ' + tag"></option>
344
+ </template>
345
+ </select>
346
+ <input type="text" class="form-control form-control-sm" style="width: 140px" placeholder="Channel filter" x-model.debounce.300ms="channelFilter" @input="fetchMessages()">
347
+ <button class="btn btn-sm btn-primary" @click="openCompose()">
348
+ <i class="ti ti-send me-1"></i>Compose
349
+ </button>
350
+ </div>
351
+ </div>
352
+
353
+ <!-- Reply banner -->
354
+ <template x-if="replyTo">
355
+ <div class="alert alert-info d-flex align-items-center py-2 mb-3">
356
+ <i class="ti ti-corner-up-left me-2"></i>
357
+ <span>Replying to <strong x-text="'#' + replyTo.id"></strong> from <strong x-text="displayTarget(replyTo.from)"></strong></span>
358
+ <button class="btn btn-sm btn-ghost-secondary ms-auto" @click="cancelReply()"><i class="ti ti-x"></i></button>
359
+ </div>
360
+ </template>
361
+
362
+ <!-- Message feed -->
363
+ <div class="card">
364
+ <div class="card-body p-0" style="max-height: calc(100vh - 180px); overflow-y: auto">
365
+ <template x-for="group in groupedMessages" :key="group.threadId">
366
+ <div>
367
+ <template x-for="m in group.messages" :key="m.id">
368
+ <div class="msg-card p-3 border-bottom" :class="{ claimed: m.claimedBy, 'thread-child': m.id !== group.threadId }">
369
+ <div class="d-flex align-items-center gap-2 mb-1">
370
+ <span class="fw-bold small" x-text="displayTarget(m.from)"></span>
371
+ <i class="ti ti-arrow-right text-secondary" style="font-size:12px"></i>
372
+ <span class="small" x-text="displayTarget(m.to)"></span>
373
+ <span class="text-secondary small ms-auto" x-text="'#' + m.id + ' · ' + fmtTime(m.createdAt)"></span>
374
+ </div>
375
+ <template x-if="m.subject">
376
+ <div class="fw-bold small mb-1" x-text="m.subject"></div>
377
+ </template>
378
+ <div class="msg-body" x-text="m.body"></div>
379
+ <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
380
+ <button class="btn btn-sm btn-ghost-primary py-0 px-1" @click="startReply(m)">
381
+ <i class="ti ti-corner-up-left" style="font-size:14px"></i> Reply
382
+ </button>
383
+ <template x-if="m.claimable && !m.claimedBy">
384
+ <button class="btn btn-sm btn-ghost-warning py-0 px-1" @click="doClaim(m.id)">
385
+ <i class="ti ti-hand-grab" style="font-size:14px"></i> Claim
386
+ </button>
387
+ </template>
388
+ <button class="btn btn-sm btn-ghost-danger py-0 px-1" @click="openConfirm('Delete Message', 'Delete message #' + m.id + '?', () => doDeleteMessage(m.id))">
389
+ <i class="ti ti-trash" style="font-size:14px"></i>
390
+ </button>
391
+ <template x-if="m.channel">
392
+ <span class="badge bg-warning-lt" x-text="'#' + m.channel"></span>
393
+ </template>
394
+ <template x-if="m.claimedBy">
395
+ <span class="badge bg-success-lt" x-text="'claimed by ' + displayTarget(m.claimedBy)"></span>
396
+ </template>
397
+ <template x-if="m.claimable && !m.claimedBy">
398
+ <span class="badge bg-warning-lt">claimable</span>
399
+ </template>
400
+ <template x-if="m.id === group.threadId && group.messages.length > 1">
401
+ <button class="btn btn-sm btn-ghost-secondary py-0 px-1 ms-auto" @click="openThread(group.threadId)">
402
+ <i class="ti ti-messages" style="font-size:14px"></i>
403
+ <span x-text="(group.messages.length - 1) + ' replies'"></span>
404
+ </button>
405
+ </template>
406
+ <template x-if="m.replyTo && m.id !== group.threadId">
407
+ <span class="text-secondary small ms-auto" x-text="'reply to #' + m.replyTo"></span>
408
+ </template>
409
+ </div>
410
+ </div>
411
+ </template>
412
+ </div>
413
+ </template>
414
+ <template x-if="filteredMessages.length === 0">
415
+ <div class="p-4 text-center text-secondary">
416
+ <i class="ti ti-messages-off" style="font-size:48px; opacity:0.3"></i>
417
+ <p class="mt-2">No messages</p>
418
+ </div>
419
+ </template>
420
+ </div>
421
+ </div>
139
422
  </div>
140
- <div id="reply-banner" class="reply-banner" style="display:none;">
141
- Replying to <strong id="reply-to-label"></strong>
142
- <span class="cancel" onclick="cancelReply()">&times; Cancel</span>
423
+
424
+ <!-- ==================== ANALYTICS ==================== -->
425
+ <div x-show="view === 'analytics'" x-cloak class="fade-in">
426
+ <h2 class="page-title mb-3">Analytics</h2>
427
+
428
+ <div class="row g-3 mb-3">
429
+ <div class="col-sm-6 col-lg-3">
430
+ <div class="card">
431
+ <div class="card-body text-center">
432
+ <div class="text-secondary small">Total Agents</div>
433
+ <div class="h2 mb-0" x-text="stats.agents ?? 0"></div>
434
+ </div>
435
+ </div>
436
+ </div>
437
+ <div class="col-sm-6 col-lg-3">
438
+ <div class="card">
439
+ <div class="card-body text-center">
440
+ <div class="text-secondary small">Online Now</div>
441
+ <div class="h2 mb-0 text-success" x-text="onlineCount"></div>
442
+ </div>
443
+ </div>
444
+ </div>
445
+ <div class="col-sm-6 col-lg-3">
446
+ <div class="card">
447
+ <div class="card-body text-center">
448
+ <div class="text-secondary small">Messages (24h)</div>
449
+ <div class="h2 mb-0 text-info" x-text="stats.messages24h ?? 0"></div>
450
+ </div>
451
+ </div>
452
+ </div>
453
+ <div class="col-sm-6 col-lg-3">
454
+ <div class="card">
455
+ <div class="card-body text-center">
456
+ <div class="text-secondary small">All-time Messages</div>
457
+ <div class="h2 mb-0" x-text="stats.messages ?? 0"></div>
458
+ </div>
459
+ </div>
460
+ </div>
461
+ </div>
462
+
463
+ <div class="row g-3">
464
+ <div class="col-lg-8">
465
+ <div class="card">
466
+ <div class="card-header"><h3 class="card-title">Message Volume</h3></div>
467
+ <div class="card-body">
468
+ <div id="chart-volume" style="height: 280px"></div>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ <div class="col-lg-4">
473
+ <div class="card">
474
+ <div class="card-header"><h3 class="card-title">Agent Status</h3></div>
475
+ <div class="card-body">
476
+ <div id="chart-status" style="height: 280px"></div>
477
+ </div>
478
+ </div>
479
+ </div>
480
+ </div>
481
+
482
+ <div class="row g-3 mt-0">
483
+ <div class="col-lg-12">
484
+ <div class="card">
485
+ <div class="card-header"><h3 class="card-title">Messages per Agent (Top 10)</h3></div>
486
+ <div class="card-body">
487
+ <div id="chart-agents" style="height: 280px"></div>
488
+ </div>
489
+ </div>
490
+ </div>
491
+ </div>
143
492
  </div>
144
- <div class="compose">
145
- <div class="compose-row">
146
- <select id="c-from"><option value="">From...</option></select>
147
- <select id="c-to"><option value="">To...</option></select>
493
+
494
+ </div>
495
+ </main>
496
+
497
+ <!-- ==================== COMPOSE MODAL ==================== -->
498
+ <div class="modal modal-blur" :class="{ show: composeOpen }" :style="composeOpen ? 'display:block' : 'display:none'" tabindex="-1" @click.self="composeOpen = false">
499
+ <div class="modal-dialog modal-lg modal-dialog-centered">
500
+ <div class="modal-content">
501
+ <div class="modal-header">
502
+ <h5 class="modal-title">
503
+ <template x-if="replyTo">
504
+ <span>Reply to #<span x-text="replyTo.id"></span></span>
505
+ </template>
506
+ <template x-if="!replyTo">
507
+ <span>Compose Message</span>
508
+ </template>
509
+ </h5>
510
+ <button class="btn-close" @click="composeOpen = false"></button>
148
511
  </div>
149
- <div class="compose-row">
150
- <input id="c-channel" placeholder="Channel (optional)" />
151
- <input id="c-subject" placeholder="Subject (optional)" />
512
+ <div class="modal-body">
513
+ <div class="row g-3">
514
+ <div class="col-md-6">
515
+ <label class="form-label">From</label>
516
+ <select class="form-select" x-model="compose.from">
517
+ <option value="">Select sender…</option>
518
+ <template x-for="a in composeAgents" :key="a.id">
519
+ <option :value="a.id" x-text="displayName(a) + ' [' + a.id.slice(-6) + ']'"></option>
520
+ </template>
521
+ </select>
522
+ </div>
523
+ <div class="col-md-6">
524
+ <label class="form-label">To</label>
525
+ <select class="form-select" x-model="compose.to">
526
+ <option value="">Select recipient…</option>
527
+ <option value="broadcast">📢 Broadcast (all)</option>
528
+ <optgroup label="Labels">
529
+ <template x-for="lbl in uniqueLabels" :key="lbl">
530
+ <option :value="'label:' + lbl" x-text="'★ ' + lbl"></option>
531
+ </template>
532
+ </optgroup>
533
+ <optgroup label="Capabilities">
534
+ <template x-for="cap in uniqueCaps" :key="cap">
535
+ <option :value="'cap:' + cap" x-text="'⚡ ' + cap"></option>
536
+ </template>
537
+ </optgroup>
538
+ <optgroup label="Tags">
539
+ <template x-for="tag in uniqueTags" :key="tag">
540
+ <option :value="'tag:' + tag" x-text="'# ' + tag"></option>
541
+ </template>
542
+ </optgroup>
543
+ <optgroup label="Agents">
544
+ <template x-for="a in composeAgents" :key="a.id">
545
+ <option :value="a.id" x-text="displayName(a) + ' [' + a.id.slice(-6) + ']'"></option>
546
+ </template>
547
+ </optgroup>
548
+ </select>
549
+ </div>
550
+ <div class="col-md-6">
551
+ <label class="form-label">Channel <span class="text-secondary">(optional)</span></label>
552
+ <input type="text" class="form-control" x-model="compose.channel" placeholder="e.g. general">
553
+ </div>
554
+ <div class="col-md-6">
555
+ <label class="form-label">Subject <span class="text-secondary">(optional)</span></label>
556
+ <input type="text" class="form-control" x-model="compose.subject">
557
+ </div>
558
+ <div class="col-12">
559
+ <label class="form-label">Message</label>
560
+ <textarea class="form-control" rows="5" x-model="compose.body" placeholder="Type your message…" x-ref="composeBody"></textarea>
561
+ </div>
562
+ <div class="col-12">
563
+ <label class="form-check">
564
+ <input type="checkbox" class="form-check-input" x-model="compose.claimable">
565
+ <span class="form-check-label">Claimable task (only one agent can claim)</span>
566
+ </label>
567
+ </div>
568
+ </div>
152
569
  </div>
153
- <textarea id="c-body" placeholder="Message body..."></textarea>
154
- <div style="margin-top:8px;display:flex;justify-content:space-between;align-items:center;">
155
- <label style="font-size:11px;color:var(--text-dim);"><input type="checkbox" id="c-claimable"> Claimable</label>
156
- <button onclick="doSend()">Send</button>
570
+ <div class="modal-footer">
571
+ <button class="btn btn-ghost-secondary" @click="composeOpen = false">Cancel</button>
572
+ <button class="btn btn-primary" @click="doSend()">
573
+ <i class="ti ti-send me-1"></i>Send
574
+ </button>
157
575
  </div>
158
576
  </div>
159
577
  </div>
160
578
  </div>
161
- </div>
579
+ <div class="modal-backdrop fade show" x-show="composeOpen" x-cloak></div>
580
+
581
+ <!-- ==================== THREAD MODAL ==================== -->
582
+ <div class="modal modal-blur" :class="{ show: threadOpen }" :style="threadOpen ? 'display:block' : 'display:none'" tabindex="-1" @click.self="threadOpen = false">
583
+ <div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
584
+ <div class="modal-content">
585
+ <div class="modal-header">
586
+ <h5 class="modal-title">Thread</h5>
587
+ <button class="btn-close" @click="threadOpen = false"></button>
588
+ </div>
589
+ <div class="modal-body p-0">
590
+ <template x-for="m in threadMessages" :key="m.id">
591
+ <div class="p-3 border-bottom" :class="{ 'ms-4 border-start': m.id !== threadMessages[0]?.id }">
592
+ <div class="d-flex align-items-center gap-2 mb-1">
593
+ <span class="fw-bold small" x-text="displayTarget(m.from)"></span>
594
+ <i class="ti ti-arrow-right text-secondary" style="font-size:12px"></i>
595
+ <span class="small" x-text="displayTarget(m.to)"></span>
596
+ <span class="text-secondary small ms-auto" x-text="'#' + m.id + ' · ' + fmtTime(m.createdAt)"></span>
597
+ </div>
598
+ <template x-if="m.subject">
599
+ <div class="fw-bold small mb-1" x-text="m.subject"></div>
600
+ </template>
601
+ <div class="msg-body" x-text="m.body"></div>
602
+ </div>
603
+ </template>
604
+ <template x-if="threadMessages.length === 0">
605
+ <div class="p-4 text-center text-secondary">Loading…</div>
606
+ </template>
607
+ </div>
608
+ <div class="modal-footer">
609
+ <button class="btn btn-ghost-secondary" @click="threadOpen = false">Close</button>
610
+ </div>
611
+ </div>
612
+ </div>
613
+ </div>
614
+ <div class="modal-backdrop fade show" x-show="threadOpen" x-cloak></div>
162
615
 
163
- <div id="thread-overlay" class="thread-overlay" style="display:none;">
164
- <div class="panel-head">
165
- Thread
166
- <span class="thread-close" onclick="closeThread()">&times;</span>
616
+ <!-- ==================== CONFIRM MODAL ==================== -->
617
+ <div class="modal modal-blur" :class="{ show: confirmModal.show }" :style="confirmModal.show ? 'display:block' : 'display:none'" tabindex="-1" @click.self="confirmModal.show = false">
618
+ <div class="modal-dialog modal-sm modal-dialog-centered">
619
+ <div class="modal-content">
620
+ <div class="modal-header">
621
+ <h5 class="modal-title" x-text="confirmModal.title"></h5>
622
+ <button class="btn-close" @click="confirmModal.show = false"></button>
623
+ </div>
624
+ <div class="modal-body" x-text="confirmModal.message"></div>
625
+ <div class="modal-footer">
626
+ <button class="btn btn-ghost-secondary" @click="confirmModal.show = false">Cancel</button>
627
+ <button class="btn btn-danger" @click="confirmModal.action?.(); confirmModal.show = false">Confirm</button>
628
+ </div>
629
+ </div>
630
+ </div>
631
+ </div>
632
+ <div class="modal-backdrop fade show" x-show="confirmModal.show" x-cloak></div>
633
+
634
+ <!-- ==================== RENAME MODAL ==================== -->
635
+ <div class="modal modal-blur" :class="{ show: renameModal.show }" :style="renameModal.show ? 'display:block' : 'display:none'" tabindex="-1" @click.self="renameModal.show = false">
636
+ <div class="modal-dialog modal-sm modal-dialog-centered">
637
+ <div class="modal-content">
638
+ <div class="modal-header">
639
+ <h5 class="modal-title">Rename Agent</h5>
640
+ <button class="btn-close" @click="renameModal.show = false"></button>
641
+ </div>
642
+ <div class="modal-body">
643
+ <label class="form-label">Label</label>
644
+ <input type="text" class="form-control" x-model="renameModal.label" placeholder="Human-friendly label (empty to clear)" x-ref="renameInput"
645
+ @keydown.enter="doRename()">
646
+ </div>
647
+ <div class="modal-footer">
648
+ <button class="btn btn-ghost-secondary" @click="renameModal.show = false">Cancel</button>
649
+ <button class="btn btn-primary" @click="doRename()">Save</button>
650
+ </div>
651
+ </div>
652
+ </div>
167
653
  </div>
168
- <div class="panel-body" id="thread-list"></div>
654
+ <div class="modal-backdrop fade show" x-show="renameModal.show" x-cloak></div>
655
+
169
656
  </div>
170
657
 
658
+ <script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
659
+ <script src="https://cdn.jsdelivr.net/npm/apexcharts@latest/dist/apexcharts.min.js"></script>
660
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
171
661
  <script>
172
- const API = window.location.origin + '/api';
173
- let agents = [];
174
- let agentsById = {};
175
- let messages = [];
176
- let selectedAgent = null;
177
- let replyTo = null; // {id, from, body} of message being replied to
178
-
179
- // Display helpers — prefer label, fallback to name, fallback to id
180
- function displayName(a) { return a?.label || a?.name || a?.id || '?'; }
181
- function displayById(id) {
182
- if (!id) return '';
183
- if (id.startsWith('tag:')) return '#' + id.slice(4);
184
- if (id.startsWith('cap:')) return '⚡' + id.slice(4);
185
- if (id.startsWith('label:')) return '★' + id.slice(6);
186
- if (id === 'broadcast') return '📢 broadcast';
187
- const a = agentsById[id];
188
- return a ? displayName(a) : id;
189
- }
662
+ function relay() {
663
+ const load = (key, fallback) => {
664
+ try { const v = localStorage.getItem('ar-' + key); return v !== null ? JSON.parse(v) : fallback; }
665
+ catch { return fallback; }
666
+ };
190
667
 
191
- async function fetchStats() {
192
- try {
193
- const r = await fetch(`${API}/stats`);
194
- const s = await r.json();
195
- document.getElementById('stat-agents').textContent = s.agents;
196
- document.getElementById('stat-online').textContent = s.online;
197
- document.getElementById('stat-msgs24').textContent = s.messagesLast24h;
198
- document.getElementById('stat-msgs').textContent = s.messages;
199
- } catch {}
200
- }
668
+ return {
669
+ // Navigation
670
+ view: load('view', 'overview'),
201
671
 
202
- async function fetchAgents() {
203
- try {
204
- const r = await fetch(`${API}/agents`);
205
- agents = await r.json();
206
- agentsById = Object.fromEntries(agents.map(a => [a.id, a]));
207
- renderAgents();
208
- updateAgentFilter();
209
- } catch {}
210
- }
672
+ // Persisted preferences
673
+ showOffline: load('showOffline', false),
674
+ autoRefresh: load('autoRefresh', true),
675
+ agentSort: load('agentSort', 'status'),
676
+ agentSortDir: load('agentSortDir', 'asc'),
211
677
 
212
- async function fetchMessages() {
213
- try {
214
- const params = new URLSearchParams();
215
- const agentId = document.getElementById('filter-agent').value;
216
- const channel = document.getElementById('filter-channel').value;
217
- if (agentId) params.set('for', agentId);
218
- if (channel) params.set('channel', channel);
219
- params.set('limit', '100');
220
- const r = await fetch(`${API}/messages?${params}`);
221
- messages = await r.json();
222
- renderMessages();
223
- } catch {}
224
- }
678
+ // Data
679
+ agents: [],
680
+ agentsById: {},
681
+ messages: [],
682
+ stats: {},
225
683
 
226
- function renderAgents() {
227
- const el = document.getElementById('agent-list');
228
- const showOffline = document.getElementById('show-offline').checked;
229
- const visible = showOffline ? agents : agents.filter(a => a.status !== 'offline');
230
- if (!visible.length) { el.innerHTML = '<div class="empty">' + (agents.length ? 'All agents offline' : 'No agents registered') + '</div>'; return; }
231
- el.innerHTML = visible.map(a => {
232
- const deletable = a.status === 'offline' && a.id !== 'user';
233
- const renameable = a.id !== 'user';
234
- const actions = `
235
- <span class="agent-actions">
236
- ${renameable ? `<button class="rename" onclick="event.stopPropagation(); doRenameAgent('${esc(a.id)}')" title="Rename (set label)">✎</button>` : ''}
237
- ${deletable ? `<button class="delete" onclick="event.stopPropagation(); doDeleteAgent('${esc(a.id)}')" title="Delete agent">&times;</button>` : ''}
238
- </span>`;
239
- const nameHtml = a.label
240
- ? `<span class="agent-label">${esc(a.label)}</span> <span style="color:var(--text-dim);font-size:11px;">(${esc(a.name)})</span>`
241
- : esc(a.name);
242
- return `
243
- <div class="agent ${selectedAgent === a.id ? 'selected' : ''}" onclick="selectAgent('${esc(a.id)}')">
244
- ${actions}
245
- <div class="agent-name"><span class="dot ${a.status}"></span>${nameHtml}</div>
246
- <div class="agent-meta">${esc(a.id)} ${a.machine ? '@ ' + esc(a.machine) : ''} ${a.rig ? '(' + esc(a.rig) + ')' : ''} &middot; ${new Date(a.createdAt).toLocaleTimeString()}</div>
247
- ${a.tags.length ? `<div class="agent-tags">${a.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>` : ''}
248
- </div>`;
249
- }).join('');
250
- }
684
+ // UI state
685
+ selectedAgent: '',
686
+ replyTo: null,
687
+ composeOpen: false,
688
+ threadOpen: false,
689
+ threadMessages: [],
690
+ connected: false,
251
691
 
252
- async function doRenameAgent(id) {
253
- const current = agentsById[id]?.label || '';
254
- const next = prompt(`Label for "${id}" (blank to clear):`, current);
255
- if (next === null) return;
256
- const r = await fetch(`${API}/agents/${encodeURIComponent(id)}/label`, {
257
- method: 'PATCH',
258
- headers: { 'Content-Type': 'application/json' },
259
- body: JSON.stringify({ label: next.trim() || null }),
260
- });
261
- if (!r.ok) {
262
- const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
263
- alert(`Rename failed: ${err.error || 'unknown error'}`);
264
- return;
265
- }
266
- refresh();
267
- }
692
+ // Compose form
693
+ compose: { from: '', to: '', body: '', channel: '', subject: '', claimable: false },
268
694
 
269
- async function doDeleteAgent(id) {
270
- if (!confirm(`Delete agent "${id}"? This also releases any tasks they've claimed.`)) return;
271
- const r = await fetch(`${API}/agents/${encodeURIComponent(id)}`, { method: 'DELETE' });
272
- if (!r.ok) {
273
- const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
274
- alert(`Delete failed: ${err.error || 'unknown error'}`);
275
- return;
276
- }
277
- if (selectedAgent === id) selectedAgent = null;
278
- refresh();
279
- }
695
+ // Modals
696
+ confirmModal: { show: false, title: '', message: '', action: null },
697
+ renameModal: { show: false, agentId: '', label: '' },
280
698
 
281
- function threadCounts() {
282
- const counts = {};
283
- for (const m of messages) {
284
- if (m.threadId) counts[m.threadId] = (counts[m.threadId] || 0) + 1;
285
- }
286
- return counts;
287
- }
699
+ // Filters
700
+ channelFilter: '',
701
+ tagFilter: '',
288
702
 
289
- function renderMessages() {
290
- const el = document.getElementById('msg-list');
291
- if (!messages.length) { el.innerHTML = '<div class="empty">No messages</div>'; return; }
292
- const tc = threadCounts();
293
- // Group by thread, sort threads newest-first, messages within thread chronologically
294
- const threads = new Map();
295
- for (const m of messages) {
296
- const tid = m.threadId || m.id;
297
- if (!threads.has(tid)) threads.set(tid, []);
298
- threads.get(tid).push(m);
299
- }
300
- for (const msgs of threads.values()) msgs.sort((a, b) => a.id - b.id);
301
- const sorted = [...threads.values()]
302
- .sort((a, b) => b[b.length - 1].id - a[a.length - 1].id)
303
- .flat();
304
- el.innerHTML = sorted.map(m => {
305
- const replies = (tc[m.threadId] || 1) - 1;
306
- const isReply = m.replyTo != null;
307
- const claimInfo = m.claimable
308
- ? (m.claimedBy
309
- ? `<span class="msg-claimed">claimed by ${esc(m.claimedBy)}</span>`
310
- : `<span class="msg-claimable">claimable</span>`)
311
- : '';
312
- const claimBtn = m.claimable && !m.claimedBy
313
- ? `<button class="msg-claim-btn" onclick="doClaim(${m.id})">Claim</button>`
314
- : '';
315
- return `
316
- <div class="msg${isReply ? ' thread-child' : ''}${m.claimedBy ? ' claimed' : ''}" data-id="${m.id}">
317
- <div class="msg-head">
318
- <span><span class="msg-from" title="${esc(m.from)}">${esc(displayById(m.from))}</span> <span class="msg-to" title="${esc(m.to)}">&rarr; ${esc(displayById(m.to))}</span></span>
319
- <span class="msg-time">#${m.id} &middot; ${new Date(m.createdAt).toLocaleString()}</span>
320
- </div>
321
- ${m.subject ? `<div class="msg-subject">${esc(m.subject)}</div>` : ''}
322
- <div class="msg-body">${esc(m.body)}</div>
323
- ${m.channel ? `<div class="msg-channel">#${esc(m.channel)}</div>` : ''}
324
- <div class="msg-footer">
325
- <button class="msg-reply-btn" onclick="startReply(${m.id}, '${esc(m.from)}')">Reply</button>
326
- ${claimBtn}
327
- <button class="msg-delete-btn" onclick="doDeleteMessage(${m.id})">Delete</button>
328
- ${claimInfo}
329
- ${!isReply && replies > 0 ? `<span class="msg-thread-link" onclick="openThread(${m.threadId})">${replies} repl${replies === 1 ? 'y' : 'ies'}</span>` : ''}
330
- ${isReply ? `<span class="msg-thread-info">reply to #${m.replyTo}</span>` : ''}
331
- </div>
332
- </div>`;
333
- }).join('');
334
- }
703
+ // Charts
704
+ chartInstances: {},
335
705
 
336
- function updateAgentFilter() {
337
- const sel = document.getElementById('filter-agent');
338
- const current = sel.value;
339
- sel.innerHTML = '<option value="">All agents</option>' +
340
- agents.map(a => `<option value="${esc(a.id)}" ${a.id === current ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
341
- updateComposeDropdowns();
342
- }
706
+ // SSE + polling
707
+ _es: null,
708
+ _statsTimer: null,
343
709
 
344
- function updateComposeDropdowns() {
345
- const fromSel = document.getElementById('c-from');
346
- const toSel = document.getElementById('c-to');
347
- const fromVal = fromSel.value;
348
- const toVal = toSel.value;
349
-
350
- const showOffline = document.getElementById('show-offline').checked;
351
- const visible = showOffline ? agents : agents.filter(a => a.status !== 'offline');
352
-
353
- // Tags/caps/labels follow the same filter so we don't suggest routing to empty groups
354
- const tags = [...new Set(visible.flatMap(a => a.tags))].sort();
355
- const caps = [...new Set(visible.flatMap(a => a.capabilities))].sort();
356
- const labels = [...new Set(visible.map(a => a.label).filter(Boolean))].sort();
357
-
358
- fromSel.innerHTML = '<option value="">From...</option>' +
359
- visible.map(a => `<option value="${esc(a.id)}" ${a.id === fromVal ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
360
-
361
- toSel.innerHTML = '<option value="">To...</option>' +
362
- '<option value="broadcast">📢 Broadcast (all)</option>' +
363
- labels.map(l => `<option value="label:${esc(l)}" ${('label:'+l) === toVal ? 'selected' : ''}>★ ${esc(l)}</option>`).join('') +
364
- caps.map(c => `<option value="cap:${esc(c)}" ${('cap:'+c) === toVal ? 'selected' : ''}>⚡ cap: ${esc(c)}</option>`).join('') +
365
- tags.map(t => `<option value="tag:${esc(t)}" ${('tag:'+t) === toVal ? 'selected' : ''}>#${esc(t)}</option>`).join('') +
366
- visible.map(a => `<option value="${esc(a.id)}" ${a.id === toVal ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
367
-
368
- // Restore selections even if the selected agent is now filtered out
369
- if (fromVal && !fromSel.querySelector(`option[value="${CSS.escape(fromVal)}"]`)) {
370
- fromSel.insertAdjacentHTML('beforeend', `<option value="${esc(fromVal)}" selected>${esc(fromVal)} (offline)</option>`);
371
- }
372
- if (toVal && !toSel.querySelector(`option[value="${CSS.escape(toVal)}"]`)) {
373
- toSel.insertAdjacentHTML('beforeend', `<option value="${esc(toVal)}" selected>${esc(toVal)} (offline)</option>`);
374
- }
375
- if (fromVal) fromSel.value = fromVal;
376
- if (toVal) toSel.value = toVal;
377
- }
710
+ async init() {
711
+ await this.refresh();
712
+ this.connectSSE();
713
+ // Slow poll for stats only — SSE handles real-time data
714
+ this._statsTimer = setInterval(() => this.fetchStats(), 30_000);
378
715
 
379
- function selectAgent(id) {
380
- selectedAgent = selectedAgent === id ? null : id;
381
- document.getElementById('filter-agent').value = selectedAgent || '';
382
- renderAgents();
383
- fetchMessages();
384
- }
716
+ this.$watch('showOffline', v => this.save('showOffline', v));
717
+ this.$watch('agentSort', v => this.save('agentSort', v));
718
+ this.$watch('agentSortDir', v => this.save('agentSortDir', v));
719
+ this.$watch('view', v => {
720
+ this.save('view', v);
721
+ if (v === 'analytics') this.$nextTick(() => this.renderCharts());
722
+ });
723
+ },
385
724
 
386
- async function doSend() {
387
- const from = document.getElementById('c-from').value.trim();
388
- const to = document.getElementById('c-to').value.trim();
389
- const body = document.getElementById('c-body').value.trim();
390
- const channel = document.getElementById('c-channel').value.trim() || undefined;
391
- const subject = document.getElementById('c-subject').value.trim() || undefined;
392
- if (!from || !to || !body) return alert('from, to, and body required');
393
- const claimable = document.getElementById('c-claimable').checked;
394
- const payload = { from, to, body, channel, subject };
395
- if (replyTo) payload.replyTo = replyTo.id;
396
- if (claimable) payload.claimable = true;
397
- const r = await fetch(`${API}/messages`, {
398
- method: 'POST',
399
- headers: { 'Content-Type': 'application/json' },
400
- body: JSON.stringify(payload),
401
- });
402
- if (!r.ok) {
403
- const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
404
- alert(`Send failed: ${err.error || 'unknown error'}`);
405
- return;
406
- }
407
- document.getElementById('c-body').value = '';
408
- document.getElementById('c-subject').value = '';
409
- cancelReply();
410
- fetchMessages();
411
- }
725
+ connectSSE() {
726
+ if (this._es) this._es.close();
727
+ const es = new EventSource(window.location.origin + '/api/events');
728
+ this._es = es;
412
729
 
413
- function startReply(msgId, fromAgent) {
414
- const msg = messages.find(m => m.id === msgId);
415
- replyTo = { id: msgId, from: fromAgent };
416
- document.getElementById('reply-banner').style.display = 'flex';
417
- document.getElementById('reply-to-label').textContent = `#${msgId} from ${fromAgent}`;
418
- // Auto-set "to" as the original sender
419
- const toSel = document.getElementById('c-to');
420
- toSel.value = fromAgent;
421
- document.getElementById('c-body').focus();
422
- }
730
+ es.addEventListener('connected', () => { this.connected = true; });
731
+ es.onerror = () => { this.connected = false; };
423
732
 
424
- function cancelReply() {
425
- replyTo = null;
426
- document.getElementById('reply-banner').style.display = 'none';
427
- }
733
+ es.addEventListener('message.new', (e) => {
734
+ const msg = JSON.parse(e.data);
735
+ if (this.messages.some(m => m.id === msg.id)) return;
736
+ if (this.selectedAgent && msg.from !== this.selectedAgent && msg.to !== this.selectedAgent) return;
737
+ if (this.channelFilter && msg.channel !== this.channelFilter) return;
738
+ this.messages.push(msg);
739
+ if (this.messages.length > 200) this.messages.shift();
740
+ this.stats.messages = (this.stats.messages ?? 0) + 1;
741
+ });
428
742
 
429
- async function openThread(threadId) {
430
- try {
431
- const r = await fetch(`${API}/messages/${threadId}/thread`);
432
- if (!r.ok) {
433
- const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
434
- alert(`Thread load failed: ${err.error || 'unknown error'}`);
435
- return;
436
- }
437
- const thread = await r.json();
438
- const el = document.getElementById('thread-list');
439
- el.innerHTML = thread.map((m, i) => `
440
- <div class="msg${i > 0 ? ' thread-child' : ''}" style="margin: 8px;">
441
- <div class="msg-head">
442
- <span><span class="msg-from" title="${esc(m.from)}">${esc(displayById(m.from))}</span> <span class="msg-to" title="${esc(m.to)}">&rarr; ${esc(displayById(m.to))}</span></span>
443
- <span class="msg-time">#${m.id} &middot; ${new Date(m.createdAt).toLocaleString()}</span>
444
- </div>
445
- ${m.subject ? `<div class="msg-subject">${esc(m.subject)}</div>` : ''}
446
- <div class="msg-body">${esc(m.body)}</div>
447
- </div>
448
- `).join('');
449
- document.getElementById('thread-overlay').style.display = 'flex';
450
- } catch (e) {
451
- alert(`Thread load failed: ${e.message || e}`);
452
- }
453
- }
743
+ es.addEventListener('agent.status', (e) => {
744
+ const data = JSON.parse(e.data);
745
+ const idx = this.agents.findIndex(a => a.id === data.id);
746
+ if (idx >= 0) {
747
+ this.agents[idx] = data;
748
+ } else {
749
+ this.agents.push(data);
750
+ }
751
+ this.agentsById[data.id] = data;
752
+ });
454
753
 
455
- function closeThread() {
456
- document.getElementById('thread-overlay').style.display = 'none';
457
- }
754
+ es.addEventListener('agent.removed', (e) => {
755
+ const { id } = JSON.parse(e.data);
756
+ this.agents = this.agents.filter(a => a.id !== id);
757
+ delete this.agentsById[id];
758
+ });
458
759
 
459
- async function doDeleteMessage(msgId) {
460
- if (!confirm(`Delete message #${msgId}?`)) return;
461
- const r = await fetch(`${API}/messages/${msgId}`, { method: 'DELETE' });
462
- if (!r.ok) {
463
- const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
464
- alert(`Delete failed: ${err.error || 'unknown error'}`);
465
- return;
466
- }
467
- fetchMessages();
468
- fetchStats();
469
- }
760
+ es.addEventListener('message.claimed', (e) => {
761
+ const { messageId, claimedBy } = JSON.parse(e.data);
762
+ const msg = this.messages.find(m => m.id === messageId);
763
+ if (msg) msg.claimedBy = claimedBy;
764
+ });
470
765
 
471
- async function doClaim(msgId) {
472
- const from = document.getElementById('c-from').value.trim();
473
- if (!from) return alert('Select "From" agent to claim as');
474
- const r = await fetch(`${API}/messages/${msgId}/claim`, {
475
- method: 'POST',
476
- headers: { 'Content-Type': 'application/json' },
477
- body: JSON.stringify({ agentId: from }),
478
- });
479
- const result = await r.json();
480
- if (!result.ok) alert(result.error || 'Claim failed');
481
- fetchMessages();
482
- }
766
+ es.addEventListener('message.deleted', (e) => {
767
+ const { messageId } = JSON.parse(e.data);
768
+ this.messages = this.messages.filter(m => m.id !== messageId);
769
+ });
770
+ },
771
+
772
+ save(key, value) {
773
+ localStorage.setItem('ar-' + key, JSON.stringify(value));
774
+ },
775
+
776
+ switchView(v) {
777
+ this.view = v;
778
+ if (v === 'messages') this.fetchMessages();
779
+ },
780
+
781
+ // ── API ──
782
+
783
+ async api(method, path, body) {
784
+ const opts = { method, headers: {} };
785
+ if (body) {
786
+ opts.headers['Content-Type'] = 'application/json';
787
+ opts.body = JSON.stringify(body);
788
+ }
789
+ const r = await fetch(window.location.origin + '/api' + path, opts);
790
+ if (!r.ok) {
791
+ const text = await r.text();
792
+ throw new Error(text || r.statusText);
793
+ }
794
+ return r.json();
795
+ },
796
+
797
+ async refresh() {
798
+ await Promise.all([this.fetchStats(), this.fetchAgents(), this.fetchMessages()]);
799
+ },
800
+
801
+ async fetchStats() {
802
+ try { this.stats = await this.api('GET', '/stats'); } catch {}
803
+ },
804
+
805
+ async fetchAgents() {
806
+ try {
807
+ this.agents = await this.api('GET', '/agents');
808
+ this.agentsById = {};
809
+ for (const a of this.agents) this.agentsById[a.id] = a;
810
+ } catch {}
811
+ },
812
+
813
+ async fetchMessages() {
814
+ try {
815
+ let path = '/messages?limit=100';
816
+ if (this.selectedAgent) path += '&for=' + encodeURIComponent(this.selectedAgent);
817
+ if (this.channelFilter) path += '&channel=' + encodeURIComponent(this.channelFilter);
818
+ this.messages = await this.api('GET', path);
819
+ } catch {}
820
+ },
821
+
822
+ // ── Computed ──
823
+
824
+ get onlineCount() {
825
+ return this.agents.filter(a => a.status === 'online').length;
826
+ },
827
+
828
+ get sortedAgents() {
829
+ let list = this.showOffline ? [...this.agents] : this.agents.filter(a => a.status === 'online');
830
+ const dir = this.agentSortDir === 'desc' ? -1 : 1;
831
+ return list.sort((a, b) => {
832
+ let cmp = 0;
833
+ switch (this.agentSort) {
834
+ case 'name': cmp = this.displayName(a).localeCompare(this.displayName(b)); break;
835
+ case 'status': {
836
+ const order = { online: 0, busy: 1, idle: 2, offline: 3 };
837
+ cmp = (order[a.status] ?? 9) - (order[b.status] ?? 9);
838
+ break;
839
+ }
840
+ case 'lastSeen': cmp = new Date(b.lastSeen) - new Date(a.lastSeen); break;
841
+ case 'created': cmp = new Date(b.createdAt) - new Date(a.createdAt); break;
842
+ }
843
+ return cmp * dir;
844
+ });
845
+ },
846
+
847
+ get filteredMessages() {
848
+ if (!this.tagFilter) return this.messages;
849
+ const tag = this.tagFilter;
850
+ return this.messages.filter(m => {
851
+ if (m.to === 'tag:' + tag) return true;
852
+ const fromAgent = this.agentsById[m.from];
853
+ if (fromAgent?.tags?.includes(tag)) return true;
854
+ const toAgent = this.agentsById[m.to];
855
+ if (toAgent?.tags?.includes(tag)) return true;
856
+ return false;
857
+ });
858
+ },
859
+
860
+ get groupedMessages() {
861
+ const threads = new Map();
862
+ for (const m of this.filteredMessages) {
863
+ const tid = m.threadId || m.id;
864
+ if (!threads.has(tid)) threads.set(tid, { threadId: tid, messages: [] });
865
+ threads.get(tid).messages.push(m);
866
+ }
867
+ for (const group of threads.values()) {
868
+ group.messages.sort((a, b) => a.id - b.id);
869
+ }
870
+ return [...threads.values()].sort((a, b) => {
871
+ const aLast = a.messages[a.messages.length - 1].id;
872
+ const bLast = b.messages[b.messages.length - 1].id;
873
+ return bLast - aLast;
874
+ });
875
+ },
876
+
877
+ get composeAgents() {
878
+ return this.showOffline ? this.agents : this.agents.filter(a => a.status === 'online');
879
+ },
880
+
881
+ get uniqueLabels() {
882
+ return [...new Set(this.agents.filter(a => a.label).map(a => a.label))];
883
+ },
884
+
885
+ get uniqueCaps() {
886
+ return [...new Set(this.agents.flatMap(a => a.capabilities || []))];
887
+ },
888
+
889
+ get uniqueTags() {
890
+ return [...new Set(this.agents.flatMap(a => a.tags || []))];
891
+ },
892
+
893
+ // ── Display helpers ──
894
+
895
+ displayName(agent) {
896
+ if (!agent) return '?';
897
+ return agent.label || agent.name || agent.id.slice(-12);
898
+ },
899
+
900
+ displayTarget(target) {
901
+ if (!target) return '?';
902
+ if (target === 'broadcast') return 'broadcast';
903
+ if (target.startsWith('tag:')) return '#' + target.slice(4);
904
+ if (target.startsWith('cap:')) return target.slice(4);
905
+ if (target.startsWith('label:')) return target.slice(6);
906
+ const agent = this.agentsById[target];
907
+ return agent ? this.displayName(agent) : target.slice(-8);
908
+ },
909
+
910
+ timeAgo(iso) {
911
+ if (!iso) return '';
912
+ const diff = (Date.now() - new Date(iso).getTime()) / 1000;
913
+ if (diff < 60) return Math.floor(diff) + 's ago';
914
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
915
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
916
+ return Math.floor(diff / 86400) + 'd ago';
917
+ },
918
+
919
+ fmtTime(iso) {
920
+ if (!iso) return '';
921
+ return new Date(iso).toLocaleString();
922
+ },
923
+
924
+ // ── Actions ──
925
+
926
+ openCompose() {
927
+ if (!this.replyTo) {
928
+ this.compose = { from: 'user', to: '', body: '', channel: '', subject: '', claimable: false };
929
+ }
930
+ this.composeOpen = true;
931
+ this.$nextTick(() => this.$refs.composeBody?.focus());
932
+ },
933
+
934
+ openComposeToAgent(agent) {
935
+ this.replyTo = null;
936
+ this.compose = { from: 'user', to: agent.id, body: '', channel: '', subject: '', claimable: false };
937
+ this.composeOpen = true;
938
+ this.$nextTick(() => this.$refs.composeBody?.focus());
939
+ },
940
+
941
+ startReply(msg) {
942
+ this.replyTo = { id: msg.id, from: msg.from };
943
+ this.compose.to = msg.from;
944
+ this.compose.from = '';
945
+ this.compose.body = '';
946
+ this.compose.channel = msg.channel || '';
947
+ this.compose.subject = '';
948
+ this.compose.claimable = false;
949
+ this.openCompose();
950
+ },
951
+
952
+ cancelReply() {
953
+ this.replyTo = null;
954
+ },
955
+
956
+ async doSend() {
957
+ if (!this.compose.from || !this.compose.to || !this.compose.body) {
958
+ alert('From, To, and Message are required.');
959
+ return;
960
+ }
961
+ const payload = {
962
+ from: this.compose.from,
963
+ to: this.compose.to,
964
+ body: this.compose.body,
965
+ };
966
+ if (this.compose.channel) payload.channel = this.compose.channel;
967
+ if (this.compose.subject) payload.subject = this.compose.subject;
968
+ if (this.replyTo) payload.replyTo = this.replyTo.id;
969
+ if (this.compose.claimable) payload.claimable = true;
970
+
971
+ try {
972
+ await this.api('POST', '/messages', payload);
973
+ this.composeOpen = false;
974
+ this.replyTo = null;
975
+ this.compose = { from: '', to: '', body: '', channel: '', subject: '', claimable: false };
976
+ } catch (e) {
977
+ alert('Send failed: ' + e.message);
978
+ }
979
+ },
980
+
981
+ async doClaim(msgId) {
982
+ if (!this.compose.from && !this.selectedAgent) {
983
+ alert('Select a "From" agent first (open Compose to pick one).');
984
+ return;
985
+ }
986
+ const agentId = this.compose.from || this.selectedAgent;
987
+ try {
988
+ const result = await this.api('POST', '/messages/' + msgId + '/claim', { agentId });
989
+ if (!result.ok) alert('Claim failed: ' + (result.error || 'unknown'));
990
+ } catch (e) {
991
+ alert('Claim failed: ' + e.message);
992
+ }
993
+ },
994
+
995
+ async doDeleteMessage(id) {
996
+ try {
997
+ await this.api('DELETE', '/messages/' + id);
998
+ this.messages = this.messages.filter(m => m.id !== id);
999
+ } catch (e) {
1000
+ alert('Delete failed: ' + e.message);
1001
+ }
1002
+ },
1003
+
1004
+ async openThread(threadId) {
1005
+ this.threadMessages = [];
1006
+ this.threadOpen = true;
1007
+ try {
1008
+ this.threadMessages = await this.api('GET', '/messages/' + threadId + '/thread');
1009
+ } catch (e) {
1010
+ alert('Failed to load thread: ' + e.message);
1011
+ }
1012
+ },
1013
+
1014
+ openRename(agent) {
1015
+ this.renameModal = { show: true, agentId: agent.id, label: agent.label || '' };
1016
+ this.$nextTick(() => this.$refs.renameInput?.focus());
1017
+ },
1018
+
1019
+ async doRename() {
1020
+ const label = this.renameModal.label.trim() || null;
1021
+ try {
1022
+ await this.api('PATCH', '/agents/' + this.renameModal.agentId + '/label', { label });
1023
+ this.renameModal.show = false;
1024
+ } catch (e) {
1025
+ alert('Rename failed: ' + e.message);
1026
+ }
1027
+ },
1028
+
1029
+ openConfirm(title, message, action) {
1030
+ this.confirmModal = { show: true, title, message, action };
1031
+ },
1032
+
1033
+ async doDeleteAgent(id) {
1034
+ try {
1035
+ await this.api('DELETE', '/agents/' + id);
1036
+ if (this.selectedAgent === id) this.selectedAgent = '';
1037
+ this.agents = this.agents.filter(a => a.id !== id);
1038
+ delete this.agentsById[id];
1039
+ } catch (e) {
1040
+ alert('Delete failed: ' + e.message);
1041
+ }
1042
+ },
1043
+
1044
+ // ── Charts ──
1045
+
1046
+ renderCharts() {
1047
+ this.renderVolumeChart();
1048
+ this.renderStatusChart();
1049
+ this.renderAgentChart();
1050
+ },
1051
+
1052
+ renderVolumeChart() {
1053
+ if (this.chartInstances.volume) this.chartInstances.volume.destroy();
1054
+ const el = document.querySelector('#chart-volume');
1055
+ if (!el) return;
1056
+
1057
+ const buckets = {};
1058
+ for (const m of this.messages) {
1059
+ const day = m.createdAt ? new Date(m.createdAt).toISOString().split('T')[0] : null;
1060
+ if (day) buckets[day] = (buckets[day] || 0) + 1;
1061
+ }
1062
+ const sorted = Object.entries(buckets).sort((a, b) => a[0].localeCompare(b[0]));
1063
+
1064
+ this.chartInstances.volume = new ApexCharts(el, {
1065
+ chart: { type: 'area', height: 280, background: 'transparent', toolbar: { show: false } },
1066
+ theme: { mode: 'dark' },
1067
+ series: [{ name: 'Messages', data: sorted.map(([d, c]) => ({ x: d, y: c })) }],
1068
+ xaxis: { type: 'datetime' },
1069
+ stroke: { curve: 'smooth', width: 2 },
1070
+ fill: { type: 'gradient', gradient: { opacityFrom: 0.4, opacityTo: 0 } },
1071
+ dataLabels: { enabled: false },
1072
+ colors: ['#4299e1'],
1073
+ grid: { borderColor: 'rgba(255,255,255,0.06)' },
1074
+ });
1075
+ this.chartInstances.volume.render();
1076
+ },
1077
+
1078
+ renderStatusChart() {
1079
+ if (this.chartInstances.status) this.chartInstances.status.destroy();
1080
+ const el = document.querySelector('#chart-status');
1081
+ if (!el) return;
1082
+
1083
+ const counts = { online: 0, idle: 0, busy: 0, offline: 0 };
1084
+ for (const a of this.agents) counts[a.status] = (counts[a.status] || 0) + 1;
1085
+ const labels = Object.keys(counts).filter(k => counts[k] > 0);
1086
+ const series = labels.map(k => counts[k]);
1087
+ const colorMap = { online: '#48bb78', idle: '#ecc94b', busy: '#f56565', offline: '#718096' };
1088
+
1089
+ this.chartInstances.status = new ApexCharts(el, {
1090
+ chart: { type: 'donut', height: 280, background: 'transparent' },
1091
+ theme: { mode: 'dark' },
1092
+ series,
1093
+ labels,
1094
+ colors: labels.map(l => colorMap[l] || '#718096'),
1095
+ legend: { position: 'bottom' },
1096
+ dataLabels: { enabled: true },
1097
+ });
1098
+ this.chartInstances.status.render();
1099
+ },
1100
+
1101
+ renderAgentChart() {
1102
+ if (this.chartInstances.agents) this.chartInstances.agents.destroy();
1103
+ const el = document.querySelector('#chart-agents');
1104
+ if (!el) return;
483
1105
 
484
- function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
1106
+ const counts = {};
1107
+ for (const m of this.messages) {
1108
+ const name = this.displayTarget(m.from);
1109
+ counts[name] = (counts[name] || 0) + 1;
1110
+ }
1111
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10);
485
1112
 
486
- // Poll
487
- function refresh() {
488
- fetchStats();
489
- fetchAgents();
490
- fetchMessages();
1113
+ this.chartInstances.agents = new ApexCharts(el, {
1114
+ chart: { type: 'bar', height: 280, background: 'transparent', toolbar: { show: false } },
1115
+ theme: { mode: 'dark' },
1116
+ series: [{ name: 'Messages', data: sorted.map(([, c]) => c) }],
1117
+ xaxis: { categories: sorted.map(([n]) => n) },
1118
+ colors: ['#4299e1'],
1119
+ plotOptions: { bar: { borderRadius: 4, distributed: true } },
1120
+ legend: { show: false },
1121
+ grid: { borderColor: 'rgba(255,255,255,0.06)' },
1122
+ });
1123
+ this.chartInstances.agents.render();
1124
+ },
1125
+ };
491
1126
  }
492
- refresh();
493
- setInterval(() => {
494
- if (document.getElementById('auto-refresh').checked) refresh();
495
- }, 3000);
496
1127
 
497
- document.getElementById('filter-agent').addEventListener('change', fetchMessages);
498
- document.getElementById('filter-channel').addEventListener('input', fetchMessages);
1128
+ document.addEventListener('alpine:init', () => {
1129
+ Alpine.data('relay', relay);
1130
+ });
499
1131
  </script>
500
1132
  </body>
501
1133
  </html>