clodds 1.2.1 → 1.2.3

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.
@@ -0,0 +1,750 @@
1
+ /**
2
+ * Main controller — wires sidebar, chat, WS, commands
3
+ */
4
+ import { Storage } from './storage.js';
5
+ import { WSClient } from './ws.js?v=4';
6
+ import { Sidebar } from './sidebar.js';
7
+ import { Chat } from './chat.js';
8
+ import { CommandPalette } from './commands.js';
9
+
10
+ class App {
11
+ constructor() {
12
+ this.ws = new WSClient();
13
+ this.activeSessionId = null;
14
+ this.userId = null;
15
+ }
16
+
17
+ async init() {
18
+ // Resolve userId & token
19
+ const params = new URLSearchParams(location.search);
20
+ const queryToken = params.get('token');
21
+ if (queryToken) {
22
+ Storage.set('webchat_token', queryToken);
23
+ // Strip token from URL to prevent leaking via bookmarks/history
24
+ params.delete('token');
25
+ const clean = params.toString();
26
+ history.replaceState(null, '', location.pathname + (clean ? '?' + clean : ''));
27
+ }
28
+ const token = Storage.get('webchat_token') || '';
29
+
30
+ this.userId = Storage.get('userId') || 'web-' + Date.now();
31
+ Storage.set('userId', this.userId);
32
+
33
+ // DOM refs
34
+ const sidebarEl = document.querySelector('.sidebar');
35
+ const messagesEl = document.getElementById('messages');
36
+ const typingEl = document.getElementById('typing');
37
+ const welcomeEl = document.getElementById('welcome');
38
+ const inputEl = document.getElementById('input');
39
+ const sendBtnEl = document.getElementById('send-btn');
40
+ const paletteEl = document.getElementById('cmd-palette');
41
+ const statusDot = document.getElementById('status-dot');
42
+ const headerTitle = document.getElementById('header-title');
43
+ const sidebarToggle = document.getElementById('sidebar-toggle');
44
+ const newChatBtn = document.getElementById('new-chat-btn');
45
+ const backdropEl = document.querySelector('.sidebar-backdrop');
46
+
47
+ // Init components
48
+ this.sidebar = new Sidebar(sidebarEl);
49
+ this.chat = new Chat(messagesEl, typingEl, welcomeEl);
50
+ this.commands = new CommandPalette(paletteEl, inputEl, sendBtnEl);
51
+
52
+ // Scroll-to-bottom button
53
+ const scrollBtn = document.getElementById('scroll-bottom');
54
+ messagesEl.addEventListener('scroll', () => {
55
+ const atBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 100;
56
+ scrollBtn.classList.toggle('visible', !atBottom);
57
+ });
58
+ scrollBtn.addEventListener('click', () => {
59
+ messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: 'smooth' });
60
+ });
61
+
62
+ // Sidebar callbacks
63
+ this.sidebar.onSelect = (id) => this.switchSession(id);
64
+ this.sidebar.onDelete = (id) => this.deleteSession(id);
65
+ this.sidebar.onRename = (id, title) => {
66
+ const headerTitle = document.getElementById('header-title');
67
+ if (this.activeSessionId === id && headerTitle) {
68
+ headerTitle.textContent = title;
69
+ }
70
+ fetch(`/api/chat/sessions/${id}`, {
71
+ method: 'PATCH',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ title, userId: this.userId }),
74
+ }).catch(() => {});
75
+ };
76
+ // Artifact/code click — switch to session and scroll to message
77
+ this.sidebar.onArtifactClick = (sessionId, messageIndex) => {
78
+ this._navigateToMessage(sessionId, messageIndex);
79
+ };
80
+ this.sidebar.onCodeClick = (sessionId, messageIndex) => {
81
+ this._navigateToMessage(sessionId, messageIndex);
82
+ };
83
+
84
+ // Language change — update speech recognition (set later once recognition is created)
85
+ this._recognition = null;
86
+ this.sidebar.onLanguageChange = (lang) => {
87
+ if (this._recognition) this._recognition.lang = lang;
88
+ };
89
+
90
+ // Chat edit callback — put text back into input
91
+ this.chat.onEdit = (text) => {
92
+ inputEl.value = text;
93
+ inputEl.style.height = 'auto';
94
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + 'px';
95
+ sendBtnEl.classList.toggle('active', text.trim().length > 0);
96
+ inputEl.focus();
97
+ };
98
+
99
+ newChatBtn?.addEventListener('click', () => this.newChat());
100
+
101
+ // Sidebar toggle (shared logic for desktop + mobile buttons)
102
+ const toggleSidebar = () => {
103
+ this.sidebar.toggle();
104
+ backdropEl?.classList.toggle('visible', !this.sidebar.collapsed);
105
+ if (!this.sidebar.collapsed) this.commands.hide();
106
+ };
107
+ sidebarToggle?.addEventListener('click', toggleSidebar);
108
+ const sidebarToggleMobile = document.getElementById('sidebar-toggle-mobile');
109
+ sidebarToggleMobile?.addEventListener('click', toggleSidebar);
110
+
111
+ backdropEl?.addEventListener('click', () => {
112
+ if (!this.sidebar.collapsed) {
113
+ this.sidebar.toggle();
114
+ backdropEl.classList.remove('visible');
115
+ }
116
+ });
117
+
118
+ // Input handling (textarea auto-resize)
119
+ const autoResize = () => {
120
+ inputEl.style.height = 'auto';
121
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + 'px';
122
+ };
123
+
124
+ inputEl.addEventListener('input', () => {
125
+ autoResize();
126
+ sendBtnEl.classList.toggle('active', inputEl.value.trim().length > 0 || !!this._pendingAttachment);
127
+ this.commands.handleInput(inputEl.value);
128
+ });
129
+
130
+ inputEl.addEventListener('keydown', (e) => {
131
+ if (this.commands.handleKeydown(e)) return;
132
+ if (e.key === 'Enter' && !e.shiftKey) {
133
+ e.preventDefault();
134
+ this._send();
135
+ }
136
+ });
137
+
138
+ sendBtnEl.addEventListener('click', () => {
139
+ if (this._generating) {
140
+ this.chat.hideTyping();
141
+ this._setGenerating(false);
142
+ return;
143
+ }
144
+ this._send();
145
+ });
146
+
147
+ // Attachment button + file preview
148
+ const attachBtn = document.getElementById('attach-btn');
149
+ const fileInput = document.getElementById('file-input');
150
+ const filePreview = document.getElementById('file-preview');
151
+ const filePreviewIcon = filePreview?.querySelector('.file-preview-icon');
152
+ const filePreviewName = filePreview?.querySelector('.file-preview-name');
153
+ const filePreviewRemove = filePreview?.querySelector('.file-preview-remove');
154
+
155
+ const showFilePreview = (name, mime) => {
156
+ if (!filePreview) return;
157
+ const icon = mime?.startsWith('image/') ? '\uD83D\uDDBC\uFE0F' :
158
+ mime === 'application/pdf' ? '\uD83D\uDCC4' :
159
+ mime?.includes('json') ? '\uD83D\uDCCB' : '\uD83D\uDCCE';
160
+ filePreviewIcon.textContent = icon;
161
+ filePreviewName.textContent = name;
162
+ filePreview.style.display = 'flex';
163
+ attachBtn?.classList.add('has-file');
164
+ sendBtnEl.classList.add('active');
165
+ };
166
+
167
+ this._clearFilePreview = () => {
168
+ this._pendingAttachment = null;
169
+ if (filePreview) filePreview.style.display = 'none';
170
+ if (attachBtn) { attachBtn.classList.remove('has-file'); attachBtn.title = 'Attach file'; }
171
+ };
172
+
173
+ attachBtn?.addEventListener('click', () => {
174
+ if (fileInput) fileInput.value = '';
175
+ fileInput?.click();
176
+ });
177
+ filePreviewRemove?.addEventListener('click', () => {
178
+ this._clearFilePreview();
179
+ if (!inputEl.value.trim()) sendBtnEl.classList.remove('active');
180
+ });
181
+ fileInput?.addEventListener('change', () => {
182
+ const file = fileInput.files?.[0];
183
+ if (!file) return;
184
+ const reader = new FileReader();
185
+ reader.onload = () => {
186
+ this._pendingAttachment = {
187
+ filename: file.name,
188
+ mimeType: file.type,
189
+ data: reader.result?.split(',')[1] || '', // base64
190
+ };
191
+ showFilePreview(file.name, file.type);
192
+ };
193
+ reader.onerror = () => {
194
+ this.chat.addMessage('Failed to read file.', 'system');
195
+ };
196
+ reader.readAsDataURL(file);
197
+ fileInput.value = '';
198
+ });
199
+
200
+ // Handle drag-and-drop files (prevent browser navigation + attach file)
201
+ document.addEventListener('dragover', (e) => e.preventDefault());
202
+ document.addEventListener('drop', (e) => {
203
+ e.preventDefault();
204
+ const file = e.dataTransfer?.files?.[0];
205
+ if (file && fileInput) {
206
+ const dt = new DataTransfer();
207
+ dt.items.add(file);
208
+ fileInput.files = dt.files;
209
+ fileInput.dispatchEvent(new Event('change'));
210
+ }
211
+ });
212
+
213
+ // Paste image support (Cmd+V / Ctrl+V)
214
+ document.addEventListener('paste', (e) => {
215
+ const items = e.clipboardData?.items;
216
+ if (!items) return;
217
+ for (const item of items) {
218
+ if (item.type.startsWith('image/')) {
219
+ e.preventDefault();
220
+ const blob = item.getAsFile();
221
+ if (!blob || !fileInput) return;
222
+ const dt = new DataTransfer();
223
+ dt.items.add(new File([blob], `pasted-image-${Date.now()}.png`, { type: blob.type }));
224
+ fileInput.files = dt.files;
225
+ fileInput.dispatchEvent(new Event('change'));
226
+ return;
227
+ }
228
+ }
229
+ });
230
+
231
+ // Voice input (Web Speech API)
232
+ const micBtn = document.getElementById('mic-btn');
233
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
234
+ if (SpeechRecognition && micBtn) {
235
+ const recognition = this._recognition = new SpeechRecognition();
236
+ recognition.continuous = false;
237
+ recognition.interimResults = true;
238
+ recognition.lang = Storage.get('webchat_language') || 'en-US';
239
+ let listening = false;
240
+ let textBeforeVoice = '';
241
+
242
+ micBtn.addEventListener('click', () => {
243
+ if (listening) {
244
+ recognition.abort();
245
+ return;
246
+ }
247
+ textBeforeVoice = inputEl.value;
248
+ listening = true;
249
+ micBtn.classList.add('listening');
250
+ micBtn.title = 'Stop listening';
251
+ recognition.start();
252
+ });
253
+
254
+ recognition.onresult = (e) => {
255
+ let interim = '';
256
+ let final = '';
257
+ for (let i = e.resultIndex; i < e.results.length; i++) {
258
+ const transcript = e.results[i][0].transcript;
259
+ if (e.results[i].isFinal) {
260
+ final += transcript;
261
+ } else {
262
+ interim += transcript;
263
+ }
264
+ }
265
+ const separator = textBeforeVoice && !textBeforeVoice.endsWith(' ') ? ' ' : '';
266
+ inputEl.value = textBeforeVoice + separator + (final || interim);
267
+ autoResize();
268
+ sendBtnEl.classList.toggle('active', inputEl.value.trim().length > 0);
269
+ };
270
+
271
+ const stopListening = () => {
272
+ listening = false;
273
+ micBtn.classList.remove('listening');
274
+ micBtn.title = 'Voice input';
275
+ };
276
+
277
+ recognition.onend = stopListening;
278
+ recognition.onerror = (e) => {
279
+ stopListening();
280
+ if (e.error !== 'aborted' && e.error !== 'no-speech') {
281
+ console.warn('Speech recognition error:', e.error);
282
+ }
283
+ };
284
+ } else if (micBtn) {
285
+ micBtn.classList.add('unsupported');
286
+ }
287
+
288
+ document.addEventListener('click', (e) => {
289
+ if (!paletteEl.contains(e.target) && e.target !== inputEl) {
290
+ this.commands.hide();
291
+ }
292
+ });
293
+
294
+ // Welcome chip clicks
295
+ document.querySelectorAll('.welcome-card').forEach(chip => {
296
+ chip.addEventListener('click', () => {
297
+ inputEl.value = chip.dataset.msg;
298
+ this._send();
299
+ });
300
+ });
301
+
302
+ // Reconnect banner
303
+ const reconnectBanner = document.getElementById('reconnect-banner');
304
+
305
+ // Tab notification state
306
+ this._unreadCount = 0;
307
+ this._originalTitle = document.title;
308
+ document.addEventListener('visibilitychange', () => {
309
+ if (!document.hidden && this._unreadCount > 0) {
310
+ this._unreadCount = 0;
311
+ document.title = this._originalTitle;
312
+ }
313
+ });
314
+
315
+ // Time-based greeting + themed subtitle + live market pulse
316
+ const greetingEl = document.getElementById('welcome-greeting');
317
+ const subEl = document.querySelector('.welcome-sub');
318
+ if (greetingEl) {
319
+ const hour = new Date().getHours();
320
+ const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
321
+ greetingEl.textContent = greeting;
322
+
323
+ if (subEl) {
324
+ const subs = hour < 12 ? [
325
+ 'What odds are you exploring today?',
326
+ 'Markets are waking up. Ready to trade?',
327
+ 'Time to find your edge.',
328
+ ] : hour < 18 ? [
329
+ 'What odds are you exploring?',
330
+ 'Time to predict the future.',
331
+ 'The markets are moving. Are you?',
332
+ ] : [
333
+ 'Time to predict the future.',
334
+ 'What odds do you want to explore?',
335
+ 'Markets never sleep. Neither does Clodds.',
336
+ ];
337
+ subEl.textContent = subs[Math.floor(Math.random() * subs.length)];
338
+ }
339
+ }
340
+
341
+ // Live market pulse
342
+ const pulseEl = document.getElementById('welcome-pulse');
343
+ if (pulseEl) {
344
+ this._loadMarketPulse(pulseEl);
345
+ }
346
+
347
+ // Main element ref for welcome-mode
348
+ this._mainEl = document.querySelector('.main');
349
+
350
+ // Stop generation
351
+ this._generating = false;
352
+ this._stopSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
353
+ this._sendSvg = sendBtnEl.innerHTML;
354
+ this._sendBtn = sendBtnEl;
355
+
356
+ // WS handlers
357
+ this.ws.on('open', () => {
358
+ statusDot.className = 'status-dot';
359
+ statusDot.title = 'Authenticating...';
360
+ });
361
+
362
+ this.ws.on('close', () => {
363
+ statusDot.className = 'status-dot error';
364
+ statusDot.title = 'Reconnecting...';
365
+ reconnectBanner?.classList.add('visible');
366
+ this.chat.hideTyping();
367
+ this._setGenerating(false);
368
+ });
369
+
370
+ this.ws.on('message', (msg) => {
371
+ this.chat.hideTyping();
372
+ this._setGenerating(false);
373
+
374
+ if (msg.type === 'authenticated') {
375
+ statusDot.className = 'status-dot connected';
376
+ statusDot.title = 'Connected';
377
+ reconnectBanner?.classList.remove('visible');
378
+ // Re-fetch messages after reconnect to recover any missed responses
379
+ // Skip if switchSession already loaded history (flag cleared after use)
380
+ if (this.activeSessionId && !this._skipNextRefresh) {
381
+ this._refreshHistory(this.activeSessionId);
382
+ }
383
+ this._skipNextRefresh = false;
384
+ } else if (msg.type === 'switched') {
385
+ // Session switch confirmed
386
+ } else if (msg.type === 'message') {
387
+ this._setWelcomeMode(false);
388
+ this.chat.addBotMessage(msg.text, msg.messageId, msg.attachments);
389
+ // Feed new bot message to sidebar for live artifact extraction
390
+ if (this.activeSessionId && msg.text) {
391
+ this.sidebar.feedMessages(this.activeSessionId, [{ role: 'assistant', content: msg.text }]);
392
+ }
393
+ if (document.hidden) {
394
+ this._unreadCount++;
395
+ document.title = `(${this._unreadCount}) New message - Clodds`;
396
+ }
397
+ } else if (msg.type === 'edit') {
398
+ this.chat.editMessage(msg.messageId, msg.text);
399
+ } else if (msg.type === 'delete') {
400
+ this.chat.deleteMessage(msg.messageId);
401
+ } else if (msg.type === 'error') {
402
+ if (msg.message === 'Invalid token') {
403
+ const retry = prompt('Authentication required. Enter WebChat token:');
404
+ if (retry) {
405
+ Storage.set('webchat_token', retry);
406
+ location.reload();
407
+ } else {
408
+ this.chat.addMessage('Authentication failed. Set token or pass ?token= in URL.', 'system');
409
+ }
410
+ } else {
411
+ this.chat.addMessage(msg.message, 'system');
412
+ }
413
+ }
414
+ });
415
+
416
+ // Load sessions, then connect
417
+ await this.sidebar.loadSessions();
418
+
419
+ const lastSessionId = Storage.get('lastSessionId');
420
+ const hasExisting = this.sidebar.sessions.length > 0;
421
+
422
+ if (lastSessionId && this.sidebar.sessions.find(s => s.id === lastSessionId)) {
423
+ await this.switchSession(lastSessionId);
424
+ } else if (hasExisting) {
425
+ await this.switchSession(this.sidebar.sessions[0].id);
426
+ } else {
427
+ // Connect WS without a session — will create one on first message
428
+ this.ws.connect(token, this.userId);
429
+ this._showGreeting();
430
+ }
431
+
432
+ inputEl.focus();
433
+ }
434
+
435
+ async switchSession(sessionId) {
436
+ if (this.activeSessionId === sessionId && this.ws.connected) return;
437
+
438
+ this.chat.hideTyping();
439
+ this.activeSessionId = sessionId;
440
+ this.sidebar.setActive(sessionId);
441
+ Storage.set('lastSessionId', sessionId);
442
+
443
+ // Update header title
444
+ const session = this.sidebar.sessions.find(s => s.id === sessionId);
445
+ const headerTitle = document.getElementById('header-title');
446
+ if (headerTitle) {
447
+ headerTitle.textContent = session?.title || 'Clodds';
448
+ }
449
+
450
+ // Load messages from API (guard against race if user switched again)
451
+ try {
452
+ const r = await fetch(`/api/chat/sessions/${sessionId}?userId=${encodeURIComponent(this.userId)}`);
453
+ if (this.activeSessionId !== sessionId) return; // stale response
454
+ if (r.ok) {
455
+ const data = await r.json();
456
+ const msgs = data.messages || [];
457
+ this.chat.loadHistory(msgs);
458
+ this._setWelcomeMode(!msgs.length);
459
+ // Feed to sidebar for artifact/code extraction
460
+ this.sidebar.feedMessages(sessionId, msgs);
461
+ } else {
462
+ this.chat.clear();
463
+ this.chat.showWelcome();
464
+ this._setWelcomeMode(true);
465
+ }
466
+ } catch {
467
+ if (this.activeSessionId !== sessionId) return;
468
+ this.chat.clear();
469
+ this.chat.showWelcome();
470
+ this._setWelcomeMode(true);
471
+ }
472
+
473
+ // Connect or switch WS (skip refresh since we just loaded history above)
474
+ const token = Storage.get('webchat_token') || '';
475
+ if (!this.ws.connected) {
476
+ this._skipNextRefresh = true;
477
+ this.ws.connect(token, this.userId, sessionId);
478
+ } else {
479
+ this.ws.switchSession(sessionId);
480
+ }
481
+
482
+ // Close mobile sidebar
483
+ const backdropEl = document.querySelector('.sidebar-backdrop');
484
+ if (backdropEl?.classList.contains('visible')) {
485
+ this.sidebar.toggle();
486
+ backdropEl.classList.remove('visible');
487
+ }
488
+ }
489
+
490
+ async _refreshHistory(sessionId) {
491
+ // Deduplicate concurrent calls for the same session
492
+ const seq = (this._refreshSeq = (this._refreshSeq || 0) + 1);
493
+ try {
494
+ const r = await fetch(`/api/chat/sessions/${sessionId}?userId=${encodeURIComponent(this.userId)}`);
495
+ if (this.activeSessionId !== sessionId || this._refreshSeq !== seq) return;
496
+ if (r.ok) {
497
+ const data = await r.json();
498
+ if (this.activeSessionId !== sessionId || this._refreshSeq !== seq) return;
499
+ const msgs = data.messages || [];
500
+ this.chat.loadHistory(msgs);
501
+ this.sidebar.feedMessages(sessionId, msgs);
502
+ }
503
+ } catch { /* ignore */ }
504
+ }
505
+
506
+ async newChat() {
507
+ try {
508
+ const r = await fetch('/api/chat/sessions', {
509
+ method: 'POST',
510
+ headers: { 'Content-Type': 'application/json' },
511
+ body: JSON.stringify({ userId: this.userId }),
512
+ });
513
+ if (!r.ok) return;
514
+ const data = await r.json();
515
+ this.sidebar.addSession(data.session);
516
+ await this.switchSession(data.session.id);
517
+ } catch { /* ignore */ }
518
+ }
519
+
520
+ async deleteSession(sessionId) {
521
+ if (!confirm('Delete this conversation?')) return;
522
+ try {
523
+ await fetch(`/api/chat/sessions/${sessionId}?userId=${encodeURIComponent(this.userId)}`, {
524
+ method: 'DELETE',
525
+ });
526
+ } catch { /* ignore */ }
527
+
528
+ this.sidebar.removeSession(sessionId);
529
+
530
+ if (this.activeSessionId === sessionId) {
531
+ this.chat.hideTyping();
532
+ if (this.sidebar.sessions.length > 0) {
533
+ await this.switchSession(this.sidebar.sessions[0].id);
534
+ } else {
535
+ this.activeSessionId = null;
536
+ this.chat.clear();
537
+ this.chat.showWelcome();
538
+ this._setWelcomeMode(true);
539
+ Storage.remove('lastSessionId');
540
+ const headerTitle = document.getElementById('header-title');
541
+ if (headerTitle) headerTitle.textContent = 'Clodds';
542
+ }
543
+ }
544
+ }
545
+
546
+ async _send() {
547
+ if (this._sending) return;
548
+ const inputEl = document.getElementById('input');
549
+ const sendBtnEl = document.getElementById('send-btn');
550
+ const text = inputEl.value.trim();
551
+ if (!text && !this._pendingAttachment) return;
552
+ this._sending = true;
553
+
554
+ // If no active session, create one first
555
+ if (!this.activeSessionId) {
556
+ try {
557
+ const r = await fetch('/api/chat/sessions', {
558
+ method: 'POST',
559
+ headers: { 'Content-Type': 'application/json' },
560
+ body: JSON.stringify({ userId: this.userId }),
561
+ });
562
+ if (r.ok) {
563
+ const data = await r.json();
564
+ this.sidebar.addSession(data.session);
565
+ this.activeSessionId = data.session.id;
566
+ this.sidebar.setActive(data.session.id);
567
+ Storage.set('lastSessionId', data.session.id);
568
+
569
+ const token = Storage.get('webchat_token') || '';
570
+ this.ws.connect(token, this.userId, data.session.id);
571
+ // Wait for auth with timeout
572
+ await new Promise((resolve) => {
573
+ let elapsed = 0;
574
+ const check = () => {
575
+ if (this.ws.authenticated || elapsed >= 5000) return resolve();
576
+ elapsed += 50;
577
+ setTimeout(check, 50);
578
+ };
579
+ setTimeout(check, 50);
580
+ });
581
+ } else {
582
+ this.chat.addMessage('Failed to create session. Please try again.', 'system');
583
+ this._sending = false;
584
+ return;
585
+ }
586
+ } catch {
587
+ this.chat.addMessage('Failed to create session. Please try again.', 'system');
588
+ this._sending = false;
589
+ return;
590
+ }
591
+ }
592
+
593
+ // Check if WS is actually ready before sending
594
+ if (!this.ws.connected) {
595
+ this.chat.addMessage('Connection lost. Please wait and try again.', 'system');
596
+ this._sending = false;
597
+ return;
598
+ }
599
+
600
+ this._setWelcomeMode(false);
601
+ const displayText = text || (this._pendingAttachment ? '\uD83D\uDCCE ' + this._pendingAttachment.filename : '');
602
+ if (displayText) this.chat.addMessage(displayText, 'user');
603
+ if (this._pendingAttachment) {
604
+ this.ws.send(text, [this._pendingAttachment]);
605
+ this._clearFilePreview();
606
+ } else {
607
+ this.ws.send(text);
608
+ }
609
+ inputEl.value = '';
610
+ inputEl.style.height = 'auto';
611
+ sendBtnEl.classList.remove('active');
612
+ this.chat.showTyping();
613
+ this._setGenerating(true);
614
+ this.commands.hide();
615
+
616
+ // Auto-title: if session has no title, set from first message
617
+ if (this.activeSessionId) {
618
+ const session = this.sidebar.sessions.find(s => s.id === this.activeSessionId);
619
+ if (session && !session.title) {
620
+ const titleSrc = text || displayText;
621
+ const title = titleSrc.slice(0, 50) + (titleSrc.length > 50 ? '...' : '');
622
+ session.title = title;
623
+ this.sidebar.updateSession(session.id, { title });
624
+ const headerTitle = document.getElementById('header-title');
625
+ if (headerTitle) headerTitle.textContent = title;
626
+
627
+ // Persist title
628
+ fetch(`/api/chat/sessions/${this.activeSessionId}`, {
629
+ method: 'PATCH',
630
+ headers: { 'Content-Type': 'application/json' },
631
+ body: JSON.stringify({ title, userId: this.userId }),
632
+ }).catch(() => {});
633
+ }
634
+ }
635
+ this._sending = false;
636
+ }
637
+
638
+ _setWelcomeMode(on) {
639
+ if (this._mainEl) {
640
+ this._mainEl.classList.toggle('welcome-mode', on);
641
+ }
642
+ }
643
+
644
+ _showGreeting() {
645
+ this._setWelcomeMode(true);
646
+ }
647
+
648
+ async _navigateToMessage(sessionId, messageIndex) {
649
+ // Switch to chats tab
650
+ this.sidebar.switchTab('chats');
651
+ // Switch to session if needed
652
+ if (this.activeSessionId !== sessionId) {
653
+ await this.switchSession(sessionId);
654
+ }
655
+ // Scroll to the message by index
656
+ const messagesEl = document.getElementById('messages');
657
+ if (!messagesEl) return;
658
+ // Messages are direct children of #messages (skipping welcome div)
659
+ const msgEls = Array.from(messagesEl.children).filter(el => el.classList.contains('msg-row') || el.classList.contains('msg-system'));
660
+ if (messageIndex >= 0 && messageIndex < msgEls.length) {
661
+ const target = msgEls[messageIndex];
662
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
663
+ // Brief highlight
664
+ target.style.outline = '2px solid var(--accent)';
665
+ target.style.outlineOffset = '2px';
666
+ target.style.borderRadius = '8px';
667
+ setTimeout(() => {
668
+ target.style.outline = '';
669
+ target.style.outlineOffset = '';
670
+ target.style.borderRadius = '';
671
+ }, 2000);
672
+ }
673
+ }
674
+
675
+ async _loadMarketPulse(el) {
676
+ try {
677
+ const r = await fetch('/api/feeds/search?q=&limit=50');
678
+ if (!r.ok) return;
679
+ const data = await r.json();
680
+ const markets = data?.data?.markets || [];
681
+ if (!markets.length) return;
682
+
683
+ // Count platforms
684
+ const platforms = new Set(markets.map(m => m.platform));
685
+
686
+ // Find biggest movers (markets with prices near extremes)
687
+ const movers = markets
688
+ .filter(m => m.outcomes?.length >= 2)
689
+ .map(m => {
690
+ const price = m.outcomes[0]?.price ?? 0.5;
691
+ return { q: m.question, price, dist: Math.abs(price - 0.5) };
692
+ })
693
+ .sort((a, b) => b.dist - a.dist);
694
+
695
+ const hot = movers[0];
696
+
697
+ el.innerHTML = '';
698
+
699
+ // Live dot + market count
700
+ const countItem = document.createElement('span');
701
+ countItem.className = 'welcome-pulse-item';
702
+ countItem.innerHTML = '<span class="welcome-pulse-dot live"></span>'
703
+ + '<span class="welcome-pulse-value">' + markets.length + '</span> markets tracked';
704
+ el.appendChild(countItem);
705
+
706
+ // Platform count
707
+ const platItem = document.createElement('span');
708
+ platItem.className = 'welcome-pulse-item';
709
+ platItem.innerHTML = '<span class="welcome-pulse-value">' + platforms.size + '</span> platforms';
710
+ el.appendChild(platItem);
711
+
712
+ // Hot market
713
+ if (hot) {
714
+ const pct = Math.round(hot.price * 100);
715
+ const hotItem = document.createElement('span');
716
+ hotItem.className = 'welcome-pulse-item';
717
+ const q = hot.q.length > 30 ? hot.q.slice(0, 30) + '...' : hot.q;
718
+ hotItem.innerHTML = '<span class="welcome-pulse-value ' + (pct > 50 ? 'up' : 'down') + '">'
719
+ + pct + '%</span> ' + q;
720
+ el.appendChild(hotItem);
721
+ }
722
+ } catch { /* silent — pulse is optional */ }
723
+ }
724
+
725
+ _setGenerating(on) {
726
+ this._generating = on;
727
+ clearTimeout(this._genTimeout);
728
+ const btn = this._sendBtn;
729
+ if (!btn) return;
730
+ if (on) {
731
+ btn.classList.add('stop-mode');
732
+ btn.innerHTML = this._stopSvg;
733
+ btn.title = 'Stop generating';
734
+ btn.classList.add('active');
735
+ // Auto-reset after 90s to prevent stuck state
736
+ this._genTimeout = setTimeout(() => {
737
+ this.chat.hideTyping();
738
+ this._setGenerating(false);
739
+ }, 90000);
740
+ } else {
741
+ btn.classList.remove('stop-mode');
742
+ btn.innerHTML = this._sendSvg;
743
+ btn.title = 'Send';
744
+ }
745
+ }
746
+ }
747
+
748
+ // Boot
749
+ const app = new App();
750
+ app.init().catch(console.error);