total-recall-brain 3.41.4 → 3.41.6

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 (147) hide show
  1. package/bin/total-recall.mjs +10 -6
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/index-4tGQfTa2.js +5605 -0
  4. package/frontend/dist/assets/index-DtwsDyFM.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/package.json +11 -0
  7. package/frontend/src/App.tsx +47 -122
  8. package/frontend/src/components/plugins/plugin-frame.ts +1 -0
  9. package/frontend/src/index.css +152 -86
  10. package/frontend/src/pages/HelpPage.tsx +13 -10
  11. package/frontend/src/pages/MemoryPage.tsx +28 -30
  12. package/frontend/src/pages/NetworkPage.tsx +3 -3
  13. package/frontend/src/pages/PluginsPage.tsx +6 -5
  14. package/frontend/src/pages/RulesPage.tsx +16 -12
  15. package/package.json +4 -3
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
  17. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +4 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
  19. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +4 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3121 -33
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +4 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3122 -34
  23. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +4 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3127 -49
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -2
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +1 -1
  27. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +136 -7
  28. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +135 -26
  29. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +3121 -33
  30. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +5 -2
  31. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +228 -9
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +24 -30
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +29 -16
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +73 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +182 -365
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +33 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +40 -14
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +4 -4
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/agy.mjs +187 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/claude.mjs +275 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cline.mjs +132 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/codex.mjs +187 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cursor.mjs +158 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/devin.mjs +157 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/dsh.mjs +183 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/grok.mjs +132 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +173 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +87 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/openclaw.mjs +173 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/pi.mjs +129 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +12 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/json-reply.mjs +22 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +72 -70
  54. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-catalog.mjs +195 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +11 -0
  56. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +36 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +169 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +136 -346
  59. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +1 -1
  60. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/ssss-store.mjs +20 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/telemetry.mjs +43 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +156 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +117 -35
  64. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +51 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +398 -57
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +304 -46
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +367 -41
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +5 -2
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +33 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +2 -2
  71. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  74. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  75. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +151 -4
  76. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  77. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  78. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +899 -662
  79. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  80. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/mermaid-diagrams/SKILL.md +44 -0
  81. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/write-connector/SKILL.md +56 -0
  82. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3097 -81
  83. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +4 -1
  84. package/src/cli/agent.mjs +12 -1
  85. package/src/cli/compact.mjs +108 -0
  86. package/src/cli/connect.mjs +56 -1
  87. package/src/cli/context.mjs +55 -23
  88. package/src/cli/doctor.mjs +5 -2
  89. package/src/cli/forget.mjs +2 -0
  90. package/src/cli/harness.mjs +62 -50
  91. package/src/cli/memory-entry.mjs +2 -2
  92. package/src/cli/preflight.mjs +152 -16
  93. package/src/cli/release.mjs +386 -0
  94. package/src/cli/remember.mjs +19 -1
  95. package/src/cli/rules.mjs +54 -12
  96. package/src/cli/startup.mjs +1 -1
  97. package/src/cli/status.mjs +12 -2
  98. package/src/core/claude-hooks.mjs +41 -3
  99. package/src/core/cli-commands.mjs +2 -0
  100. package/src/core/cli-semantic-index.mjs +3 -2
  101. package/src/core/conflict-detector.mjs +23 -12
  102. package/src/core/context-compactor.mjs +145 -0
  103. package/src/core/context-policy.mjs +20 -0
  104. package/src/core/duplicate-guard.mjs +350 -0
  105. package/src/core/meta-harness.mjs +31 -11
  106. package/src/core/preflight-gate.mjs +2 -2
  107. package/src/core/preflight-receipt.mjs +29 -1
  108. package/src/core/scaffold-plugins.mjs +7 -0
  109. package/src/core/stdio-flush.mjs +23 -0
  110. package/src/core/surface.mjs +33 -10
  111. package/src/server/invoke-durability.mjs +39 -0
  112. package/src/server/invoke-write-child.mjs +25 -0
  113. package/src/server/routes/invoke.mjs +7 -0
  114. package/src/server/routes/plugins.mjs +2 -2
  115. package/frontend/dist/assets/index-BIQmqDKP.css +0 -1
  116. package/frontend/dist/assets/index-CRzjQLu7.js +0 -5631
  117. package/frontend/src/components/DaemonLogsTab.tsx +0 -54
  118. package/frontend/src/components/ResearchAgendaTab.tsx +0 -712
  119. package/frontend/src/components/TaskQueueTab.tsx +0 -193
  120. package/frontend/src/components/VoiceInput.tsx +0 -112
  121. package/frontend/src/pages/ChatPage.tsx +0 -1059
  122. package/frontend/src/pages/CollabPage.tsx +0 -470
  123. package/frontend/src/pages/DesignDocsPage.tsx +0 -621
  124. package/frontend/src/pages/FilesPage.tsx +0 -393
  125. package/frontend/src/pages/HealthPage.tsx +0 -359
  126. package/frontend/src/pages/InboxPage.tsx +0 -83
  127. package/frontend/src/pages/InstructionsPage.tsx +0 -583
  128. package/frontend/src/pages/IntegrationsPage.tsx +0 -761
  129. package/frontend/src/pages/MeshPage.css +0 -58
  130. package/frontend/src/pages/MeshPage.tsx +0 -1219
  131. package/frontend/src/pages/OkfPage.tsx +0 -535
  132. package/frontend/src/pages/SandboxPage.tsx +0 -113
  133. package/frontend/src/pages/SkillsPage.tsx +0 -1994
  134. package/frontend/src/pages/TasksPage.tsx +0 -427
  135. package/frontend/src/pages/UsagePage.tsx +0 -702
  136. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/README.md +0 -65
  137. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/cli.mjs +0 -76
  138. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/package.json +0 -19
  139. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -266
  140. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/adapters/dsh.mjs +0 -446
  141. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/documentation.mjs +0 -152
  142. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/executor.mjs +0 -71
  143. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/runtime.mjs +0 -36
  144. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/setup.mjs +0 -35
  145. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/worker.mjs +0 -175
  146. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +0 -32
  147. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +0 -575
@@ -1,1059 +0,0 @@
1
- import { useState, useRef, useEffect, useCallback } from 'react'
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'
4
- import type { ChatThread } from '../api'
5
- import type { ChatMessage, MemoryNode, ResearchItem, UpdateCheckResult } from '../types'
6
- import Graph3D from '../components/Graph3D'
7
- import { VoiceInput } from '../components/VoiceInput'
8
-
9
-
10
- let msgId = 0
11
-
12
- function getCurrentTimestamp(): number {
13
- return Date.now()
14
- }
15
-
16
- function formatRelativeTime(timestamp: number): string {
17
- const diffMs = getCurrentTimestamp() - timestamp
18
- if (diffMs < 60000) return 'just now'
19
- const diffMins = Math.floor(diffMs / 60000)
20
- if (diffMins < 60) return `${diffMins}m ago`
21
- const diffHours = Math.floor(diffMins / 60)
22
- if (diffHours < 24) return `${diffHours}h ago`
23
- const diffDays = Math.floor(diffHours / 24)
24
- return `${diffDays}d ago`
25
- }
26
-
27
- export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrainId?: string; onBrainChange?: (id: string) => void }) {
28
- const navigate = useNavigate()
29
- const [messages, setMessages] = useState<ChatMessage[]>([])
30
- const [input, setInput] = useState('')
31
- const [loading, setLoading] = useState(false)
32
- const messagesEnd = useRef<HTMLDivElement>(null)
33
- const textareaRef = useRef<HTMLTextAreaElement>(null)
34
- const abortControllerRef = useRef<AbortController | null>(null)
35
- const audioRef = useRef<HTMLAudioElement | null>(null)
36
-
37
- const [voiceMode, setVoiceMode] = useState(false)
38
- const [kokoroEnabled, setKokoroEnabled] = useState<boolean | null>(null)
39
- const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null)
40
- const [deepResearchMode, setDeepResearchMode] = useState(false)
41
- const [detectedUrl, setDetectedUrl] = useState<string | null>(null)
42
- const [urlToast, setUrlToast] = useState<string | null>(null)
43
-
44
- // Extension banner state
45
- const [extensionStatus, setExtensionStatus] = useState<{ available: boolean; connected: boolean } | null>(null)
46
- const [extensionBannerDismissed, setExtensionBannerDismissed] = useState(
47
- () => localStorage.getItem('tr-ext-banner-dismissed') === 'true'
48
- )
49
- const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(
50
- () => localStorage.getItem('tr-update-banner-dismissed-version')
51
- )
52
-
53
- // Threads listing & session control state
54
- const [threads, setThreads] = useState<ChatThread[]>([])
55
- const [activeThreadId, setActiveThreadId] = useState<string | undefined>(undefined)
56
-
57
- // Grounding nodes & suggested discussions state
58
- const [selectedGroundingNodes, setSelectedGroundingNodes] = useState<string[]>([])
59
- const [allMemoryNodes, setAllMemoryNodes] = useState<MemoryNode[]>([])
60
- const [researchItems, setResearchItems] = useState<ResearchItem[]>([])
61
- const [showNodeSelector, setShowNodeSelector] = useState(false)
62
- const [nodeSearchQuery, setNodeSearchQuery] = useState('')
63
- const nodeSelectorRef = useRef<HTMLDivElement>(null)
64
-
65
- const DEFAULT_GEMINI_MODELS = [
66
- { id: 'gemini-3.8-flash-high', displayName: 'Gemini 3.8 Flash (High)' },
67
- { id: 'gemini-3.8-flash-medium', displayName: 'Gemini 3.8 Flash (Medium)' },
68
- { id: 'gemini-3.8-flash-low', displayName: 'Gemini 3.8 Flash (Low)' },
69
- { id: 'gemini-3.7-flash-high', displayName: 'Gemini 3.7 Flash (High)' },
70
- { id: 'gemini-3.7-flash-medium', displayName: 'Gemini 3.7 Flash (Medium)' },
71
- { id: 'gemini-3.6-flash-high', displayName: 'Gemini 3.6 Flash (High)' },
72
- { id: 'gemini-3.1-pro-high', displayName: 'Gemini 3.1 Pro (High)' },
73
- { id: 'gemini-3.5-flash', displayName: 'Gemini 3.5 Flash' },
74
- { id: 'gemini-3.5-pro', displayName: 'Gemini 3.5 Pro' },
75
- ]
76
-
77
- // Model Selector state
78
- const [availableModels, setAvailableModels] = useState<string[]>([])
79
- const [selectedModel, setSelectedModel] = useState<string>(() => {
80
- const saved = localStorage.getItem('selectedModel');
81
- return (saved && saved !== 'gemini') ? saved : 'antigravity';
82
- })
83
- const [selectedSubModel, setSelectedSubModel] = useState<string>(() => localStorage.getItem('selectedSubModel') || 'gemini-3.8-flash-high')
84
- const [geminiModels, setGeminiModels] = useState<{ id: string; displayName: string }[]>(DEFAULT_GEMINI_MODELS)
85
- const [showModelDropdown, setShowModelDropdown] = useState(false)
86
- const dropdownRef = useRef<HTMLDivElement>(null)
87
-
88
- const scrollToBottom = useCallback(() => {
89
- messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
90
- }, [])
91
-
92
- useEffect(scrollToBottom, [messages, scrollToBottom])
93
-
94
- const refreshThreads = useCallback(() => {
95
- fetchChatThreads()
96
- .then(list => {
97
- setThreads(list)
98
- })
99
- .catch(console.error)
100
-
101
- listResearch()
102
- .then(res => setResearchItems(res.items || []))
103
- .catch(console.error)
104
- }, [])
105
-
106
- // Load threads list, suggestions, and health on mount
107
- useEffect(() => {
108
- fetchChatThreads()
109
- .then(list => {
110
- setThreads(list)
111
- if (list.length > 0) {
112
- setActiveThreadId(list[0].id)
113
- // Auto-switch brain to the most recent thread's brain
114
- if (list[0].brainId && onBrainChange) {
115
- onBrainChange(list[0].brainId)
116
- }
117
- }
118
- })
119
- .catch(console.error)
120
-
121
- listResearch()
122
- .then(res => setResearchItems(res.items || []))
123
- .catch(console.error)
124
-
125
- fetchHealth()
126
- .then(health => {
127
- const agents = health.cli_agents && health.cli_agents.length > 0
128
- ? health.cli_agents
129
- : ['antigravity', 'grok', 'claude', 'codex'];
130
- setAvailableModels(agents);
131
-
132
- const saved = localStorage.getItem('selectedModel');
133
- if (saved && agents.includes(saved)) {
134
- setSelectedModel(saved);
135
- } else {
136
- const defaultAgent = agents.includes('antigravity')
137
- ? 'antigravity'
138
- : agents.includes('grok')
139
- ? 'grok'
140
- : agents[0];
141
- setSelectedModel(defaultAgent);
142
- localStorage.setItem('selectedModel', defaultAgent);
143
- }
144
- })
145
- .catch(() => {
146
- const agents = ['antigravity', 'grok', 'claude', 'codex'];
147
- setAvailableModels(agents);
148
- const saved = localStorage.getItem('selectedModel');
149
- if (saved && agents.includes(saved)) {
150
- setSelectedModel(saved);
151
- } else {
152
- setSelectedModel('antigravity');
153
- localStorage.setItem('selectedModel', 'antigravity');
154
- }
155
- })
156
-
157
- fetchGeminiModels()
158
- .then(models => {
159
- const list = models && models.length > 0 ? models : DEFAULT_GEMINI_MODELS;
160
- setGeminiModels(list);
161
- const savedSub = localStorage.getItem('selectedSubModel');
162
- const exists = list.some(m => m.id === savedSub);
163
- if (savedSub && exists) {
164
- setSelectedSubModel(savedSub);
165
- } else {
166
- setSelectedSubModel(list[0].id);
167
- localStorage.setItem('selectedSubModel', list[0].id);
168
- }
169
- })
170
- .catch(() => {
171
- setGeminiModels(DEFAULT_GEMINI_MODELS);
172
- const savedSub = localStorage.getItem('selectedSubModel');
173
- const exists = DEFAULT_GEMINI_MODELS.some(m => m.id === savedSub);
174
- if (savedSub && exists) {
175
- setSelectedSubModel(savedSub);
176
- } else {
177
- setSelectedSubModel(DEFAULT_GEMINI_MODELS[0].id);
178
- localStorage.setItem('selectedSubModel', DEFAULT_GEMINI_MODELS[0].id);
179
- }
180
- })
181
- // eslint-disable-next-line react-hooks/exhaustive-deps -- onBrainChange is stable from parent
182
- }, [])
183
-
184
- // Periodic update check
185
- useEffect(() => {
186
- let active = true
187
- const check = () => {
188
- checkUpdate()
189
- .then(data => {
190
- if (active) setUpdateInfo(data)
191
- })
192
- .catch(console.error)
193
- }
194
- check()
195
- const interval = setInterval(check, 3600000)
196
- return () => {
197
- active = false
198
- clearInterval(interval)
199
- }
200
- }, [])
201
-
202
- // Reload memory nodes when active selected brain changes or background SSE triggers
203
- useEffect(() => {
204
- let active = true
205
-
206
- const loadMemoryNodes = () => {
207
- listMemory(activeBrainId)
208
- .then(nodes => {
209
- if (!active) return
210
- setAllMemoryNodes(nodes)
211
- // Ensure selected grounding nodes stay in sync if a node is deleted in another tab/process
212
- setSelectedGroundingNodes(prev => prev.filter(slug => nodes.some(n => n.slug === slug)))
213
- })
214
- .catch(console.error)
215
- }
216
-
217
- // Initial load
218
- loadMemoryNodes()
219
-
220
- // Listen to Cognitive Daemon SSE for memory surface rebuilds
221
- const sseUrl = "http://localhost:9111/stream"
222
- const eventSource = new EventSource(sseUrl)
223
-
224
- eventSource.onmessage = (event) => {
225
- if (!active) return
226
- try {
227
- const logEntry = JSON.parse(event.data)
228
- if (
229
- logEntry.sender === 'conflict-detector' ||
230
- logEntry.sender === 'memory-ingestion' ||
231
- logEntry.message?.includes('Surface compilation') ||
232
- logEntry.message?.includes('Brain embeddings rebuilt') ||
233
- logEntry.message?.includes('Graph indices rebuilt') ||
234
- logEntry.message?.includes('deleted')
235
- ) {
236
- loadMemoryNodes()
237
- }
238
- } catch {
239
- // ignore parsing errors from stream
240
- }
241
- }
242
-
243
- return () => {
244
- active = false
245
- eventSource.close()
246
- }
247
- }, [activeBrainId])
248
-
249
- // Click outside listener for grounding node selector popover and model selector dropdown
250
- useEffect(() => {
251
- function handleClickOutside(event: MouseEvent) {
252
- if (nodeSelectorRef.current && !nodeSelectorRef.current.contains(event.target as Node)) {
253
- setShowNodeSelector(false)
254
- }
255
- if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
256
- setShowModelDropdown(false)
257
- }
258
- }
259
- document.addEventListener("mousedown", handleClickOutside)
260
- return () => {
261
- document.removeEventListener("mousedown", handleClickOutside)
262
- }
263
- }, [nodeSelectorRef, dropdownRef])
264
-
265
- // Load chat history when activeThreadId changes
266
- useEffect(() => {
267
- fetchChatHistory(activeThreadId)
268
- .then(history => {
269
- if (history) {
270
- setMessages(history as ChatMessage[])
271
- msgId = Math.max(msgId, history.length + 1)
272
- } else {
273
- setMessages([])
274
- }
275
- })
276
- .catch(err => {
277
- console.error(err)
278
- setMessages([])
279
- })
280
- }, [activeThreadId])
281
-
282
-
283
- // Probe the Kokoro endpoint once when voice mode is turned on so we know
284
- // whether to call /api/tts or fall back to the browser engine.
285
- useEffect(() => {
286
- if (!voiceMode || kokoroEnabled !== null) return
287
- fetchTtsStatus().then(s => setKokoroEnabled(s.enabled)).catch(() => setKokoroEnabled(false))
288
- }, [voiceMode, kokoroEnabled])
289
-
290
- // Check Chrome extension availability (once on mount)
291
- useEffect(() => {
292
- if (!extensionBannerDismissed) {
293
- fetchExtensionStatus().then(setExtensionStatus).catch(() => {})
294
- }
295
- }, [extensionBannerDismissed])
296
-
297
- const speakBrowser = (text: string) => {
298
- if (!('speechSynthesis' in window)) return
299
- window.speechSynthesis.cancel()
300
- const utterance = new SpeechSynthesisUtterance(text)
301
- utterance.rate = 1.05
302
- utterance.pitch = 0.95
303
- window.speechSynthesis.speak(utterance)
304
- }
305
-
306
- const speak = async (text: string) => {
307
- if (!voiceMode || !text.trim()) return
308
- if (kokoroEnabled === false) { speakBrowser(text); return }
309
- try {
310
- const blob = await fetchTtsAudio(text)
311
- if (!blob) {
312
- // 503 → Kokoro not configured. Remember that and fall back.
313
- setKokoroEnabled(false)
314
- speakBrowser(text)
315
- return
316
- }
317
- const url = URL.createObjectURL(blob)
318
- if (audioRef.current) {
319
- audioRef.current.pause()
320
- URL.revokeObjectURL(audioRef.current.src)
321
- }
322
- const audio = new Audio(url)
323
- audioRef.current = audio
324
- audio.onended = () => URL.revokeObjectURL(url)
325
- audio.play().catch(() => speakBrowser(text))
326
- } catch {
327
- speakBrowser(text)
328
- }
329
- }
330
-
331
- const handleNewChat = () => {
332
- const newId = `thread-${getCurrentTimestamp()}`
333
- setActiveThreadId(newId)
334
- setMessages([])
335
- setInput('')
336
- setSelectedGroundingNodes([])
337
- if (textareaRef.current) textareaRef.current.style.height = '44px'
338
- }
339
-
340
- const handleDeleteThread = async (e: React.MouseEvent, id: string) => {
341
- e.stopPropagation()
342
- if (!confirm('Are you sure you want to delete this conversation thread?')) return
343
- try {
344
- await deleteChatThread(id)
345
- if (activeThreadId === id) {
346
- const remaining = threads.filter(t => t.id !== id)
347
- if (remaining.length > 0) {
348
- setActiveThreadId(remaining[0].id)
349
- } else {
350
- setActiveThreadId(undefined)
351
- setMessages([])
352
- }
353
- }
354
- refreshThreads()
355
- } catch (err) {
356
- console.error('Failed to delete thread:', err)
357
- }
358
- }
359
-
360
-
361
-
362
- const handleSend = async () => {
363
- const text = input.trim()
364
- if (!text || loading) return
365
-
366
- const sessionId = activeThreadId || `thread-${getCurrentTimestamp()}`
367
- if (!activeThreadId) {
368
- setActiveThreadId(sessionId)
369
- }
370
-
371
- const userMsg: ChatMessage = { id: String(++msgId), role: 'user', content: text, timestamp: getCurrentTimestamp() }
372
- setMessages(prev => [...prev, userMsg])
373
- setInput('')
374
- setLoading(true)
375
-
376
- abortControllerRef.current = new AbortController()
377
-
378
- // Auto-resize textarea back
379
- if (textareaRef.current) textareaRef.current.style.height = '44px'
380
-
381
- try {
382
- if (deepResearchMode) {
383
- setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: '🕵️ **Deep Research Agents dispatched.** Gathering facts across parallel web streams...', timestamp: getCurrentTimestamp() }]);
384
-
385
- const { slug } = await createTask('proactive-research', text, text);
386
-
387
- // Poll for task completion
388
- let taskCompleted = false;
389
- while (!taskCompleted) {
390
- await new Promise(r => setTimeout(r, 2000));
391
- const tasks = await listTasks();
392
- const t = tasks.find(x => x.slug === slug);
393
- if (t && (t.status === 'completed' || t.status === 'failed')) {
394
- taskCompleted = true;
395
- setMessages(prev => prev.filter(m => m.content !== '🕵️ **Deep Research Agents dispatched.** Gathering facts across parallel web streams...'));
396
- setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: t.body || '', timestamp: getCurrentTimestamp() }]);
397
- speak(t.body || '');
398
- }
399
- }
400
- setLoading(false);
401
- return;
402
- }
403
-
404
- const modelWithSubModel = ['gemini', 'antigravity', 'agy'].includes(selectedModel) && selectedSubModel
405
- ? `${selectedModel}:${selectedSubModel}`
406
- : selectedModel
407
-
408
- const historyToSend = [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
409
- const reply = await sendChat(historyToSend, abortControllerRef.current.signal, sessionId, selectedGroundingNodes, modelWithSubModel, activeBrainId)
410
- const assistantMsg: ChatMessage = { id: String(++msgId), role: 'assistant', content: reply, timestamp: getCurrentTimestamp(), versions: [reply], currentVersionIndex: 0 }
411
- setMessages(prev => [...prev, assistantMsg])
412
- speak(reply)
413
- refreshThreads()
414
- } catch (e: unknown) {
415
- if ((e as Error).name === 'AbortError') {
416
- setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: '⛔ Generation stopped by user.', timestamp: getCurrentTimestamp(), versions: ['⛔ Generation stopped by user.'], currentVersionIndex: 0 }])
417
- } else {
418
- const errorMsg: ChatMessage = { id: String(++msgId), role: 'assistant', content: `⚠️ Error: ${(e as Error).message}`, timestamp: getCurrentTimestamp(), versions: [`⚠️ Error: ${(e as Error).message}`], currentVersionIndex: 0 }
419
- setMessages(prev => [...prev, errorMsg])
420
- }
421
- } finally {
422
- setLoading(false)
423
- abortControllerRef.current = null
424
- }
425
- }
426
-
427
- const handleRegenerate = async (msgIndex: number) => {
428
- if (loading) return;
429
- const historyToSend = messages.slice(0, msgIndex).map(m => ({ role: m.role, content: m.content }))
430
- setLoading(true)
431
- abortControllerRef.current = new AbortController()
432
- try {
433
- const modelWithSubModel = ['gemini', 'antigravity', 'agy'].includes(selectedModel) && selectedSubModel
434
- ? `${selectedModel}:${selectedSubModel}`
435
- : selectedModel
436
-
437
- const reply = await sendChat(historyToSend, abortControllerRef.current.signal, activeThreadId, undefined, modelWithSubModel, activeBrainId)
438
- setMessages(prev => {
439
- const next = [...prev]
440
- const target = next[msgIndex]
441
- const versions = target.versions || [target.content]
442
- versions.push(reply)
443
- target.versions = versions
444
- target.currentVersionIndex = versions.length - 1
445
- target.content = reply
446
- return next
447
- })
448
- speak(reply)
449
- refreshThreads()
450
- } catch (e: unknown) {
451
- if ((e as Error).name !== 'AbortError') console.error(e)
452
- } finally {
453
- setLoading(false)
454
- abortControllerRef.current = null
455
- }
456
- }
457
-
458
- const handleCopy = (content: string) => {
459
- navigator.clipboard.writeText(content).catch(console.error)
460
- }
461
-
462
- const handleVersionSwitch = (msgIndex: number, delta: number) => {
463
- setMessages(prev => {
464
- const next = [...prev]
465
- const target = next[msgIndex]
466
- if (!target.versions) return prev
467
- const newIndex = Math.max(0, Math.min(target.versions.length - 1, (target.currentVersionIndex || 0) + delta))
468
- target.currentVersionIndex = newIndex
469
- target.content = target.versions[newIndex]
470
- return next
471
- })
472
- }
473
-
474
- const handleStop = () => {
475
- if (abortControllerRef.current) {
476
- abortControllerRef.current.abort()
477
- abortControllerRef.current = null
478
- setLoading(false)
479
- }
480
- }
481
-
482
- const handleKeyDown = (e: React.KeyboardEvent) => {
483
- if (e.key === 'Enter' && !e.shiftKey) {
484
- e.preventDefault()
485
- handleSend()
486
- }
487
- }
488
-
489
- const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
490
- const value = e.target.value
491
- setInput(value)
492
- const ta = e.target
493
- ta.style.height = '44px'
494
- ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'
495
-
496
- // Detect URLs
497
- const urlMatch = value.match(/https?:\/\/[^\s]+/)
498
- setDetectedUrl(urlMatch ? urlMatch[0] : null)
499
- }
500
-
501
- const handleUrlAction = async (action: string) => {
502
- if (!detectedUrl) return
503
- try {
504
- await shareToApi({ url: detectedUrl, action })
505
- setUrlToast(`✅ URL sent to ${action}`)
506
- setDetectedUrl(null)
507
- setTimeout(() => setUrlToast(null), 3000)
508
- } catch (err) {
509
- console.error('Share API error:', err)
510
- setUrlToast('⚠️ Failed to process URL')
511
- setTimeout(() => setUrlToast(null), 3000)
512
- }
513
- }
514
-
515
- const filteredNodes = allMemoryNodes.filter(node =>
516
- node.title.toLowerCase().includes(nodeSearchQuery.toLowerCase()) ||
517
- node.slug.toLowerCase().includes(nodeSearchQuery.toLowerCase()) ||
518
- (node.category && node.category.toLowerCase().includes(nodeSearchQuery.toLowerCase()))
519
- )
520
-
521
- return (
522
- <div className="chat-layout">
523
- {/* Threads Sidebar */}
524
- <aside className="chat-threads-sidebar">
525
- <div className="sidebar-header">
526
- <button className="new-chat-btn" onClick={handleNewChat}>
527
- <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 }}>
528
- <line x1="12" y1="5" x2="12" y2="19"></line>
529
- <line x1="5" y1="12" x2="19" y2="12"></line>
530
- </svg>
531
- New Chat
532
- </button>
533
- </div>
534
- <div className="thread-list">
535
- {threads.map(t => (
536
- <div
537
- key={t.id}
538
- className={`thread-item ${activeThreadId === t.id ? 'active' : ''}`}
539
- onClick={() => {
540
- setActiveThreadId(t.id)
541
- // Auto-switch brain context when selecting a thread
542
- if (t.brainId && onBrainChange) {
543
- onBrainChange(t.brainId)
544
- }
545
- }}
546
- >
547
- <div className="thread-info">
548
- <div className="thread-title" title={t.title}>{t.title}</div>
549
- <div className="thread-meta">
550
- <span>{t.turns} {t.turns === 1 ? 'turn' : 'turns'}</span>
551
- <span>•</span>
552
- <span>{formatRelativeTime(t.lastUpdated)}</span>
553
- </div>
554
- </div>
555
- <button
556
- className="thread-delete-btn"
557
- onClick={(e) => handleDeleteThread(e, t.id)}
558
- title="Delete chat thread"
559
- >
560
- <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">
561
- <polyline points="3 6 5 6 21 6"></polyline>
562
- <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>
563
- <line x1="10" y1="11" x2="10" y2="17"></line>
564
- <line x1="14" y1="11" x2="14" y2="17"></line>
565
- </svg>
566
- </button>
567
- </div>
568
- ))}
569
- </div>
570
- </aside>
571
-
572
- {/* Main Chat Area */}
573
- <div className="chat-container" style={{ position: 'relative', overflow: 'hidden' }}>
574
- {/* Decorative graph only — never intercepts clicks (was blocking model/provider UI) */}
575
- <div
576
- aria-hidden
577
- style={{
578
- position: 'absolute',
579
- top: 0,
580
- left: 0,
581
- right: 0,
582
- bottom: 0,
583
- zIndex: 0,
584
- pointerEvents: 'none',
585
- opacity: messages.length > 0 ? 0.22 : 0.55,
586
- transition: 'opacity 0.3s ease',
587
- }}
588
- >
589
- <Graph3D
590
- threads={threads}
591
- memoryNodes={allMemoryNodes}
592
- researchItems={researchItems}
593
- onOpenThread={() => {}}
594
- onGroundMemoryNode={() => {}}
595
- selectedGroundingNodes={selectedGroundingNodes}
596
- interactive={false}
597
- />
598
- </div>
599
- <header
600
- className="chat-header animate-fade-in"
601
- style={{ position: 'relative', zIndex: 40, overflow: 'visible' }}
602
- >
603
- <div className="chat-header-title">
604
- <h2>Chat Session</h2>
605
- {activeThreadId && (
606
- <span className="badge badge-accent" style={{ textTransform: 'none', letterSpacing: 'normal', fontSize: '11px', padding: '2px 8px' }}>
607
- {activeThreadId}
608
- </span>
609
- )}
610
- </div>
611
- <div className="chat-header-model-selector" ref={dropdownRef} style={{ position: 'relative', zIndex: 50 }}>
612
- <span className="selector-label">Model / provider:</span>
613
- <div className="model-selector-dropdown-wrapper" style={{ position: 'relative', zIndex: 50 }}>
614
- <button
615
- type="button"
616
- className={`model-selector-dropdown-btn ${showModelDropdown ? 'active' : ''}`}
617
- onClick={(e) => {
618
- e.stopPropagation()
619
- setShowModelDropdown((v) => !v)
620
- }}
621
- title="Select CLI agent / provider"
622
- >
623
- <span style={{ textTransform: 'capitalize' }}>
624
- {selectedModel || 'Select Model'}
625
- {['gemini', 'antigravity', 'agy'].includes(selectedModel) && selectedSubModel
626
- ? ` · ${geminiModels.find(m => m.id === selectedSubModel)?.displayName || selectedSubModel}`
627
- : ''}
628
- </span>
629
- <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">
630
- <polyline points="6 9 12 15 18 9"></polyline>
631
- </svg>
632
- </button>
633
- {showModelDropdown && (
634
- <div
635
- className="model-selector-menu glass"
636
- style={{ zIndex: 200, pointerEvents: 'auto' }}
637
- role="listbox"
638
- >
639
- {availableModels.map(modelName => (
640
- <div
641
- key={modelName}
642
- role="option"
643
- aria-selected={selectedModel === modelName}
644
- className={`model-selector-item ${selectedModel === modelName ? 'selected' : ''}`}
645
- onClick={(e) => {
646
- e.stopPropagation()
647
- setSelectedModel(modelName)
648
- localStorage.setItem('selectedModel', modelName)
649
- setSelectedSubModel('')
650
- localStorage.removeItem('selectedSubModel')
651
- setShowModelDropdown(false)
652
- }}
653
- >
654
- <div className="model-selector-item-info">
655
- <span className="model-selector-item-name" style={{ textTransform: 'capitalize' }}>{modelName}</span>
656
- <span className="model-selector-item-desc">
657
- {modelName === 'antigravity' && 'Google Antigravity'}
658
- {modelName === 'agy' && 'Google Antigravity'}
659
- {modelName === 'grok' && 'xAI Grok Build CLI'}
660
- {modelName === 'gemini' && 'Google Gemini CLI'}
661
- {modelName === 'claude' && 'Anthropic Claude CLI'}
662
- {modelName === 'codex' && 'Codex Coding Agent'}
663
- </span>
664
- </div>
665
- <span className="model-selector-item-badge">CLI</span>
666
- </div>
667
- ))}
668
- </div>
669
- )}
670
- </div>
671
- {['gemini', 'antigravity', 'agy'].includes(selectedModel) && geminiModels.length > 0 && (
672
- <div className="chat-header-submodel-selector" style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 16 }}>
673
- <span className="selector-label" style={{ fontSize: '12px', color: 'var(--text-tertiary)', fontWeight: 500 }}>Base:</span>
674
- <select
675
- value={selectedSubModel}
676
- onChange={(e) => {
677
- setSelectedSubModel(e.target.value)
678
- localStorage.setItem('selectedSubModel', e.target.value)
679
- }}
680
- className="submodel-select"
681
- style={{
682
- background: 'rgba(255, 255, 255, 0.04)',
683
- border: '1px solid var(--border)',
684
- borderRadius: 'var(--radius-sm)',
685
- color: 'var(--text-secondary)',
686
- padding: '6px 12px',
687
- fontSize: '13px',
688
- fontWeight: 500,
689
- outline: 'none',
690
- cursor: 'pointer',
691
- transition: 'all var(--transition-fast)'
692
- }}
693
- >
694
- {geminiModels.map(m => (
695
- <option key={m.id} value={m.id} style={{ background: '#12121a', color: 'var(--text-secondary)' }}>{m.displayName}</option>
696
- ))}
697
- </select>
698
- </div>
699
- )}
700
- </div>
701
- </header>
702
-
703
- {/* Update Available Banner */}
704
- {updateInfo?.updateAvailable && dismissedUpdateVersion !== updateInfo.latestVersion && (
705
- <div className="animate-fade-in" style={{
706
- margin: '0 16px 12px',
707
- padding: '12px 16px',
708
- background: 'linear-gradient(135deg, rgba(63, 185, 80, 0.12), rgba(34, 197, 94, 0.12))',
709
- border: '1px solid rgba(63, 185, 80, 0.3)',
710
- borderRadius: 10,
711
- display: 'flex',
712
- alignItems: 'center',
713
- gap: 12,
714
- position: 'relative',
715
- zIndex: 40,
716
- }}>
717
- <span style={{ fontSize: 22, flexShrink: 0 }}>🚀</span>
718
- <div style={{ flex: 1, minWidth: 0 }}>
719
- <div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
720
- System Update Available
721
- </div>
722
- <div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
723
- Version {updateInfo.latestVersion} is available. Upgrade to load the latest features.
724
- </div>
725
- </div>
726
- <button
727
- onClick={() => navigate('/health')}
728
- style={{
729
- flexShrink: 0,
730
- padding: '6px 14px',
731
- background: '#3fb950',
732
- color: '#fff',
733
- border: 'none',
734
- borderRadius: 6,
735
- fontWeight: 600,
736
- fontSize: 12,
737
- cursor: 'pointer',
738
- whiteSpace: 'nowrap'
739
- }}
740
- >
741
- Go to Update
742
- </button>
743
- <button
744
- onClick={() => {
745
- if (updateInfo?.latestVersion) {
746
- localStorage.setItem('tr-update-banner-dismissed-version', updateInfo.latestVersion)
747
- setDismissedUpdateVersion(updateInfo.latestVersion)
748
- }
749
- }}
750
- title="Dismiss banner"
751
- aria-label="Dismiss update banner"
752
- style={{
753
- background: 'transparent',
754
- border: 'none',
755
- color: 'var(--text-muted)',
756
- fontSize: 16,
757
- cursor: 'pointer',
758
- padding: '4px 6px',
759
- lineHeight: 1,
760
- }}
761
- >
762
- ✕
763
- </button>
764
- </div>
765
- )}
766
-
767
- {/* Chrome Extension Banner — shows when available but not connected */}
768
- {extensionStatus?.available && !extensionStatus.connected && !extensionBannerDismissed && (
769
- <div style={{
770
- margin: '0 16px 0',
771
- padding: '12px 16px',
772
- background: 'linear-gradient(135deg, rgba(108, 92, 231, 0.12), rgba(99, 179, 237, 0.12))',
773
- border: '1px solid rgba(108, 92, 231, 0.3)',
774
- borderRadius: 10,
775
- display: 'flex',
776
- alignItems: 'center',
777
- gap: 12,
778
- animation: 'fadeIn 0.3s ease',
779
- position: 'relative',
780
- zIndex: 40,
781
- }}>
782
- <span style={{ fontSize: 22, flexShrink: 0 }}>🧩</span>
783
- <div style={{ flex: 1, minWidth: 0 }}>
784
- <div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
785
- Chrome Extension Available
786
- </div>
787
- <div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
788
- Browse with your brain — contextual memory, quick capture, and research from any page
789
- </div>
790
- </div>
791
- <a
792
- href="/api/extension/download"
793
- download
794
- style={{
795
- flexShrink: 0,
796
- padding: '6px 14px',
797
- background: 'linear-gradient(135deg, #3b82f6, #93c5fd)',
798
- color: '#fff',
799
- borderRadius: 6,
800
- fontWeight: 500,
801
- fontSize: 12,
802
- textDecoration: 'none',
803
- whiteSpace: 'nowrap'
804
- }}
805
- >
806
- ⬇ Download
807
- </a>
808
- <button
809
- onClick={() => {
810
- setExtensionBannerDismissed(true)
811
- localStorage.setItem('tr-ext-banner-dismissed', 'true')
812
- }}
813
- style={{
814
- flexShrink: 0,
815
- background: 'none',
816
- border: 'none',
817
- color: 'var(--text-tertiary)',
818
- cursor: 'pointer',
819
- fontSize: 16,
820
- padding: '0 2px',
821
- lineHeight: 1
822
- }}
823
- title="Dismiss"
824
- >✕</button>
825
- </div>
826
- )}
827
-
828
- <div className="chat-messages" style={{ position: 'relative', zIndex: 1, pointerEvents: 'auto' }}>
829
- <div style={{
830
- position: 'relative',
831
- display: 'flex',
832
- flexDirection: 'column',
833
- gap: '16px',
834
- width: '100%',
835
- maxWidth: 800,
836
- margin: '0 auto',
837
- pointerEvents: 'auto'
838
- }}>
839
- {messages.map((m, index) => (
840
- <div key={m.id} className={`message message-${m.role}`} style={{
841
- backdropFilter: 'blur(8px)',
842
- WebkitBackdropFilter: 'blur(8px)',
843
- background: m.role === 'user' ? 'rgba(108, 92, 231, 0.75)' : 'rgba(18, 18, 26, 0.75)',
844
- border: '1px solid rgba(255, 255, 255, 0.08)'
845
- }}>
846
- <div style={{ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' }}>{m.content}</div>
847
- {m.role === 'assistant' && (
848
- <div style={{ display: 'flex', gap: 12, marginTop: 12, fontSize: 13, color: 'var(--text-tertiary)', alignItems: 'center', userSelect: 'none' }}>
849
- {m.versions && m.versions.length > 1 && (
850
- <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginRight: 4 }}>
851
- <button
852
- onClick={() => handleVersionSwitch(index, -1)}
853
- disabled={(m.currentVersionIndex || 0) === 0}
854
- style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 2, opacity: (m.currentVersionIndex || 0) === 0 ? 0.3 : 1 }}
855
- >◄</button>
856
- <span>{(m.currentVersionIndex || 0) + 1} / {m.versions.length}</span>
857
- <button
858
- onClick={() => handleVersionSwitch(index, 1)}
859
- disabled={(m.currentVersionIndex || 0) === m.versions.length - 1}
860
- style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 2, opacity: (m.currentVersionIndex || 0) === m.versions.length - 1 ? 0.3 : 1 }}
861
- >►</button>
862
- </div>
863
- )}
864
- <button onClick={() => handleCopy(m.content)} title="Copy to clipboard" style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
865
- <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>
866
- </button>
867
- <button onClick={() => speak(m.content)} title="Read Aloud" style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
868
- <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>
869
- </button>
870
- <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 }}>
871
- <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>
872
- </button>
873
- </div>
874
- )}
875
- </div>
876
- ))}
877
- {loading && (
878
- <div className="message message-assistant" style={{
879
- backdropFilter: 'blur(8px)',
880
- WebkitBackdropFilter: 'blur(8px)',
881
- background: 'rgba(18, 18, 26, 0.75)',
882
- border: '1px solid rgba(255, 255, 255, 0.08)'
883
- }}>
884
- <span className="skeleton" style={{ display: 'inline-block', width: 180, height: 16 }} />
885
- </div>
886
- )}
887
- <div ref={messagesEnd} />
888
- </div>
889
- </div>
890
- <div className="chat-input-bar" style={{ position: 'relative', zIndex: 30, pointerEvents: 'auto' }}>
891
- {selectedGroundingNodes.length > 0 && (
892
- <div className="grounding-pills">
893
- {selectedGroundingNodes.map(slug => {
894
- const node = allMemoryNodes.find(n => n.slug === slug);
895
- return (
896
- <div key={slug} className="grounding-pill animate-fade-in">
897
- <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">
898
- <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
899
- <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"/>
900
- </svg>
901
- <span className="grounding-pill-title">{node?.title || slug}</span>
902
- <button className="grounding-pill-remove" onClick={() => setSelectedGroundingNodes(prev => prev.filter(s => s !== slug))} title="Remove grounding context">×</button>
903
- </div>
904
- );
905
- })}
906
- </div>
907
- )}
908
- <div className="chat-input-wrapper">
909
-
910
- <button
911
- className={`btn btn-ghost ${deepResearchMode ? 'active' : ''}`}
912
- 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)' }}
913
- onClick={() => setDeepResearchMode(!deepResearchMode)}
914
- title={deepResearchMode ? "Deep Research On" : "Deep Research Off"}
915
- >
916
- <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 }}>
917
- <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"/>
918
- </svg>
919
- </button>
920
-
921
- <div style={{ position: 'relative', display: 'flex' }} ref={nodeSelectorRef}>
922
- <button
923
- className={`btn btn-ghost ${selectedGroundingNodes.length > 0 ? 'active' : ''}`}
924
- 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)' }}
925
- onClick={() => setShowNodeSelector(!showNodeSelector)}
926
- title="Ground with brain nodes"
927
- >
928
- <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 }}>
929
- <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
930
- <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"/>
931
- </svg>
932
- {selectedGroundingNodes.length > 0 && (
933
- <span className="grounding-count-badge">{selectedGroundingNodes.length}</span>
934
- )}
935
- </button>
936
-
937
- {showNodeSelector && (
938
- <div className="node-selector-popover glass">
939
- <div className="node-selector-header">
940
- <h4>Ground with Brain Nodes</h4>
941
- <input
942
- type="text"
943
- placeholder="Search nodes..."
944
- value={nodeSearchQuery}
945
- onChange={e => setNodeSearchQuery(e.target.value)}
946
- className="node-search-input"
947
- autoFocus
948
- />
949
- </div>
950
- <div className="node-selector-list">
951
- {filteredNodes.length === 0 ? (
952
- <div className="node-selector-empty">No nodes found</div>
953
- ) : (
954
- filteredNodes.map(node => {
955
- const isSelected = selectedGroundingNodes.includes(node.slug);
956
- return (
957
- <div
958
- key={node.slug}
959
- className={`node-selector-item ${isSelected ? 'selected' : ''}`}
960
- onClick={() => {
961
- if (isSelected) {
962
- setSelectedGroundingNodes(prev => prev.filter(s => s !== node.slug));
963
- } else {
964
- setSelectedGroundingNodes(prev => [...prev, node.slug]);
965
- }
966
- }}
967
- >
968
- <span className="node-selector-item-checkbox">
969
- {isSelected ? '✓' : ''}
970
- </span>
971
- <div className="node-selector-item-info">
972
- <span className="node-selector-item-title">{node.title}</span>
973
- <span className="node-selector-item-meta">{node.category} • {node.status || 'active'}</span>
974
- </div>
975
- </div>
976
- );
977
- })
978
- )}
979
- </div>
980
- </div>
981
- )}
982
- </div>
983
-
984
- <VoiceInput
985
- onTranscript={(text) => {
986
- setInput(prev => prev + (prev.endsWith(' ') || prev === '' ? '' : ' ') + text);
987
- setTimeout(() => {
988
- if (textareaRef.current) {
989
- textareaRef.current.focus();
990
- // trigger auto-resize
991
- const evt = new Event('input', { bubbles: true });
992
- textareaRef.current.dispatchEvent(evt);
993
- }
994
- }, 10);
995
- }}
996
- />
997
- <div style={{ position: 'relative', flex: 1, display: 'flex', alignItems: 'center' }}>
998
- <textarea
999
- ref={textareaRef}
1000
- id="chat-input"
1001
- value={input}
1002
- onChange={handleInput}
1003
- onKeyDown={handleKeyDown}
1004
- placeholder="Message your brain…"
1005
- rows={1}
1006
- disabled={loading}
1007
- style={{ paddingRight: 44 }}
1008
- />
1009
- <button
1010
- className={`btn btn-ghost ${voiceMode ? 'active' : ''}`}
1011
- style={{ position: 'absolute', right: 4, bottom: 4, width: 36, height: 36, padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', color: voiceMode ? '#ef4444' : 'var(--text-tertiary)', border: 'none', background: 'transparent' }}
1012
- onClick={() => {
1013
- setVoiceMode(!voiceMode)
1014
- if (voiceMode && 'speechSynthesis' in window) window.speechSynthesis.cancel()
1015
- }}
1016
- title={voiceMode ? "Voice Mode On" : "Voice Mode Off"}
1017
- >
1018
- {voiceMode ? (
1019
- <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 }}>
1020
- <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"/>
1021
- </svg>
1022
- ) : (
1023
- <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 }}>
1024
- <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"/>
1025
- </svg>
1026
- )}
1027
- </button>
1028
- </div>
1029
- {loading ? (
1030
- <button className="chat-send-btn" onClick={handleStop} id="chat-stop" style={{ background: 'var(--error)', color: '#fff' }} title="Stop Generation">
1031
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style={{ width: 20, height: 20 }}>
1032
- <rect x="6" y="6" width="12" height="12" rx="2" ry="2"/>
1033
- </svg>
1034
- </button>
1035
- ) : (
1036
- <button className="chat-send-btn" onClick={handleSend} disabled={!input.trim()} id="chat-send" title="Send Message">
1037
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
1038
- <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" />
1039
- </svg>
1040
- </button>
1041
- )}
1042
- </div>
1043
- {detectedUrl && (
1044
- <div style={{ display: 'flex', gap: '8px', padding: '8px 12px', background: 'var(--bg-secondary, #1e1e2e)', borderRadius: '8px', alignItems: 'center', fontSize: '0.85rem', marginTop: 4 }}>
1045
- <span style={{ color: 'var(--text-secondary)' }}>🔗 URL detected</span>
1046
- <button className="btn btn-sm" onClick={() => handleUrlAction('research')}>🔬 Research</button>
1047
- <button className="btn btn-sm" onClick={() => handleUrlAction('remember')}>📌 Remember</button>
1048
- </div>
1049
- )}
1050
- {urlToast && (
1051
- <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' }}>
1052
- {urlToast}
1053
- </div>
1054
- )}
1055
- </div>
1056
- </div>
1057
- </div>
1058
- )
1059
- }