@delt/claude-alarm 0.2.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.
@@ -1,705 +1,835 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Claude Alarm - Dashboard</title>
7
- <style>
8
- :root {
9
- --bg: #0f1117;
10
- --surface: #1a1d27;
11
- --border: #2a2d3a;
12
- --text: #e1e4ed;
13
- --text-dim: #8b8fa3;
14
- --accent: #7c6aef;
15
- --accent-dim: #5a4db8;
16
- --green: #3dd68c;
17
- --yellow: #f5c542;
18
- --red: #ef4444;
19
- --blue: #60a5fa;
20
- }
21
- * { margin: 0; padding: 0; box-sizing: border-box; }
22
- body {
23
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
24
- background: var(--bg);
25
- color: var(--text);
26
- min-height: 100vh;
27
- }
28
- header {
29
- border-bottom: 1px solid var(--border);
30
- padding: 16px 24px;
31
- display: flex;
32
- align-items: center;
33
- justify-content: space-between;
34
- }
35
- header h1 { font-size: 18px; font-weight: 600; }
36
- .status-badge {
37
- display: inline-flex;
38
- align-items: center;
39
- gap: 6px;
40
- font-size: 13px;
41
- color: var(--text-dim);
42
- }
43
- .status-dot {
44
- width: 8px; height: 8px;
45
- border-radius: 50%;
46
- background: var(--red);
47
- }
48
- .status-dot.connected { background: var(--green); }
49
-
50
- .container {
51
- display: grid;
52
- grid-template-columns: 300px 1fr 320px;
53
- height: calc(100vh - 57px);
54
- }
55
-
56
- /* Sessions panel */
57
- .sessions-panel {
58
- border-right: 1px solid var(--border);
59
- overflow-y: auto;
60
- padding: 12px;
61
- }
62
- .sessions-panel h2 {
63
- font-size: 13px;
64
- text-transform: uppercase;
65
- letter-spacing: 0.5px;
66
- color: var(--text-dim);
67
- padding: 8px 8px 12px;
68
- }
69
- .session-card {
70
- background: var(--surface);
71
- border: 1px solid var(--border);
72
- border-radius: 8px;
73
- padding: 12px;
74
- margin-bottom: 8px;
75
- cursor: pointer;
76
- transition: border-color 0.15s;
77
- }
78
- .session-card:hover, .session-card.active {
79
- border-color: var(--accent);
80
- }
81
- .session-name {
82
- font-size: 14px;
83
- font-weight: 500;
84
- margin-bottom: 4px;
85
- }
86
- .session-id {
87
- font-size: 11px;
88
- color: var(--text-dim);
89
- font-family: monospace;
90
- }
91
- .session-status {
92
- display: inline-block;
93
- font-size: 11px;
94
- padding: 2px 8px;
95
- border-radius: 10px;
96
- margin-top: 6px;
97
- font-weight: 500;
98
- }
99
- .session-status.idle { background: rgba(139,143,163,0.15); color: var(--text-dim); }
100
- .session-status.working { background: rgba(96,165,250,0.15); color: var(--blue); }
101
- .session-status.waiting_input { background: rgba(245,197,66,0.15); color: var(--yellow); }
102
- .session-cwd {
103
- font-size: 11px;
104
- color: var(--text-dim);
105
- font-family: monospace;
106
- margin-top: 4px;
107
- overflow: hidden;
108
- text-overflow: ellipsis;
109
- white-space: nowrap;
110
- }
111
- .channel-badge {
112
- display: inline-block;
113
- font-size: 10px;
114
- padding: 1px 6px;
115
- border-radius: 8px;
116
- margin-left: 6px;
117
- font-weight: 500;
118
- }
119
- .channel-badge.enabled { background: rgba(61,214,140,0.15); color: var(--green); }
120
- .channel-badge.disabled { background: rgba(239,68,68,0.15); color: var(--red); }
121
-
122
- .no-sessions {
123
- color: var(--text-dim);
124
- font-size: 13px;
125
- text-align: center;
126
- padding: 40px 20px;
127
- }
128
-
129
- /* Messages panel */
130
- .messages-panel {
131
- display: flex;
132
- flex-direction: column;
133
- }
134
- .messages-header {
135
- padding: 12px 20px;
136
- border-bottom: 1px solid var(--border);
137
- font-size: 14px;
138
- font-weight: 500;
139
- }
140
- .messages-list {
141
- flex: 1;
142
- overflow-y: auto;
143
- padding: 16px 20px;
144
- }
145
- .message {
146
- margin-bottom: 12px;
147
- padding: 10px 14px;
148
- border-radius: 8px;
149
- font-size: 13px;
150
- line-height: 1.5;
151
- max-width: 80%;
152
- }
153
- .message.from-session {
154
- background: var(--surface);
155
- border: 1px solid var(--border);
156
- }
157
- .message.from-dashboard {
158
- background: rgba(124,106,239,0.12);
159
- border: 1px solid rgba(124,106,239,0.25);
160
- margin-left: auto;
161
- }
162
- .message-meta {
163
- font-size: 11px;
164
- color: var(--text-dim);
165
- margin-bottom: 4px;
166
- }
167
- .message-input-area {
168
- border-top: 1px solid var(--border);
169
- padding: 12px 20px;
170
- display: flex;
171
- gap: 8px;
172
- }
173
- .message-input-area textarea {
174
- flex: 1;
175
- background: var(--surface);
176
- border: 1px solid var(--border);
177
- border-radius: 6px;
178
- padding: 8px 12px;
179
- color: var(--text);
180
- font-size: 13px;
181
- outline: none;
182
- resize: none;
183
- min-height: 36px;
184
- max-height: 120px;
185
- font-family: inherit;
186
- line-height: 1.4;
187
- }
188
- .message-input-area textarea:focus { border-color: var(--accent); }
189
- .message-input-area button {
190
- background: var(--accent);
191
- color: white;
192
- border: none;
193
- border-radius: 6px;
194
- padding: 8px 16px;
195
- cursor: pointer;
196
- font-size: 13px;
197
- font-weight: 500;
198
- }
199
- .message-input-area button:hover { background: var(--accent-dim); }
200
- .message-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
201
-
202
- /* Notifications panel */
203
- .notifications-panel {
204
- border-left: 1px solid var(--border);
205
- overflow-y: auto;
206
- padding: 12px;
207
- }
208
- .notifications-panel h2 {
209
- font-size: 13px;
210
- text-transform: uppercase;
211
- letter-spacing: 0.5px;
212
- color: var(--text-dim);
213
- padding: 8px 8px 12px;
214
- }
215
- .notif-item {
216
- background: var(--surface);
217
- border: 1px solid var(--border);
218
- border-radius: 8px;
219
- padding: 10px 12px;
220
- margin-bottom: 8px;
221
- font-size: 13px;
222
- }
223
- .notif-item .notif-title {
224
- font-weight: 500;
225
- margin-bottom: 2px;
226
- }
227
- .notif-item .notif-message { color: var(--text-dim); }
228
- .notif-item .notif-time {
229
- font-size: 11px;
230
- color: var(--text-dim);
231
- margin-top: 4px;
232
- }
233
- .notif-level {
234
- display: inline-block;
235
- width: 6px; height: 6px;
236
- border-radius: 50%;
237
- margin-right: 6px;
238
- }
239
- .notif-level.info { background: var(--blue); }
240
- .notif-level.success { background: var(--green); }
241
- .notif-level.warning { background: var(--yellow); }
242
- .notif-level.error { background: var(--red); }
243
-
244
- .message-body h2, .message-body h3, .message-body h4 {
245
- margin: 8px 0 4px;
246
- font-size: 14px;
247
- }
248
- .message-body h2 { font-size: 16px; }
249
- .message-body h3 { font-size: 15px; }
250
- .message-body pre {
251
- background: var(--bg);
252
- border: 1px solid var(--border);
253
- border-radius: 6px;
254
- padding: 8px 10px;
255
- overflow-x: auto;
256
- margin: 6px 0;
257
- font-size: 12px;
258
- }
259
- .message-body code {
260
- background: var(--bg);
261
- padding: 1px 4px;
262
- border-radius: 3px;
263
- font-size: 12px;
264
- font-family: monospace;
265
- }
266
- .message-body pre code {
267
- background: none;
268
- padding: 0;
269
- }
270
- .message-body table {
271
- border-collapse: collapse;
272
- margin: 6px 0;
273
- font-size: 12px;
274
- width: 100%;
275
- }
276
- .message-body th, .message-body td {
277
- border: 1px solid var(--border);
278
- padding: 4px 8px;
279
- text-align: left;
280
- }
281
- .message-body th {
282
- background: var(--bg);
283
- font-weight: 600;
284
- }
285
- .message-body ul {
286
- margin: 4px 0;
287
- padding-left: 20px;
288
- }
289
- .message-body li { margin: 2px 0; }
290
- .message-body strong { font-weight: 600; }
291
-
292
- .empty-state {
293
- color: var(--text-dim);
294
- font-size: 13px;
295
- text-align: center;
296
- padding: 40px 20px;
297
- }
298
-
299
- /* Token auth overlay */
300
- .token-overlay {
301
- position: fixed;
302
- inset: 0;
303
- background: rgba(0,0,0,0.8);
304
- display: flex;
305
- align-items: center;
306
- justify-content: center;
307
- z-index: 100;
308
- }
309
- .token-overlay.hidden { display: none; }
310
- .token-form {
311
- background: var(--surface);
312
- border: 1px solid var(--border);
313
- border-radius: 12px;
314
- padding: 32px;
315
- max-width: 400px;
316
- width: 90%;
317
- text-align: center;
318
- }
319
- .token-form h2 { font-size: 18px; margin-bottom: 8px; }
320
- .token-form p { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; }
321
- .token-form input {
322
- width: 100%;
323
- background: var(--bg);
324
- border: 1px solid var(--border);
325
- border-radius: 6px;
326
- padding: 10px 12px;
327
- color: var(--text);
328
- font-size: 14px;
329
- font-family: monospace;
330
- outline: none;
331
- margin-bottom: 12px;
332
- }
333
- .token-form input:focus { border-color: var(--accent); }
334
- .token-form button {
335
- background: var(--accent);
336
- color: white;
337
- border: none;
338
- border-radius: 6px;
339
- padding: 10px 24px;
340
- cursor: pointer;
341
- font-size: 14px;
342
- font-weight: 500;
343
- }
344
- .token-form button:hover { background: var(--accent-dim); }
345
- .token-error { color: var(--red); font-size: 12px; margin-top: 8px; display: none; }
346
- </style>
347
- </head>
348
- <body>
349
- <header>
350
- <h1>Claude Alarm</h1>
351
- <div class="status-badge">
352
- <span class="status-dot" id="connDot"></span>
353
- <span id="connLabel">Connecting...</span>
354
- </div>
355
- </header>
356
-
357
- <div class="token-overlay hidden" id="tokenOverlay">
358
- <div class="token-form">
359
- <h2>Authentication Required</h2>
360
- <p>Enter the hub token to connect. Find it by running: <code>claude-alarm token</code></p>
361
- <input type="text" id="tokenInput" placeholder="Paste token here..." autocomplete="off">
362
- <button id="tokenSubmit">Connect</button>
363
- <div class="token-error" id="tokenError">Connection failed. Check your token.</div>
364
- </div>
365
- </div>
366
-
367
- <div class="container">
368
- <!-- Sessions -->
369
- <div class="sessions-panel">
370
- <h2>Sessions</h2>
371
- <div id="sessionsList"></div>
372
- </div>
373
-
374
- <!-- Messages -->
375
- <div class="messages-panel">
376
- <div class="messages-header" id="messagesHeader">Select a session</div>
377
- <div class="messages-list" id="messagesList">
378
- <div class="empty-state">Select a session to view messages</div>
379
- </div>
380
- <div class="message-input-area">
381
- <textarea id="msgInput" placeholder="Send a message to session... (Shift+Enter for new line)" disabled rows="1"></textarea>
382
- <button id="sendBtn" disabled>Send</button>
383
- </div>
384
- </div>
385
-
386
- <!-- Notifications -->
387
- <div class="notifications-panel">
388
- <h2>Notifications</h2>
389
- <div id="notifList">
390
- <div class="empty-state">No notifications yet</div>
391
- </div>
392
- </div>
393
- </div>
394
-
395
- <script>
396
- (function() {
397
- const state = {
398
- ws: null,
399
- sessions: {},
400
- selectedSession: null,
401
- messages: {}, // sessionId -> [{from, content, time}]
402
- notifications: [],
403
- token: null,
404
- };
405
-
406
- const $ = (sel) => document.querySelector(sel);
407
-
408
- // --- Token handling ---
409
- function getToken() {
410
- // Check URL query parameter first
411
- const params = new URLSearchParams(location.search);
412
- const urlToken = params.get('token');
413
- if (urlToken) {
414
- sessionStorage.setItem('claude-alarm-token', urlToken);
415
- return urlToken;
416
- }
417
- // Check sessionStorage
418
- return sessionStorage.getItem('claude-alarm-token');
419
- }
420
-
421
- function showTokenForm() {
422
- $('#tokenOverlay').classList.remove('hidden');
423
- }
424
-
425
- function hideTokenForm() {
426
- $('#tokenOverlay').classList.add('hidden');
427
- }
428
-
429
- $('#tokenSubmit').addEventListener('click', () => {
430
- const token = $('#tokenInput').value.trim();
431
- if (!token) return;
432
- state.token = token;
433
- sessionStorage.setItem('claude-alarm-token', token);
434
- $('#tokenError').style.display = 'none';
435
- hideTokenForm();
436
- connect();
437
- });
438
-
439
- $('#tokenInput').addEventListener('keydown', (e) => {
440
- if (e.key === 'Enter') $('#tokenSubmit').click();
441
- });
442
-
443
- // --- WebSocket ---
444
- function connect() {
445
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
446
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
447
- const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard${tokenQuery}`);
448
-
449
- ws.onopen = () => {
450
- state.ws = ws;
451
- hideTokenForm();
452
- $('#connDot').classList.add('connected');
453
- $('#connLabel').textContent = 'Connected';
454
- };
455
-
456
- ws.onclose = () => {
457
- state.ws = null;
458
- $('#connDot').classList.remove('connected');
459
- $('#connLabel').textContent = 'Disconnected';
460
- setTimeout(connect, 3000);
461
- };
462
-
463
- ws.onerror = () => {
464
- // If connection fails, it might be a token issue
465
- if (!state.token) {
466
- showTokenForm();
467
- } else {
468
- $('#tokenError').style.display = 'block';
469
- showTokenForm();
470
- }
471
- ws.close();
472
- };
473
-
474
- ws.onmessage = (e) => {
475
- try { handleMessage(JSON.parse(e.data)); } catch {}
476
- };
477
- }
478
-
479
- function handleMessage(msg) {
480
- switch (msg.type) {
481
- case 'sessions_list':
482
- state.sessions = {};
483
- msg.sessions.forEach(s => { state.sessions[s.id] = s; });
484
- renderSessions();
485
- break;
486
-
487
- case 'session_connected':
488
- state.sessions[msg.session.id] = msg.session;
489
- if (!state.messages[msg.session.id]) state.messages[msg.session.id] = [];
490
- renderSessions();
491
- break;
492
-
493
- case 'session_disconnected':
494
- delete state.sessions[msg.sessionId];
495
- if (state.selectedSession === msg.sessionId) {
496
- state.selectedSession = null;
497
- renderMessages();
498
- }
499
- renderSessions();
500
- break;
501
-
502
- case 'session_updated':
503
- state.sessions[msg.session.id] = msg.session;
504
- renderSessions();
505
- break;
506
-
507
- case 'reply_from_session':
508
- if (!state.messages[msg.sessionId]) state.messages[msg.sessionId] = [];
509
- state.messages[msg.sessionId].push({
510
- from: 'session',
511
- content: msg.content,
512
- time: msg.timestamp,
513
- });
514
- if (state.selectedSession === msg.sessionId) renderMessages();
515
- break;
516
-
517
- case 'notification':
518
- state.notifications.unshift({
519
- sessionId: msg.sessionId,
520
- title: msg.title,
521
- message: msg.message,
522
- level: msg.level || 'info',
523
- time: msg.timestamp,
524
- });
525
- renderNotifications();
526
- break;
527
- }
528
- }
529
-
530
- // --- Render ---
531
- function renderSessions() {
532
- const el = $('#sessionsList');
533
- const ids = Object.keys(state.sessions);
534
- if (!ids.length) {
535
- el.innerHTML = '<div class="no-sessions">No active sessions.<br>Start Claude Code with the channel to see sessions here.</div>';
536
- return;
537
- }
538
- el.innerHTML = ids.map(id => {
539
- const s = state.sessions[id];
540
- const active = state.selectedSession === id ? ' active' : '';
541
- const cwdDisplay = s.cwd ? s.cwd.replace(/^.*[/\\]/, '') : '';
542
- return `<div class="session-card${active}" data-id="${id}">
543
- <div class="session-name">${esc(s.name)}</div>
544
- ${cwdDisplay ? `<div class="session-cwd" title="${esc(s.cwd)}">${esc(cwdDisplay)}</div>` : ''}
545
- <span class="session-status ${s.status}">${s.status.replace('_', ' ')}</span>
546
- </div>`;
547
- }).join('');
548
-
549
- el.querySelectorAll('.session-card').forEach(card => {
550
- card.addEventListener('click', () => selectSession(card.dataset.id));
551
- });
552
- }
553
-
554
- function selectSession(id) {
555
- state.selectedSession = id;
556
- if (!state.messages[id]) state.messages[id] = [];
557
- $('#msgInput').disabled = false;
558
- $('#sendBtn').disabled = false;
559
- $('#msgInput').placeholder = 'Send a message to session... (Shift+Enter for new line)';
560
- renderSessions();
561
- renderMessages();
562
- }
563
-
564
- function renderMessages() {
565
- const el = $('#messagesList');
566
- const header = $('#messagesHeader');
567
-
568
- if (!state.selectedSession) {
569
- header.textContent = 'Select a session';
570
- el.innerHTML = '<div class="empty-state">Select a session to view messages</div>';
571
- $('#msgInput').disabled = true;
572
- $('#sendBtn').disabled = true;
573
- return;
574
- }
575
-
576
- const s = state.sessions[state.selectedSession];
577
- header.textContent = s ? s.name : state.selectedSession.slice(0, 12);
578
-
579
- const msgs = state.messages[state.selectedSession] || [];
580
- if (!msgs.length) {
581
- el.innerHTML = '<div class="empty-state">No messages yet</div>';
582
- return;
583
- }
584
-
585
- el.innerHTML = msgs.map(m => {
586
- const cls = m.from === 'session' ? 'from-session' : 'from-dashboard';
587
- const timeStr = new Date(m.time).toLocaleTimeString();
588
- const content = m.from === 'session' ? renderMarkdown(m.content) : esc(m.content);
589
- return `<div class="message ${cls}">
590
- <div class="message-meta">${m.from === 'session' ? 'Claude' : 'You'} &middot; ${timeStr}</div>
591
- <div class="message-body">${content}</div>
592
- </div>`;
593
- }).join('');
594
-
595
- el.scrollTop = el.scrollHeight;
596
- }
597
-
598
- function renderNotifications() {
599
- const el = $('#notifList');
600
- if (!state.notifications.length) {
601
- el.innerHTML = '<div class="empty-state">No notifications yet</div>';
602
- return;
603
- }
604
- el.innerHTML = state.notifications.slice(0, 50).map(n => {
605
- const timeStr = new Date(n.time).toLocaleTimeString();
606
- const session = state.sessions[n.sessionId];
607
- const sName = session ? session.name : n.sessionId.slice(0, 8);
608
- return `<div class="notif-item" data-session="${n.sessionId}" style="cursor:pointer">
609
- <div class="notif-title"><span class="notif-level ${n.level}"></span>${esc(n.title)}</div>
610
- <div class="notif-message">${esc(n.message)}</div>
611
- <div class="notif-time">${sName} &middot; ${timeStr}</div>
612
- </div>`;
613
- }).join('');
614
-
615
- el.querySelectorAll('.notif-item').forEach(item => {
616
- item.addEventListener('click', () => {
617
- const sid = item.dataset.session;
618
- if (sid && state.sessions[sid]) selectSession(sid);
619
- });
620
- });
621
- }
622
-
623
- // --- Send ---
624
- function sendMessage() {
625
- const input = $('#msgInput');
626
- const content = input.value.trim();
627
- if (!content || !state.selectedSession || !state.ws) return;
628
-
629
- state.ws.send(JSON.stringify({
630
- type: 'message_to_session',
631
- sessionId: state.selectedSession,
632
- content,
633
- }));
634
-
635
- if (!state.messages[state.selectedSession]) state.messages[state.selectedSession] = [];
636
- state.messages[state.selectedSession].push({
637
- from: 'dashboard',
638
- content,
639
- time: Date.now(),
640
- });
641
-
642
- input.value = '';
643
- input.style.height = 'auto';
644
- renderMessages();
645
- }
646
-
647
- $('#sendBtn').addEventListener('click', sendMessage);
648
- $('#msgInput').addEventListener('keydown', (e) => {
649
- if (e.key === 'Enter' && !e.shiftKey) {
650
- e.preventDefault();
651
- sendMessage();
652
- }
653
- });
654
- $('#msgInput').addEventListener('input', function() {
655
- this.style.height = 'auto';
656
- this.style.height = Math.min(this.scrollHeight, 120) + 'px';
657
- });
658
-
659
- function esc(s) {
660
- const d = document.createElement('div');
661
- d.textContent = s;
662
- return d.innerHTML;
663
- }
664
-
665
- function renderMarkdown(text) {
666
- let html = esc(text);
667
- // Code blocks (```)
668
- html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
669
- // Inline code
670
- html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
671
- // Bold
672
- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
673
- // Italic
674
- html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
675
- // Headers
676
- html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
677
- html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
678
- html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
679
- // Tables
680
- html = html.replace(/^(\|.+\|)\n\|[-| :]+\|\n((?:\|.+\|\n?)*)/gm, (_, header, body) => {
681
- const ths = header.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
682
- const rows = body.trim().split('\n').map(row => {
683
- const tds = row.split('|').filter(c => c.trim()).map(c => `<td>${c.trim()}</td>`).join('');
684
- return `<tr>${tds}</tr>`;
685
- }).join('');
686
- return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
687
- });
688
- // List items
689
- html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
690
- html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
691
- // Line breaks (but not inside pre/table)
692
- html = html.replace(/\n/g, '<br>');
693
- // Clean up extra <br> around block elements
694
- html = html.replace(/<br>\s*(<\/?(?:pre|h[2-4]|ul|li|table|thead|tbody|tr))/g, '$1');
695
- html = html.replace(/(<\/(?:pre|h[2-4]|ul|table)>)\s*<br>/g, '$1');
696
- return html;
697
- }
698
-
699
- // Start - try to get token from URL or storage
700
- state.token = getToken();
701
- connect();
702
- })();
703
- </script>
704
- </body>
705
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Claude Alarm - Dashboard</title>
7
+ <style>
8
+ :root, [data-theme="dark"] {
9
+ --bg: #0f1117;
10
+ --surface: #1a1d27;
11
+ --border: #2a2d3a;
12
+ --text: #e1e4ed;
13
+ --text-dim: #8b8fa3;
14
+ --accent: #7c6aef;
15
+ --accent-dim: #5a4db8;
16
+ --green: #3dd68c;
17
+ --yellow: #f5c542;
18
+ --red: #ef4444;
19
+ --blue: #60a5fa;
20
+ }
21
+ [data-theme="light"] {
22
+ --bg: #f5f5f7;
23
+ --surface: #ffffff;
24
+ --border: #d1d5db;
25
+ --text: #1f2937;
26
+ --text-dim: #6b7280;
27
+ --accent: #7c6aef;
28
+ --accent-dim: #5a4db8;
29
+ --green: #22c55e;
30
+ --yellow: #eab308;
31
+ --red: #ef4444;
32
+ --blue: #3b82f6;
33
+ }
34
+ * { margin: 0; padding: 0; box-sizing: border-box; }
35
+ body {
36
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
37
+ background: var(--bg);
38
+ color: var(--text);
39
+ min-height: 100vh;
40
+ }
41
+ /* Hide scrollbars */
42
+ ::-webkit-scrollbar { display: none; }
43
+ * { -ms-overflow-style: none; scrollbar-width: none; }
44
+
45
+ header {
46
+ border-bottom: 1px solid var(--border);
47
+ padding: 16px 24px;
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ }
52
+ header h1 { font-size: 18px; font-weight: 600; }
53
+ .header-right {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 12px;
57
+ }
58
+ .theme-toggle {
59
+ background: var(--surface);
60
+ border: 1px solid var(--border);
61
+ border-radius: 6px;
62
+ padding: 4px 10px;
63
+ cursor: pointer;
64
+ font-size: 16px;
65
+ line-height: 1;
66
+ color: var(--text);
67
+ }
68
+ .theme-toggle:hover { border-color: var(--accent); }
69
+ .status-badge {
70
+ display: inline-flex;
71
+ align-items: center;
72
+ gap: 6px;
73
+ font-size: 13px;
74
+ color: var(--text-dim);
75
+ }
76
+ .status-dot {
77
+ width: 8px; height: 8px;
78
+ border-radius: 50%;
79
+ background: var(--red);
80
+ }
81
+ .status-dot.connected { background: var(--green); }
82
+
83
+ .container {
84
+ display: grid;
85
+ grid-template-columns: 300px 1fr 320px;
86
+ height: calc(100vh - 57px);
87
+ }
88
+
89
+ /* Sessions panel */
90
+ .sessions-panel {
91
+ border-right: 1px solid var(--border);
92
+ overflow-y: auto;
93
+ padding: 12px;
94
+ }
95
+ .sessions-panel h2 {
96
+ font-size: 13px;
97
+ text-transform: uppercase;
98
+ letter-spacing: 0.5px;
99
+ color: var(--text-dim);
100
+ padding: 8px 8px 12px;
101
+ }
102
+ .session-card {
103
+ background: var(--surface);
104
+ border: 1px solid var(--border);
105
+ border-radius: 8px;
106
+ padding: 12px;
107
+ margin-bottom: 8px;
108
+ cursor: pointer;
109
+ transition: border-color 0.15s;
110
+ }
111
+ .session-card:hover, .session-card.active {
112
+ border-color: var(--accent);
113
+ }
114
+ .session-name {
115
+ font-size: 14px;
116
+ font-weight: 500;
117
+ margin-bottom: 4px;
118
+ }
119
+ .session-status {
120
+ display: inline-block;
121
+ font-size: 11px;
122
+ padding: 2px 8px;
123
+ border-radius: 10px;
124
+ margin-top: 6px;
125
+ font-weight: 500;
126
+ }
127
+ .session-status.idle { background: rgba(139,143,163,0.15); color: var(--text-dim); }
128
+ .session-status.working { background: rgba(96,165,250,0.15); color: var(--blue); }
129
+ .session-status.waiting_input { background: rgba(245,197,66,0.15); color: var(--yellow); }
130
+ .session-cwd {
131
+ font-size: 11px;
132
+ color: var(--text-dim);
133
+ font-family: monospace;
134
+ margin-top: 4px;
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ white-space: nowrap;
138
+ }
139
+ .no-sessions {
140
+ color: var(--text-dim);
141
+ font-size: 13px;
142
+ text-align: center;
143
+ padding: 40px 20px;
144
+ }
145
+
146
+ /* Messages panel */
147
+ .messages-panel {
148
+ display: flex;
149
+ flex-direction: column;
150
+ }
151
+ .messages-header {
152
+ padding: 12px 20px;
153
+ border-bottom: 1px solid var(--border);
154
+ font-size: 14px;
155
+ font-weight: 500;
156
+ }
157
+ .messages-list {
158
+ flex: 1;
159
+ overflow-y: auto;
160
+ padding: 16px 20px;
161
+ }
162
+ .message {
163
+ margin-bottom: 12px;
164
+ padding: 10px 14px;
165
+ border-radius: 8px;
166
+ font-size: 13px;
167
+ line-height: 1.5;
168
+ max-width: 80%;
169
+ }
170
+ .message.from-session {
171
+ background: var(--surface);
172
+ border: 1px solid var(--border);
173
+ }
174
+ .message.from-dashboard {
175
+ background: rgba(124,106,239,0.12);
176
+ border: 1px solid rgba(124,106,239,0.25);
177
+ margin-left: auto;
178
+ }
179
+ .message-meta {
180
+ font-size: 11px;
181
+ color: var(--text-dim);
182
+ margin-bottom: 4px;
183
+ }
184
+ .message-body img {
185
+ max-width: 100%;
186
+ border-radius: 6px;
187
+ margin-top: 4px;
188
+ }
189
+
190
+ /* Image preview */
191
+ .image-preview {
192
+ border-top: 1px solid var(--border);
193
+ padding: 8px 20px;
194
+ display: none;
195
+ align-items: center;
196
+ gap: 8px;
197
+ background: var(--surface);
198
+ }
199
+ .image-preview.active { display: flex; }
200
+ .image-preview img {
201
+ max-height: 60px;
202
+ border-radius: 4px;
203
+ border: 1px solid var(--border);
204
+ }
205
+ .image-preview .preview-name {
206
+ flex: 1;
207
+ font-size: 12px;
208
+ color: var(--text-dim);
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
211
+ white-space: nowrap;
212
+ }
213
+ .image-preview .preview-cancel {
214
+ background: none;
215
+ border: none;
216
+ color: var(--red);
217
+ cursor: pointer;
218
+ font-size: 18px;
219
+ padding: 2px 6px;
220
+ }
221
+
222
+ /* Drag overlay */
223
+ .drag-overlay {
224
+ position: absolute;
225
+ inset: 0;
226
+ background: rgba(124,106,239,0.15);
227
+ border: 2px dashed var(--accent);
228
+ border-radius: 8px;
229
+ display: none;
230
+ align-items: center;
231
+ justify-content: center;
232
+ font-size: 16px;
233
+ color: var(--accent);
234
+ z-index: 10;
235
+ pointer-events: none;
236
+ }
237
+ .drag-overlay.active { display: flex; }
238
+
239
+ .message-input-area {
240
+ border-top: 1px solid var(--border);
241
+ padding: 12px 20px;
242
+ display: flex;
243
+ gap: 8px;
244
+ align-items: flex-end;
245
+ }
246
+ .message-input-area textarea {
247
+ flex: 1;
248
+ background: var(--surface);
249
+ border: 1px solid var(--border);
250
+ border-radius: 6px;
251
+ padding: 8px 12px;
252
+ color: var(--text);
253
+ font-size: 13px;
254
+ outline: none;
255
+ resize: none;
256
+ min-height: 36px;
257
+ max-height: 120px;
258
+ font-family: inherit;
259
+ line-height: 1.4;
260
+ }
261
+ .message-input-area textarea:focus { border-color: var(--accent); }
262
+ .message-input-area button {
263
+ background: var(--accent);
264
+ color: white;
265
+ border: none;
266
+ border-radius: 6px;
267
+ padding: 8px 12px;
268
+ cursor: pointer;
269
+ font-size: 13px;
270
+ font-weight: 500;
271
+ min-height: 36px;
272
+ }
273
+ .message-input-area button:hover { background: var(--accent-dim); }
274
+ .message-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
275
+ .attach-btn {
276
+ background: var(--surface) !important;
277
+ border: 1px solid var(--border) !important;
278
+ color: var(--text) !important;
279
+ font-size: 18px !important;
280
+ padding: 6px 10px !important;
281
+ }
282
+ .attach-btn:hover { border-color: var(--accent) !important; }
283
+ .attach-btn:disabled { display: none !important; }
284
+
285
+ /* Notifications panel */
286
+ .notifications-panel {
287
+ border-left: 1px solid var(--border);
288
+ overflow-y: auto;
289
+ padding: 12px;
290
+ }
291
+ .notifications-panel h2 {
292
+ font-size: 13px;
293
+ text-transform: uppercase;
294
+ letter-spacing: 0.5px;
295
+ color: var(--text-dim);
296
+ padding: 8px 8px 12px;
297
+ }
298
+ .notif-item {
299
+ background: var(--surface);
300
+ border: 1px solid var(--border);
301
+ border-radius: 8px;
302
+ padding: 10px 12px;
303
+ margin-bottom: 8px;
304
+ font-size: 13px;
305
+ cursor: pointer;
306
+ }
307
+ .notif-item:hover { border-color: var(--accent); }
308
+ .notif-item .notif-title { font-weight: 500; margin-bottom: 2px; }
309
+ .notif-item .notif-message { color: var(--text-dim); }
310
+ .notif-item .notif-time { font-size: 11px; color: var(--text-dim); margin-top: 4px; }
311
+ .notif-level {
312
+ display: inline-block;
313
+ width: 6px; height: 6px;
314
+ border-radius: 50%;
315
+ margin-right: 6px;
316
+ }
317
+ .notif-level.info { background: var(--blue); }
318
+ .notif-level.success { background: var(--green); }
319
+ .notif-level.warning { background: var(--yellow); }
320
+ .notif-level.error { background: var(--red); }
321
+
322
+ .message-body h2, .message-body h3, .message-body h4 { margin: 8px 0 4px; font-size: 14px; }
323
+ .message-body h2 { font-size: 16px; }
324
+ .message-body h3 { font-size: 15px; }
325
+ .message-body pre {
326
+ background: var(--bg);
327
+ border: 1px solid var(--border);
328
+ border-radius: 6px;
329
+ padding: 8px 10px;
330
+ overflow-x: auto;
331
+ margin: 6px 0;
332
+ font-size: 12px;
333
+ }
334
+ .message-body code {
335
+ background: var(--bg);
336
+ padding: 1px 4px;
337
+ border-radius: 3px;
338
+ font-size: 12px;
339
+ font-family: monospace;
340
+ }
341
+ .message-body pre code { background: none; padding: 0; }
342
+ .message-body table { border-collapse: collapse; margin: 6px 0; font-size: 12px; width: 100%; }
343
+ .message-body th, .message-body td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
344
+ .message-body th { background: var(--bg); font-weight: 600; }
345
+ .message-body ul { margin: 4px 0; padding-left: 20px; }
346
+ .message-body li { margin: 2px 0; }
347
+ .message-body strong { font-weight: 600; }
348
+
349
+ .empty-state {
350
+ color: var(--text-dim);
351
+ font-size: 13px;
352
+ text-align: center;
353
+ padding: 40px 20px;
354
+ }
355
+
356
+ /* Token auth overlay */
357
+ .token-overlay {
358
+ position: fixed;
359
+ inset: 0;
360
+ background: rgba(0,0,0,0.8);
361
+ display: flex;
362
+ align-items: center;
363
+ justify-content: center;
364
+ z-index: 100;
365
+ }
366
+ .token-overlay.hidden { display: none; }
367
+ .token-form {
368
+ background: var(--surface);
369
+ border: 1px solid var(--border);
370
+ border-radius: 12px;
371
+ padding: 32px;
372
+ max-width: 400px;
373
+ width: 90%;
374
+ text-align: center;
375
+ }
376
+ .token-form h2 { font-size: 18px; margin-bottom: 8px; }
377
+ .token-form p { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; }
378
+ .token-form input {
379
+ width: 100%;
380
+ background: var(--bg);
381
+ border: 1px solid var(--border);
382
+ border-radius: 6px;
383
+ padding: 10px 12px;
384
+ color: var(--text);
385
+ font-size: 14px;
386
+ font-family: monospace;
387
+ outline: none;
388
+ margin-bottom: 12px;
389
+ }
390
+ .token-form input:focus { border-color: var(--accent); }
391
+ .token-form button {
392
+ background: var(--accent);
393
+ color: white;
394
+ border: none;
395
+ border-radius: 6px;
396
+ padding: 10px 24px;
397
+ cursor: pointer;
398
+ font-size: 14px;
399
+ font-weight: 500;
400
+ }
401
+ .token-form button:hover { background: var(--accent-dim); }
402
+ .token-error { color: var(--red); font-size: 12px; margin-top: 8px; display: none; }
403
+ </style>
404
+ </head>
405
+ <body>
406
+ <header>
407
+ <h1>Claude Alarm</h1>
408
+ <div class="header-right">
409
+ <button class="theme-toggle" id="themeToggle" title="Toggle theme">&#9790;</button>
410
+ <div class="status-badge">
411
+ <span class="status-dot" id="connDot"></span>
412
+ <span id="connLabel">Connecting...</span>
413
+ </div>
414
+ </div>
415
+ </header>
416
+
417
+ <div class="token-overlay hidden" id="tokenOverlay">
418
+ <div class="token-form">
419
+ <h2>Authentication Required</h2>
420
+ <p>Enter the hub token to connect. Find it by running: <code>claude-alarm token</code></p>
421
+ <input type="text" id="tokenInput" placeholder="Paste token here..." autocomplete="off">
422
+ <button id="tokenSubmit">Connect</button>
423
+ <div class="token-error" id="tokenError">Connection failed. Check your token.</div>
424
+ </div>
425
+ </div>
426
+
427
+ <div class="container">
428
+ <div class="sessions-panel">
429
+ <h2>Sessions</h2>
430
+ <div id="sessionsList"></div>
431
+ </div>
432
+
433
+ <div class="messages-panel" style="position:relative">
434
+ <div class="messages-header" id="messagesHeader">Select a session</div>
435
+ <div class="messages-list" id="messagesList">
436
+ <div class="empty-state">Select a session to view messages</div>
437
+ </div>
438
+ <div class="drag-overlay" id="dragOverlay">Drop image here</div>
439
+ <div class="image-preview" id="imagePreview">
440
+ <img id="previewImg" src="" alt="preview">
441
+ <span class="preview-name" id="previewName"></span>
442
+ <button class="preview-cancel" id="previewCancel">&times;</button>
443
+ </div>
444
+ <div class="message-input-area">
445
+ <button class="attach-btn" id="attachBtn" disabled title="Attach image">&#128206;</button>
446
+ <input type="file" id="fileInput" accept="image/*" style="display:none">
447
+ <textarea id="msgInput" placeholder="Send a message to session... (Shift+Enter for new line)" disabled rows="1"></textarea>
448
+ <button id="sendBtn" disabled>Send</button>
449
+ </div>
450
+ </div>
451
+
452
+ <div class="notifications-panel">
453
+ <h2>Notifications</h2>
454
+ <div id="notifList">
455
+ <div class="empty-state">No notifications yet</div>
456
+ </div>
457
+ </div>
458
+ </div>
459
+
460
+ <script>
461
+ (function() {
462
+ const state = {
463
+ ws: null,
464
+ sessions: {},
465
+ selectedSession: null,
466
+ messages: {},
467
+ notifications: [],
468
+ token: null,
469
+ pendingImage: null,
470
+ };
471
+
472
+ const $ = (sel) => document.querySelector(sel);
473
+
474
+ // --- Theme ---
475
+ function initTheme() {
476
+ const saved = localStorage.getItem('claude-alarm-theme') || 'dark';
477
+ document.documentElement.setAttribute('data-theme', saved);
478
+ updateThemeIcon(saved);
479
+ }
480
+ function toggleTheme() {
481
+ const current = document.documentElement.getAttribute('data-theme');
482
+ const next = current === 'dark' ? 'light' : 'dark';
483
+ document.documentElement.setAttribute('data-theme', next);
484
+ localStorage.setItem('claude-alarm-theme', next);
485
+ updateThemeIcon(next);
486
+ }
487
+ function updateThemeIcon(theme) {
488
+ $('#themeToggle').innerHTML = theme === 'dark' ? '&#9790;' : '&#9728;';
489
+ }
490
+ $('#themeToggle').addEventListener('click', toggleTheme);
491
+ initTheme();
492
+
493
+ // --- Token handling ---
494
+ function getToken() {
495
+ const params = new URLSearchParams(location.search);
496
+ const urlToken = params.get('token');
497
+ if (urlToken) {
498
+ sessionStorage.setItem('claude-alarm-token', urlToken);
499
+ return urlToken;
500
+ }
501
+ return sessionStorage.getItem('claude-alarm-token');
502
+ }
503
+
504
+ function showTokenForm() { $('#tokenOverlay').classList.remove('hidden'); }
505
+ function hideTokenForm() { $('#tokenOverlay').classList.add('hidden'); }
506
+
507
+ $('#tokenSubmit').addEventListener('click', () => {
508
+ const token = $('#tokenInput').value.trim();
509
+ if (!token) return;
510
+ state.token = token;
511
+ sessionStorage.setItem('claude-alarm-token', token);
512
+ $('#tokenError').style.display = 'none';
513
+ hideTokenForm();
514
+ connect();
515
+ });
516
+ $('#tokenInput').addEventListener('keydown', (e) => {
517
+ if (e.key === 'Enter') $('#tokenSubmit').click();
518
+ });
519
+
520
+ // --- WebSocket ---
521
+ function connect() {
522
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
523
+ const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
524
+ const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard${tokenQuery}`);
525
+
526
+ ws.onopen = () => {
527
+ state.ws = ws;
528
+ hideTokenForm();
529
+ $('#connDot').classList.add('connected');
530
+ $('#connLabel').textContent = 'Connected';
531
+ };
532
+ ws.onclose = () => {
533
+ state.ws = null;
534
+ $('#connDot').classList.remove('connected');
535
+ $('#connLabel').textContent = 'Disconnected';
536
+ setTimeout(connect, 3000);
537
+ };
538
+ ws.onerror = () => {
539
+ if (!state.token) { showTokenForm(); } else { $('#tokenError').style.display = 'block'; showTokenForm(); }
540
+ ws.close();
541
+ };
542
+ ws.onmessage = (e) => { try { handleMessage(JSON.parse(e.data)); } catch {} };
543
+ }
544
+
545
+ function handleMessage(msg) {
546
+ switch (msg.type) {
547
+ case 'sessions_list':
548
+ state.sessions = {};
549
+ msg.sessions.forEach(s => { state.sessions[s.id] = s; });
550
+ renderSessions();
551
+ break;
552
+ case 'session_connected':
553
+ state.sessions[msg.session.id] = msg.session;
554
+ if (!state.messages[msg.session.id]) state.messages[msg.session.id] = [];
555
+ renderSessions();
556
+ break;
557
+ case 'session_disconnected':
558
+ delete state.sessions[msg.sessionId];
559
+ if (state.selectedSession === msg.sessionId) { state.selectedSession = null; renderMessages(); }
560
+ renderSessions();
561
+ break;
562
+ case 'session_updated':
563
+ state.sessions[msg.session.id] = msg.session;
564
+ renderSessions();
565
+ break;
566
+ case 'reply_from_session':
567
+ if (!state.messages[msg.sessionId]) state.messages[msg.sessionId] = [];
568
+ state.messages[msg.sessionId].push({ from: 'session', content: msg.content, time: msg.timestamp });
569
+ if (state.selectedSession === msg.sessionId) renderMessages();
570
+ break;
571
+ case 'notification':
572
+ state.notifications.unshift({ sessionId: msg.sessionId, title: msg.title, message: msg.message, level: msg.level || 'info', time: msg.timestamp });
573
+ renderNotifications();
574
+ break;
575
+ }
576
+ }
577
+
578
+ // --- Render ---
579
+ function renderSessions() {
580
+ const el = $('#sessionsList');
581
+ const ids = Object.keys(state.sessions);
582
+ if (!ids.length) {
583
+ el.innerHTML = '<div class="no-sessions">No active sessions.<br>Start Claude Code with the channel to see sessions here.</div>';
584
+ return;
585
+ }
586
+ el.innerHTML = ids.map(id => {
587
+ const s = state.sessions[id];
588
+ const active = state.selectedSession === id ? ' active' : '';
589
+ const cwdDisplay = s.cwd ? s.cwd.replace(/^.*[/\\]/, '') : '';
590
+ return `<div class="session-card${active}" data-id="${id}">
591
+ <div class="session-name">${esc(s.name)}</div>
592
+ ${cwdDisplay ? `<div class="session-cwd" title="${esc(s.cwd)}">${esc(cwdDisplay)}</div>` : ''}
593
+ <span class="session-status ${s.status}">${s.status.replace('_', ' ')}</span>
594
+ </div>`;
595
+ }).join('');
596
+ el.querySelectorAll('.session-card').forEach(card => {
597
+ card.addEventListener('click', () => selectSession(card.dataset.id));
598
+ });
599
+ updateImageUI();
600
+ }
601
+
602
+ function selectSession(id) {
603
+ state.selectedSession = id;
604
+ if (!state.messages[id]) state.messages[id] = [];
605
+ $('#msgInput').disabled = false;
606
+ $('#sendBtn').disabled = false;
607
+ $('#msgInput').placeholder = 'Send a message to session... (Shift+Enter for new line)';
608
+ renderSessions();
609
+ renderMessages();
610
+ updateImageUI();
611
+ }
612
+
613
+ function updateImageUI() {
614
+ const s = state.selectedSession ? state.sessions[state.selectedSession] : null;
615
+ const canImage = s && s.isLocal;
616
+ $('#attachBtn').disabled = !canImage;
617
+ }
618
+
619
+ function renderMessages() {
620
+ const el = $('#messagesList');
621
+ const header = $('#messagesHeader');
622
+
623
+ if (!state.selectedSession) {
624
+ header.textContent = 'Select a session';
625
+ el.innerHTML = '<div class="empty-state">Select a session to view messages</div>';
626
+ $('#msgInput').disabled = true;
627
+ $('#sendBtn').disabled = true;
628
+ return;
629
+ }
630
+
631
+ const s = state.sessions[state.selectedSession];
632
+ header.textContent = s ? s.name : state.selectedSession.slice(0, 12);
633
+
634
+ const msgs = state.messages[state.selectedSession] || [];
635
+ if (!msgs.length) {
636
+ el.innerHTML = '<div class="empty-state">No messages yet</div>';
637
+ return;
638
+ }
639
+
640
+ el.innerHTML = msgs.map(m => {
641
+ const cls = m.from === 'session' ? 'from-session' : 'from-dashboard';
642
+ const timeStr = new Date(m.time).toLocaleTimeString();
643
+ let content;
644
+ if (m.imageData) {
645
+ content = `<img src="${m.imageData}" alt="${esc(m.imageName || 'image')}">`;
646
+ if (m.content) content = esc(m.content).replace(/\n/g, '<br>') + '<br>' + content;
647
+ } else {
648
+ content = m.from === 'session' ? renderMarkdown(m.content) : esc(m.content).replace(/\n/g, '<br>');
649
+ }
650
+ return `<div class="message ${cls}">
651
+ <div class="message-meta">${m.from === 'session' ? 'Claude' : 'You'} &middot; ${timeStr}</div>
652
+ <div class="message-body">${content}</div>
653
+ </div>`;
654
+ }).join('');
655
+
656
+ el.scrollTop = el.scrollHeight;
657
+ }
658
+
659
+ function renderNotifications() {
660
+ const el = $('#notifList');
661
+ if (!state.notifications.length) {
662
+ el.innerHTML = '<div class="empty-state">No notifications yet</div>';
663
+ return;
664
+ }
665
+ el.innerHTML = state.notifications.slice(0, 50).map(n => {
666
+ const timeStr = new Date(n.time).toLocaleTimeString();
667
+ const session = state.sessions[n.sessionId];
668
+ const sName = session ? session.name : n.sessionId.slice(0, 8);
669
+ return `<div class="notif-item" data-session="${n.sessionId}">
670
+ <div class="notif-title"><span class="notif-level ${n.level}"></span>${esc(n.title)}</div>
671
+ <div class="notif-message">${esc(n.message)}</div>
672
+ <div class="notif-time">${sName} &middot; ${timeStr}</div>
673
+ </div>`;
674
+ }).join('');
675
+ el.querySelectorAll('.notif-item').forEach(item => {
676
+ item.addEventListener('click', () => {
677
+ const sid = item.dataset.session;
678
+ if (sid && state.sessions[sid]) selectSession(sid);
679
+ });
680
+ });
681
+ }
682
+
683
+ // --- Image handling ---
684
+ function handleImageFile(file) {
685
+ if (!file || !file.type.startsWith('image/')) return;
686
+ if (file.size > 10 * 1024 * 1024) { alert('Image must be under 10MB'); return; }
687
+ const reader = new FileReader();
688
+ reader.onload = () => {
689
+ state.pendingImage = { data: reader.result, mimeType: file.type, name: file.name };
690
+ $('#imagePreview').classList.add('active');
691
+ $('#previewImg').src = reader.result;
692
+ $('#previewName').textContent = file.name;
693
+ };
694
+ reader.readAsDataURL(file);
695
+ }
696
+
697
+ function clearPendingImage() {
698
+ state.pendingImage = null;
699
+ $('#imagePreview').classList.remove('active');
700
+ $('#previewImg').src = '';
701
+ $('#previewName').textContent = '';
702
+ }
703
+
704
+ $('#previewCancel').addEventListener('click', clearPendingImage);
705
+ $('#attachBtn').addEventListener('click', () => $('#fileInput').click());
706
+ $('#fileInput').addEventListener('change', (e) => {
707
+ if (e.target.files[0]) handleImageFile(e.target.files[0]);
708
+ e.target.value = '';
709
+ });
710
+
711
+ // Ctrl+V paste
712
+ document.addEventListener('paste', (e) => {
713
+ if (!state.selectedSession) return;
714
+ const s = state.sessions[state.selectedSession];
715
+ if (!s || !s.isLocal) return;
716
+ const items = e.clipboardData?.items;
717
+ if (!items) return;
718
+ for (const item of items) {
719
+ if (item.type.startsWith('image/')) {
720
+ e.preventDefault();
721
+ handleImageFile(item.getAsFile());
722
+ return;
723
+ }
724
+ }
725
+ });
726
+
727
+ // Drag and drop
728
+ const msgPanel = document.querySelector('.messages-panel');
729
+ let dragCounter = 0;
730
+ msgPanel.addEventListener('dragenter', (e) => {
731
+ e.preventDefault();
732
+ dragCounter++;
733
+ const s = state.selectedSession ? state.sessions[state.selectedSession] : null;
734
+ if (s && s.isLocal) $('#dragOverlay').classList.add('active');
735
+ });
736
+ msgPanel.addEventListener('dragleave', (e) => {
737
+ e.preventDefault();
738
+ dragCounter--;
739
+ if (dragCounter <= 0) { dragCounter = 0; $('#dragOverlay').classList.remove('active'); }
740
+ });
741
+ msgPanel.addEventListener('dragover', (e) => e.preventDefault());
742
+ msgPanel.addEventListener('drop', (e) => {
743
+ e.preventDefault();
744
+ dragCounter = 0;
745
+ $('#dragOverlay').classList.remove('active');
746
+ const file = e.dataTransfer?.files?.[0];
747
+ if (file) handleImageFile(file);
748
+ });
749
+
750
+ // --- Send ---
751
+ function sendMessage() {
752
+ const input = $('#msgInput');
753
+ const content = input.value.trim();
754
+ const hasImage = !!state.pendingImage;
755
+ if (!content && !hasImage) return;
756
+ if (!state.selectedSession || !state.ws) return;
757
+
758
+ // Send text
759
+ if (content) {
760
+ state.ws.send(JSON.stringify({ type: 'message_to_session', sessionId: state.selectedSession, content }));
761
+ }
762
+
763
+ // Send image
764
+ if (hasImage) {
765
+ state.ws.send(JSON.stringify({
766
+ type: 'image_upload',
767
+ sessionId: state.selectedSession,
768
+ imageData: state.pendingImage.data,
769
+ mimeType: state.pendingImage.mimeType,
770
+ originalName: state.pendingImage.name,
771
+ }));
772
+ }
773
+
774
+ // Add to local messages
775
+ if (!state.messages[state.selectedSession]) state.messages[state.selectedSession] = [];
776
+ state.messages[state.selectedSession].push({
777
+ from: 'dashboard',
778
+ content: content || '',
779
+ imageData: hasImage ? state.pendingImage.data : null,
780
+ imageName: hasImage ? state.pendingImage.name : null,
781
+ time: Date.now(),
782
+ });
783
+
784
+ input.value = '';
785
+ input.style.height = 'auto';
786
+ clearPendingImage();
787
+ renderMessages();
788
+ }
789
+
790
+ $('#sendBtn').addEventListener('click', sendMessage);
791
+ $('#msgInput').addEventListener('keydown', (e) => {
792
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
793
+ });
794
+ $('#msgInput').addEventListener('input', function() {
795
+ this.style.height = 'auto';
796
+ this.style.height = Math.min(this.scrollHeight, 120) + 'px';
797
+ });
798
+
799
+ function esc(s) {
800
+ const d = document.createElement('div');
801
+ d.textContent = s;
802
+ return d.innerHTML;
803
+ }
804
+
805
+ function renderMarkdown(text) {
806
+ let html = esc(text);
807
+ html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
808
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
809
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
810
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
811
+ html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
812
+ html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
813
+ html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
814
+ html = html.replace(/^(\|.+\|)\n\|[-| :]+\|\n((?:\|.+\|\n?)*)/gm, (_, header, body) => {
815
+ const ths = header.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
816
+ const rows = body.trim().split('\n').map(row => {
817
+ const tds = row.split('|').filter(c => c.trim()).map(c => `<td>${c.trim()}</td>`).join('');
818
+ return `<tr>${tds}</tr>`;
819
+ }).join('');
820
+ return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
821
+ });
822
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
823
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
824
+ html = html.replace(/\n/g, '<br>');
825
+ html = html.replace(/<br>\s*(<\/?(?:pre|h[2-4]|ul|li|table|thead|tbody|tr))/g, '$1');
826
+ html = html.replace(/(<\/(?:pre|h[2-4]|ul|table)>)\s*<br>/g, '$1');
827
+ return html;
828
+ }
829
+
830
+ state.token = getToken();
831
+ connect();
832
+ })();
833
+ </script>
834
+ </body>
835
+ </html>