total-recall-brain 3.5.0 → 3.6.1

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,7 +1,8 @@
1
1
  import { useState, useRef, useEffect, useCallback } from 'react'
2
- import { sendChat, createTask, listTasks, fetchTtsStatus, fetchTtsAudio, fetchChatHistory, fetchChatThreads, deleteChatThread, listMemory, listResearch, fetchHealth, fetchGeminiModels, shareToApi, fetchExtensionStatus } from '../api'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { sendChat, createTask, listTasks, fetchTtsStatus, fetchTtsAudio, fetchChatHistory, fetchChatThreads, deleteChatThread, listMemory, listResearch, fetchHealth, fetchGeminiModels, shareToApi, fetchExtensionStatus, checkUpdate } from '../api'
3
4
  import type { ChatThread } from '../api'
4
- import type { ChatMessage, MemoryNode, ResearchItem } from '../types'
5
+ import type { ChatMessage, MemoryNode, ResearchItem, UpdateCheckResult } from '../types'
5
6
  import Graph3D from '../components/Graph3D'
6
7
 
7
8
 
@@ -23,6 +24,7 @@ function formatRelativeTime(timestamp: number): string {
23
24
  }
24
25
 
25
26
  export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrainId?: string; onBrainChange?: (id: string) => void }) {
27
+ const navigate = useNavigate()
26
28
  const [messages, setMessages] = useState<ChatMessage[]>([])
27
29
  const [input, setInput] = useState('')
28
30
  const [loading, setLoading] = useState(false)
@@ -33,6 +35,7 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
33
35
 
34
36
  const [voiceMode, setVoiceMode] = useState(false)
35
37
  const [kokoroEnabled, setKokoroEnabled] = useState<boolean | null>(null)
38
+ const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null)
36
39
  const [deepResearchMode, setDeepResearchMode] = useState(false)
37
40
  const [detectedUrl, setDetectedUrl] = useState<string | null>(null)
38
41
  const [urlToast, setUrlToast] = useState<string | null>(null)
@@ -149,6 +152,24 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
149
152
  // eslint-disable-next-line react-hooks/exhaustive-deps -- onBrainChange is stable from parent
150
153
  }, [])
151
154
 
155
+ // Periodic update check
156
+ useEffect(() => {
157
+ let active = true
158
+ const check = () => {
159
+ checkUpdate()
160
+ .then(data => {
161
+ if (active) setUpdateInfo(data)
162
+ })
163
+ .catch(console.error)
164
+ }
165
+ check()
166
+ const interval = setInterval(check, 3600000)
167
+ return () => {
168
+ active = false
169
+ clearInterval(interval)
170
+ }
171
+ }, [])
172
+
152
173
  // Reload memory nodes when active selected brain changes
153
174
  useEffect(() => {
154
175
  listMemory(activeBrainId)
@@ -564,6 +585,47 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
564
585
  </div>
565
586
  </header>
566
587
 
588
+ {/* Update Available Banner */}
589
+ {updateInfo?.updateAvailable && (
590
+ <div className="animate-fade-in" style={{
591
+ margin: '0 16px 12px',
592
+ padding: '12px 16px',
593
+ background: 'linear-gradient(135deg, rgba(63, 185, 80, 0.12), rgba(34, 197, 94, 0.12))',
594
+ border: '1px solid rgba(63, 185, 80, 0.3)',
595
+ borderRadius: 10,
596
+ display: 'flex',
597
+ alignItems: 'center',
598
+ gap: 12,
599
+ }}>
600
+ <span style={{ fontSize: 22, flexShrink: 0 }}>🚀</span>
601
+ <div style={{ flex: 1, minWidth: 0 }}>
602
+ <div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
603
+ System Update Available
604
+ </div>
605
+ <div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
606
+ Version {updateInfo.latestVersion} is available. Upgrade to load the latest features.
607
+ </div>
608
+ </div>
609
+ <button
610
+ onClick={() => navigate('/health')}
611
+ style={{
612
+ flexShrink: 0,
613
+ padding: '6px 14px',
614
+ background: '#3fb950',
615
+ color: '#fff',
616
+ border: 'none',
617
+ borderRadius: 6,
618
+ fontWeight: 600,
619
+ fontSize: 12,
620
+ cursor: 'pointer',
621
+ whiteSpace: 'nowrap'
622
+ }}
623
+ >
624
+ Go to Update
625
+ </button>
626
+ </div>
627
+ )}
628
+
567
629
  {/* Chrome Extension Banner — shows when available but not connected */}
568
630
  {extensionStatus?.available && !extensionStatus.connected && !extensionBannerDismissed && (
569
631
  <div style={{
@@ -623,8 +685,18 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
623
685
  </div>
624
686
  )}
625
687
 
626
- <div className="chat-messages">
627
- {messages.length === 0 && (
688
+ <div className="chat-messages" style={{ position: 'relative' }}>
689
+ <div style={{
690
+ position: 'absolute',
691
+ top: 0,
692
+ left: 0,
693
+ right: 0,
694
+ bottom: 0,
695
+ zIndex: 1,
696
+ pointerEvents: messages.length > 0 ? 'none' : 'auto',
697
+ opacity: messages.length > 0 ? 0.35 : 1,
698
+ transition: 'opacity 0.3s ease'
699
+ }}>
628
700
  <Graph3D
629
701
  threads={threads}
630
702
  memoryNodes={allMemoryNodes}
@@ -637,9 +709,24 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
637
709
  }}
638
710
  selectedGroundingNodes={selectedGroundingNodes}
639
711
  />
640
- )}
641
- {messages.map((m, index) => (
642
- <div key={m.id} className={`message message-${m.role}`}>
712
+ </div>
713
+
714
+ <div style={{
715
+ position: 'relative',
716
+ zIndex: 2,
717
+ display: 'flex',
718
+ flexDirection: 'column',
719
+ gap: '16px',
720
+ width: '100%',
721
+ pointerEvents: 'auto'
722
+ }}>
723
+ {messages.map((m, index) => (
724
+ <div key={m.id} className={`message message-${m.role}`} style={{
725
+ backdropFilter: 'blur(8px)',
726
+ WebkitBackdropFilter: 'blur(8px)',
727
+ background: m.role === 'user' ? 'rgba(108, 92, 231, 0.75)' : 'rgba(18, 18, 26, 0.75)',
728
+ border: '1px solid rgba(255, 255, 255, 0.08)'
729
+ }}>
643
730
  <div style={{ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' }}>{m.content}</div>
644
731
  {m.role === 'assistant' && (
645
732
  <div style={{ display: 'flex', gap: 12, marginTop: 12, fontSize: 13, color: 'var(--text-tertiary)', alignItems: 'center', userSelect: 'none' }}>
@@ -672,11 +759,17 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
672
759
  </div>
673
760
  ))}
674
761
  {loading && (
675
- <div className="message message-assistant">
762
+ <div className="message message-assistant" style={{
763
+ backdropFilter: 'blur(8px)',
764
+ WebkitBackdropFilter: 'blur(8px)',
765
+ background: 'rgba(18, 18, 26, 0.75)',
766
+ border: '1px solid rgba(255, 255, 255, 0.08)'
767
+ }}>
676
768
  <span className="skeleton" style={{ display: 'inline-block', width: 180, height: 16 }} />
677
769
  </div>
678
770
  )}
679
771
  <div ref={messagesEnd} />
772
+ </div>
680
773
  </div>
681
774
  <div className="chat-input-bar">
682
775
  {selectedGroundingNodes.length > 0 && (
@@ -0,0 +1,470 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+
3
+ // ─── Interfaces ──────────────────────────────────────────────────────────────
4
+ export interface CollabGroup {
5
+ id: string;
6
+ name: string;
7
+ code: string;
8
+ owner: string;
9
+ members: string[];
10
+ created_at: string;
11
+ }
12
+
13
+ interface Annotation {
14
+ id: string;
15
+ url: string;
16
+ groupCode: string;
17
+ author: string;
18
+ text: string;
19
+ excerpt: string;
20
+ created_at: string;
21
+ }
22
+
23
+ interface ChatMessage {
24
+ username: string;
25
+ text: string;
26
+ created_at: string;
27
+ }
28
+
29
+ // ─── Component: AuthView ───────────────────────────────────────────────────────
30
+ function CollabAuthView({ onAuthSuccess }: { onAuthSuccess: (token: string, username: string) => void }) {
31
+ const [isLogin, setIsLogin] = useState(true);
32
+ const [username, setUsername] = useState('');
33
+ const [password, setPassword] = useState('');
34
+ const [error, setError] = useState('');
35
+ const [loading, setLoading] = useState(false);
36
+
37
+ const handleSubmit = async (e: React.FormEvent) => {
38
+ e.preventDefault();
39
+ if (!username || !password) {
40
+ setError('Please fill in all fields');
41
+ return;
42
+ }
43
+ setError('');
44
+ setLoading(true);
45
+ const endpoint = isLogin ? '/api/collab/auth/login' : '/api/collab/auth/register';
46
+
47
+ try {
48
+ const response = await fetch(endpoint, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ username, password }),
52
+ });
53
+ const data = await response.json();
54
+ if (!response.ok) throw new Error(data.error || 'Authentication failed');
55
+ onAuthSuccess(data.token, data.username);
56
+ } catch (err: unknown) {
57
+ setError((err as Error).message || 'Connection error');
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ };
62
+
63
+ return (
64
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh', padding: '20px' }}>
65
+ <div className="glass-panel" style={{ width: '100%', maxWidth: '400px', padding: '30px', background: 'rgba(255,255,255,0.02)', border: '1px solid var(--border)', borderRadius: '12px' }}>
66
+ <h2 style={{ fontSize: '22px', marginBottom: '8px', textAlign: 'center' }}>Total Recall Collaboration</h2>
67
+ <p style={{ fontSize: '13px', color: 'var(--text-secondary)', textAlign: 'center', marginBottom: '24px' }}>Sign in to connect with team workspaces</p>
68
+ <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
69
+ <div>
70
+ <label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '6px' }}>Username</label>
71
+ <input type="text" value={username} onChange={e => setUsername(e.target.value)} disabled={loading} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '8px 12px', borderRadius: '6px', width: '100%' }} />
72
+ </div>
73
+ <div>
74
+ <label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '6px' }}>Password</label>
75
+ <input type="password" value={password} onChange={e => setPassword(e.target.value)} disabled={loading} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '8px 12px', borderRadius: '6px', width: '100%' }} />
76
+ </div>
77
+ {error && <div style={{ padding: '8px', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)', borderRadius: '6px', color: '#ef4444', fontSize: '12px', textAlign: 'center' }}>{error}</div>}
78
+ <button type="submit" className="btn btn-purple" style={{ width: '100%', padding: '10px' }} disabled={loading}>
79
+ {loading ? 'Processing...' : isLogin ? 'Access Collab Hub' : 'Register Collab Account'}
80
+ </button>
81
+ </form>
82
+ <div style={{ marginTop: '20px', textAlign: 'center', fontSize: '13px' }}>
83
+ <button onClick={() => { setIsLogin(!isLogin); setError(''); }} style={{ background: 'none', border: 'none', color: 'var(--purple-primary)', cursor: 'pointer' }}>
84
+ {isLogin ? 'Create an account instead' : 'Already have an account? Login'}
85
+ </button>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ // ─── Component: CollabDashboard ───────────────────────────────────────────────
93
+ function CollabDashboard({ token, username, onLogout, onSelectSandbox }: { token: string, username: string, onLogout: () => void, onSelectSandbox: () => void }) {
94
+ const [groups, setGroups] = useState<CollabGroup[]>([]);
95
+ const [newGroupName, setNewGroupName] = useState('');
96
+ const [joinCode, setJoinCode] = useState('');
97
+ const [error, setError] = useState('');
98
+ const [success, setSuccess] = useState('');
99
+ const [copiedCode, setCopiedCode] = useState<string | null>(null); const [refreshTrigger, setRefreshTrigger] = useState(0);
100
+
101
+ useEffect(() => {
102
+ let active = true;
103
+ const fetchGroups = async () => {
104
+ try {
105
+ const response = await fetch('/api/collab/groups', {
106
+ headers: { Authorization: `Bearer ${token}` },
107
+ });
108
+ if (response.ok && active) {
109
+ setGroups(await response.json());
110
+ }
111
+ } catch (err) {
112
+ console.error(err);
113
+ }
114
+ };
115
+ fetchGroups();
116
+ return () => { active = false; };
117
+ }, [token, refreshTrigger]);
118
+
119
+ const handleCreate = async (e: React.FormEvent) => {
120
+ e.preventDefault();
121
+ if (!newGroupName.trim()) return;
122
+ setError(''); setSuccess('');
123
+ try {
124
+ const response = await fetch('/api/collab/groups', {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
127
+ body: JSON.stringify({ name: newGroupName }),
128
+ });
129
+ const data = await response.json();
130
+ if (!response.ok) throw new Error(data.error);
131
+ setSuccess(`Group "${data.name}" created!`);
132
+ setNewGroupName('');
133
+ setRefreshTrigger(prev => prev + 1);
134
+ } catch (err: unknown) { setError((err as Error).message); }
135
+ };
136
+
137
+ const handleJoin = async (e: React.FormEvent) => {
138
+ e.preventDefault();
139
+ if (!joinCode.trim()) return;
140
+ setError(''); setSuccess('');
141
+ try {
142
+ const response = await fetch('/api/collab/groups/join', {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
145
+ body: JSON.stringify({ code: joinCode }),
146
+ });
147
+ const data = await response.json();
148
+ if (!response.ok) throw new Error(data.error);
149
+ setSuccess(`Joined group "${data.group.name}"!`);
150
+ setJoinCode('');
151
+ setRefreshTrigger(prev => prev + 1);
152
+ } catch (err: unknown) { setError((err as Error).message); }
153
+ };
154
+
155
+ const copyCode = (code: string) => {
156
+ navigator.clipboard.writeText(code);
157
+ setCopiedCode(code);
158
+ setTimeout(() => setCopiedCode(null), 2000);
159
+ };
160
+
161
+ return (
162
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
163
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(255,255,255,0.02)', padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--border)' }}>
164
+ <div>
165
+ <h2 style={{ fontSize: '18px' }}>Collab Spaces for <span style={{ color: 'var(--purple-primary)' }}>{username}</span></h2>
166
+ <p style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>Share annotations and real-time page chat rooms</p>
167
+ </div>
168
+ <div style={{ display: 'flex', gap: '10px' }}>
169
+ <button className="btn btn-purple" onClick={onSelectSandbox}>🌐 Open Page Sandbox</button>
170
+ <button className="btn btn-ghost" onClick={onLogout} style={{ color: '#f87171' }}>Logout</button>
171
+ </div>
172
+ </div>
173
+
174
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '20px' }}>
175
+ <div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', padding: '20px', borderRadius: '8px' }}>
176
+ <h3 style={{ fontSize: '15px', marginBottom: '16px' }}>My Workspaces ({groups.length})</h3>
177
+ {groups.length === 0 ? (
178
+ <p style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>No collaboration groups yet. Create or join one.</p>
179
+ ) : (
180
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
181
+ {groups.map(g => (
182
+ <div key={g.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(255,255,255,0.02)', padding: '12px 16px', borderRadius: '6px', border: '1px solid var(--border)' }}>
183
+ <div>
184
+ <h4 style={{ fontSize: '14px', color: '#fff' }}>{g.name}</h4>
185
+ <p style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>Members: {g.members.length} â€ĸ Invite Code: <strong style={{ color: 'var(--purple-primary)' }}>{g.code}</strong></p>
186
+ </div>
187
+ <button className="btn btn-ghost btn-sm" onClick={() => copyCode(g.code)}>{copiedCode === g.code ? 'Copied' : 'Copy Code'}</button>
188
+ </div>
189
+ ))}
190
+ </div>
191
+ )}
192
+ </div>
193
+
194
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
195
+ <div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', padding: '16px', borderRadius: '8px' }}>
196
+ <h3 style={{ fontSize: '14px', marginBottom: '12px' }}>Join a Space</h3>
197
+ <form onSubmit={handleJoin} style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
198
+ <input type="text" placeholder="Invite Code" value={joinCode} onChange={e => setJoinCode(e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', fontSize: '13px' }} />
199
+ <button type="submit" className="btn btn-purple btn-sm">Join Group</button>
200
+ </form>
201
+ </div>
202
+
203
+ <div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', padding: '16px', borderRadius: '8px' }}>
204
+ <h3 style={{ fontSize: '14px', marginBottom: '12px' }}>Create a Space</h3>
205
+ <form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
206
+ <input type="text" placeholder="Space Name" value={newGroupName} onChange={e => setNewGroupName(e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', fontSize: '13px' }} />
207
+ <button type="submit" className="btn btn-purple btn-sm">Create Group</button>
208
+ </form>
209
+ </div>
210
+
211
+ {(error || success) && <div style={{ textAlign: 'center', padding: '8px', fontSize: '12px', color: error ? '#f87171' : '#34d399', background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', borderRadius: '6px' }}>{error || success}</div>}
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ // ─── Component: CollabSandbox ─────────────────────────────────────────────────
219
+ function CollabSandbox({ token, username, onBack }: { token: string, username: string, onBack: () => void }) {
220
+ const [groups, setGroups] = useState<CollabGroup[]>([]);
221
+ const [selectedGroup, setSelectedGroup] = useState<CollabGroup | null>(null);
222
+ const [simulatedUrl, setSimulatedUrl] = useState('https://news.ycombinator.com');
223
+ const [activeUrl, setActiveUrl] = useState('https://news.ycombinator.com');
224
+ const [annotations, setAnnotations] = useState<Annotation[]>([]);
225
+ const [noteText, setNoteText] = useState('');
226
+ const [noteExcerpt, setNoteExcerpt] = useState('');
227
+ const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
228
+ const [chatInput, setChatInput] = useState('');
229
+ const [activeUsers, setActiveUsers] = useState<string[]>([]);
230
+ const [systemLogs, setSystemLogs] = useState<string[]>([]);
231
+ const [wsConnected, setWsConnected] = useState(false);
232
+
233
+ const socketRef = useRef<WebSocket | null>(null);
234
+ const chatEndRef = useRef<HTMLDivElement | null>(null);
235
+
236
+ useEffect(() => {
237
+ const fetchGroups = async () => {
238
+ try {
239
+ const response = await fetch('/api/collab/groups', {
240
+ headers: { Authorization: `Bearer ${token}` },
241
+ });
242
+ if (response.ok) {
243
+ const data = await response.json();
244
+ setGroups(data);
245
+ if (data.length > 0) setSelectedGroup(data[0]);
246
+ }
247
+ } catch (err) {
248
+ console.error(err);
249
+ }
250
+ };
251
+ fetchGroups();
252
+ }, [token]); const [refreshTrigger, setRefreshTrigger] = useState(0);
253
+
254
+ useEffect(() => {
255
+ let active = true;
256
+ const fetchAnnotations = async () => {
257
+ try {
258
+ const response = await fetch(`/api/collab/annotations?url=${encodeURIComponent(activeUrl)}`, {
259
+ headers: { Authorization: `Bearer ${token}` },
260
+ });
261
+ if (response.ok && active) {
262
+ setAnnotations(await response.json());
263
+ }
264
+ } catch (err) {
265
+ console.error(err);
266
+ }
267
+ };
268
+ fetchAnnotations();
269
+ return () => { active = false; };
270
+ }, [activeUrl, token, groups, refreshTrigger]);
271
+
272
+ useEffect(() => {
273
+ const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
274
+ const wsHost = window.location.host;
275
+ const wsUrl = `${wsProto}//${wsHost}/collab-ws?token=${token}`;
276
+ const ws = new WebSocket(wsUrl);
277
+ socketRef.current = ws;
278
+
279
+ ws.onopen = () => {
280
+ setWsConnected(true);
281
+ ws.send(JSON.stringify({ type: 'SUBSCRIBE', url: activeUrl }));
282
+ setSystemLogs(prev => [...prev, `Subscribed to context channel: ${activeUrl}`]);
283
+ };
284
+
285
+ ws.onmessage = (event) => {
286
+ try {
287
+ const data = JSON.parse(event.data);
288
+ if (data.type === 'CHAT_MESSAGE') {
289
+ setChatMessages(prev => [...prev, data]);
290
+ } else if (data.type === 'USER_JOINED') {
291
+ setSystemLogs(prev => [...prev, `${data.username} joined this page`]);
292
+ setActiveUsers(prev => prev.includes(data.username) ? prev : [...prev, data.username]);
293
+ } else if (data.type === 'USER_LEFT') {
294
+ setSystemLogs(prev => [...prev, `${data.username} left`]);
295
+ setActiveUsers(prev => prev.filter(u => u !== data.username));
296
+ } else if (data.type === 'ANNOTATION_ADDED') {
297
+ setRefreshTrigger(prev => prev + 1);
298
+ }
299
+ } catch (err) {
300
+ console.error(err);
301
+ }
302
+ };
303
+
304
+ ws.onclose = () => {
305
+ setWsConnected(false);
306
+ setSystemLogs(prev => [...prev, 'Disconnected.']);
307
+ };
308
+
309
+ return () => { ws.close(); };
310
+ }, [activeUrl, token]);
311
+
312
+ useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [chatMessages, systemLogs]);
313
+
314
+ const handleUrlSubmit = (e: React.FormEvent) => {
315
+ e.preventDefault();
316
+ if (!simulatedUrl.trim()) return;
317
+ setChatMessages([]); setSystemLogs([]); setActiveUsers([]);
318
+ setActiveUrl(simulatedUrl);
319
+ };
320
+
321
+ const handleAddAnnotation = async (e: React.FormEvent) => {
322
+ e.preventDefault();
323
+ if (!noteText.trim() || !selectedGroup) return;
324
+ try {
325
+ const response = await fetch('/api/collab/annotations', {
326
+ method: 'POST',
327
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
328
+ body: JSON.stringify({ url: activeUrl, groupCode: selectedGroup.code, text: noteText, excerpt: noteExcerpt }),
329
+ });
330
+ if (response.ok) {
331
+ setNoteText(''); setNoteExcerpt('');
332
+ setRefreshTrigger(prev => prev + 1);
333
+ }
334
+ } catch (err) {
335
+ console.error(err);
336
+ }
337
+ };
338
+
339
+ const handleSendChat = (e: React.FormEvent) => {
340
+ e.preventDefault();
341
+ if (!chatInput.trim() || !socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) return;
342
+ socketRef.current.send(JSON.stringify({ type: 'CHAT_MESSAGE', text: chatInput }));
343
+ setChatInput('');
344
+ };
345
+
346
+ return (
347
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
348
+ <div style={{ display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'space-between', background: 'rgba(255,255,255,0.02)', padding: '12px 20px', borderRadius: '8px', border: '1px solid var(--border)', flexWrap: 'wrap' }}>
349
+ <button className="btn btn-ghost" onClick={onBack}>← Back to Workspaces</button>
350
+ <form onSubmit={handleUrlSubmit} style={{ display: 'flex', gap: '10px', flex: '1', minWidth: '250px' }}>
351
+ <input type="text" value={simulatedUrl} onChange={e => setSimulatedUrl(e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 12px', borderRadius: '6px', flex: '1', fontFamily: 'monospace' }} />
352
+ <button type="submit" className="btn btn-purple">Simulate Visit</button>
353
+ </form>
354
+ <div style={{ fontSize: '13px', color: wsConnected ? '#34d399' : '#f87171' }}>● {wsConnected ? 'Connected' : 'Disconnected'}</div>
355
+ </div>
356
+
357
+ <div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
358
+ Simulated browser context: <strong style={{ color: 'var(--purple-primary)', fontFamily: 'monospace' }}>{activeUrl}</strong>
359
+ </div>
360
+
361
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: '20px', height: '500px' }}>
362
+ {/* Annotations */}
363
+ <div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', borderRadius: '8px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
364
+ <div style={{ flex: '1', padding: '16px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '10px' }}>
365
+ <h3 style={{ fontSize: '14px', marginBottom: '8px' }}>Persistent Notes on Web Page</h3>
366
+ {annotations.length === 0 ? (
367
+ <p style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>No notes pinned on this page context.</p>
368
+ ) : (
369
+ annotations.map(ann => (
370
+ <div key={ann.id} style={{ background: 'rgba(255,255,255,0.02)', padding: '10px 14px', borderRadius: '6px', border: '1px solid var(--border)' }}>
371
+ <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '4px' }}>
372
+ <span>By <strong>{ann.author}</strong></span>
373
+ <span>{new Date(ann.created_at).toLocaleTimeString()}</span>
374
+ </div>
375
+ {ann.excerpt && <blockquote style={{ borderLeft: '2px solid var(--purple-primary)', paddingLeft: '8px', fontSize: '12px', color: 'var(--text-secondary)', margin: '4px 0 8px 0', background: 'rgba(0,0,0,0.1)', padding: '4px' }}>"{ann.excerpt}"</blockquote>}
376
+ <p style={{ fontSize: '13px', color: '#fff' }}>{ann.text}</p>
377
+ </div>
378
+ ))
379
+ )}
380
+ </div>
381
+
382
+ <form onSubmit={handleAddAnnotation} style={{ borderTop: '1px solid var(--border)', padding: '12px', background: 'rgba(0,0,0,0.1)', display: 'flex', flexDirection: 'column', gap: '8px' }}>
383
+ <div style={{ display: 'flex', gap: '8px' }}>
384
+ <select value={selectedGroup?.code || ''} onChange={e => setSelectedGroup(groups.find(g => g.code === e.target.value) || null)} style={{ background: 'rgba(0,0,0,0.4)', border: '1px solid var(--border)', color: '#fff', padding: '6px', borderRadius: '6px', fontSize: '12px' }}>
385
+ {groups.map(g => <option key={g.code} value={g.code}>{g.name}</option>)}
386
+ </select>
387
+ <input type="text" placeholder="Excerpt (optional context)" value={noteExcerpt} onChange={e => setNoteExcerpt(e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', flex: '1', fontSize: '12px' }} />
388
+ </div>
389
+ <div style={{ display: 'flex', gap: '8px' }}>
390
+ <input type="text" placeholder="Add annotation comment..." value={noteText} onChange={e => setNoteText(e.target.value)} required style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', flex: '1', fontSize: '12px' }} />
391
+ <button type="submit" className="btn btn-purple btn-sm" disabled={groups.length === 0}>Pin</button>
392
+ </div>
393
+ </form>
394
+ </div>
395
+
396
+ {/* Chat Panel */}
397
+ <div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', borderRadius: '8px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
398
+ <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,0.1)' }}>
399
+ <h3 style={{ fontSize: '13px' }}>Live Web Room {activeUsers.length > 0 && <span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>({activeUsers.join(', ')})</span>}</h3>
400
+ </div>
401
+ <div style={{ flex: '1', padding: '12px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
402
+ {systemLogs.map((log, idx) => <div key={idx} style={{ fontSize: '10px', color: 'var(--text-secondary)', fontStyle: 'italic' }}>â„šī¸ {log}</div>)}
403
+ {chatMessages.map((msg, idx) => {
404
+ const isSelf = msg.username.toLowerCase() === username.toLowerCase();
405
+ return (
406
+ <div key={idx} style={{ display: 'flex', flexDirection: 'column', alignSelf: isSelf ? 'flex-end' : 'flex-start', maxWidth: '85%' }}>
407
+ <span style={{ fontSize: '9px', color: 'var(--text-secondary)', alignSelf: isSelf ? 'flex-end' : 'flex-start' }}>{msg.username} â€ĸ {new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
408
+ <div style={{ padding: '8px 12px', borderRadius: '8px', background: isSelf ? 'var(--purple-primary)' : 'rgba(255,255,255,0.04)', fontSize: '12px', color: '#fff', wordBreak: 'break-word', marginTop: '2px' }}>{msg.text}</div>
409
+ </div>
410
+ );
411
+ })}
412
+ <div ref={chatEndRef} />
413
+ </div>
414
+ <form onSubmit={handleSendChat} style={{ borderTop: '1px solid var(--border)', padding: '10px', display: 'flex', gap: '6px' }}>
415
+ <input type="text" placeholder="Send chat..." value={chatInput} onChange={e => setChatInput(e.target.value)} required disabled={!wsConnected} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', flex: '1', fontSize: '12px' }} />
416
+ <button type="submit" className="btn btn-purple btn-sm" disabled={!wsConnected}>Send</button>
417
+ </form>
418
+ </div>
419
+ </div>
420
+ </div>
421
+ );
422
+ }
423
+
424
+ // ─── Main Integrated CollabPage Component ─────────────────────────────────────
425
+ export default function CollabPage() {
426
+ const [token, setToken] = useState<string | null>(() => localStorage.getItem('collab_token'));
427
+ const [username, setUsername] = useState<string | null>(() => localStorage.getItem('collab_username'));
428
+ const [view, setView] = useState<'login' | 'dashboard' | 'sandbox'>(() => {
429
+ const savedToken = localStorage.getItem('collab_token');
430
+ const savedUser = localStorage.getItem('collab_username');
431
+ return (savedToken && savedUser) ? 'dashboard' : 'login';
432
+ });
433
+
434
+ const handleAuthSuccess = (newToken: string, newUsername: string) => {
435
+ localStorage.setItem('collab_token', newToken);
436
+ localStorage.setItem('collab_username', newUsername);
437
+ setToken(newToken);
438
+ setUsername(newUsername);
439
+ setView('dashboard');
440
+ };
441
+
442
+ const handleLogout = () => {
443
+ localStorage.removeItem('collab_token');
444
+ localStorage.removeItem('collab_username');
445
+ setToken(null);
446
+ setUsername(null);
447
+ setView('login');
448
+ };
449
+
450
+ return (
451
+ <div className="page" style={{ padding: '24px', maxWidth: '1100px', margin: '0 auto' }}>
452
+ {view === 'login' && <CollabAuthView onAuthSuccess={handleAuthSuccess} />}
453
+ {view === 'dashboard' && token && username && (
454
+ <CollabDashboard
455
+ token={token}
456
+ username={username}
457
+ onLogout={handleLogout}
458
+ onSelectSandbox={() => setView('sandbox')}
459
+ />
460
+ )}
461
+ {view === 'sandbox' && token && username && (
462
+ <CollabSandbox
463
+ token={token}
464
+ username={username}
465
+ onBack={() => setView('dashboard')}
466
+ />
467
+ )}
468
+ </div>
469
+ );
470
+ }