total-recall-brain 3.1.1 → 3.2.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.
Files changed (62) 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 +806 -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/remember.mjs +1 -1
  62. package/src/core/research-queue.mjs +5 -1
@@ -0,0 +1,806 @@
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>('')
61
+ const [selectedSubModel, setSelectedSubModel] = useState<string>('')
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
+ if (health.cli_agents && health.cli_agents.length > 0) {
106
+ setAvailableModels(health.cli_agents)
107
+ setSelectedModel(health.cli_agents[0])
108
+ } else {
109
+ setAvailableModels(['antigravity', 'gemini', 'claude', 'codex'])
110
+ setSelectedModel('gemini')
111
+ }
112
+ })
113
+ .catch(() => {
114
+ setAvailableModels(['antigravity', 'gemini', 'claude', 'codex'])
115
+ setSelectedModel('gemini')
116
+ })
117
+
118
+ fetchGeminiModels()
119
+ .then(models => {
120
+ setGeminiModels(models)
121
+ if (models && models.length > 0) {
122
+ setSelectedSubModel(models[0].id)
123
+ }
124
+ })
125
+ .catch(console.error)
126
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- onBrainChange is stable from parent
127
+ }, [])
128
+
129
+ // Reload memory nodes when active selected brain changes
130
+ useEffect(() => {
131
+ listMemory(activeBrainId)
132
+ .then(setAllMemoryNodes)
133
+ .catch(console.error)
134
+ }, [activeBrainId])
135
+
136
+ // Click outside listener for grounding node selector popover and model selector dropdown
137
+ useEffect(() => {
138
+ function handleClickOutside(event: MouseEvent) {
139
+ if (nodeSelectorRef.current && !nodeSelectorRef.current.contains(event.target as Node)) {
140
+ setShowNodeSelector(false)
141
+ }
142
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
143
+ setShowModelDropdown(false)
144
+ }
145
+ }
146
+ document.addEventListener("mousedown", handleClickOutside)
147
+ return () => {
148
+ document.removeEventListener("mousedown", handleClickOutside)
149
+ }
150
+ }, [nodeSelectorRef, dropdownRef])
151
+
152
+ // Load chat history when activeThreadId changes
153
+ useEffect(() => {
154
+ fetchChatHistory(activeThreadId)
155
+ .then(history => {
156
+ if (history) {
157
+ setMessages(history as ChatMessage[])
158
+ msgId = Math.max(msgId, history.length + 1)
159
+ } else {
160
+ setMessages([])
161
+ }
162
+ })
163
+ .catch(err => {
164
+ console.error(err)
165
+ setMessages([])
166
+ })
167
+ }, [activeThreadId])
168
+
169
+
170
+ // Probe the Kokoro endpoint once when voice mode is turned on so we know
171
+ // whether to call /api/tts or fall back to the browser engine.
172
+ useEffect(() => {
173
+ if (!voiceMode || kokoroEnabled !== null) return
174
+ fetchTtsStatus().then(s => setKokoroEnabled(s.enabled)).catch(() => setKokoroEnabled(false))
175
+ }, [voiceMode, kokoroEnabled])
176
+
177
+ // Check Chrome extension availability (once on mount)
178
+ useEffect(() => {
179
+ if (!extensionBannerDismissed) {
180
+ fetchExtensionStatus().then(setExtensionStatus).catch(() => {})
181
+ }
182
+ }, [extensionBannerDismissed])
183
+
184
+ const speakBrowser = (text: string) => {
185
+ if (!('speechSynthesis' in window)) return
186
+ window.speechSynthesis.cancel()
187
+ const utterance = new SpeechSynthesisUtterance(text)
188
+ utterance.rate = 1.05
189
+ utterance.pitch = 0.95
190
+ window.speechSynthesis.speak(utterance)
191
+ }
192
+
193
+ const speak = async (text: string) => {
194
+ if (!voiceMode || !text.trim()) return
195
+ if (kokoroEnabled === false) { speakBrowser(text); return }
196
+ try {
197
+ const blob = await fetchTtsAudio(text)
198
+ if (!blob) {
199
+ // 503 → Kokoro not configured. Remember that and fall back.
200
+ setKokoroEnabled(false)
201
+ speakBrowser(text)
202
+ return
203
+ }
204
+ const url = URL.createObjectURL(blob)
205
+ if (audioRef.current) {
206
+ audioRef.current.pause()
207
+ URL.revokeObjectURL(audioRef.current.src)
208
+ }
209
+ const audio = new Audio(url)
210
+ audioRef.current = audio
211
+ audio.onended = () => URL.revokeObjectURL(url)
212
+ audio.play().catch(() => speakBrowser(text))
213
+ } catch {
214
+ speakBrowser(text)
215
+ }
216
+ }
217
+
218
+ const handleNewChat = () => {
219
+ const newId = `thread-${getCurrentTimestamp()}`
220
+ setActiveThreadId(newId)
221
+ setMessages([])
222
+ setInput('')
223
+ setSelectedGroundingNodes([])
224
+ if (textareaRef.current) textareaRef.current.style.height = '44px'
225
+ }
226
+
227
+ const handleDeleteThread = async (e: React.MouseEvent, id: string) => {
228
+ e.stopPropagation()
229
+ if (!confirm('Are you sure you want to delete this conversation thread?')) return
230
+ try {
231
+ await deleteChatThread(id)
232
+ if (activeThreadId === id) {
233
+ const remaining = threads.filter(t => t.id !== id)
234
+ if (remaining.length > 0) {
235
+ setActiveThreadId(remaining[0].id)
236
+ } else {
237
+ setActiveThreadId(undefined)
238
+ setMessages([])
239
+ }
240
+ }
241
+ refreshThreads()
242
+ } catch (err) {
243
+ console.error('Failed to delete thread:', err)
244
+ }
245
+ }
246
+
247
+
248
+
249
+ const handleSend = async () => {
250
+ const text = input.trim()
251
+ if (!text || loading) return
252
+
253
+ const sessionId = activeThreadId || `thread-${getCurrentTimestamp()}`
254
+ if (!activeThreadId) {
255
+ setActiveThreadId(sessionId)
256
+ }
257
+
258
+ const userMsg: ChatMessage = { id: String(++msgId), role: 'user', content: text, timestamp: getCurrentTimestamp() }
259
+ setMessages(prev => [...prev, userMsg])
260
+ setInput('')
261
+ setLoading(true)
262
+
263
+ abortControllerRef.current = new AbortController()
264
+
265
+ // Auto-resize textarea back
266
+ if (textareaRef.current) textareaRef.current.style.height = '44px'
267
+
268
+ try {
269
+ if (deepResearchMode) {
270
+ setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: '🕵️ **Deep Research Agents dispatched.** Gathering facts across parallel web streams...', timestamp: getCurrentTimestamp() }]);
271
+
272
+ const { slug } = await createTask('proactive-research', text, text);
273
+
274
+ // Poll for task completion
275
+ let taskCompleted = false;
276
+ while (!taskCompleted) {
277
+ await new Promise(r => setTimeout(r, 2000));
278
+ const tasks = await listTasks();
279
+ const t = tasks.find(x => x.slug === slug);
280
+ if (t && (t.status === 'completed' || t.status === 'failed')) {
281
+ taskCompleted = true;
282
+ setMessages(prev => prev.filter(m => m.content !== '🕵️ **Deep Research Agents dispatched.** Gathering facts across parallel web streams...'));
283
+ setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: t.body || '', timestamp: getCurrentTimestamp() }]);
284
+ speak(t.body || '');
285
+ }
286
+ }
287
+ setLoading(false);
288
+ return;
289
+ }
290
+
291
+ const modelWithSubModel = ['gemini', 'antigravity'].includes(selectedModel) && selectedSubModel
292
+ ? `${selectedModel}:${selectedSubModel}`
293
+ : selectedModel
294
+
295
+ const historyToSend = [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
296
+ const reply = await sendChat(historyToSend, abortControllerRef.current.signal, sessionId, selectedGroundingNodes, modelWithSubModel, activeBrainId)
297
+ const assistantMsg: ChatMessage = { id: String(++msgId), role: 'assistant', content: reply, timestamp: getCurrentTimestamp(), versions: [reply], currentVersionIndex: 0 }
298
+ setMessages(prev => [...prev, assistantMsg])
299
+ speak(reply)
300
+ refreshThreads()
301
+ } catch (e: unknown) {
302
+ if ((e as Error).name === 'AbortError') {
303
+ setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: '⛔ Generation stopped by user.', timestamp: getCurrentTimestamp(), versions: ['⛔ Generation stopped by user.'], currentVersionIndex: 0 }])
304
+ } else {
305
+ const errorMsg: ChatMessage = { id: String(++msgId), role: 'assistant', content: `⚠️ Error: ${(e as Error).message}`, timestamp: getCurrentTimestamp(), versions: [`⚠️ Error: ${(e as Error).message}`], currentVersionIndex: 0 }
306
+ setMessages(prev => [...prev, errorMsg])
307
+ }
308
+ } finally {
309
+ setLoading(false)
310
+ abortControllerRef.current = null
311
+ }
312
+ }
313
+
314
+ const handleRegenerate = async (msgIndex: number) => {
315
+ if (loading) return;
316
+ const historyToSend = messages.slice(0, msgIndex).map(m => ({ role: m.role, content: m.content }))
317
+ setLoading(true)
318
+ abortControllerRef.current = new AbortController()
319
+ try {
320
+ const modelWithSubModel = ['gemini', 'antigravity'].includes(selectedModel) && selectedSubModel
321
+ ? `${selectedModel}:${selectedSubModel}`
322
+ : selectedModel
323
+
324
+ const reply = await sendChat(historyToSend, abortControllerRef.current.signal, activeThreadId, undefined, modelWithSubModel, activeBrainId)
325
+ setMessages(prev => {
326
+ const next = [...prev]
327
+ const target = next[msgIndex]
328
+ const versions = target.versions || [target.content]
329
+ versions.push(reply)
330
+ target.versions = versions
331
+ target.currentVersionIndex = versions.length - 1
332
+ target.content = reply
333
+ return next
334
+ })
335
+ speak(reply)
336
+ refreshThreads()
337
+ } catch (e: unknown) {
338
+ if ((e as Error).name !== 'AbortError') console.error(e)
339
+ } finally {
340
+ setLoading(false)
341
+ abortControllerRef.current = null
342
+ }
343
+ }
344
+
345
+ const handleCopy = (content: string) => {
346
+ navigator.clipboard.writeText(content).catch(console.error)
347
+ }
348
+
349
+ const handleVersionSwitch = (msgIndex: number, delta: number) => {
350
+ setMessages(prev => {
351
+ const next = [...prev]
352
+ const target = next[msgIndex]
353
+ if (!target.versions) return prev
354
+ const newIndex = Math.max(0, Math.min(target.versions.length - 1, (target.currentVersionIndex || 0) + delta))
355
+ target.currentVersionIndex = newIndex
356
+ target.content = target.versions[newIndex]
357
+ return next
358
+ })
359
+ }
360
+
361
+ const handleStop = () => {
362
+ if (abortControllerRef.current) {
363
+ abortControllerRef.current.abort()
364
+ abortControllerRef.current = null
365
+ setLoading(false)
366
+ }
367
+ }
368
+
369
+ const handleKeyDown = (e: React.KeyboardEvent) => {
370
+ if (e.key === 'Enter' && !e.shiftKey) {
371
+ e.preventDefault()
372
+ handleSend()
373
+ }
374
+ }
375
+
376
+ const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
377
+ const value = e.target.value
378
+ setInput(value)
379
+ const ta = e.target
380
+ ta.style.height = '44px'
381
+ ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'
382
+
383
+ // Detect URLs
384
+ const urlMatch = value.match(/https?:\/\/[^\s]+/)
385
+ setDetectedUrl(urlMatch ? urlMatch[0] : null)
386
+ }
387
+
388
+ const handleUrlAction = async (action: string) => {
389
+ if (!detectedUrl) return
390
+ try {
391
+ await shareToApi({ url: detectedUrl, action })
392
+ setUrlToast(`✅ URL sent to ${action}`)
393
+ setDetectedUrl(null)
394
+ setTimeout(() => setUrlToast(null), 3000)
395
+ } catch (err) {
396
+ console.error('Share API error:', err)
397
+ setUrlToast('⚠️ Failed to process URL')
398
+ setTimeout(() => setUrlToast(null), 3000)
399
+ }
400
+ }
401
+
402
+ const filteredNodes = allMemoryNodes.filter(node =>
403
+ node.title.toLowerCase().includes(nodeSearchQuery.toLowerCase()) ||
404
+ node.slug.toLowerCase().includes(nodeSearchQuery.toLowerCase()) ||
405
+ (node.category && node.category.toLowerCase().includes(nodeSearchQuery.toLowerCase()))
406
+ )
407
+
408
+ return (
409
+ <div className="chat-layout">
410
+ {/* Threads Sidebar */}
411
+ <aside className="chat-threads-sidebar">
412
+ <div className="sidebar-header">
413
+ <button className="new-chat-btn" onClick={handleNewChat}>
414
+ <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 }}>
415
+ <line x1="12" y1="5" x2="12" y2="19"></line>
416
+ <line x1="5" y1="12" x2="19" y2="12"></line>
417
+ </svg>
418
+ New Chat
419
+ </button>
420
+ </div>
421
+ <div className="thread-list">
422
+ {threads.map(t => (
423
+ <div
424
+ key={t.id}
425
+ className={`thread-item ${activeThreadId === t.id ? 'active' : ''}`}
426
+ onClick={() => {
427
+ setActiveThreadId(t.id)
428
+ // Auto-switch brain context when selecting a thread
429
+ if (t.brainId && onBrainChange) {
430
+ onBrainChange(t.brainId)
431
+ }
432
+ }}
433
+ >
434
+ <div className="thread-info">
435
+ <div className="thread-title" title={t.title}>{t.title}</div>
436
+ <div className="thread-meta">
437
+ <span>{t.turns} {t.turns === 1 ? 'turn' : 'turns'}</span>
438
+ <span>•</span>
439
+ <span>{formatRelativeTime(t.lastUpdated)}</span>
440
+ </div>
441
+ </div>
442
+ <button
443
+ className="thread-delete-btn"
444
+ onClick={(e) => handleDeleteThread(e, t.id)}
445
+ title="Delete chat thread"
446
+ >
447
+ <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">
448
+ <polyline points="3 6 5 6 21 6"></polyline>
449
+ <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>
450
+ <line x1="10" y1="11" x2="10" y2="17"></line>
451
+ <line x1="14" y1="11" x2="14" y2="17"></line>
452
+ </svg>
453
+ </button>
454
+ </div>
455
+ ))}
456
+ </div>
457
+ </aside>
458
+
459
+ {/* Main Chat Area */}
460
+ <div className="chat-container">
461
+ <header className="chat-header animate-fade-in">
462
+ <div className="chat-header-title">
463
+ <h2>Chat Session</h2>
464
+ {activeThreadId && (
465
+ <span className="badge badge-accent" style={{ textTransform: 'none', letterSpacing: 'normal', fontSize: '11px', padding: '2px 8px' }}>
466
+ {activeThreadId}
467
+ </span>
468
+ )}
469
+ </div>
470
+ <div className="chat-header-model-selector" ref={dropdownRef}>
471
+ <span className="selector-label">Model:</span>
472
+ <div className="model-selector-dropdown-wrapper">
473
+ <button
474
+ className={`model-selector-dropdown-btn ${showModelDropdown ? 'active' : ''}`}
475
+ onClick={() => setShowModelDropdown(!showModelDropdown)}
476
+ title="Select CLI agent model"
477
+ >
478
+ <span style={{ textTransform: 'capitalize' }}>{selectedModel || 'Select Model'}</span>
479
+ <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">
480
+ <polyline points="6 9 12 15 18 9"></polyline>
481
+ </svg>
482
+ </button>
483
+ {showModelDropdown && (
484
+ <div className="model-selector-menu glass">
485
+ {availableModels.map(modelName => (
486
+ <div
487
+ key={modelName}
488
+ className={`model-selector-item ${selectedModel === modelName ? 'selected' : ''}`}
489
+ onClick={() => {
490
+ setSelectedModel(modelName)
491
+ setSelectedSubModel('')
492
+ setShowModelDropdown(false)
493
+ }}
494
+ >
495
+ <div className="model-selector-item-info">
496
+ <span className="model-selector-item-name" style={{ textTransform: 'capitalize' }}>{modelName}</span>
497
+ <span className="model-selector-item-desc">
498
+ {modelName === 'antigravity' && 'Google Antigravity'}
499
+ {modelName === 'gemini' && 'Google Gemini CLI'}
500
+ {modelName === 'claude' && 'Anthropic Claude CLI'}
501
+ {modelName === 'codex' && 'Codex Coding Agent'}
502
+ </span>
503
+ </div>
504
+ <span className="model-selector-item-badge">CLI</span>
505
+ </div>
506
+ ))}
507
+ </div>
508
+ )}
509
+ </div>
510
+ {['gemini', 'antigravity'].includes(selectedModel) && geminiModels.length > 0 && (
511
+ <div className="chat-header-submodel-selector" style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 16 }}>
512
+ <span className="selector-label" style={{ fontSize: '12px', color: 'var(--text-tertiary)', fontWeight: 500 }}>Base:</span>
513
+ <select
514
+ value={selectedSubModel}
515
+ onChange={(e) => setSelectedSubModel(e.target.value)}
516
+ className="submodel-select"
517
+ style={{
518
+ background: 'rgba(255, 255, 255, 0.04)',
519
+ border: '1px solid var(--border)',
520
+ borderRadius: 'var(--radius-sm)',
521
+ color: 'var(--text-secondary)',
522
+ padding: '6px 12px',
523
+ fontSize: '13px',
524
+ fontWeight: 500,
525
+ outline: 'none',
526
+ cursor: 'pointer',
527
+ transition: 'all var(--transition-fast)'
528
+ }}
529
+ >
530
+ {geminiModels.map(m => (
531
+ <option key={m.id} value={m.id} style={{ background: '#12121a', color: 'var(--text-secondary)' }}>{m.displayName}</option>
532
+ ))}
533
+ </select>
534
+ </div>
535
+ )}
536
+ </div>
537
+ </header>
538
+
539
+ {/* Chrome Extension Banner — shows when available but not connected */}
540
+ {extensionStatus?.available && !extensionStatus.connected && !extensionBannerDismissed && (
541
+ <div style={{
542
+ margin: '0 16px 0',
543
+ padding: '12px 16px',
544
+ background: 'linear-gradient(135deg, rgba(108, 92, 231, 0.12), rgba(99, 179, 237, 0.12))',
545
+ border: '1px solid rgba(108, 92, 231, 0.3)',
546
+ borderRadius: 10,
547
+ display: 'flex',
548
+ alignItems: 'center',
549
+ gap: 12,
550
+ animation: 'fadeIn 0.3s ease'
551
+ }}>
552
+ <span style={{ fontSize: 22, flexShrink: 0 }}>🧩</span>
553
+ <div style={{ flex: 1, minWidth: 0 }}>
554
+ <div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
555
+ Chrome Extension Available
556
+ </div>
557
+ <div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
558
+ Browse with your brain — contextual memory, quick capture, and research from any page
559
+ </div>
560
+ </div>
561
+ <a
562
+ href="/api/extension/download"
563
+ download
564
+ style={{
565
+ flexShrink: 0,
566
+ padding: '6px 14px',
567
+ background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
568
+ color: '#fff',
569
+ borderRadius: 6,
570
+ fontWeight: 500,
571
+ fontSize: 12,
572
+ textDecoration: 'none',
573
+ whiteSpace: 'nowrap'
574
+ }}
575
+ >
576
+ ⬇ Download
577
+ </a>
578
+ <button
579
+ onClick={() => {
580
+ setExtensionBannerDismissed(true)
581
+ localStorage.setItem('tr-ext-banner-dismissed', 'true')
582
+ }}
583
+ style={{
584
+ flexShrink: 0,
585
+ background: 'none',
586
+ border: 'none',
587
+ color: 'var(--text-tertiary)',
588
+ cursor: 'pointer',
589
+ fontSize: 16,
590
+ padding: '0 2px',
591
+ lineHeight: 1
592
+ }}
593
+ title="Dismiss"
594
+ >✕</button>
595
+ </div>
596
+ )}
597
+
598
+ <div className="chat-messages">
599
+ {messages.length === 0 && (
600
+ <Graph3D
601
+ threads={threads}
602
+ memoryNodes={allMemoryNodes}
603
+ researchItems={researchItems}
604
+ onOpenThread={(threadId) => setActiveThreadId(threadId)}
605
+ onGroundMemoryNode={(slug) => {
606
+ setSelectedGroundingNodes(prev =>
607
+ prev.includes(slug) ? prev.filter(s => s !== slug) : [...prev, slug]
608
+ )
609
+ }}
610
+ selectedGroundingNodes={selectedGroundingNodes}
611
+ />
612
+ )}
613
+ {messages.map((m, index) => (
614
+ <div key={m.id} className={`message message-${m.role}`}>
615
+ <div style={{ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' }}>{m.content}</div>
616
+ {m.role === 'assistant' && (
617
+ <div style={{ display: 'flex', gap: 12, marginTop: 12, fontSize: 13, color: 'var(--text-tertiary)', alignItems: 'center', userSelect: 'none' }}>
618
+ {m.versions && m.versions.length > 1 && (
619
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginRight: 4 }}>
620
+ <button
621
+ onClick={() => handleVersionSwitch(index, -1)}
622
+ disabled={(m.currentVersionIndex || 0) === 0}
623
+ style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 2, opacity: (m.currentVersionIndex || 0) === 0 ? 0.3 : 1 }}
624
+ >◄</button>
625
+ <span>{(m.currentVersionIndex || 0) + 1} / {m.versions.length}</span>
626
+ <button
627
+ onClick={() => handleVersionSwitch(index, 1)}
628
+ disabled={(m.currentVersionIndex || 0) === m.versions.length - 1}
629
+ style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 2, opacity: (m.currentVersionIndex || 0) === m.versions.length - 1 ? 0.3 : 1 }}
630
+ >►</button>
631
+ </div>
632
+ )}
633
+ <button onClick={() => handleCopy(m.content)} title="Copy to clipboard" style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
634
+ <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>
635
+ </button>
636
+ <button onClick={() => speak(m.content)} title="Read Aloud" style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
637
+ <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>
638
+ </button>
639
+ <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 }}>
640
+ <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>
641
+ </button>
642
+ </div>
643
+ )}
644
+ </div>
645
+ ))}
646
+ {loading && (
647
+ <div className="message message-assistant">
648
+ <span className="skeleton" style={{ display: 'inline-block', width: 180, height: 16 }} />
649
+ </div>
650
+ )}
651
+ <div ref={messagesEnd} />
652
+ </div>
653
+ <div className="chat-input-bar">
654
+ {selectedGroundingNodes.length > 0 && (
655
+ <div className="grounding-pills">
656
+ {selectedGroundingNodes.map(slug => {
657
+ const node = allMemoryNodes.find(n => n.slug === slug);
658
+ return (
659
+ <div key={slug} className="grounding-pill animate-fade-in">
660
+ <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">
661
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
662
+ <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"/>
663
+ </svg>
664
+ <span className="grounding-pill-title">{node?.title || slug}</span>
665
+ <button className="grounding-pill-remove" onClick={() => setSelectedGroundingNodes(prev => prev.filter(s => s !== slug))} title="Remove grounding context">×</button>
666
+ </div>
667
+ );
668
+ })}
669
+ </div>
670
+ )}
671
+ <div className="chat-input-wrapper">
672
+ <button
673
+ className={`btn btn-ghost ${voiceMode ? 'active' : ''}`}
674
+ 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)' }}
675
+ onClick={() => {
676
+ setVoiceMode(!voiceMode)
677
+ if (voiceMode && 'speechSynthesis' in window) window.speechSynthesis.cancel()
678
+ }}
679
+ title={voiceMode ? "Voice Mode On" : "Voice Mode Off"}
680
+ >
681
+ {voiceMode ? (
682
+ <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 }}>
683
+ <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"/>
684
+ </svg>
685
+ ) : (
686
+ <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 }}>
687
+ <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"/>
688
+ </svg>
689
+ )}
690
+ </button>
691
+
692
+ <button
693
+ className={`btn btn-ghost ${deepResearchMode ? 'active' : ''}`}
694
+ 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)' }}
695
+ onClick={() => setDeepResearchMode(!deepResearchMode)}
696
+ title={deepResearchMode ? "Deep Research On" : "Deep Research Off"}
697
+ >
698
+ <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 }}>
699
+ <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"/>
700
+ </svg>
701
+ </button>
702
+
703
+ <div style={{ position: 'relative', display: 'flex' }} ref={nodeSelectorRef}>
704
+ <button
705
+ className={`btn btn-ghost ${selectedGroundingNodes.length > 0 ? 'active' : ''}`}
706
+ 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)' }}
707
+ onClick={() => setShowNodeSelector(!showNodeSelector)}
708
+ title="Ground with brain nodes"
709
+ >
710
+ <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 }}>
711
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
712
+ <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"/>
713
+ </svg>
714
+ {selectedGroundingNodes.length > 0 && (
715
+ <span className="grounding-count-badge">{selectedGroundingNodes.length}</span>
716
+ )}
717
+ </button>
718
+
719
+ {showNodeSelector && (
720
+ <div className="node-selector-popover glass">
721
+ <div className="node-selector-header">
722
+ <h4>Ground with Brain Nodes</h4>
723
+ <input
724
+ type="text"
725
+ placeholder="Search nodes..."
726
+ value={nodeSearchQuery}
727
+ onChange={e => setNodeSearchQuery(e.target.value)}
728
+ className="node-search-input"
729
+ autoFocus
730
+ />
731
+ </div>
732
+ <div className="node-selector-list">
733
+ {filteredNodes.length === 0 ? (
734
+ <div className="node-selector-empty">No nodes found</div>
735
+ ) : (
736
+ filteredNodes.map(node => {
737
+ const isSelected = selectedGroundingNodes.includes(node.slug);
738
+ return (
739
+ <div
740
+ key={node.slug}
741
+ className={`node-selector-item ${isSelected ? 'selected' : ''}`}
742
+ onClick={() => {
743
+ if (isSelected) {
744
+ setSelectedGroundingNodes(prev => prev.filter(s => s !== node.slug));
745
+ } else {
746
+ setSelectedGroundingNodes(prev => [...prev, node.slug]);
747
+ }
748
+ }}
749
+ >
750
+ <span className="node-selector-item-checkbox">
751
+ {isSelected ? '✓' : ''}
752
+ </span>
753
+ <div className="node-selector-item-info">
754
+ <span className="node-selector-item-title">{node.title}</span>
755
+ <span className="node-selector-item-meta">{node.category} • {node.status || 'active'}</span>
756
+ </div>
757
+ </div>
758
+ );
759
+ })
760
+ )}
761
+ </div>
762
+ </div>
763
+ )}
764
+ </div>
765
+
766
+ <textarea
767
+ ref={textareaRef}
768
+ id="chat-input"
769
+ value={input}
770
+ onChange={handleInput}
771
+ onKeyDown={handleKeyDown}
772
+ placeholder="Message your brain…"
773
+ rows={1}
774
+ disabled={loading}
775
+ />
776
+ {loading ? (
777
+ <button className="chat-send-btn" onClick={handleStop} id="chat-stop" style={{ background: 'var(--error)', color: '#fff' }} title="Stop Generation">
778
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style={{ width: 20, height: 20 }}>
779
+ <rect x="6" y="6" width="12" height="12" rx="2" ry="2"/>
780
+ </svg>
781
+ </button>
782
+ ) : (
783
+ <button className="chat-send-btn" onClick={handleSend} disabled={!input.trim()} id="chat-send" title="Send Message">
784
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
785
+ <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" />
786
+ </svg>
787
+ </button>
788
+ )}
789
+ </div>
790
+ {detectedUrl && (
791
+ <div style={{ display: 'flex', gap: '8px', padding: '8px 12px', background: 'var(--bg-secondary, #1e1e2e)', borderRadius: '8px', alignItems: 'center', fontSize: '0.85rem', marginTop: 4 }}>
792
+ <span style={{ color: 'var(--text-secondary)' }}>🔗 URL detected</span>
793
+ <button className="btn btn-sm" onClick={() => handleUrlAction('research')}>🔬 Research</button>
794
+ <button className="btn btn-sm" onClick={() => handleUrlAction('remember')}>📌 Remember</button>
795
+ </div>
796
+ )}
797
+ {urlToast && (
798
+ <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' }}>
799
+ {urlToast}
800
+ </div>
801
+ )}
802
+ </div>
803
+ </div>
804
+ </div>
805
+ )
806
+ }