total-recall-brain 3.1.2 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/extension/background.js +99 -0
  2. package/extension/content-script.css +16 -0
  3. package/extension/content-script.js +134 -0
  4. package/extension/icons/README.md +27 -0
  5. package/extension/icons/icon-128.png +0 -0
  6. package/extension/icons/icon-16.png +0 -0
  7. package/extension/icons/icon-48.png +0 -0
  8. package/extension/lib/brain-client.js +47 -0
  9. package/extension/manifest.json +39 -0
  10. package/extension/options/options.css +218 -0
  11. package/extension/options/options.html +72 -0
  12. package/extension/options/options.js +99 -0
  13. package/extension/popup/popup.css +221 -0
  14. package/extension/popup/popup.html +44 -0
  15. package/extension/popup/popup.js +107 -0
  16. package/extension/sidepanel/sidepanel.css +340 -0
  17. package/extension/sidepanel/sidepanel.html +38 -0
  18. package/extension/sidepanel/sidepanel.js +229 -0
  19. package/frontend/README.md +73 -0
  20. package/frontend/index.html +17 -0
  21. package/frontend/package.json +31 -0
  22. package/frontend/src/App.css +2 -0
  23. package/frontend/src/App.tsx +390 -0
  24. package/frontend/src/api.ts +688 -0
  25. package/frontend/src/assets/hero.png +0 -0
  26. package/frontend/src/assets/react.svg +1 -0
  27. package/frontend/src/assets/vite.svg +1 -0
  28. package/frontend/src/components/BrainSelector.tsx +272 -0
  29. package/frontend/src/components/DaemonLogsTab.tsx +54 -0
  30. package/frontend/src/components/Graph3D.tsx +850 -0
  31. package/frontend/src/components/MarkdownUtils.tsx +99 -0
  32. package/frontend/src/components/ResearchAgendaTab.tsx +707 -0
  33. package/frontend/src/components/TaskQueueTab.tsx +185 -0
  34. package/frontend/src/index.css +1589 -0
  35. package/frontend/src/main.tsx +10 -0
  36. package/frontend/src/pages/ApiKeysPage.tsx +288 -0
  37. package/frontend/src/pages/AutomationsPage.tsx +354 -0
  38. package/frontend/src/pages/ChatPage.tsx +831 -0
  39. package/frontend/src/pages/DeploymentsPage.tsx +275 -0
  40. package/frontend/src/pages/FilesPage.tsx +384 -0
  41. package/frontend/src/pages/HealthPage.tsx +91 -0
  42. package/frontend/src/pages/IntegrationsPage.tsx +382 -0
  43. package/frontend/src/pages/LoginPage.tsx +237 -0
  44. package/frontend/src/pages/MemoryPage.tsx +1192 -0
  45. package/frontend/src/pages/SandboxPage.tsx +113 -0
  46. package/frontend/src/pages/SettingsPage.tsx +662 -0
  47. package/frontend/src/pages/SkillsPage.tsx +618 -0
  48. package/frontend/src/pages/TasksPage.tsx +392 -0
  49. package/frontend/src/pages/UsagePage.tsx +260 -0
  50. package/frontend/src/types.ts +163 -0
  51. package/frontend/tsconfig.app.json +25 -0
  52. package/frontend/tsconfig.json +7 -0
  53. package/frontend/tsconfig.node.json +24 -0
  54. package/frontend/vite.config.ts +24 -0
  55. package/package.json +8 -1
  56. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +35 -8
  57. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +13 -8
  58. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +28 -1
  59. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +28 -1
  60. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +830 -81
  61. package/src/cli/backup.mjs +172 -1
  62. package/src/cli/setup.mjs +24 -0
  63. package/src/server/rest.mjs +56 -7
@@ -0,0 +1,831 @@
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'
3
+ import type { ChatThread } from '../api'
4
+ import type { ChatMessage, MemoryNode, ResearchItem } from '../types'
5
+ import Graph3D from '../components/Graph3D'
6
+
7
+
8
+ let msgId = 0
9
+
10
+ function getCurrentTimestamp(): number {
11
+ return Date.now()
12
+ }
13
+
14
+ function formatRelativeTime(timestamp: number): string {
15
+ const diffMs = getCurrentTimestamp() - timestamp
16
+ if (diffMs < 60000) return 'just now'
17
+ const diffMins = Math.floor(diffMs / 60000)
18
+ if (diffMins < 60) return `${diffMins}m ago`
19
+ const diffHours = Math.floor(diffMins / 60)
20
+ if (diffHours < 24) return `${diffHours}h ago`
21
+ const diffDays = Math.floor(diffHours / 24)
22
+ return `${diffDays}d ago`
23
+ }
24
+
25
+ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrainId?: string; onBrainChange?: (id: string) => void }) {
26
+ const [messages, setMessages] = useState<ChatMessage[]>([])
27
+ const [input, setInput] = useState('')
28
+ const [loading, setLoading] = useState(false)
29
+ const messagesEnd = useRef<HTMLDivElement>(null)
30
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
31
+ const abortControllerRef = useRef<AbortController | null>(null)
32
+ const audioRef = useRef<HTMLAudioElement | null>(null)
33
+
34
+ const [voiceMode, setVoiceMode] = useState(false)
35
+ const [kokoroEnabled, setKokoroEnabled] = useState<boolean | null>(null)
36
+ const [deepResearchMode, setDeepResearchMode] = useState(false)
37
+ const [detectedUrl, setDetectedUrl] = useState<string | null>(null)
38
+ const [urlToast, setUrlToast] = useState<string | null>(null)
39
+
40
+ // Extension banner state
41
+ const [extensionStatus, setExtensionStatus] = useState<{ available: boolean; connected: boolean } | null>(null)
42
+ const [extensionBannerDismissed, setExtensionBannerDismissed] = useState(
43
+ () => localStorage.getItem('tr-ext-banner-dismissed') === 'true'
44
+ )
45
+
46
+ // Threads listing & session control state
47
+ const [threads, setThreads] = useState<ChatThread[]>([])
48
+ const [activeThreadId, setActiveThreadId] = useState<string | undefined>(undefined)
49
+
50
+ // Grounding nodes & suggested discussions state
51
+ const [selectedGroundingNodes, setSelectedGroundingNodes] = useState<string[]>([])
52
+ const [allMemoryNodes, setAllMemoryNodes] = useState<MemoryNode[]>([])
53
+ const [researchItems, setResearchItems] = useState<ResearchItem[]>([])
54
+ const [showNodeSelector, setShowNodeSelector] = useState(false)
55
+ const [nodeSearchQuery, setNodeSearchQuery] = useState('')
56
+ const nodeSelectorRef = useRef<HTMLDivElement>(null)
57
+
58
+ // Model Selector state
59
+ const [availableModels, setAvailableModels] = useState<string[]>([])
60
+ const [selectedModel, setSelectedModel] = useState<string>(() => localStorage.getItem('selectedModel') || '')
61
+ const [selectedSubModel, setSelectedSubModel] = useState<string>(() => localStorage.getItem('selectedSubModel') || '')
62
+ const [geminiModels, setGeminiModels] = useState<{ id: string; displayName: string }[]>([])
63
+ const [showModelDropdown, setShowModelDropdown] = useState(false)
64
+ const dropdownRef = useRef<HTMLDivElement>(null)
65
+
66
+ const scrollToBottom = useCallback(() => {
67
+ messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
68
+ }, [])
69
+
70
+ useEffect(scrollToBottom, [messages, scrollToBottom])
71
+
72
+ const refreshThreads = useCallback(() => {
73
+ fetchChatThreads()
74
+ .then(list => {
75
+ setThreads(list)
76
+ })
77
+ .catch(console.error)
78
+
79
+ listResearch()
80
+ .then(res => setResearchItems(res.items || []))
81
+ .catch(console.error)
82
+ }, [])
83
+
84
+ // Load threads list, suggestions, and health on mount
85
+ useEffect(() => {
86
+ fetchChatThreads()
87
+ .then(list => {
88
+ setThreads(list)
89
+ if (list.length > 0) {
90
+ setActiveThreadId(list[0].id)
91
+ // Auto-switch brain to the most recent thread's brain
92
+ if (list[0].brainId && onBrainChange) {
93
+ onBrainChange(list[0].brainId)
94
+ }
95
+ }
96
+ })
97
+ .catch(console.error)
98
+
99
+ listResearch()
100
+ .then(res => setResearchItems(res.items || []))
101
+ .catch(console.error)
102
+
103
+ fetchHealth()
104
+ .then(health => {
105
+ const agents = health.cli_agents && health.cli_agents.length > 0
106
+ ? health.cli_agents
107
+ : ['antigravity', 'gemini', 'claude', 'codex'];
108
+ setAvailableModels(agents);
109
+
110
+ const saved = localStorage.getItem('selectedModel');
111
+ if (saved && agents.includes(saved)) {
112
+ setSelectedModel(saved);
113
+ } else {
114
+ const defaultAgent = agents.includes('gemini') ? 'gemini' : agents[0];
115
+ setSelectedModel(defaultAgent);
116
+ localStorage.setItem('selectedModel', defaultAgent);
117
+ }
118
+ })
119
+ .catch(() => {
120
+ const agents = ['antigravity', 'gemini', 'claude', 'codex'];
121
+ setAvailableModels(agents);
122
+ const saved = localStorage.getItem('selectedModel');
123
+ if (saved && agents.includes(saved)) {
124
+ setSelectedModel(saved);
125
+ } else {
126
+ setSelectedModel('gemini');
127
+ localStorage.setItem('selectedModel', 'gemini');
128
+ }
129
+ })
130
+
131
+ fetchGeminiModels()
132
+ .then(models => {
133
+ setGeminiModels(models)
134
+ if (models && models.length > 0) {
135
+ const savedSub = localStorage.getItem('selectedSubModel');
136
+ const exists = models.some(m => m.id === savedSub);
137
+ if (savedSub && exists) {
138
+ setSelectedSubModel(savedSub);
139
+ } else {
140
+ setSelectedSubModel(models[0].id);
141
+ localStorage.setItem('selectedSubModel', models[0].id);
142
+ }
143
+ }
144
+ })
145
+ .catch(console.error)
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- onBrainChange is stable from parent
147
+ }, [])
148
+
149
+ // Reload memory nodes when active selected brain changes
150
+ useEffect(() => {
151
+ listMemory(activeBrainId)
152
+ .then(setAllMemoryNodes)
153
+ .catch(console.error)
154
+ }, [activeBrainId])
155
+
156
+ // Click outside listener for grounding node selector popover and model selector dropdown
157
+ useEffect(() => {
158
+ function handleClickOutside(event: MouseEvent) {
159
+ if (nodeSelectorRef.current && !nodeSelectorRef.current.contains(event.target as Node)) {
160
+ setShowNodeSelector(false)
161
+ }
162
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
163
+ setShowModelDropdown(false)
164
+ }
165
+ }
166
+ document.addEventListener("mousedown", handleClickOutside)
167
+ return () => {
168
+ document.removeEventListener("mousedown", handleClickOutside)
169
+ }
170
+ }, [nodeSelectorRef, dropdownRef])
171
+
172
+ // Load chat history when activeThreadId changes
173
+ useEffect(() => {
174
+ fetchChatHistory(activeThreadId)
175
+ .then(history => {
176
+ if (history) {
177
+ setMessages(history as ChatMessage[])
178
+ msgId = Math.max(msgId, history.length + 1)
179
+ } else {
180
+ setMessages([])
181
+ }
182
+ })
183
+ .catch(err => {
184
+ console.error(err)
185
+ setMessages([])
186
+ })
187
+ }, [activeThreadId])
188
+
189
+
190
+ // Probe the Kokoro endpoint once when voice mode is turned on so we know
191
+ // whether to call /api/tts or fall back to the browser engine.
192
+ useEffect(() => {
193
+ if (!voiceMode || kokoroEnabled !== null) return
194
+ fetchTtsStatus().then(s => setKokoroEnabled(s.enabled)).catch(() => setKokoroEnabled(false))
195
+ }, [voiceMode, kokoroEnabled])
196
+
197
+ // Check Chrome extension availability (once on mount)
198
+ useEffect(() => {
199
+ if (!extensionBannerDismissed) {
200
+ fetchExtensionStatus().then(setExtensionStatus).catch(() => {})
201
+ }
202
+ }, [extensionBannerDismissed])
203
+
204
+ const speakBrowser = (text: string) => {
205
+ if (!('speechSynthesis' in window)) return
206
+ window.speechSynthesis.cancel()
207
+ const utterance = new SpeechSynthesisUtterance(text)
208
+ utterance.rate = 1.05
209
+ utterance.pitch = 0.95
210
+ window.speechSynthesis.speak(utterance)
211
+ }
212
+
213
+ const speak = async (text: string) => {
214
+ if (!voiceMode || !text.trim()) return
215
+ if (kokoroEnabled === false) { speakBrowser(text); return }
216
+ try {
217
+ const blob = await fetchTtsAudio(text)
218
+ if (!blob) {
219
+ // 503 → Kokoro not configured. Remember that and fall back.
220
+ setKokoroEnabled(false)
221
+ speakBrowser(text)
222
+ return
223
+ }
224
+ const url = URL.createObjectURL(blob)
225
+ if (audioRef.current) {
226
+ audioRef.current.pause()
227
+ URL.revokeObjectURL(audioRef.current.src)
228
+ }
229
+ const audio = new Audio(url)
230
+ audioRef.current = audio
231
+ audio.onended = () => URL.revokeObjectURL(url)
232
+ audio.play().catch(() => speakBrowser(text))
233
+ } catch {
234
+ speakBrowser(text)
235
+ }
236
+ }
237
+
238
+ const handleNewChat = () => {
239
+ const newId = `thread-${getCurrentTimestamp()}`
240
+ setActiveThreadId(newId)
241
+ setMessages([])
242
+ setInput('')
243
+ setSelectedGroundingNodes([])
244
+ if (textareaRef.current) textareaRef.current.style.height = '44px'
245
+ }
246
+
247
+ const handleDeleteThread = async (e: React.MouseEvent, id: string) => {
248
+ e.stopPropagation()
249
+ if (!confirm('Are you sure you want to delete this conversation thread?')) return
250
+ try {
251
+ await deleteChatThread(id)
252
+ if (activeThreadId === id) {
253
+ const remaining = threads.filter(t => t.id !== id)
254
+ if (remaining.length > 0) {
255
+ setActiveThreadId(remaining[0].id)
256
+ } else {
257
+ setActiveThreadId(undefined)
258
+ setMessages([])
259
+ }
260
+ }
261
+ refreshThreads()
262
+ } catch (err) {
263
+ console.error('Failed to delete thread:', err)
264
+ }
265
+ }
266
+
267
+
268
+
269
+ const handleSend = async () => {
270
+ const text = input.trim()
271
+ if (!text || loading) return
272
+
273
+ const sessionId = activeThreadId || `thread-${getCurrentTimestamp()}`
274
+ if (!activeThreadId) {
275
+ setActiveThreadId(sessionId)
276
+ }
277
+
278
+ const userMsg: ChatMessage = { id: String(++msgId), role: 'user', content: text, timestamp: getCurrentTimestamp() }
279
+ setMessages(prev => [...prev, userMsg])
280
+ setInput('')
281
+ setLoading(true)
282
+
283
+ abortControllerRef.current = new AbortController()
284
+
285
+ // Auto-resize textarea back
286
+ if (textareaRef.current) textareaRef.current.style.height = '44px'
287
+
288
+ try {
289
+ if (deepResearchMode) {
290
+ setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: '🕵️ **Deep Research Agents dispatched.** Gathering facts across parallel web streams...', timestamp: getCurrentTimestamp() }]);
291
+
292
+ const { slug } = await createTask('proactive-research', text, text);
293
+
294
+ // Poll for task completion
295
+ let taskCompleted = false;
296
+ while (!taskCompleted) {
297
+ await new Promise(r => setTimeout(r, 2000));
298
+ const tasks = await listTasks();
299
+ const t = tasks.find(x => x.slug === slug);
300
+ if (t && (t.status === 'completed' || t.status === 'failed')) {
301
+ taskCompleted = true;
302
+ setMessages(prev => prev.filter(m => m.content !== '🕵️ **Deep Research Agents dispatched.** Gathering facts across parallel web streams...'));
303
+ setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: t.body || '', timestamp: getCurrentTimestamp() }]);
304
+ speak(t.body || '');
305
+ }
306
+ }
307
+ setLoading(false);
308
+ return;
309
+ }
310
+
311
+ const modelWithSubModel = ['gemini', 'antigravity'].includes(selectedModel) && selectedSubModel
312
+ ? `${selectedModel}:${selectedSubModel}`
313
+ : selectedModel
314
+
315
+ const historyToSend = [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
316
+ const reply = await sendChat(historyToSend, abortControllerRef.current.signal, sessionId, selectedGroundingNodes, modelWithSubModel, activeBrainId)
317
+ const assistantMsg: ChatMessage = { id: String(++msgId), role: 'assistant', content: reply, timestamp: getCurrentTimestamp(), versions: [reply], currentVersionIndex: 0 }
318
+ setMessages(prev => [...prev, assistantMsg])
319
+ speak(reply)
320
+ refreshThreads()
321
+ } catch (e: unknown) {
322
+ if ((e as Error).name === 'AbortError') {
323
+ setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: '⛔ Generation stopped by user.', timestamp: getCurrentTimestamp(), versions: ['⛔ Generation stopped by user.'], currentVersionIndex: 0 }])
324
+ } else {
325
+ const errorMsg: ChatMessage = { id: String(++msgId), role: 'assistant', content: `⚠️ Error: ${(e as Error).message}`, timestamp: getCurrentTimestamp(), versions: [`⚠️ Error: ${(e as Error).message}`], currentVersionIndex: 0 }
326
+ setMessages(prev => [...prev, errorMsg])
327
+ }
328
+ } finally {
329
+ setLoading(false)
330
+ abortControllerRef.current = null
331
+ }
332
+ }
333
+
334
+ const handleRegenerate = async (msgIndex: number) => {
335
+ if (loading) return;
336
+ const historyToSend = messages.slice(0, msgIndex).map(m => ({ role: m.role, content: m.content }))
337
+ setLoading(true)
338
+ abortControllerRef.current = new AbortController()
339
+ try {
340
+ const modelWithSubModel = ['gemini', 'antigravity'].includes(selectedModel) && selectedSubModel
341
+ ? `${selectedModel}:${selectedSubModel}`
342
+ : selectedModel
343
+
344
+ const reply = await sendChat(historyToSend, abortControllerRef.current.signal, activeThreadId, undefined, modelWithSubModel, activeBrainId)
345
+ setMessages(prev => {
346
+ const next = [...prev]
347
+ const target = next[msgIndex]
348
+ const versions = target.versions || [target.content]
349
+ versions.push(reply)
350
+ target.versions = versions
351
+ target.currentVersionIndex = versions.length - 1
352
+ target.content = reply
353
+ return next
354
+ })
355
+ speak(reply)
356
+ refreshThreads()
357
+ } catch (e: unknown) {
358
+ if ((e as Error).name !== 'AbortError') console.error(e)
359
+ } finally {
360
+ setLoading(false)
361
+ abortControllerRef.current = null
362
+ }
363
+ }
364
+
365
+ const handleCopy = (content: string) => {
366
+ navigator.clipboard.writeText(content).catch(console.error)
367
+ }
368
+
369
+ const handleVersionSwitch = (msgIndex: number, delta: number) => {
370
+ setMessages(prev => {
371
+ const next = [...prev]
372
+ const target = next[msgIndex]
373
+ if (!target.versions) return prev
374
+ const newIndex = Math.max(0, Math.min(target.versions.length - 1, (target.currentVersionIndex || 0) + delta))
375
+ target.currentVersionIndex = newIndex
376
+ target.content = target.versions[newIndex]
377
+ return next
378
+ })
379
+ }
380
+
381
+ const handleStop = () => {
382
+ if (abortControllerRef.current) {
383
+ abortControllerRef.current.abort()
384
+ abortControllerRef.current = null
385
+ setLoading(false)
386
+ }
387
+ }
388
+
389
+ const handleKeyDown = (e: React.KeyboardEvent) => {
390
+ if (e.key === 'Enter' && !e.shiftKey) {
391
+ e.preventDefault()
392
+ handleSend()
393
+ }
394
+ }
395
+
396
+ const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
397
+ const value = e.target.value
398
+ setInput(value)
399
+ const ta = e.target
400
+ ta.style.height = '44px'
401
+ ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'
402
+
403
+ // Detect URLs
404
+ const urlMatch = value.match(/https?:\/\/[^\s]+/)
405
+ setDetectedUrl(urlMatch ? urlMatch[0] : null)
406
+ }
407
+
408
+ const handleUrlAction = async (action: string) => {
409
+ if (!detectedUrl) return
410
+ try {
411
+ await shareToApi({ url: detectedUrl, action })
412
+ setUrlToast(`✅ URL sent to ${action}`)
413
+ setDetectedUrl(null)
414
+ setTimeout(() => setUrlToast(null), 3000)
415
+ } catch (err) {
416
+ console.error('Share API error:', err)
417
+ setUrlToast('⚠️ Failed to process URL')
418
+ setTimeout(() => setUrlToast(null), 3000)
419
+ }
420
+ }
421
+
422
+ const filteredNodes = allMemoryNodes.filter(node =>
423
+ node.title.toLowerCase().includes(nodeSearchQuery.toLowerCase()) ||
424
+ node.slug.toLowerCase().includes(nodeSearchQuery.toLowerCase()) ||
425
+ (node.category && node.category.toLowerCase().includes(nodeSearchQuery.toLowerCase()))
426
+ )
427
+
428
+ return (
429
+ <div className="chat-layout">
430
+ {/* Threads Sidebar */}
431
+ <aside className="chat-threads-sidebar">
432
+ <div className="sidebar-header">
433
+ <button className="new-chat-btn" onClick={handleNewChat}>
434
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 6 }}>
435
+ <line x1="12" y1="5" x2="12" y2="19"></line>
436
+ <line x1="5" y1="12" x2="19" y2="12"></line>
437
+ </svg>
438
+ New Chat
439
+ </button>
440
+ </div>
441
+ <div className="thread-list">
442
+ {threads.map(t => (
443
+ <div
444
+ key={t.id}
445
+ className={`thread-item ${activeThreadId === t.id ? 'active' : ''}`}
446
+ onClick={() => {
447
+ setActiveThreadId(t.id)
448
+ // Auto-switch brain context when selecting a thread
449
+ if (t.brainId && onBrainChange) {
450
+ onBrainChange(t.brainId)
451
+ }
452
+ }}
453
+ >
454
+ <div className="thread-info">
455
+ <div className="thread-title" title={t.title}>{t.title}</div>
456
+ <div className="thread-meta">
457
+ <span>{t.turns} {t.turns === 1 ? 'turn' : 'turns'}</span>
458
+ <span>•</span>
459
+ <span>{formatRelativeTime(t.lastUpdated)}</span>
460
+ </div>
461
+ </div>
462
+ <button
463
+ className="thread-delete-btn"
464
+ onClick={(e) => handleDeleteThread(e, t.id)}
465
+ title="Delete chat thread"
466
+ >
467
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
468
+ <polyline points="3 6 5 6 21 6"></polyline>
469
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
470
+ <line x1="10" y1="11" x2="10" y2="17"></line>
471
+ <line x1="14" y1="11" x2="14" y2="17"></line>
472
+ </svg>
473
+ </button>
474
+ </div>
475
+ ))}
476
+ </div>
477
+ </aside>
478
+
479
+ {/* Main Chat Area */}
480
+ <div className="chat-container">
481
+ <header className="chat-header animate-fade-in">
482
+ <div className="chat-header-title">
483
+ <h2>Chat Session</h2>
484
+ {activeThreadId && (
485
+ <span className="badge badge-accent" style={{ textTransform: 'none', letterSpacing: 'normal', fontSize: '11px', padding: '2px 8px' }}>
486
+ {activeThreadId}
487
+ </span>
488
+ )}
489
+ </div>
490
+ <div className="chat-header-model-selector" ref={dropdownRef}>
491
+ <span className="selector-label">Model:</span>
492
+ <div className="model-selector-dropdown-wrapper">
493
+ <button
494
+ className={`model-selector-dropdown-btn ${showModelDropdown ? 'active' : ''}`}
495
+ onClick={() => setShowModelDropdown(!showModelDropdown)}
496
+ title="Select CLI agent model"
497
+ >
498
+ <span style={{ textTransform: 'capitalize' }}>{selectedModel || 'Select Model'}</span>
499
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
500
+ <polyline points="6 9 12 15 18 9"></polyline>
501
+ </svg>
502
+ </button>
503
+ {showModelDropdown && (
504
+ <div className="model-selector-menu glass">
505
+ {availableModels.map(modelName => (
506
+ <div
507
+ key={modelName}
508
+ className={`model-selector-item ${selectedModel === modelName ? 'selected' : ''}`}
509
+ onClick={() => {
510
+ setSelectedModel(modelName)
511
+ localStorage.setItem('selectedModel', modelName)
512
+ setSelectedSubModel('')
513
+ localStorage.removeItem('selectedSubModel')
514
+ setShowModelDropdown(false)
515
+ }}
516
+ >
517
+ <div className="model-selector-item-info">
518
+ <span className="model-selector-item-name" style={{ textTransform: 'capitalize' }}>{modelName}</span>
519
+ <span className="model-selector-item-desc">
520
+ {modelName === 'antigravity' && 'Google Antigravity'}
521
+ {modelName === 'gemini' && 'Google Gemini CLI'}
522
+ {modelName === 'claude' && 'Anthropic Claude CLI'}
523
+ {modelName === 'codex' && 'Codex Coding Agent'}
524
+ </span>
525
+ </div>
526
+ <span className="model-selector-item-badge">CLI</span>
527
+ </div>
528
+ ))}
529
+ </div>
530
+ )}
531
+ </div>
532
+ {['gemini', 'antigravity'].includes(selectedModel) && geminiModels.length > 0 && (
533
+ <div className="chat-header-submodel-selector" style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 16 }}>
534
+ <span className="selector-label" style={{ fontSize: '12px', color: 'var(--text-tertiary)', fontWeight: 500 }}>Base:</span>
535
+ <select
536
+ value={selectedSubModel}
537
+ onChange={(e) => {
538
+ setSelectedSubModel(e.target.value)
539
+ localStorage.setItem('selectedSubModel', e.target.value)
540
+ }}
541
+ className="submodel-select"
542
+ style={{
543
+ background: 'rgba(255, 255, 255, 0.04)',
544
+ border: '1px solid var(--border)',
545
+ borderRadius: 'var(--radius-sm)',
546
+ color: 'var(--text-secondary)',
547
+ padding: '6px 12px',
548
+ fontSize: '13px',
549
+ fontWeight: 500,
550
+ outline: 'none',
551
+ cursor: 'pointer',
552
+ transition: 'all var(--transition-fast)'
553
+ }}
554
+ >
555
+ {geminiModels.map(m => (
556
+ <option key={m.id} value={m.id} style={{ background: '#12121a', color: 'var(--text-secondary)' }}>{m.displayName}</option>
557
+ ))}
558
+ </select>
559
+ </div>
560
+ )}
561
+ </div>
562
+ </header>
563
+
564
+ {/* Chrome Extension Banner — shows when available but not connected */}
565
+ {extensionStatus?.available && !extensionStatus.connected && !extensionBannerDismissed && (
566
+ <div style={{
567
+ margin: '0 16px 0',
568
+ padding: '12px 16px',
569
+ background: 'linear-gradient(135deg, rgba(108, 92, 231, 0.12), rgba(99, 179, 237, 0.12))',
570
+ border: '1px solid rgba(108, 92, 231, 0.3)',
571
+ borderRadius: 10,
572
+ display: 'flex',
573
+ alignItems: 'center',
574
+ gap: 12,
575
+ animation: 'fadeIn 0.3s ease'
576
+ }}>
577
+ <span style={{ fontSize: 22, flexShrink: 0 }}>🧩</span>
578
+ <div style={{ flex: 1, minWidth: 0 }}>
579
+ <div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
580
+ Chrome Extension Available
581
+ </div>
582
+ <div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
583
+ Browse with your brain — contextual memory, quick capture, and research from any page
584
+ </div>
585
+ </div>
586
+ <a
587
+ href="/api/extension/download"
588
+ download
589
+ style={{
590
+ flexShrink: 0,
591
+ padding: '6px 14px',
592
+ background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
593
+ color: '#fff',
594
+ borderRadius: 6,
595
+ fontWeight: 500,
596
+ fontSize: 12,
597
+ textDecoration: 'none',
598
+ whiteSpace: 'nowrap'
599
+ }}
600
+ >
601
+ ⬇ Download
602
+ </a>
603
+ <button
604
+ onClick={() => {
605
+ setExtensionBannerDismissed(true)
606
+ localStorage.setItem('tr-ext-banner-dismissed', 'true')
607
+ }}
608
+ style={{
609
+ flexShrink: 0,
610
+ background: 'none',
611
+ border: 'none',
612
+ color: 'var(--text-tertiary)',
613
+ cursor: 'pointer',
614
+ fontSize: 16,
615
+ padding: '0 2px',
616
+ lineHeight: 1
617
+ }}
618
+ title="Dismiss"
619
+ >✕</button>
620
+ </div>
621
+ )}
622
+
623
+ <div className="chat-messages">
624
+ {messages.length === 0 && (
625
+ <Graph3D
626
+ threads={threads}
627
+ memoryNodes={allMemoryNodes}
628
+ researchItems={researchItems}
629
+ onOpenThread={(threadId) => setActiveThreadId(threadId)}
630
+ onGroundMemoryNode={(slug) => {
631
+ setSelectedGroundingNodes(prev =>
632
+ prev.includes(slug) ? prev.filter(s => s !== slug) : [...prev, slug]
633
+ )
634
+ }}
635
+ selectedGroundingNodes={selectedGroundingNodes}
636
+ />
637
+ )}
638
+ {messages.map((m, index) => (
639
+ <div key={m.id} className={`message message-${m.role}`}>
640
+ <div style={{ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' }}>{m.content}</div>
641
+ {m.role === 'assistant' && (
642
+ <div style={{ display: 'flex', gap: 12, marginTop: 12, fontSize: 13, color: 'var(--text-tertiary)', alignItems: 'center', userSelect: 'none' }}>
643
+ {m.versions && m.versions.length > 1 && (
644
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginRight: 4 }}>
645
+ <button
646
+ onClick={() => handleVersionSwitch(index, -1)}
647
+ disabled={(m.currentVersionIndex || 0) === 0}
648
+ style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 2, opacity: (m.currentVersionIndex || 0) === 0 ? 0.3 : 1 }}
649
+ >◄</button>
650
+ <span>{(m.currentVersionIndex || 0) + 1} / {m.versions.length}</span>
651
+ <button
652
+ onClick={() => handleVersionSwitch(index, 1)}
653
+ disabled={(m.currentVersionIndex || 0) === m.versions.length - 1}
654
+ style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 2, opacity: (m.currentVersionIndex || 0) === m.versions.length - 1 ? 0.3 : 1 }}
655
+ >►</button>
656
+ </div>
657
+ )}
658
+ <button onClick={() => handleCopy(m.content)} title="Copy to clipboard" style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
659
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
660
+ </button>
661
+ <button onClick={() => speak(m.content)} title="Read Aloud" style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
662
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>
663
+ </button>
664
+ <button onClick={() => handleRegenerate(index)} disabled={loading} title="Regenerate Response" style={{ background: 'none', border: 'none', color: 'inherit', cursor: loading ? 'default' : 'pointer', display: 'flex', alignItems: 'center', gap: 4, opacity: loading ? 0.3 : 1 }}>
665
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"></polyline><polyline points="23 20 23 14 17 14"></polyline><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path></svg>
666
+ </button>
667
+ </div>
668
+ )}
669
+ </div>
670
+ ))}
671
+ {loading && (
672
+ <div className="message message-assistant">
673
+ <span className="skeleton" style={{ display: 'inline-block', width: 180, height: 16 }} />
674
+ </div>
675
+ )}
676
+ <div ref={messagesEnd} />
677
+ </div>
678
+ <div className="chat-input-bar">
679
+ {selectedGroundingNodes.length > 0 && (
680
+ <div className="grounding-pills">
681
+ {selectedGroundingNodes.map(slug => {
682
+ const node = allMemoryNodes.find(n => n.slug === slug);
683
+ return (
684
+ <div key={slug} className="grounding-pill animate-fade-in">
685
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
686
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
687
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
688
+ </svg>
689
+ <span className="grounding-pill-title">{node?.title || slug}</span>
690
+ <button className="grounding-pill-remove" onClick={() => setSelectedGroundingNodes(prev => prev.filter(s => s !== slug))} title="Remove grounding context">×</button>
691
+ </div>
692
+ );
693
+ })}
694
+ </div>
695
+ )}
696
+ <div className="chat-input-wrapper">
697
+ <button
698
+ className={`btn btn-ghost ${voiceMode ? 'active' : ''}`}
699
+ style={{ width: 44, height: 44, padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', color: voiceMode ? 'var(--accent)' : 'var(--text-tertiary)', borderColor: voiceMode ? 'var(--accent)' : 'var(--border)' }}
700
+ onClick={() => {
701
+ setVoiceMode(!voiceMode)
702
+ if (voiceMode && 'speechSynthesis' in window) window.speechSynthesis.cancel()
703
+ }}
704
+ title={voiceMode ? "Voice Mode On" : "Voice Mode Off"}
705
+ >
706
+ {voiceMode ? (
707
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 20, height: 20 }}>
708
+ <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/><line x1="8" x2="16" y1="22" y2="22"/>
709
+ </svg>
710
+ ) : (
711
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 20, height: 20 }}>
712
+ <line x1="1" x2="23" y1="1" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V5a3 3 0 0 0-5.94-.88"/><path d="M19 10v2a7 7 0 0 1-1.39 4.2M5 10v2a7 7 0 0 0 1.93 4.88"/><line x1="12" x2="12" y1="19" y2="22"/><line x1="8" x2="16" y1="22" y2="22"/>
713
+ </svg>
714
+ )}
715
+ </button>
716
+
717
+ <button
718
+ className={`btn btn-ghost ${deepResearchMode ? 'active' : ''}`}
719
+ style={{ width: 44, height: 44, padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', color: deepResearchMode ? 'var(--accent)' : 'var(--text-tertiary)', borderColor: deepResearchMode ? 'var(--accent)' : 'var(--border)' }}
720
+ onClick={() => setDeepResearchMode(!deepResearchMode)}
721
+ title={deepResearchMode ? "Deep Research On" : "Deep Research Off"}
722
+ >
723
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 18, height: 18 }}>
724
+ <path d="M12 2v20"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
725
+ </svg>
726
+ </button>
727
+
728
+ <div style={{ position: 'relative', display: 'flex' }} ref={nodeSelectorRef}>
729
+ <button
730
+ className={`btn btn-ghost ${selectedGroundingNodes.length > 0 ? 'active' : ''}`}
731
+ style={{ width: 44, height: 44, padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', color: selectedGroundingNodes.length > 0 ? 'var(--accent)' : 'var(--text-tertiary)', borderColor: selectedGroundingNodes.length > 0 ? 'var(--accent)' : 'var(--border)' }}
732
+ onClick={() => setShowNodeSelector(!showNodeSelector)}
733
+ title="Ground with brain nodes"
734
+ >
735
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 18, height: 18 }}>
736
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
737
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
738
+ </svg>
739
+ {selectedGroundingNodes.length > 0 && (
740
+ <span className="grounding-count-badge">{selectedGroundingNodes.length}</span>
741
+ )}
742
+ </button>
743
+
744
+ {showNodeSelector && (
745
+ <div className="node-selector-popover glass">
746
+ <div className="node-selector-header">
747
+ <h4>Ground with Brain Nodes</h4>
748
+ <input
749
+ type="text"
750
+ placeholder="Search nodes..."
751
+ value={nodeSearchQuery}
752
+ onChange={e => setNodeSearchQuery(e.target.value)}
753
+ className="node-search-input"
754
+ autoFocus
755
+ />
756
+ </div>
757
+ <div className="node-selector-list">
758
+ {filteredNodes.length === 0 ? (
759
+ <div className="node-selector-empty">No nodes found</div>
760
+ ) : (
761
+ filteredNodes.map(node => {
762
+ const isSelected = selectedGroundingNodes.includes(node.slug);
763
+ return (
764
+ <div
765
+ key={node.slug}
766
+ className={`node-selector-item ${isSelected ? 'selected' : ''}`}
767
+ onClick={() => {
768
+ if (isSelected) {
769
+ setSelectedGroundingNodes(prev => prev.filter(s => s !== node.slug));
770
+ } else {
771
+ setSelectedGroundingNodes(prev => [...prev, node.slug]);
772
+ }
773
+ }}
774
+ >
775
+ <span className="node-selector-item-checkbox">
776
+ {isSelected ? '✓' : ''}
777
+ </span>
778
+ <div className="node-selector-item-info">
779
+ <span className="node-selector-item-title">{node.title}</span>
780
+ <span className="node-selector-item-meta">{node.category} • {node.status || 'active'}</span>
781
+ </div>
782
+ </div>
783
+ );
784
+ })
785
+ )}
786
+ </div>
787
+ </div>
788
+ )}
789
+ </div>
790
+
791
+ <textarea
792
+ ref={textareaRef}
793
+ id="chat-input"
794
+ value={input}
795
+ onChange={handleInput}
796
+ onKeyDown={handleKeyDown}
797
+ placeholder="Message your brain…"
798
+ rows={1}
799
+ disabled={loading}
800
+ />
801
+ {loading ? (
802
+ <button className="chat-send-btn" onClick={handleStop} id="chat-stop" style={{ background: 'var(--error)', color: '#fff' }} title="Stop Generation">
803
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style={{ width: 20, height: 20 }}>
804
+ <rect x="6" y="6" width="12" height="12" rx="2" ry="2"/>
805
+ </svg>
806
+ </button>
807
+ ) : (
808
+ <button className="chat-send-btn" onClick={handleSend} disabled={!input.trim()} id="chat-send" title="Send Message">
809
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
810
+ <path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
811
+ </svg>
812
+ </button>
813
+ )}
814
+ </div>
815
+ {detectedUrl && (
816
+ <div style={{ display: 'flex', gap: '8px', padding: '8px 12px', background: 'var(--bg-secondary, #1e1e2e)', borderRadius: '8px', alignItems: 'center', fontSize: '0.85rem', marginTop: 4 }}>
817
+ <span style={{ color: 'var(--text-secondary)' }}>🔗 URL detected</span>
818
+ <button className="btn btn-sm" onClick={() => handleUrlAction('research')}>🔬 Research</button>
819
+ <button className="btn btn-sm" onClick={() => handleUrlAction('remember')}>📌 Remember</button>
820
+ </div>
821
+ )}
822
+ {urlToast && (
823
+ <div style={{ padding: '6px 12px', background: 'rgba(46, 204, 113, 0.1)', border: '1px solid rgba(46, 204, 113, 0.3)', borderRadius: '8px', fontSize: '0.8rem', color: 'var(--success)', marginTop: 4, textAlign: 'center' }}>
824
+ {urlToast}
825
+ </div>
826
+ )}
827
+ </div>
828
+ </div>
829
+ </div>
830
+ )
831
+ }