code-context-control 2.28.0__py3-none-any.whl

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 (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
oracle/oracle.html ADDED
@@ -0,0 +1,3900 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Oracle — Memory Agent</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0a0a0b;
10
+ --bg2: #141416;
11
+ --bg3: #1c1c1f;
12
+ --border: #27272a;
13
+ --accent: #a855f7;
14
+ --green: #22c55e;
15
+ --red: #ef4444;
16
+ --yellow: #eab308;
17
+ --blue: #3b82f6;
18
+ --muted: #3f3f46;
19
+ --text: #fafafa;
20
+ --text2: #71717a;
21
+ }
22
+ body[data-theme="light"] {
23
+ --bg: #fafafa;
24
+ --bg2: #ffffff;
25
+ --bg3: #f4f4f5;
26
+ --border: #e4e4e7;
27
+ --accent: #9333ea;
28
+ --green: #16a34a;
29
+ --red: #dc2626;
30
+ --yellow: #ca8a04;
31
+ --blue: #2563eb;
32
+ --muted: #d4d4d8;
33
+ --text: #09090b;
34
+ --text2: #71717a;
35
+ }
36
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
37
+ body { font-family: 'Inter', system-ui, sans-serif; font-size: 14px; background: var(--bg); color: var(--text); min-height: 100vh; }
38
+
39
+ /* ── Header ── */
40
+ header {
41
+ background: var(--bg2); border-bottom: 1px solid var(--border);
42
+ padding: 0 24px; height: 52px; display: flex; align-items: center;
43
+ justify-content: space-between; position: sticky; top: 0; z-index: 10;
44
+ }
45
+ .logo { font-size: 17px; font-weight: 700; letter-spacing: .5px; display: flex; align-items: center; gap: 10px; }
46
+ .logo span { color: var(--accent); }
47
+ .header-right { display: flex; align-items: center; gap: 8px; }
48
+ .badge { background: var(--bg3); border: 1px solid var(--border); border-radius: 9999px; padding: 3px 12px; font-size: 11px; color: var(--text2); }
49
+ .badge .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
50
+ .dot-ok { background: var(--green); }
51
+ .dot-warn { background: var(--yellow); }
52
+ .dot-err { background: var(--red); }
53
+ .dot-off { background: var(--muted); }
54
+ .header-btn { background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; padding: 4px 12px; font-size: 11px; font-weight: 500; color: var(--text2); cursor: pointer; transition: all .15s; text-decoration: none; white-space: nowrap; }
55
+ .header-btn:hover { border-color: var(--accent); color: var(--text); }
56
+
57
+ /* ── Status dropdown ── */
58
+ .status-wrap { position: relative; }
59
+ .status-badge { cursor: pointer; user-select: none; }
60
+ .status-dd {
61
+ display: none; position: absolute; top: calc(100% + 6px); right: 0;
62
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
63
+ min-width: 240px; box-shadow: 0 8px 24px rgba(0,0,0,.45); z-index: 20;
64
+ padding: 6px 0; font-size: 12px;
65
+ }
66
+ .status-dd.open { display: block; }
67
+ .status-dd-row { display: flex; align-items: center; gap: 8px; padding: 7px 14px; }
68
+ .status-dd-row:hover { background: var(--bg3); }
69
+ .status-dd-label { flex: 1; color: var(--text); }
70
+ .status-dd-val { color: var(--text2); font-size: 11px; text-align: right; }
71
+ .status-dd-sep { height: 1px; background: var(--border); margin: 4px 14px; }
72
+ .status-dd-sub { padding: 4px 14px 6px; font-size: 11px; color: var(--text2); }
73
+
74
+ /* ── Activity spinner ── */
75
+ .activity-spinner {
76
+ width: 18px; height: 18px; border: 2px solid var(--border);
77
+ border-top-color: var(--accent); border-radius: 50%;
78
+ animation: spin .7s linear infinite; display: none;
79
+ }
80
+ .activity-spinner.active { display: inline-block; }
81
+ @keyframes spin { to { transform: rotate(360deg); } }
82
+
83
+ .activity-label {
84
+ font-size: 11px; color: var(--accent); display: none;
85
+ max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
86
+ }
87
+ .activity-label.active { display: inline-block; }
88
+
89
+ /* ── Toast notifications ── */
90
+ .toast-container {
91
+ position: fixed; bottom: 20px; right: 20px; z-index: 1000;
92
+ display: flex; flex-direction: column-reverse; gap: 8px;
93
+ pointer-events: none; max-width: 380px;
94
+ }
95
+ .toast {
96
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
97
+ padding: 10px 16px; font-size: 13px; box-shadow: 0 8px 24px rgba(0,0,0,.4);
98
+ display: flex; align-items: flex-start; gap: 10px;
99
+ pointer-events: auto; animation: toastIn .25s ease-out;
100
+ transition: opacity .2s, transform .2s;
101
+ }
102
+ .toast.leaving { opacity: 0; transform: translateX(20px); }
103
+ .toast-icon { flex-shrink: 0; font-size: 16px; line-height: 1; margin-top: 1px; }
104
+ .toast-body { flex: 1; min-width: 0; }
105
+ .toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; }
106
+ .toast-msg { font-size: 12px; color: var(--text2); word-break: break-word; }
107
+ .toast-close { flex-shrink: 0; background: none; border: none; color: var(--text2); cursor: pointer; font-size: 14px; padding: 0 2px; line-height: 1; }
108
+ .toast-close:hover { color: var(--text); }
109
+ .toast.toast-success { border-left: 3px solid var(--green); }
110
+ .toast.toast-error { border-left: 3px solid var(--red); }
111
+ .toast.toast-info { border-left: 3px solid var(--blue); }
112
+ .toast.toast-warning { border-left: 3px solid var(--yellow); }
113
+ @keyframes toastIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
114
+
115
+ /* ── Progress bar ── */
116
+ .progress-bar {
117
+ position: fixed; top: 52px; left: 0; right: 0; height: 3px; z-index: 11;
118
+ background: transparent; overflow: hidden;
119
+ }
120
+ .progress-bar .track {
121
+ height: 100%; width: 30%; background: var(--accent);
122
+ border-radius: 0 2px 2px 0; display: none;
123
+ animation: progressSlide 1.2s ease-in-out infinite;
124
+ }
125
+ .progress-bar .track.active { display: block; }
126
+ @keyframes progressSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
127
+
128
+ /* ── Busy indicator (global) ── */
129
+ .busy-pill {
130
+ position: fixed; top: 10px; right: 16px; z-index: 12;
131
+ display: none; align-items: center; gap: 8px;
132
+ padding: 6px 12px; font-size: 11px; color: var(--text);
133
+ background: var(--bg2); border: 1px solid var(--border);
134
+ border-radius: 999px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
135
+ max-width: 420px;
136
+ }
137
+ .busy-pill.active { display: inline-flex; }
138
+ .busy-pill .spinner {
139
+ width: 12px; height: 12px; border-radius: 50%;
140
+ border: 2px solid var(--border); border-top-color: var(--accent);
141
+ animation: busySpin 0.8s linear infinite;
142
+ }
143
+ .busy-pill .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
144
+ .busy-pill .elapsed { color: var(--text2); font-variant-numeric: tabular-nums; font-size: 10px; }
145
+ @keyframes busySpin { to { transform: rotate(360deg); } }
146
+
147
+ /* Panel-level busy overlay (for tab-scoped operations) */
148
+ .panel-busy {
149
+ position: relative;
150
+ }
151
+ .panel-busy::after {
152
+ content: attr(data-busy-label);
153
+ position: absolute; inset: 0;
154
+ display: flex; align-items: center; justify-content: center;
155
+ background: rgba(0,0,0,0.35); color: var(--text); font-size: 12px;
156
+ font-weight: 500; border-radius: 6px; pointer-events: none;
157
+ backdrop-filter: blur(1px); z-index: 5;
158
+ }
159
+
160
+ /* ── Tabs ── */
161
+ .tabs { display: flex; border-bottom: 1px solid var(--border); background: var(--bg2); padding: 0 24px; }
162
+ .tab { padding: 10px 18px; font-size: 13px; font-weight: 500; color: var(--text2); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
163
+ .tab:hover { color: var(--text); }
164
+ .tab.active { color: var(--accent); border-color: var(--accent); }
165
+
166
+ /* ── Content ── */
167
+ main { max-width: 960px; margin: 0 auto; padding: 24px; }
168
+ main.wide { max-width: none; padding: 24px 32px; }
169
+ .panel { display: none; }
170
+ .panel.active { display: block; }
171
+
172
+ /* ── Cards ── */
173
+ .card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
174
+ .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
175
+ .card-title { font-weight: 600; font-size: 14px; }
176
+ .card-meta { font-size: 12px; color: var(--text2); }
177
+
178
+ /* ── Project cards ── */
179
+ .proj-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
180
+ .proj-search { flex: 1; min-width: 160px; padding: 7px 12px; font-size: 13px; background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; color: var(--text); }
181
+ .proj-search:focus { outline: none; border-color: var(--accent); }
182
+ .proj-search::placeholder { color: var(--text2); }
183
+ .filter-chip { padding: 4px 10px; font-size: 11px; border-radius: 9999px; border: 1px solid var(--border); background: var(--bg3); color: var(--text2); cursor: pointer; transition: all .15s; white-space: nowrap; }
184
+ .filter-chip:hover { border-color: var(--text2); }
185
+ .filter-chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
186
+ .proj-stats-bar { display: flex; gap: 16px; margin-bottom: 16px; font-size: 12px; color: var(--text2); }
187
+ .proj-stats-bar .stat-val { font-weight: 600; color: var(--text); margin-right: 3px; }
188
+
189
+ .proj-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 0; margin-bottom: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
190
+ .proj-card:hover { border-color: var(--accent); }
191
+ .proj-card-row { display: flex; align-items: center; padding: 14px 16px; gap: 14px; }
192
+ .proj-status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
193
+ .proj-info { flex: 1; min-width: 0; }
194
+ .proj-name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
195
+ .proj-path { font-size: 11px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
196
+ .proj-badges { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
197
+ .proj-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; background: var(--bg3); color: var(--text2); white-space: nowrap; }
198
+ .proj-badge-facts { color: var(--accent); }
199
+ .proj-badge-issues { color: var(--yellow); }
200
+ .proj-reviewed { font-size: 11px; color: var(--text2); white-space: nowrap; min-width: 80px; text-align: right; }
201
+ .proj-expand { background: var(--bg3); border-top: 1px solid var(--border); padding: 14px 16px; display: none; }
202
+ .proj-expand.open { display: block; }
203
+
204
+ /* ── Buttons ── */
205
+ .btn { border: none; border-radius: 6px; padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all .15s; }
206
+ .btn:hover:not(:disabled) { opacity: .85; }
207
+ .btn:active { transform: scale(.97); }
208
+ .btn:disabled { opacity: .4; cursor: default; }
209
+ .btn-primary { background: var(--accent); color: #fff; }
210
+ .btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text2); }
211
+ .btn-sm { padding: 4px 10px; font-size: 11px; }
212
+ .btn-danger { background: var(--red); color: #fff; }
213
+
214
+ /* ── Insight badges ── */
215
+ .insight-type { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
216
+ .type-pattern { background: rgba(168,85,247,.15); color: var(--accent); }
217
+ .type-risk { background: rgba(239,68,68,.15); color: var(--red); }
218
+ .type-opportunity { background: rgba(34,197,94,.15); color: var(--green); }
219
+ .type-convention { background: rgba(59,130,246,.15); color: var(--blue); }
220
+ .type-dependency { background: rgba(234,179,8,.15); color: var(--yellow); }
221
+ .type-drift { background: rgba(239,68,68,.15); color: var(--red); }
222
+
223
+ /* ── Tier bars ── */
224
+ .tier-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; gap: 2px; margin: 8px 0; }
225
+ .tier-core { background: var(--accent); }
226
+ .tier-active { background: var(--green); }
227
+ .tier-dormant { background: var(--yellow); }
228
+ .tier-ephemeral { background: var(--muted); }
229
+
230
+ /* ── Details panel ── */
231
+ .details { background: var(--bg3); border-radius: 6px; padding: 14px; margin-top: 8px; display: none; }
232
+ .details.open { display: block; }
233
+ .detail-row { display: flex; justify-content: space-between; font-size: 12px; padding: 4px 0; }
234
+ .detail-label { color: var(--text2); }
235
+
236
+ /* ── Form fields ── */
237
+ .field { margin-bottom: 14px; }
238
+ .field label { display: block; font-size: 12px; font-weight: 500; color: var(--text2); margin-bottom: 4px; }
239
+ .field input, .field select { width: 100%; padding: 8px 12px; font-size: 13px; background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; color: var(--text); }
240
+ .field input:focus, .field select:focus { outline: none; border-color: var(--accent); }
241
+
242
+ /* ── Status message ── */
243
+ .status-msg { font-size: 12px; color: var(--text2); padding: 8px; text-align: center; }
244
+
245
+ /* ── Suggestion card ── */
246
+ .suggestion { border-left: 3px solid var(--yellow); }
247
+ .suggestion-actions { display: flex; gap: 6px; margin-top: 8px; }
248
+
249
+ /* ── Empty state ── */
250
+ .empty { text-align: center; color: var(--text2); padding: 40px; font-size: 13px; }
251
+
252
+ /* ── Tags ── */
253
+ .tag { display: inline-block; padding: 1px 7px; border-radius: 4px; font-size: 10px; font-weight: 500; background: var(--bg3); color: var(--text2); border: 1px solid var(--border); white-space: nowrap; }
254
+ .tag-active { background: rgba(34,197,94,.12); color: var(--green); border-color: rgba(34,197,94,.25); }
255
+
256
+ /* ── Scrollbar ── */
257
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
258
+ ::-webkit-scrollbar-track { background: var(--bg); border-radius: 4px; }
259
+ ::-webkit-scrollbar-thumb { background: var(--muted); border-radius: 4px; border: 2px solid var(--bg); }
260
+ ::-webkit-scrollbar-thumb:hover { background: var(--text2); }
261
+ ::-webkit-scrollbar-corner { background: var(--bg); }
262
+ * { scrollbar-width: thin; scrollbar-color: var(--muted) var(--bg); }
263
+
264
+ /* ── Chat ── */
265
+ .chat-layout { display: flex; height: calc(100vh - 120px); gap: 0; }
266
+ .chat-sidebar {
267
+ width: 240px; border-right: 1px solid var(--border); overflow-y: auto; padding: 12px;
268
+ flex-shrink: 0; display: flex; flex-direction: column; gap: 8px;
269
+ }
270
+ .chat-sidebar-header { display: flex; gap: 6px; }
271
+ .chat-sidebar-header .btn { flex: 1; }
272
+ .conv-item {
273
+ padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
274
+ transition: background .15s; display: flex; justify-content: space-between; align-items: center; gap: 6px;
275
+ }
276
+ .conv-item:hover { background: var(--bg3); }
277
+ .conv-item.active { background: var(--bg3); border-left: 2px solid var(--accent); }
278
+ .conv-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
279
+ .conv-time { font-size: 10px; color: var(--text2); white-space: nowrap; }
280
+ .conv-del { opacity: 0; background: none; border: none; color: var(--text2); cursor: pointer; font-size: 13px; padding: 0 2px; }
281
+ .conv-item:hover .conv-del { opacity: 1; }
282
+ .conv-del:hover { color: var(--red); }
283
+ .conv-rename { opacity: 0; background: none; border: none; color: var(--text2); cursor: pointer; font-size: 12px; padding: 0 2px; }
284
+ .conv-item:hover .conv-rename { opacity: 1; }
285
+ .conv-rename:hover { color: var(--accent); }
286
+ .conv-title-input {
287
+ background: transparent; border: none; border-bottom: 1px solid var(--accent); color: var(--text);
288
+ font-size: 12px; width: 100%; outline: none; padding: 0; font-family: inherit;
289
+ }
290
+ .conv-search {
291
+ width: 100%; background: var(--bg3); border: 1px solid var(--border); border-radius: 6px;
292
+ padding: 6px 8px; color: var(--text); font-size: 11px; outline: none; box-sizing: border-box;
293
+ }
294
+ .conv-search:focus { border-color: var(--accent); }
295
+ .conv-search::placeholder { color: var(--text2); }
296
+ .conv-group-label {
297
+ font-size: 10px; font-weight: 600; color: var(--text2); text-transform: uppercase;
298
+ letter-spacing: .5px; padding: 8px 10px 4px; margin-top: 4px;
299
+ }
300
+ .sidebar-toggle {
301
+ display: none; position: absolute; top: 8px; left: 8px; z-index: 15;
302
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 6px;
303
+ padding: 4px 8px; cursor: pointer; color: var(--text2); font-size: 16px; line-height: 1;
304
+ }
305
+ .sidebar-toggle:hover { color: var(--text); background: var(--bg3); }
306
+ @media (max-width: 900px) {
307
+ .sidebar-toggle { display: block; }
308
+ .chat-sidebar {
309
+ position: absolute; left: 0; top: 0; bottom: 0; z-index: 12;
310
+ background: var(--bg2); transform: translateX(-100%); transition: transform .2s;
311
+ }
312
+ .chat-sidebar.open { transform: translateX(0); box-shadow: 4px 0 12px rgba(0,0,0,.2); }
313
+ .chat-layout { position: relative; }
314
+ }
315
+
316
+ .chat-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
317
+ .chat-messages { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 16px; }
318
+
319
+ .chat-welcome {
320
+ flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
321
+ color: var(--text2); text-align: center;
322
+ }
323
+ .chat-welcome h2 { font-size: 22px; color: var(--text); font-weight: 700; }
324
+ .chat-welcome p { font-size: 13px; max-width: 400px; }
325
+ .chat-suggestions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 8px; }
326
+ .chat-suggestions button {
327
+ background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
328
+ padding: 8px 14px; font-size: 12px; color: var(--text2); cursor: pointer; transition: all .15s;
329
+ }
330
+ .chat-suggestions button:hover { border-color: var(--accent); color: var(--text); }
331
+
332
+ /* Messages */
333
+ .msg { max-width: 100%; animation: msgFadeIn .2s ease-out; }
334
+ @keyframes msgFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
335
+ .msg-user { align-self: flex-end; max-width: 75%; }
336
+ .msg-bubble {
337
+ padding: 10px 14px; border-radius: 12px; font-size: 13px; line-height: 1.55;
338
+ word-break: break-word; white-space: pre-wrap;
339
+ }
340
+ .msg-user .msg-bubble { background: var(--accent); color: #fff; border-radius: 12px 12px 2px 12px; }
341
+ .msg-assistant { align-self: flex-start; max-width: 90%; }
342
+ .msg-assistant .msg-bubble {
343
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 2px 12px 12px 12px;
344
+ white-space: normal; overflow: hidden;
345
+ }
346
+ .msg-assistant .msg-content { line-height: 1.48; font-size: 13px; }
347
+ .msg-assistant .msg-content h1,
348
+ .msg-assistant .msg-content h2,
349
+ .msg-assistant .msg-content h3 { margin: 10px 0 4px; font-weight: 600; }
350
+ .msg-assistant .msg-content h1 { font-size: 16px; }
351
+ .msg-assistant .msg-content h2 { font-size: 15px; }
352
+ .msg-assistant .msg-content h3 { font-size: 14px; }
353
+ .msg-assistant .msg-content p { margin: 5px 0; }
354
+ .msg-assistant .msg-content ul, .msg-assistant .msg-content ol { margin: 5px 0; padding-left: 18px; }
355
+ .msg-assistant .msg-content li { margin: 2px 0; }
356
+ .msg-assistant .msg-content p:first-child { margin-top: 0; }
357
+ .msg-assistant .msg-content p:last-child { margin-bottom: 0; }
358
+ .msg-assistant .msg-content pre {
359
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
360
+ padding: 10px 12px; overflow-x: auto; margin: 8px 0; font-size: 12px;
361
+ max-width: 100%; white-space: pre;
362
+ }
363
+ .msg-assistant .msg-content code {
364
+ font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 12px;
365
+ }
366
+ .msg-assistant .msg-content :not(pre) > code {
367
+ background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-size: 12px;
368
+ }
369
+ .msg-assistant .msg-content table { border-collapse: collapse; margin: 8px 0; width: 100%; max-width: 100%; display: block; overflow-x: auto; font-size: 12px; }
370
+ .msg-assistant .msg-content th, .msg-assistant .msg-content td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; white-space: nowrap; }
371
+ .msg-assistant .msg-content th { background: var(--bg3); font-weight: 600; }
372
+ .msg-assistant .msg-content a { color: var(--accent); text-decoration: none; }
373
+ .msg-assistant .msg-content a:hover { text-decoration: underline; }
374
+ .msg-assistant .msg-content strong { font-weight: 600; }
375
+ .msg-assistant .msg-content em { font-style: italic; }
376
+ .msg-assistant .msg-content blockquote { border-left: 3px solid var(--accent); padding-left: 12px; color: var(--text2); margin: 8px 0; }
377
+ .msg-assistant .msg-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
378
+
379
+ /* Turn metadata footer — model, duration, tokens on assistant bubbles */
380
+ .msg-meta {
381
+ display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center;
382
+ padding: 4px 2px 0; font-size: 10px; color: var(--text2);
383
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
384
+ opacity: .75; transition: opacity .15s;
385
+ }
386
+ .msg:hover .msg-meta { opacity: 1; }
387
+ .msg-meta span { white-space: nowrap; }
388
+ .msg-meta .msg-meta-sep { opacity: .5; }
389
+
390
+ /* Message footer — unified bar for timestamp, actions, activity */
391
+ .msg-footer {
392
+ display: flex; align-items: center; gap: 8px; margin-top: 4px; min-height: 18px;
393
+ font-size: 10px; color: var(--text2);
394
+ }
395
+ .msg-footer-ts { opacity: .65; transition: opacity .15s; white-space: nowrap; }
396
+ .msg:hover .msg-footer-ts { opacity: 1; }
397
+ .msg-footer-actions {
398
+ display: flex; gap: 2px; opacity: .45; transition: opacity .15s; margin-left: auto;
399
+ }
400
+ .msg:hover .msg-footer-actions, .msg:focus-within .msg-footer-actions { opacity: 1; }
401
+ .msg-footer-btn {
402
+ background: none; border: none; color: var(--text2); cursor: pointer;
403
+ padding: 2px 5px; border-radius: 3px; font-size: 10px; display: flex; align-items: center; gap: 3px;
404
+ transition: all .12s; white-space: nowrap;
405
+ }
406
+ .msg-footer-btn:hover { background: var(--bg3); color: var(--text); }
407
+ .msg-footer-btn svg { width: 11px; height: 11px; }
408
+ .msg-user .msg-footer { justify-content: flex-end; }
409
+ .msg-user .msg-footer-actions { margin-left: 0; }
410
+
411
+ /* Typing indicator */
412
+ .typing-indicator { display: flex; gap: 5px; padding: 10px 14px; align-items: center; }
413
+ .typing-indicator span {
414
+ width: 7px; height: 7px; background: var(--text2); border-radius: 50%;
415
+ animation: typingBounce 1.2s ease-in-out infinite;
416
+ }
417
+ .typing-indicator span:nth-child(2) { animation-delay: .2s; }
418
+ .typing-indicator span:nth-child(3) { animation-delay: .4s; }
419
+ @keyframes typingBounce { 0%,60%,100% { transform: translateY(0); } 30% { transform: translateY(-5px); } }
420
+
421
+ /* Tool calls inline */
422
+ .msg-tool {
423
+ align-self: flex-start; max-width: 90%;
424
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
425
+ padding: 0; overflow: hidden; font-size: 12px;
426
+ }
427
+ .tool-header {
428
+ display: flex; align-items: center; gap: 8px; padding: 8px 12px;
429
+ cursor: pointer; user-select: none;
430
+ }
431
+ .tool-header:hover { background: var(--bg3); }
432
+ .tool-badge {
433
+ background: var(--accent); color: #fff; padding: 2px 8px; border-radius: 4px;
434
+ font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .3px;
435
+ }
436
+ .tool-label { color: var(--text2); font-size: 11px; }
437
+ .tool-chevron { color: var(--text2); font-size: 10px; margin-left: auto; transition: transform .2s; }
438
+ .tool-chevron.open { transform: rotate(90deg); }
439
+ .tool-body { display: none; border-top: 1px solid var(--border); padding: 10px 12px; }
440
+ .tool-body.open { display: block; }
441
+ .tool-body pre {
442
+ background: var(--bg); border-radius: 4px; padding: 8px; margin: 4px 0;
443
+ font-size: 11px; max-height: 300px; overflow: auto; white-space: pre-wrap; word-break: break-all;
444
+ }
445
+ .tool-body .tool-section-label { font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; font-weight: 600; }
446
+ .tool-status-ok { color: var(--green); }
447
+ .tool-status-err { color: var(--red); }
448
+ @keyframes tool-spin { 0%,100% { opacity:.3; } 50% { opacity:1; } }
449
+ .tool-spinner { color: var(--yellow); animation: tool-spin 1s ease-in-out infinite; font-size: 10px; margin-left: 4px; }
450
+
451
+ /* Sub-agent activity (nested inside delegate_task tool blocks) */
452
+ .agent-activity {
453
+ margin-top: 10px; padding: 10px; border-left: 3px solid var(--accent);
454
+ background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 0 6px 6px 0;
455
+ display: flex; flex-direction: column; gap: 8px;
456
+ }
457
+ .agent-activity.done { border-left-color: var(--green); }
458
+ .agent-header { display: flex; align-items: center; gap: 8px; }
459
+ .agent-badge {
460
+ background: var(--accent); color: #fff; padding: 2px 8px; border-radius: 4px;
461
+ font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .3px;
462
+ }
463
+ .agent-round { color: var(--text2); font-size: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
464
+ .agent-spinner { color: var(--yellow); animation: tool-spin 1s ease-in-out infinite; font-size: 10px; margin-left: auto; }
465
+ .agent-thinking { margin: 0; }
466
+ .agent-tools { display: flex; flex-direction: column; gap: 4px; }
467
+ .agent-sub-tool {
468
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
469
+ font-size: 11px; overflow: hidden;
470
+ }
471
+ .agent-sub-header {
472
+ display: flex; align-items: center; gap: 6px; padding: 5px 8px;
473
+ cursor: pointer; user-select: none;
474
+ }
475
+ .agent-sub-header:hover { background: var(--bg3); }
476
+ .agent-sub-badge {
477
+ background: var(--bg3); color: var(--text2); padding: 1px 6px; border-radius: 3px;
478
+ font-weight: 600; font-size: 9px; text-transform: uppercase; letter-spacing: .3px;
479
+ }
480
+ .agent-sub-label { color: var(--text2); font-size: 10px; }
481
+ .agent-sub-body { display: none; border-top: 1px solid var(--border); padding: 6px 8px; }
482
+ .agent-sub-body.open { display: block; }
483
+ .agent-sub-body pre {
484
+ background: var(--bg2); border-radius: 3px; padding: 6px; margin: 3px 0;
485
+ font-size: 10px; max-height: 200px; overflow: auto; white-space: pre-wrap; word-break: break-all;
486
+ }
487
+ .agent-response {
488
+ color: var(--text); font-size: 12px; line-height: 1.5; white-space: pre-wrap;
489
+ max-height: 220px; overflow: auto; min-height: 0;
490
+ padding: 2px 0;
491
+ }
492
+ .agent-response:not(:empty) { min-height: 16px; }
493
+ .agent-footer {
494
+ display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center;
495
+ font-size: 10px; color: var(--text2);
496
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
497
+ }
498
+ .agent-footer:empty { display: none; }
499
+
500
+ /* Streaming cursor */
501
+ .streaming-cursor::after { content: "\258B"; color: var(--accent); animation: blink 1s step-end infinite; }
502
+ @keyframes blink { 50% { opacity: 0; } }
503
+ .stream-status {
504
+ display: flex; align-items: center; gap: 6px; margin-bottom: 8px; min-height: 18px;
505
+ color: var(--text2); font-size: 11px;
506
+ }
507
+ .stream-status.hidden { display: none; }
508
+ .stream-phase {
509
+ display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px;
510
+ border: 1px solid var(--border); border-radius: 999px; background: var(--bg3);
511
+ color: var(--text2); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px;
512
+ }
513
+ .stream-phase::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
514
+ .stream-status[data-state="thinking"] .stream-phase::before { background: var(--yellow); }
515
+ .stream-status[data-state="tool"] .stream-phase::before { background: var(--blue); }
516
+ .stream-status[data-state="retry"] .stream-phase::before { background: var(--red); }
517
+ .stream-status[data-state="writing"] .stream-phase::before { background: var(--green); }
518
+ .stream-detail { color: var(--text2); font-size: 11px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
519
+ .stream-elapsed { color: var(--text2); font-size: 11px; font-variant-numeric: tabular-nums; margin-left: auto; padding-left: 8px; white-space: nowrap; }
520
+ .stream-activity { display: flex; flex-direction: column; gap: 1px; margin: 4px 0 8px; font-size: 11px; color: var(--text2); font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); border-left: 2px solid var(--border); padding-left: 8px; max-height: 140px; overflow-y: auto; }
521
+ .stream-activity:empty { display: none; }
522
+ .stream-activity-row { display: flex; gap: 8px; line-height: 1.4; align-items: baseline; }
523
+ .stream-activity-row .sa-time { color: var(--text3, var(--text2)); font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; opacity: 0.7; }
524
+ .stream-activity-row .sa-text { color: var(--text2); flex: 1; min-width: 0; word-break: break-word; }
525
+ .stream-activity-row.sa-active .sa-text { color: var(--text); }
526
+ .stream-activity-row.sa-active .sa-text::after { content: " …"; color: var(--accent); animation: blink 1s step-end infinite; }
527
+ .stream-activity-row.sa-done .sa-text::before { content: "✓ "; color: var(--green); }
528
+ .stream-activity-row.sa-tool .sa-text { color: var(--blue); }
529
+ .stream-activity-row.sa-tool.sa-done .sa-text::before { content: "✓ "; color: var(--green); }
530
+ .stream-activity-row.sa-err .sa-text { color: var(--red); }
531
+ /* Paint containment: keep markdown re-renders from reflowing siblings. */
532
+ .msg-streaming { contain: layout style; }
533
+ .msg-streaming .response-content { contain: layout style; overflow-wrap: anywhere; }
534
+ .msg-streaming .thinking-content { contain: layout style; }
535
+ #chatMessages { overflow-anchor: none; }
536
+
537
+ /* Thinking block */
538
+ .thinking-block {
539
+ background: color-mix(in srgb, var(--accent) 8%, transparent); border-left: 3px solid var(--accent);
540
+ border-radius: 6px; padding: 8px 12px; margin-bottom: 8px; font-size: 12px; color: var(--text2);
541
+ line-height: 1.5; position: relative;
542
+ }
543
+ .thinking-block summary {
544
+ cursor: pointer; font-size: 11px; font-weight: 600; color: var(--accent);
545
+ text-transform: uppercase; letter-spacing: .5px; user-select: none;
546
+ list-style: none; display: flex; align-items: center; gap: 6px;
547
+ }
548
+ .thinking-block summary::-webkit-details-marker { display: none; }
549
+ .thinking-block summary::before { content: "\25B6 "; font-size: 9px; transition: transform .15s; }
550
+ .thinking-block[open] summary::before { transform: rotate(90deg); }
551
+ .thinking-block .thinking-content { margin-top: 6px; white-space: pre-wrap; max-height: 240px; overflow: auto; }
552
+ .thinking-block.streaming { border-left-color: var(--yellow); }
553
+ .thinking-block.streaming summary { color: var(--yellow); }
554
+ @keyframes think-pulse { 0%,100% { opacity: .6; } 50% { opacity: 1; } }
555
+ .thinking-block.streaming summary::after {
556
+ content: " thinking..."; font-weight: 400; font-style: italic;
557
+ animation: think-pulse 1.5s ease-in-out infinite;
558
+ }
559
+
560
+ /* Input bar */
561
+ .chat-input-bar {
562
+ padding: 12px 24px; border-top: 1px solid var(--border);
563
+ display: flex; gap: 8px; align-items: flex-end; background: var(--bg2);
564
+ }
565
+ .chat-input-bar textarea {
566
+ width: 100%; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
567
+ padding: 10px 12px; color: var(--text); font-family: inherit; font-size: 13px;
568
+ min-height: 40px; max-height: 300px; line-height: 1.4; box-sizing: border-box; resize: vertical;
569
+ }
570
+ .chat-input-bar textarea:focus { outline: none; border-color: var(--accent); }
571
+ .chat-input-bar textarea::placeholder { color: var(--text2); }
572
+ .chat-send {
573
+ background: var(--accent); color: #fff; border: none; border-radius: 8px;
574
+ padding: 10px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
575
+ transition: opacity .15s; white-space: nowrap;
576
+ }
577
+ .chat-send:hover { opacity: .85; }
578
+ .chat-send:disabled { opacity: .4; cursor: default; }
579
+ .chat-stop {
580
+ background: var(--red); color: #fff; border: none; border-radius: 8px;
581
+ padding: 10px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
582
+ }
583
+
584
+ /* System / error messages */
585
+ .msg-system {
586
+ align-self: center; text-align: center; font-size: 12px; color: var(--text2);
587
+ padding: 4px 12px; background: var(--bg3); border-radius: 12px;
588
+ }
589
+ .msg-error {
590
+ align-self: center; text-align: center; font-size: 12px; color: var(--red);
591
+ padding: 6px 14px; background: rgba(239,68,68,.08); border: 1px solid rgba(239,68,68,.2); border-radius: 8px;
592
+ }
593
+
594
+ /* Chat status bar */
595
+ .chat-status-bar {
596
+ display: none; padding: 4px 24px; background: var(--bg2); border-top: 1px solid var(--border);
597
+ font-size: 11px; color: var(--text2); gap: 8px; align-items: center; flex-wrap: wrap;
598
+ }
599
+ .chat-status-bar.active { display: flex; }
600
+ .chat-status-dot {
601
+ width: 6px; height: 6px; border-radius: 50%; background: var(--accent);
602
+ animation: pulse 1.2s ease-in-out infinite; flex-shrink: 0;
603
+ }
604
+ @keyframes pulse { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
605
+ .chat-status-trail {
606
+ display: flex; gap: 4px; align-items: center; flex-wrap: wrap; flex: 1; min-width: 0;
607
+ }
608
+ .chat-status-step { color: var(--text2); opacity: .5; white-space: nowrap; }
609
+ .chat-status-step.active { opacity: 1; color: var(--text); font-weight: 500; }
610
+ .chat-status-sep { color: var(--text2); opacity: .3; font-size: 9px; }
611
+ .chat-status-detail { color: var(--text2); font-size: 10px; margin-left: 4px; }
612
+ .chat-status-stats {
613
+ margin-left: auto; display: flex; gap: 10px; font-size: 10px; color: var(--text2);
614
+ }
615
+ .chat-status-stats span { white-space: nowrap; }
616
+
617
+ /* ── Slash command autocomplete ── */
618
+ .chat-input-wrap { position: relative; flex: 1; min-width: 0; }
619
+ .chat-cmd-overlay {
620
+ display: none; position: absolute; bottom: 100%; left: 0; right: 0;
621
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
622
+ box-shadow: 0 -4px 20px rgba(0,0,0,.3); max-height: 260px; overflow-y: auto;
623
+ margin-bottom: 6px; z-index: 20;
624
+ }
625
+ .chat-cmd-overlay.open { display: block; }
626
+ .cmd-row {
627
+ display: flex; align-items: baseline; gap: 8px; padding: 8px 14px;
628
+ cursor: pointer; transition: background .1s; font-size: 13px;
629
+ }
630
+ .cmd-row:hover, .cmd-row.active { background: var(--bg3); }
631
+ .cmd-name { font-weight: 600; color: var(--accent); white-space: nowrap; }
632
+ .cmd-args { color: var(--text2); font-size: 12px; opacity: .6; white-space: nowrap; }
633
+ .cmd-desc { color: var(--text2); font-size: 11px; margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
634
+ .cmd-group { background: var(--bg3); padding: 4px 14px; font-size: 10px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--border); }
635
+
636
+ /* Ghost hint overlay on textarea */
637
+ .chat-ghost {
638
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
639
+ padding: 10px 12px; font-family: inherit; font-size: 13px; line-height: 1.4;
640
+ pointer-events: none; white-space: pre-wrap; word-break: break-word; overflow: hidden;
641
+ }
642
+ .chat-ghost-visible { color: transparent; }
643
+ .chat-ghost-hint { color: var(--text2); opacity: .45; }
644
+
645
+ /* State indicator pills */
646
+ .chat-state-bar {
647
+ display: none; padding: 4px 24px; gap: 6px; align-items: center; flex-wrap: wrap;
648
+ border-bottom: 1px solid var(--border); background: var(--bg2);
649
+ }
650
+ .chat-state-bar.active { display: flex; }
651
+ .state-pill {
652
+ display: inline-flex; align-items: center; gap: 4px;
653
+ padding: 2px 10px; border-radius: 9999px; font-size: 11px; font-weight: 500;
654
+ background: var(--bg3); border: 1px solid var(--border); color: var(--text2);
655
+ }
656
+ .state-pill-accent { border-color: var(--accent); color: var(--accent); background: rgba(168,85,247,.08); }
657
+ .state-pill .pill-label { opacity: .6; font-size: 10px; }
658
+
659
+ /* Command result message */
660
+ .msg-command {
661
+ align-self: flex-start; max-width: 90%; font-size: 12px;
662
+ padding: 10px 14px; background: var(--bg3); border: 1px solid var(--border);
663
+ border-radius: 8px; border-left: 3px solid var(--accent);
664
+ }
665
+ .msg-command .cmd-title { font-weight: 600; color: var(--accent); margin-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
666
+ .msg-command .cmd-body { color: var(--text); line-height: 1.5; }
667
+ .msg-command .cmd-body strong { font-weight: 600; }
668
+ .msg-command .cmd-body ul { margin: 4px 0; padding-left: 16px; }
669
+ .msg-command .cmd-body li { margin: 2px 0; }
670
+
671
+ /* Health result card */
672
+ .health-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; margin: 6px 0; font-size: 12px; }
673
+ .health-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
674
+ .health-card-name { font-weight: 600; }
675
+ .health-card-status { font-size: 11px; font-weight: 500; }
676
+ .health-card-issues { color: var(--text2); font-size: 11px; }
677
+
678
+ /* Chat panel uses full width */
679
+ #panel-chat main, #panel-chat { max-width: none; }
680
+
681
+ /* ── Code block copy headers ── */
682
+ .code-block-wrap { position: relative; border-radius: 6px; overflow: hidden; margin: 8px 0; border: 1px solid var(--border); }
683
+ .code-block-header {
684
+ display: flex; justify-content: space-between; align-items: center;
685
+ padding: 4px 12px; font-size: 10px; font-family: 'JetBrains Mono', monospace;
686
+ background: var(--bg3); color: var(--text2); border-bottom: 1px solid var(--border);
687
+ }
688
+ .code-block-header .code-copy-btn {
689
+ background: none; border: 1px solid var(--border); border-radius: 4px;
690
+ padding: 2px 8px; cursor: pointer; font-size: 10px; color: var(--text2);
691
+ font-family: 'JetBrains Mono', monospace; transition: all .15s;
692
+ }
693
+ .code-block-header .code-copy-btn:hover { border-color: var(--accent); color: var(--text); }
694
+ .code-block-wrap pre { margin: 0; border: none; border-radius: 0; }
695
+
696
+ /* Legacy msg-actions — hidden; use msg-footer instead */
697
+ .msg-actions { display: none !important; }
698
+
699
+ /* ── Activity summary in assistant messages ── */
700
+ .msg-activity-summary {
701
+ margin-top: 6px; padding: 6px 8px; font-size: 11px;
702
+ background: var(--bg2); border-radius: 6px; border: 1px solid var(--border);
703
+ }
704
+ .msg-activity-header {
705
+ display: flex; align-items: center; gap: 6px; cursor: pointer; color: var(--text2);
706
+ user-select: none; padding: 2px 0; flex-wrap: wrap;
707
+ }
708
+ .msg-activity-header:hover { color: var(--text); }
709
+ .msg-activity-header .chevron { transition: transform .15s; font-size: 9px; }
710
+ .msg-activity-header .chevron.open { transform: rotate(90deg); }
711
+ .msg-activity-badge { background: var(--accent); color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: 600; }
712
+ .msg-activity-badge.ok { background: var(--green); }
713
+ .msg-activity-badge.err { background: var(--red); }
714
+ .msg-activity-badge.neutral { background: var(--bg3); color: var(--text2); border: 1px solid var(--border); }
715
+ .msg-activity-body { display: none; padding: 6px 0 2px 16px; }
716
+ .msg-activity-body.open { display: block; }
717
+ .activity-tool-row {
718
+ display: flex; gap: 6px; align-items: baseline; color: var(--text2); margin-bottom: 2px; font-size: 10px;
719
+ }
720
+ .activity-tool-name {
721
+ font-family: 'JetBrains Mono', monospace; font-size: 10px;
722
+ color: var(--accent); min-width: 65px; flex-shrink: 0;
723
+ }
724
+ .activity-tool-args {
725
+ font-family: 'JetBrains Mono', monospace; font-size: 10px; opacity: .6;
726
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
727
+ }
728
+ .activity-files { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
729
+ .activity-file-badge {
730
+ font-family: 'JetBrains Mono', monospace; font-size: 10px;
731
+ color: var(--blue); background: rgba(59,130,246,.1); padding: 1px 5px; border-radius: 3px;
732
+ }
733
+ .activity-summary-line { color: var(--text2); font-size: 10px; margin-top: 4px; }
734
+
735
+ /* ── Conversation toolbar ── */
736
+ .chat-toolbar {
737
+ display: flex; align-items: center; gap: 8px; padding: 6px 24px;
738
+ border-bottom: 1px solid var(--border); background: var(--bg2); font-size: 11px; flex-wrap: wrap;
739
+ }
740
+ .chat-toolbar-search {
741
+ display: flex; align-items: center; gap: 4px; background: var(--bg3);
742
+ border: 1px solid var(--border); border-radius: 6px; padding: 3px 8px;
743
+ }
744
+ .chat-toolbar-search input {
745
+ background: none; border: none; outline: none; color: var(--text);
746
+ font-size: 11px; width: 140px; font-family: inherit;
747
+ }
748
+ .chat-toolbar-search input::placeholder { color: var(--text2); }
749
+ .chat-toolbar-btn {
750
+ background: var(--bg3); border: 1px solid var(--border); border-radius: 6px;
751
+ padding: 4px 10px; cursor: pointer; color: var(--text2); font-size: 11px;
752
+ display: flex; align-items: center; gap: 4px; transition: all .12s;
753
+ }
754
+ .chat-toolbar-btn:hover { border-color: var(--accent); color: var(--text); }
755
+ .chat-toolbar-info { color: var(--text2); font-size: 10px; margin-right: auto; display: flex; align-items: center; gap: 8px; }
756
+ .chat-toolbar-info .source-badge {
757
+ padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: 600;
758
+ background: rgba(168,85,247,.15); color: var(--accent); border: 1px solid rgba(168,85,247,.25);
759
+ }
760
+
761
+ /* Export dropdown */
762
+ .export-dd { position: relative; }
763
+ .export-dd-menu {
764
+ display: none; position: absolute; top: 100%; right: 0; margin-top: 4px;
765
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 6px;
766
+ overflow: hidden; z-index: 20; min-width: 150px; box-shadow: 0 4px 12px rgba(0,0,0,.4);
767
+ }
768
+ .export-dd-menu.open { display: block; }
769
+ .export-dd-item {
770
+ padding: 8px 14px; cursor: pointer; font-size: 12px; color: var(--text); transition: background .12s;
771
+ border-bottom: 1px solid var(--border);
772
+ }
773
+ .export-dd-item:last-child { border-bottom: none; }
774
+ .export-dd-item:hover { background: var(--bg3); }
775
+
776
+ /* ── Search highlight ── */
777
+ .search-highlight { background: rgba(234,179,8,.25); color: inherit; padding: 1px 2px; border-radius: 2px; }
778
+
779
+ /* ── Toast notification ── */
780
+ .chat-toast {
781
+ position: fixed; bottom: 80px; right: 24px; background: var(--bg2); border: 1px solid var(--accent);
782
+ color: var(--text); padding: 8px 16px; border-radius: 8px; font-size: 12px;
783
+ box-shadow: 0 4px 12px rgba(0,0,0,.4); z-index: 100; animation: fadeUp .3s ease;
784
+ }
785
+ </style>
786
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
787
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
788
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-dark">
789
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-light" disabled>
790
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.2/cytoscape.min.js"></script>
791
+ <script src="https://cdn.jsdelivr.net/npm/layout-base@2.0.1/layout-base.js"></script>
792
+ <script src="https://cdn.jsdelivr.net/npm/cose-base@2.2.0/cose-base.js"></script>
793
+ <script src="https://cdn.jsdelivr.net/npm/cytoscape-fcose@2.2.0/cytoscape-fcose.js"></script>
794
+ </head>
795
+ <body data-theme="dark">
796
+
797
+ <header>
798
+ <div class="logo">
799
+ <span>Oracle</span> Memory Agent
800
+ <div class="activity-spinner" id="activitySpinner"></div>
801
+ <div class="activity-label" id="activityLabel"></div>
802
+ </div>
803
+ <div class="header-right">
804
+ <div class="status-wrap">
805
+ <div class="badge status-badge" id="statusBadge" onclick="toggleStatusDD()"><span class="dot dot-off" id="statusDot"></span><span id="statusLabel">Status</span></div>
806
+ <div class="status-dd" id="statusDD">
807
+ <div class="status-dd-row">
808
+ <span class="dot dot-off" id="ddOllamaDot"></span>
809
+ <span class="status-dd-label">Ollama API</span>
810
+ <span class="status-dd-val" id="ddOllamaVal">checking...</span>
811
+ </div>
812
+ <div class="status-dd-sub" id="ddModelInfo">Model: ...</div>
813
+ <div class="status-dd-sep"></div>
814
+ <div class="status-dd-row">
815
+ <span class="dot dot-off" id="ddReviewDot"></span>
816
+ <span class="status-dd-label">Review Agent</span>
817
+ <span class="status-dd-val" id="ddReviewVal">checking...</span>
818
+ </div>
819
+ <div class="status-dd-sub" id="ddReviewInfo"></div>
820
+ <div class="status-dd-sep"></div>
821
+ <div class="status-dd-row">
822
+ <span class="dot dot-off" id="ddHubDot"></span>
823
+ <span class="status-dd-label">C3 Hub</span>
824
+ <span class="status-dd-val" id="ddHubVal">checking...</span>
825
+ </div>
826
+ </div>
827
+ </div>
828
+ <a class="header-btn" id="hubLink" href="#" target="_blank" style="display:none">Open Hub</a>
829
+ <button class="header-btn" id="themeToggle" onclick="toggleTheme()">Theme: Dark</button>
830
+ </div>
831
+ </header>
832
+
833
+ <div class="progress-bar"><div class="track" id="progressTrack"></div></div>
834
+ <div id="oracleBusy" class="busy-pill" role="status" aria-live="polite">
835
+ <span class="spinner"></span>
836
+ <span class="label" id="oracleBusyLabel">Working...</span>
837
+ <span class="elapsed" id="oracleBusyElapsed"></span>
838
+ </div>
839
+
840
+ <div class="tabs">
841
+ <div class="tab active" data-tab="chat">Chat</div>
842
+ <div class="tab" data-tab="projects">Projects</div>
843
+ <div class="tab" data-tab="insights">Insights</div>
844
+ <div class="tab" data-tab="crossgraph">Cross-Graph</div>
845
+ <div class="tab" data-tab="suggestions">Suggestions</div>
846
+ <div class="tab" data-tab="agents">Team / Agents</div>
847
+ <div class="tab" data-tab="settings">Settings</div>
848
+ </div>
849
+
850
+ <!-- ── Chat panel (outside main for full-width layout) ── -->
851
+ <div class="panel active" id="panel-chat">
852
+ <div class="chat-layout">
853
+ <div class="chat-sidebar">
854
+ <div class="chat-sidebar-header">
855
+ <button class="btn btn-primary btn-sm" onclick="chatNewConversation()" style="flex:1">+ New Chat</button>
856
+ </div>
857
+ <input class="conv-search" id="convSearch" type="text" placeholder="Filter chats..." oninput="chatFilterConversations()">
858
+ <div id="convList"></div>
859
+ </div>
860
+ <div class="chat-main">
861
+ <button class="sidebar-toggle" onclick="document.querySelector('.chat-sidebar').classList.toggle('open')" title="Toggle sidebar">&#9776;</button>
862
+ <div class="chat-state-bar" id="chatStateBar"></div>
863
+ <div class="chat-toolbar" id="chatToolbar" style="display:none">
864
+ <div class="chat-toolbar-info">
865
+ <span class="source-badge" id="toolbarSource"></span>
866
+ <span id="toolbarTurnCount"></span>
867
+ </div>
868
+ <div class="chat-toolbar-search">
869
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
870
+ <input type="text" id="toolbarSearchInput" placeholder="Search in conversation..." oninput="chatToolbarSearch(this.value)">
871
+ <span id="toolbarSearchCount" style="font-size:10px;color:var(--text2);white-space:nowrap"></span>
872
+ <button class="msg-action-btn" onclick="chatToolbarSearchNav(-1)" style="padding:2px" title="Previous">
873
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 18l-6-6 6-6"/></svg>
874
+ </button>
875
+ <button class="msg-action-btn" onclick="chatToolbarSearchNav(1)" style="padding:2px" title="Next">
876
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 18l6-6-6-6"/></svg>
877
+ </button>
878
+ </div>
879
+ <button class="chat-toolbar-btn" onclick="chatCopyAll()" title="Copy entire conversation">
880
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
881
+ Copy All
882
+ </button>
883
+ <div class="export-dd">
884
+ <button class="chat-toolbar-btn" onclick="chatToggleExport()" title="Export conversation">
885
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
886
+ Export
887
+ </button>
888
+ <div class="export-dd-menu" id="exportMenu">
889
+ <div class="export-dd-item" onclick="chatExport('markdown')">Export as Markdown</div>
890
+ <div class="export-dd-item" onclick="chatExport('json')">Export as JSON</div>
891
+ </div>
892
+ </div>
893
+ </div>
894
+ <div class="chat-messages" id="chatMessages">
895
+ <div class="chat-welcome" id="chatWelcome">
896
+ <div style="font-size:40px;opacity:.3">&#128300;</div>
897
+ <h2>Oracle Chat</h2>
898
+ <p>Ask about your projects, memory patterns, health, or cross-project insights. Type <code>/</code> for commands.</p>
899
+ <div class="chat-suggestions">
900
+ <button onclick="chatSendSuggested('What projects do I have?')">&#128193; List my projects</button>
901
+ <button onclick="chatSendSuggested('Show memory health across all projects')">&#128153; Memory health overview</button>
902
+ <button onclick="chatSendSuggested('Find patterns and insights across my projects')">&#128270; Cross-project patterns</button>
903
+ <button onclick="chatSendSuggested('Which projects have stale or duplicate facts?')">&#128214; Stale/duplicate facts</button>
904
+ </div>
905
+ <div style="font-size:11px;color:var(--text2);margin-top:12px">Enter to send &middot; Shift+Enter for newline &middot; / for commands</div>
906
+ </div>
907
+ </div>
908
+ <div class="chat-status-bar" id="chatStatusBar">
909
+ <div class="chat-status-dot"></div>
910
+ <div class="chat-status-trail" id="chatStatusTrail"></div>
911
+ <span class="chat-status-detail" id="chatStatusDetail"></span>
912
+ <div class="chat-status-stats" id="chatStatusStats"></div>
913
+ </div>
914
+ <div class="chat-input-bar">
915
+ <div class="chat-input-wrap">
916
+ <div class="chat-cmd-overlay" id="chatCmdOverlay"></div>
917
+ <div class="chat-ghost" id="chatGhost"></div>
918
+ <textarea id="chatInput" placeholder="Ask about your projects... (/ for commands, Shift+Enter for newline)" rows="1"
919
+ onkeydown="chatInputKeydown(event)" oninput="chatOnInput(this)"></textarea>
920
+ </div>
921
+ <button class="chat-send" id="chatSendBtn" onclick="chatSendMessage()">Send</button>
922
+ </div>
923
+ </div>
924
+ </div>
925
+ </div>
926
+
927
+ <main>
928
+ <!-- ── Projects ── -->
929
+ <div class="panel" id="panel-projects">
930
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
931
+ <h2 style="font-size:16px;font-weight:600">Registered Projects</h2>
932
+ <div style="display:flex;gap:6px">
933
+ <button class="btn btn-ghost btn-sm" onclick="reviewAllProjects()">Review All</button>
934
+ <button class="btn btn-ghost btn-sm" onclick="scanProjects()">Rescan</button>
935
+ </div>
936
+ </div>
937
+ <div id="reviewAllProgress" style="display:none;margin-bottom:12px">
938
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
939
+ <span style="font-size:12px;font-weight:500" id="reviewAllLabel">Reviewing...</span>
940
+ <span style="font-size:11px;color:var(--text2)" id="reviewAllCount">0/0</span>
941
+ </div>
942
+ <div style="height:6px;background:var(--bg3);border-radius:3px;overflow:hidden">
943
+ <div id="reviewAllBar" style="height:100%;background:var(--accent);border-radius:3px;transition:width .3s ease;width:0%"></div>
944
+ </div>
945
+ </div>
946
+ <div class="proj-stats-bar" id="projStatsBar"></div>
947
+ <div class="proj-toolbar">
948
+ <input class="proj-search" id="projSearch" type="text" placeholder="Search projects..." oninput="renderProjects()">
949
+ <div class="filter-chip active" data-health="all" onclick="setHealthFilter('all')">All</div>
950
+ <div class="filter-chip" data-health="ok" onclick="setHealthFilter('ok')"><span class="dot dot-ok" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:3px"></span>OK</div>
951
+ <div class="filter-chip" data-health="warning" onclick="setHealthFilter('warning')"><span class="dot dot-warn" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:3px"></span>Warn</div>
952
+ <div class="filter-chip" data-health="error" onclick="setHealthFilter('error')"><span class="dot dot-err" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:3px"></span>Error</div>
953
+ <div class="filter-chip" data-health="unknown" onclick="setHealthFilter('unknown')"><span class="dot dot-off" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:3px"></span>Not reviewed</div>
954
+ <select class="proj-search" style="flex:none;width:auto;padding:5px 8px" id="projTagFilter" onchange="renderProjects()">
955
+ <option value="">All Tags</option>
956
+ </select>
957
+ <select class="proj-search" style="flex:none;width:auto;padding:5px 8px" id="projSort" onchange="renderProjects()">
958
+ <option value="name">Sort: Name</option>
959
+ <option value="facts">Sort: Facts</option>
960
+ <option value="reviewed">Sort: Reviewed</option>
961
+ <option value="health">Sort: Health</option>
962
+ </select>
963
+ </div>
964
+ <div id="projectsList"></div>
965
+ <div id="projectsEmpty" class="empty" style="display:none">No projects discovered. Is the C3 Hub running?</div>
966
+ </div>
967
+
968
+ <!-- ── Insights ── -->
969
+ <div class="panel" id="panel-insights">
970
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
971
+ <h2 style="font-size:16px;font-weight:600">Cross-Project Insights</h2>
972
+ <button class="btn btn-primary btn-sm" id="btnGenInsights" onclick="generateInsights()">Generate New Insights</button>
973
+ </div>
974
+ <div id="insightsList"></div>
975
+ <div id="insightsEmpty" class="empty" style="display:none">No insights yet. Click "Generate" with 2+ projects.</div>
976
+ </div>
977
+
978
+ <!-- ── Cross-Graph ── -->
979
+ <div class="panel" id="panel-crossgraph">
980
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:10px">
981
+ <div>
982
+ <h2 style="font-size:16px;font-weight:600">Federated Memory Graph</h2>
983
+ <p style="font-size:12px;color:var(--text2);margin-top:4px">Unified graph across up to 99 C3 projects. Cross-project edges use embeddings (Ollama) with TF-IDF fallback.</p>
984
+ </div>
985
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
986
+ <label style="font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px">
987
+ min-sim
988
+ <input type="range" id="cgMinSim" min="0.3" max="0.95" step="0.05" value="0.75" oninput="cgMinSimLabel.textContent=this.value">
989
+ <span id="cgMinSimLabel" style="font-family:monospace;width:30px">0.75</span>
990
+ </label>
991
+ <label style="font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px">
992
+ top-k
993
+ <input type="number" id="cgTopK" min="1" max="10" value="3" style="width:50px;padding:2px 4px">
994
+ </label>
995
+ <button class="btn btn-sm" onclick="cgToggleHelp()">Help</button>
996
+ <button class="btn btn-sm" onclick="cgFit()">Fit</button>
997
+ <button class="btn btn-sm" onclick="cgRelayout()">Relayout</button>
998
+ <button class="btn btn-primary btn-sm" onclick="cgRebuild()">Rebuild</button>
999
+ <button class="btn btn-primary btn-sm" onclick="cgGenerateInsights()" id="cgGenBtn">Generate Cross-Insights</button>
1000
+ </div>
1001
+ </div>
1002
+
1003
+ <div id="cgHelp" style="background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px;font-size:11px;display:grid;grid-template-columns:1fr 1fr;gap:14px;color:var(--text2)">
1004
+ <div>
1005
+ <div style="font-weight:600;color:var(--text);margin-bottom:6px;text-transform:uppercase;letter-spacing:1px;font-size:10px">Nodes</div>
1006
+ <div>Each node is a <b>fact</b> colored by its source <b>project</b>. Node size = recall count (bigger = more used).</div>
1007
+ <div id="cgProjectLegend" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px"></div>
1008
+ </div>
1009
+ <div>
1010
+ <div style="font-weight:600;color:var(--text);margin-bottom:6px;text-transform:uppercase;letter-spacing:1px;font-size:10px">Edges & Interactions</div>
1011
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px"><span style="width:26px;height:2px;background:#9aa0a6;display:inline-block"></span><b>within-project</b> — co-recall / touches</div>
1012
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px"><span style="width:26px;height:2px;background:#b388ff;display:inline-block"></span><b>cross_similar</b> — similar facts across projects</div>
1013
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"><span style="width:26px;height:2px;background:#4c8bf5;display:inline-block"></span><b>linked_via_insight</b> — shared Oracle insight</div>
1014
+ <ul style="margin:0;padding-left:18px;line-height:1.6">
1015
+ <li><b>Click</b> node → side panel with cross-project neighbors</li>
1016
+ <li><b>Drag</b> canvas to pan · <b>scroll</b> to zoom</li>
1017
+ <li><b>Rebuild</b> forces re-embedding; <b>Relayout</b> only re-runs force layout</li>
1018
+ <li><b>Generate Cross-Insights</b> asks Oracle LLM to classify similarities</li>
1019
+ </ul>
1020
+ </div>
1021
+ </div>
1022
+
1023
+ <div id="cgStatus" style="font-size:11px;color:var(--text2);margin-bottom:8px"></div>
1024
+ <div style="display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:12px;align-items:stretch;height:calc(100vh - 260px);min-height:520px">
1025
+ <div id="cgCanvas" style="width:100%;height:100%;min-height:520px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;overflow:hidden"></div>
1026
+ <div id="cgDetail" style="background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:12px;font-size:12px;color:var(--text2);min-height:140px;overflow-y:auto">
1027
+ <div style="color:var(--text3,var(--text2))">Select a node to view details.</div>
1028
+ </div>
1029
+ </div>
1030
+ </div>
1031
+
1032
+ <!-- ── Suggestions ── -->
1033
+ <div class="panel" id="panel-suggestions">
1034
+ <div style="margin-bottom:16px">
1035
+ <h2 style="font-size:16px;font-weight:600">Pending Suggestions</h2>
1036
+ <p style="font-size:12px;color:var(--text2);margin-top:4px">Oracle's recommended changes to project memory. Review and approve or dismiss.</p>
1037
+ </div>
1038
+ <div id="suggestionsList"></div>
1039
+ <div id="suggestionsEmpty" class="empty" style="display:none">No pending suggestions.</div>
1040
+ </div>
1041
+
1042
+ <!-- ── Agents ── -->
1043
+ <div class="panel" id="panel-agents">
1044
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
1045
+ <div>
1046
+ <h2 style="font-size:16px;font-weight:600">Team / Agents</h2>
1047
+ <p style="font-size:12px;color:var(--text2);margin-top:4px">Manage specialized sub-agents. Oracle can delegate tasks to active agents.</p>
1048
+ </div>
1049
+ <button class="btn btn-primary btn-sm" onclick="openAgentModal()">Add Custom Agent</button>
1050
+ </div>
1051
+ <div id="agentsList"></div>
1052
+ <div id="agentsEmpty" class="empty" style="display:none">No agents configured.</div>
1053
+ </div>
1054
+
1055
+ <!-- ── Settings ── -->
1056
+ <div class="panel" id="panel-settings">
1057
+ <h2 style="font-size:16px;font-weight:600;margin-bottom:16px">Configuration</h2>
1058
+ <div class="card">
1059
+ <div class="field">
1060
+ <label>Ollama Model</label>
1061
+ <div style="position:relative">
1062
+ <input id="cfgModel" type="text" list="cfgModelList" placeholder="Type or select a model..." autocomplete="off">
1063
+ <datalist id="cfgModelList"></datalist>
1064
+ </div>
1065
+ </div>
1066
+ <div class="field">
1067
+ <label>Ollama URL</label>
1068
+ <input id="cfgOllamaUrl" type="text" value="https://ollama.com">
1069
+ </div>
1070
+ <div class="field">
1071
+ <label>Ollama API Key</label>
1072
+ <div style="display:flex;align-items:center;gap:8px">
1073
+ <input id="cfgApiKey" type="password" placeholder="OLLAMA_API_KEY (or set env var)" style="flex:1;width:0;min-width:0">
1074
+ <label style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--text2);cursor:pointer;white-space:nowrap;margin:0;flex-shrink:0"><input type="checkbox" id="cfgApiKeyEdit" onchange="toggleApiKeyEdit()"> Edit key</label>
1075
+ </div>
1076
+ </div>
1077
+ <div class="field">
1078
+ <label>Hub URL</label>
1079
+ <input id="cfgHubUrl" type="text" value="http://localhost:3330">
1080
+ </div>
1081
+ <div class="field">
1082
+ <label>Review Interval (minutes)</label>
1083
+ <input id="cfgInterval" type="number" min="1" value="30">
1084
+ </div>
1085
+ <div style="display:flex;gap:8px;margin-top:16px">
1086
+ <button class="btn btn-primary" onclick="saveSettings()">Save</button>
1087
+ <button class="btn btn-ghost" onclick="testConnection()">Test Connection</button>
1088
+ </div>
1089
+ <div id="settingsMsg" class="status-msg"></div>
1090
+ </div>
1091
+ </div>
1092
+
1093
+ <!-- Agent Modal -->
1094
+ <div id="agentModal" class="modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:999;align-items:center;justify-content:center">
1095
+ <div style="background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:24px;width:100%;max-width:500px;box-shadow:0 10px 30px rgba(0,0,0,.5)">
1096
+ <h2 id="agentModalTitle" style="font-size:18px;margin-bottom:16px;font-weight:600">Edit Agent</h2>
1097
+ <input type="hidden" id="agentId">
1098
+ <div class="field">
1099
+ <label>Name</label>
1100
+ <input id="agentName" type="text" placeholder="e.g. Architect">
1101
+ </div>
1102
+ <div class="field">
1103
+ <label>Description (Specialization)</label>
1104
+ <input id="agentDesc" type="text" placeholder="e.g. Expert in system architecture...">
1105
+ </div>
1106
+ <div class="field">
1107
+ <label>System Prompt</label>
1108
+ <textarea id="agentPrompt" rows="4" style="width:100%;padding:8px;font-size:12px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);resize:vertical" placeholder="You are the Architect..."></textarea>
1109
+ </div>
1110
+ <div class="field">
1111
+ <label>Model</label>
1112
+ <input id="agentModel" type="text" placeholder="e.g. gemma4:31b-cloud">
1113
+ </div>
1114
+ <div class="field" style="display:flex;align-items:center;gap:8px">
1115
+ <input id="agentActive" type="checkbox" style="width:auto">
1116
+ <label style="margin:0;cursor:pointer" for="agentActive">Active</label>
1117
+ </div>
1118
+ <div style="display:flex;gap:8px;margin-top:24px;justify-content:flex-end">
1119
+ <button class="btn btn-ghost" onclick="closeAgentModal()">Cancel</button>
1120
+ <button class="btn btn-primary" onclick="saveAgent()">Save</button>
1121
+ </div>
1122
+ </div>
1123
+ </div>
1124
+ </main>
1125
+
1126
+ <div class="toast-container" id="toastContainer"></div>
1127
+
1128
+ <script>
1129
+ const API = '';
1130
+ const ORACLE_BUILD_TIME = "2026-04-11 oracle-v2";
1131
+
1132
+ // ═══════════════════════════════════════════════════════════
1133
+ // ─�� Toast notification system ──
1134
+ // ═══════════════════════════════════════════════════════════
1135
+ const TOAST_ICONS = { success: '\u2713', error: '\u2717', info: '\u24D8', warning: '\u26A0' };
1136
+
1137
+ function toast(title, msg, type = 'info', duration = 4000) {
1138
+ const container = document.getElementById('toastContainer');
1139
+ const el = document.createElement('div');
1140
+ el.className = `toast toast-${type}`;
1141
+ el.innerHTML = `
1142
+ <span class="toast-icon">${TOAST_ICONS[type] || TOAST_ICONS.info}</span>
1143
+ <div class="toast-body">
1144
+ <div class="toast-title">${esc(title)}</div>
1145
+ ${msg ? `<div class="toast-msg">${esc(msg)}</div>` : ''}
1146
+ </div>
1147
+ <button class="toast-close" onclick="this.closest('.toast').remove()">\u2715</button>
1148
+ `;
1149
+ container.appendChild(el);
1150
+ if (duration > 0) {
1151
+ setTimeout(() => {
1152
+ el.classList.add('leaving');
1153
+ setTimeout(() => el.remove(), 250);
1154
+ }, duration);
1155
+ }
1156
+ }
1157
+
1158
+ // ═══════════════════════════════════════════════════════════
1159
+ // ── Activity / busy indicator ──
1160
+ // ═══════════════════════════════════════════════════════════
1161
+ let _busyCount = 0;
1162
+ let _busyLabel = '';
1163
+
1164
+ function busyStart(label) {
1165
+ _busyCount++;
1166
+ _busyLabel = label || _busyLabel;
1167
+ document.getElementById('activitySpinner').classList.add('active');
1168
+ document.getElementById('progressTrack').classList.add('active');
1169
+ const lbl = document.getElementById('activityLabel');
1170
+ lbl.textContent = _busyLabel;
1171
+ lbl.classList.add('active');
1172
+ }
1173
+
1174
+ function busyEnd() {
1175
+ _busyCount = Math.max(0, _busyCount - 1);
1176
+ if (_busyCount === 0) {
1177
+ document.getElementById('activitySpinner').classList.remove('active');
1178
+ document.getElementById('progressTrack').classList.remove('active');
1179
+ document.getElementById('activityLabel').classList.remove('active');
1180
+ _busyLabel = '';
1181
+ }
1182
+ }
1183
+
1184
+ /** Wrap an async action with busy indicator + toast notifications. */
1185
+ async function tracked(label, fn, { successMsg, errorMsg, silent } = {}) {
1186
+ busyStart(label);
1187
+ try {
1188
+ const result = await fn();
1189
+ if (!silent) toast(label, successMsg || 'Done', 'success', 3000);
1190
+ return result;
1191
+ } catch (e) {
1192
+ toast(label, errorMsg || e.message || 'Request failed', 'error', 6000);
1193
+ throw e;
1194
+ } finally {
1195
+ busyEnd();
1196
+ }
1197
+ }
1198
+
1199
+ // ═══════════════════════════════════════════════════════════
1200
+ // ── Theme toggle ──
1201
+ // ═══════════════════════════════════════════════════════════
1202
+ let _currentTheme = 'dark';
1203
+
1204
+ function applyTheme(theme) {
1205
+ _currentTheme = theme;
1206
+ document.body.dataset.theme = theme;
1207
+ document.getElementById('themeToggle').textContent = 'Theme: ' + (theme === 'dark' ? 'Dark' : 'Light');
1208
+ // Toggle highlight.js theme
1209
+ const dEl = document.getElementById('hljs-dark');
1210
+ const lEl = document.getElementById('hljs-light');
1211
+ if (dEl) dEl.disabled = theme !== 'dark';
1212
+ if (lEl) lEl.disabled = theme !== 'light';
1213
+ }
1214
+
1215
+ function toggleTheme() {
1216
+ const next = _currentTheme === 'dark' ? 'light' : 'dark';
1217
+ applyTheme(next);
1218
+ // Persist to config
1219
+ api('/api/config', { method: 'POST', body: { theme: next } }).catch(() => {});
1220
+ }
1221
+
1222
+ // ═══════════════════════════════════════════════════════════
1223
+ // ── Tab switching ──
1224
+ // ═══════════════════════════════════════════════════════════
1225
+ document.querySelectorAll('.tab').forEach(tab => {
1226
+ tab.addEventListener('click', () => {
1227
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
1228
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
1229
+ tab.classList.add('active');
1230
+ const panel = document.getElementById('panel-' + tab.dataset.tab);
1231
+ if (panel) panel.classList.add('active');
1232
+ // Show/hide main depending on whether chat is active
1233
+ const mainEl = document.querySelector('main');
1234
+ mainEl.style.display = tab.dataset.tab === 'chat' ? 'none' : 'block';
1235
+ // Full-width for the cross-graph tab; constrained for others
1236
+ if (tab.dataset.tab === 'crossgraph') {
1237
+ mainEl.classList.add('wide');
1238
+ if (!window._cgLoaded) { cgLoad(); window._cgLoaded = true; }
1239
+ else { setTimeout(() => { if (_cgCy) { _cgCy.resize(); _cgCy.fit(undefined, 40); } }, 50); }
1240
+ } else {
1241
+ mainEl.classList.remove('wide');
1242
+ }
1243
+ });
1244
+ });
1245
+
1246
+ // ═══════════════════════════════════════════════════════════
1247
+ // ── Global busy indicator ──
1248
+ // ═══════════════════════════════════════════════════════════
1249
+ const _busyStack = [];
1250
+ let _busyTimer = null;
1251
+ function oracleBusy(label, opts = {}) {
1252
+ const id = Math.random().toString(36).slice(2, 9);
1253
+ _busyStack.push({ id, label, started: Date.now(), panel: opts.panel || null });
1254
+ _renderBusy();
1255
+ if (opts.panel) {
1256
+ const el = document.getElementById('panel-' + opts.panel);
1257
+ if (el) { el.classList.add('panel-busy'); el.setAttribute('data-busy-label', label); }
1258
+ }
1259
+ return id;
1260
+ }
1261
+ function oracleIdle(id) {
1262
+ const idx = _busyStack.findIndex(b => b.id === id);
1263
+ if (idx >= 0) {
1264
+ const [removed] = _busyStack.splice(idx, 1);
1265
+ if (removed.panel) {
1266
+ const el = document.getElementById('panel-' + removed.panel);
1267
+ if (el && !_busyStack.some(b => b.panel === removed.panel)) {
1268
+ el.classList.remove('panel-busy');
1269
+ el.removeAttribute('data-busy-label');
1270
+ }
1271
+ }
1272
+ }
1273
+ _renderBusy();
1274
+ }
1275
+ function _renderBusy() {
1276
+ const pill = document.getElementById('oracleBusy');
1277
+ const lbl = document.getElementById('oracleBusyLabel');
1278
+ const ela = document.getElementById('oracleBusyElapsed');
1279
+ const track = document.getElementById('progressTrack');
1280
+ if (_busyStack.length === 0) {
1281
+ pill.classList.remove('active');
1282
+ if (track) track.classList.remove('active');
1283
+ if (_busyTimer) { clearInterval(_busyTimer); _busyTimer = null; }
1284
+ return;
1285
+ }
1286
+ const top = _busyStack[_busyStack.length - 1];
1287
+ const suffix = _busyStack.length > 1 ? ` (+${_busyStack.length - 1})` : '';
1288
+ lbl.textContent = top.label + suffix;
1289
+ pill.classList.add('active');
1290
+ if (track) track.classList.add('active');
1291
+ const tick = () => {
1292
+ if (!_busyStack.length) return;
1293
+ const s = Math.floor((Date.now() - _busyStack[_busyStack.length - 1].started) / 1000);
1294
+ ela.textContent = s >= 1 ? `${s}s` : '';
1295
+ };
1296
+ tick();
1297
+ if (!_busyTimer) _busyTimer = setInterval(tick, 500);
1298
+ }
1299
+ async function withBusy(label, fn, opts) {
1300
+ const id = oracleBusy(label, opts);
1301
+ try { return await fn(); }
1302
+ finally { oracleIdle(id); }
1303
+ }
1304
+
1305
+ // ═══════════════════════════════════════════════════════════
1306
+ // ── Cross-Graph (federated memory graph) ──
1307
+ // ═══════════════════════════════════════════════════════════
1308
+ let _cgCy = null;
1309
+ const CG_PALETTE = ["#4c8bf5","#b388ff","#ef5350","#ffb74d","#66bb6a","#26c6da","#ec407a","#ab47bc","#8d6e63","#78909c","#ffca28","#ff7043"];
1310
+ const _cgProjectColor = {};
1311
+ function cgColorFor(slug) {
1312
+ if (!(slug in _cgProjectColor)) {
1313
+ _cgProjectColor[slug] = CG_PALETTE[Object.keys(_cgProjectColor).length % CG_PALETTE.length];
1314
+ }
1315
+ return _cgProjectColor[slug];
1316
+ }
1317
+ function cgToggleHelp() {
1318
+ const el = document.getElementById('cgHelp');
1319
+ el.style.display = el.style.display === 'none' ? 'grid' : 'none';
1320
+ }
1321
+ function cgRelayout() {
1322
+ if (!_cgCy) return;
1323
+ _cgCy.resize();
1324
+ try { _cgCy.layout({ name: 'fcose', animate: true, quality: 'default', nodeRepulsion: 4500 }).run(); }
1325
+ catch (e) { _cgCy.layout({ name: 'cose', animate: true }).run(); }
1326
+ setTimeout(() => { if (_cgCy) _cgCy.fit(undefined, 40); }, 50);
1327
+ }
1328
+ function cgFit() { if (_cgCy) { _cgCy.resize(); _cgCy.fit(undefined, 40); } }
1329
+
1330
+ async function cgLoad(force) {
1331
+ const minSim = parseFloat(document.getElementById('cgMinSim').value);
1332
+ const topK = parseInt(document.getElementById('cgTopK').value, 10);
1333
+ const status = document.getElementById('cgStatus');
1334
+ status.textContent = 'Loading federated graph...';
1335
+ const busyLabel = force ? 'Rebuilding federated graph (embedding facts)...' : 'Loading federated graph...';
1336
+ const busyId = oracleBusy(busyLabel, { panel: 'crossgraph' });
1337
+ let data;
1338
+ try {
1339
+ const qs = `?min_sim=${minSim}&top_k=${topK}` + (force ? '&force=1' : '');
1340
+ data = await api('/api/graph/federated' + qs);
1341
+ } catch (e) { status.textContent = 'Graph load failed: ' + e; oracleIdle(busyId); return; }
1342
+
1343
+ const s = data.stats || {};
1344
+ status.textContent = `${s.projects || 0} projects · ${s.total_nodes || 0} nodes · within=${s.within_project || 0}, cross=${s.cross_similar || 0}, insight=${s.linked_via_insight || 0} · sim=${s.similarity_method || 'n/a'}`;
1345
+
1346
+ // Legend
1347
+ const leg = document.getElementById('cgProjectLegend');
1348
+ leg.innerHTML = '';
1349
+ (data.projects || []).forEach(p => {
1350
+ const col = cgColorFor(p.slug);
1351
+ const chip = document.createElement('div');
1352
+ chip.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:10px;background:var(--bg3,var(--bg2));padding:2px 6px;border-radius:10px';
1353
+ chip.innerHTML = `<span style="width:8px;height:8px;border-radius:50%;background:${col};display:inline-block"></span><span>${p.name} (${p.fact_count})</span>`;
1354
+ leg.appendChild(chip);
1355
+ });
1356
+
1357
+ const elements = [
1358
+ ...(data.nodes || []).map(n => ({
1359
+ data: {
1360
+ id: n.id,
1361
+ label: n.label || n.id,
1362
+ project: n.project,
1363
+ category: n.category,
1364
+ relevance: n.relevance || 0,
1365
+ },
1366
+ })),
1367
+ ...(data.edges || [])
1368
+ .filter(e => e.src && e.dst && !e.src.startsWith('project:') && !e.dst.startsWith('project:'))
1369
+ .map((e, i) => ({
1370
+ data: {
1371
+ id: 'e' + i,
1372
+ source: e.src,
1373
+ target: e.dst,
1374
+ type: e.type,
1375
+ scope: e.scope,
1376
+ weight: e.weight || 1,
1377
+ },
1378
+ })),
1379
+ ];
1380
+ // Drop edges referencing missing nodes (defensive — e.g. orphaned refs)
1381
+ const _nodeIds = new Set(elements.filter(x => !x.data.source).map(x => x.data.id));
1382
+ for (let i = elements.length - 1; i >= 0; i--) {
1383
+ const d = elements[i].data;
1384
+ if (d.source && (!_nodeIds.has(d.source) || !_nodeIds.has(d.target))) elements.splice(i, 1);
1385
+ }
1386
+
1387
+ if (!_cgCy && window.cytoscape) {
1388
+ _cgCy = cytoscape({
1389
+ container: document.getElementById('cgCanvas'),
1390
+ elements,
1391
+ style: [
1392
+ { selector: 'node', style: {
1393
+ 'background-color': ele => cgColorFor(ele.data('project')),
1394
+ 'label': 'data(label)',
1395
+ 'color': '#e8eaed', 'font-size': 9,
1396
+ 'text-wrap': 'ellipsis', 'text-max-width': 120,
1397
+ 'text-valign': 'bottom', 'text-margin-y': 4,
1398
+ 'width': ele => 12 + Math.min(22, (ele.data('relevance') || 0) * 2),
1399
+ 'height': ele => 12 + Math.min(22, (ele.data('relevance') || 0) * 2),
1400
+ 'border-width': 1, 'border-color': '#1a1a1a',
1401
+ }},
1402
+ { selector: 'node:selected', style: { 'border-width': 3, 'border-color': '#ffd54f' } },
1403
+ { selector: 'edge', style: {
1404
+ 'curve-style': 'bezier',
1405
+ 'width': ele => Math.max(0.5, Math.min(4, (ele.data('weight') || 1) * 1.5)),
1406
+ 'opacity': 0.6,
1407
+ 'line-color': ele => {
1408
+ const sc = ele.data('scope');
1409
+ if (sc === 'cross_similar') return '#b388ff';
1410
+ if (sc === 'linked_via_insight') return '#4c8bf5';
1411
+ return '#9aa0a6';
1412
+ },
1413
+ 'line-style': ele => ele.data('scope') === 'cross_similar' ? 'dashed' : 'solid',
1414
+ 'target-arrow-shape': 'none',
1415
+ }},
1416
+ ],
1417
+ });
1418
+ _cgCy.on('tap', 'node', evt => cgShowDetail(evt.target.data('id')));
1419
+ } else if (_cgCy) {
1420
+ _cgCy.elements().remove();
1421
+ _cgCy.add(elements);
1422
+ }
1423
+ cgRelayout();
1424
+ oracleIdle(busyId);
1425
+ }
1426
+
1427
+ async function cgShowDetail(nodeId) {
1428
+ const el = document.getElementById('cgDetail');
1429
+ el.innerHTML = '<div style="color:var(--text2)">Loading...</div>';
1430
+ try {
1431
+ const d = await api('/api/graph/federated/node/' + encodeURIComponent(nodeId));
1432
+ const n = d.node || {};
1433
+ const col = cgColorFor(n.project);
1434
+ const neighbors = d.neighbors || [];
1435
+ const crossN = neighbors.filter(x => x.scope === 'cross_similar');
1436
+ const withinN = neighbors.filter(x => x.scope === 'within_project');
1437
+ el.innerHTML = `
1438
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
1439
+ <span style="width:10px;height:10px;border-radius:50%;background:${col};display:inline-block"></span>
1440
+ <span style="font-size:10px;color:var(--text2)">${n.project || ''}</span>
1441
+ <span style="margin-left:auto;font-size:10px;color:var(--text3,var(--text2))">${n.category || 'general'}</span>
1442
+ </div>
1443
+ <div style="color:var(--text);font-size:12px;line-height:1.45;margin-bottom:8px">${(n.text || n.label || '').replace(/</g,'&lt;')}</div>
1444
+ <div style="font-family:monospace;font-size:10px;color:var(--text3,var(--text2));margin-bottom:10px">recalls=${n.relevance || 0} · confidence=${(n.confidence ?? 1).toFixed(2)}</div>
1445
+ <div style="font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);margin-bottom:4px">Cross-project similar (${crossN.length})</div>
1446
+ <div style="display:flex;flex-direction:column;gap:3px;margin-bottom:10px;max-height:180px;overflow-y:auto">
1447
+ ${crossN.map(x => `<div style="padding:4px 6px;background:var(--bg3,var(--bg));border-radius:4px;cursor:pointer" onclick="cgShowDetail('${x.id}')">
1448
+ <div style="font-size:10px;color:${cgColorFor(x.project)}">${x.project || ''}</div>
1449
+ <div style="font-size:11px">${(x.label || '').replace(/</g,'&lt;')}</div>
1450
+ <div style="font-family:monospace;font-size:9px;color:var(--text3,var(--text2))">w=${(x.weight || 0).toFixed(2)}</div>
1451
+ </div>`).join('') || '<div style="font-size:10px;color:var(--text3)">None above threshold.</div>'}
1452
+ </div>
1453
+ <div style="font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);margin-bottom:4px">Within-project (${withinN.length})</div>
1454
+ <div style="font-size:10px;color:var(--text3,var(--text2))">${withinN.length ? withinN.length + ' edges' : 'none'}</div>
1455
+ `;
1456
+ } catch (e) {
1457
+ el.textContent = 'Detail failed: ' + e;
1458
+ }
1459
+ }
1460
+
1461
+ async function cgRebuild() {
1462
+ const btn = event?.target; if (btn) btn.disabled = true;
1463
+ await withBusy('Rebuilding federated graph...', async () => {
1464
+ try { await api('/api/graph/federated/rebuild', { method: 'POST', body: {} }); await cgLoad(true); }
1465
+ catch (e) { alert('Rebuild failed: ' + e); }
1466
+ }, { panel: 'crossgraph' });
1467
+ if (btn) btn.disabled = false;
1468
+ }
1469
+
1470
+ async function cgGenerateInsights() {
1471
+ const btn = document.getElementById('cgGenBtn');
1472
+ btn.disabled = true; btn.textContent = 'Generating...';
1473
+ await withBusy('Oracle is thinking — generating cross-project insights via LLM...', async () => {
1474
+ try {
1475
+ const r = await api('/api/insights/cross', { method: 'POST', body: {} });
1476
+ alert(`Generated ${r.generated || 0} cross-project insights.`);
1477
+ } catch (e) { alert('Generation failed: ' + e); }
1478
+ }, { panel: 'crossgraph' });
1479
+ btn.disabled = false; btn.textContent = 'Generate Cross-Insights';
1480
+ }
1481
+
1482
+ document.getElementById('cgMinSim').addEventListener('change', () => cgLoad());
1483
+ document.getElementById('cgTopK').addEventListener('change', () => cgLoad());
1484
+ // Hide main initially since chat is the default tab
1485
+ document.querySelector('main').style.display = 'none';
1486
+
1487
+ // ═══════════════════════════════════════════════════════════
1488
+ // ── API helpers ──
1489
+ // ═══════════════════════════════════════════════════════════
1490
+ async function api(path, opts = {}) {
1491
+ const res = await fetch(API + path, {
1492
+ headers: { 'Content-Type': 'application/json' },
1493
+ ...opts,
1494
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
1495
+ });
1496
+ if (!res.ok) {
1497
+ const err = await res.json().catch(() => ({}));
1498
+ throw new Error(err.error || `HTTP ${res.status}`);
1499
+ }
1500
+ return res.json();
1501
+ }
1502
+
1503
+ // ═══════════════════════════════════════════════════════════
1504
+ // ── Header status ──
1505
+ // ═══════════════════════════════════════════════════════════
1506
+ function toggleStatusDD() {
1507
+ const dd = document.getElementById('statusDD');
1508
+ dd.classList.toggle('open');
1509
+ }
1510
+ // Close dropdown on outside click
1511
+ document.addEventListener('click', (e) => {
1512
+ const wrap = document.querySelector('.status-wrap');
1513
+ if (wrap && !wrap.contains(e.target)) document.getElementById('statusDD').classList.remove('open');
1514
+ });
1515
+
1516
+ async function refreshHeader() {
1517
+ let ollamaOk = false, reviewOk = false, hubOk = false;
1518
+ let model = '?', modelVerified = false;
1519
+
1520
+ // Fetch all status from Oracle's own endpoints (no cross-origin)
1521
+ try {
1522
+ const [health, ollama, review] = await Promise.all([
1523
+ api('/api/health'),
1524
+ api('/api/ollama/status').catch(() => ({})),
1525
+ api('/api/review/status').catch(() => ({})),
1526
+ ]);
1527
+
1528
+ // Ollama
1529
+ ollamaOk = !!health.ollama_available;
1530
+ model = health.model || '?';
1531
+ // model_verified is a tri-state: null=verifying, true=ok, false=failed
1532
+ const mvRaw = health.model_verified;
1533
+ const mvStatus = mvRaw === true ? 'verified' : mvRaw === false ? 'failed' : 'verifying';
1534
+ modelVerified = mvRaw === true;
1535
+ document.getElementById('ddOllamaDot').className = 'dot ' + (ollamaOk ? 'dot-ok' : 'dot-err');
1536
+ document.getElementById('ddOllamaVal').textContent = ollamaOk ? 'connected' : 'unreachable';
1537
+ const mvSuffix = !ollamaOk ? '' : mvStatus === 'verified' ? ' \u2713' : mvStatus === 'failed' ? ' \u2717 (verify failed)' : ' (verifying...)';
1538
+ document.getElementById('ddModelInfo').textContent = 'Model: ' + model + mvSuffix;
1539
+
1540
+ // Review agent
1541
+ reviewOk = !!review.running;
1542
+ document.getElementById('ddReviewDot').className = 'dot ' + (reviewOk ? 'dot-ok' : 'dot-off');
1543
+ document.getElementById('ddReviewVal').textContent = reviewOk ? 'running' : 'stopped';
1544
+ document.getElementById('ddReviewInfo').textContent = review.last_run
1545
+ ? 'Last run: ' + timeAgo(new Date(review.last_run).getTime()) + ' \u00b7 ' + (review.projects_tracked||0) + ' project(s)'
1546
+ : 'No review yet';
1547
+
1548
+ // Hub (checked server-side by Oracle, no CORS issue)
1549
+ hubOk = !!health.hub_available;
1550
+ document.getElementById('ddHubDot').className = 'dot ' + (hubOk ? 'dot-ok' : 'dot-err');
1551
+ document.getElementById('ddHubVal').textContent = hubOk ? 'connected' : 'unreachable';
1552
+ } catch { /* ignore */ }
1553
+
1554
+ // Aggregate status pill
1555
+ const okCount = [ollamaOk, reviewOk, hubOk].filter(Boolean).length;
1556
+ const dot = document.getElementById('statusDot');
1557
+ const label = document.getElementById('statusLabel');
1558
+ if (okCount === 3) {
1559
+ dot.className = 'dot dot-ok';
1560
+ label.textContent = 'All systems OK';
1561
+ } else if (okCount === 0) {
1562
+ dot.className = 'dot dot-err';
1563
+ label.textContent = 'All offline';
1564
+ } else {
1565
+ dot.className = 'dot dot-warn';
1566
+ label.textContent = okCount + '/3 online';
1567
+ }
1568
+ }
1569
+
1570
+ // ═══════════════════════════════════════════════════════════
1571
+ // ── Projects ──
1572
+ // ═══════════════════════════════════════════════════════════
1573
+ let projectsData = [];
1574
+ let healthFilter = 'all';
1575
+ const _detailsCache = {}; // path -> { health, facts, ts }
1576
+ const _CACHE_TTL = 60000; // 60s
1577
+
1578
+ function _getCached(path) {
1579
+ const c = _detailsCache[path];
1580
+ if (c && Date.now() - c.ts < _CACHE_TTL) return c;
1581
+ return null;
1582
+ }
1583
+ function _setCache(path, health, facts) {
1584
+ _detailsCache[path] = { health, facts, ts: Date.now() };
1585
+ }
1586
+ function _invalidateCache(path) { delete _detailsCache[path]; }
1587
+
1588
+ function setHealthFilter(f) {
1589
+ healthFilter = f;
1590
+ document.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.health === f));
1591
+ renderProjects();
1592
+ }
1593
+
1594
+ async function loadProjects() {
1595
+ try {
1596
+ projectsData = await api('/api/projects');
1597
+ _populateTagFilter();
1598
+ renderProjects();
1599
+ } catch { projectsData = []; renderProjects(); }
1600
+ }
1601
+
1602
+ function _populateTagFilter() {
1603
+ const sel = document.getElementById('projTagFilter');
1604
+ const current = sel.value;
1605
+ const tags = new Set();
1606
+ projectsData.forEach(p => (p.tags || []).forEach(t => tags.add(t)));
1607
+ const sorted = [...tags].sort();
1608
+ sel.innerHTML = '<option value="">All Tags</option>' +
1609
+ sorted.map(t => `<option value="${esc(t)}"${t===current?' selected':''}>${esc(t)}</option>`).join('');
1610
+ }
1611
+
1612
+ function _filteredProjects() {
1613
+ const q = (document.getElementById('projSearch')?.value || '').toLowerCase();
1614
+ const tagFilter = document.getElementById('projTagFilter')?.value || '';
1615
+ let list = projectsData;
1616
+ if (q) list = list.filter(p => (p.name||'').toLowerCase().includes(q) || (p.path||'').toLowerCase().includes(q) || (p.tags||[]).some(t => t.toLowerCase().includes(q)));
1617
+ if (healthFilter !== 'all') list = list.filter(p => (p.health_status || 'unknown') === healthFilter);
1618
+ if (tagFilter) list = list.filter(p => (p.tags||[]).some(t => t === tagFilter || t.startsWith(tagFilter + '/')));
1619
+ const sort = document.getElementById('projSort')?.value || 'name';
1620
+ const healthOrder = { ok: 0, warning: 1, error: 2, unknown: 3 };
1621
+ list = [...list].sort((a, b) => {
1622
+ if (sort === 'name') return (a.name||'').localeCompare(b.name||'');
1623
+ if (sort === 'facts') return (b.fact_count||0) - (a.fact_count||0);
1624
+ if (sort === 'reviewed') return (b.last_reviewed||'') < (a.last_reviewed||'') ? -1 : 1;
1625
+ if (sort === 'health') return (healthOrder[a.health_status]||3) - (healthOrder[b.health_status]||3);
1626
+ return 0;
1627
+ });
1628
+ return list;
1629
+ }
1630
+
1631
+ function renderProjects() {
1632
+ const container = document.getElementById('projectsList');
1633
+ const empty = document.getElementById('projectsEmpty');
1634
+ const filtered = _filteredProjects();
1635
+
1636
+ // Stats bar
1637
+ const total = projectsData.length;
1638
+ const totalFacts = projectsData.reduce((s, p) => s + (p.fact_count||0), 0);
1639
+ const okCount = projectsData.filter(p => p.health_status === 'ok').length;
1640
+ const warnCount = projectsData.filter(p => p.health_status === 'warning').length;
1641
+ const errCount = projectsData.filter(p => p.health_status === 'error').length;
1642
+ document.getElementById('projStatsBar').innerHTML = `
1643
+ <span><span class="stat-val">${total}</span>project${total!==1?'s':''}</span>
1644
+ <span><span class="stat-val">${totalFacts}</span>total facts</span>
1645
+ <span style="color:var(--green)"><span class="stat-val">${okCount}</span>healthy</span>
1646
+ ${warnCount ? `<span style="color:var(--yellow)"><span class="stat-val">${warnCount}</span>warnings</span>` : ''}
1647
+ ${errCount ? `<span style="color:var(--red)"><span class="stat-val">${errCount}</span>errors</span>` : ''}
1648
+ `;
1649
+
1650
+ if (!projectsData.length) { container.innerHTML = ''; empty.style.display = ''; return; }
1651
+ empty.style.display = 'none';
1652
+
1653
+ if (!filtered.length) {
1654
+ container.innerHTML = '<div class="empty" style="padding:24px">No projects match filters.</div>';
1655
+ return;
1656
+ }
1657
+
1658
+ container.innerHTML = filtered.map((p) => {
1659
+ const idx = projectsData.indexOf(p);
1660
+ const dotColor = p.health_status === 'ok' ? 'var(--green)' : p.health_status === 'warning' ? 'var(--yellow)' : p.health_status === 'error' ? 'var(--red)' : 'var(--muted)';
1661
+ const reviewedStr = p.last_reviewed ? timeAgo(new Date(p.last_reviewed).getTime()) : 'never';
1662
+ const tagsHtml = (p.tags||[]).map(t => `<span class="tag">${esc(t)}</span>`).join(' ');
1663
+ return `
1664
+ <div class="proj-card" onclick="toggleDetails(${idx})">
1665
+ <div class="proj-card-row">
1666
+ <div class="proj-status-dot" style="background:${dotColor}" title="${esc(p.health_status||'unknown')}"></div>
1667
+ <div class="proj-info">
1668
+ <div class="proj-name">${esc(p.name)}${p.active ? ' <span class="tag tag-active">active</span>' : ''}${p.ide ? ` <span class="tag">${esc(p.ide)}</span>` : ''}</div>
1669
+ <div class="proj-path">${esc(p.path)}</div>
1670
+ ${tagsHtml ? `<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">${tagsHtml}</div>` : ''}
1671
+ </div>
1672
+ <div class="proj-badges">
1673
+ <span class="proj-badge proj-badge-facts">${p.fact_count||0} facts</span>
1674
+ ${p.health_issues ? `<span class="proj-badge proj-badge-issues">${p.health_issues} issue${p.health_issues!==1?'s':''}</span>` : ''}
1675
+ ${!p.has_c3 ? '<span class="proj-badge" style="color:var(--red)">no .c3</span>' : ''}
1676
+ </div>
1677
+ <div class="proj-reviewed" title="${p.last_reviewed ? 'Reviewed: '+p.last_reviewed : 'Not yet reviewed'}">
1678
+ ${reviewedStr}
1679
+ </div>
1680
+ <button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();reviewProject(${idx})" style="flex-shrink:0">Review</button>
1681
+ </div>
1682
+ <div class="proj-expand" id="projExpand-${idx}"></div>
1683
+ </div>
1684
+ `;
1685
+ }).join('');
1686
+ }
1687
+
1688
+ function _renderDetails(el, p, health, facts) {
1689
+ const stats = facts.stats || {};
1690
+ const tiers = stats.by_tier || {};
1691
+ const cats = stats.by_category || {};
1692
+ const catHtml = Object.entries(cats).sort((a,b) => b[1]-a[1]).slice(0,6)
1693
+ .map(([k,v]) => `<span class="proj-badge" style="font-size:10px">${esc(k)}: ${v}</span>`).join(' ');
1694
+ el.innerHTML = `
1695
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:12px">
1696
+ <div>
1697
+ <div style="font-weight:600;margin-bottom:6px;color:var(--text)">Tier Distribution</div>
1698
+ <div style="display:flex;gap:10px;margin-bottom:6px">
1699
+ <span style="color:var(--accent)">Core: ${tiers.core||0}</span>
1700
+ <span style="color:var(--green)">Active: ${tiers.active||0}</span>
1701
+ <span style="color:var(--yellow)">Dormant: ${tiers.dormant||0}</span>
1702
+ <span style="color:var(--muted)">Ephemeral: ${tiers.ephemeral||0}</span>
1703
+ </div>
1704
+ <div class="tier-bar" style="margin:4px 0 12px">
1705
+ <div class="tier-core" style="flex:${tiers.core||0}"></div>
1706
+ <div class="tier-active" style="flex:${tiers.active||0}"></div>
1707
+ <div class="tier-dormant" style="flex:${tiers.dormant||0}"></div>
1708
+ <div class="tier-ephemeral" style="flex:${tiers.ephemeral||0}"></div>
1709
+ </div>
1710
+ <div style="font-weight:600;margin-bottom:4px;color:var(--text)">Categories</div>
1711
+ <div style="display:flex;flex-wrap:wrap;gap:4px">${catHtml || '<span style="color:var(--text2)">none</span>'}</div>
1712
+ </div>
1713
+ <div>
1714
+ <div style="font-weight:600;margin-bottom:6px;color:var(--text)">Graph</div>
1715
+ <div style="color:var(--text2);line-height:1.8">
1716
+ ${health.graph_stats?.total_edges||0} edges \u00b7 ${health.graph_stats?.total_nodes||0} nodes<br>
1717
+ ${health.graph_stats?.orphaned_edges ? `<span style="color:var(--yellow)">${health.graph_stats.orphaned_edges} orphaned</span>` : '<span style="color:var(--green)">0 orphaned</span>'}
1718
+ </div>
1719
+ <div style="font-weight:600;margin:8px 0 4px;color:var(--text)">Freshness</div>
1720
+ <div style="color:var(--text2)">${health.freshness?.days_since_last_fact != null ? (health.freshness.days_since_last_fact === 0 ? 'Updated today' : health.freshness.days_since_last_fact + ' day' + (health.freshness.days_since_last_fact!==1?'s':'') + ' since last fact') : 'unknown'}</div>
1721
+ </div>
1722
+ </div>
1723
+ ${(health.issues||[]).length ? `<div style="margin-top:10px;font-size:12px"><div style="font-weight:600;margin-bottom:4px;color:var(--text)">Issues</div>` +
1724
+ health.issues.map(i => `<div style="padding:2px 0;color:${i.severity==='error'?'var(--red)':i.severity==='warning'?'var(--yellow)':'var(--text2)'}">
1725
+ ${i.severity==='error'?'\u2717':i.severity==='warning'?'\u26A0':'\u24D8'} ${esc(i.message)}</div>`).join('') + '</div>' : ''}
1726
+ ${p.notes ? `<div style="margin-top:10px;font-size:12px"><div style="font-weight:600;margin-bottom:4px;color:var(--text)">Notes</div><div style="color:var(--text2)">${esc(p.notes)}</div></div>` : ''}
1727
+ ${facts.facts?.length ? `<div style="margin-top:10px;font-size:12px"><div style="font-weight:600;margin-bottom:4px;color:var(--text)">Top Facts</div>` +
1728
+ facts.facts.slice(0,5).map(f => `<div style="padding:3px 0;border-bottom:1px solid var(--border)">
1729
+ <span style="color:var(--accent);font-size:10px;font-weight:600">[${esc(f.category)}]</span> ${esc(f.fact?.substring(0,150))}</div>`).join('') + '</div>' : ''}
1730
+ `;
1731
+ }
1732
+
1733
+ async function toggleDetails(idx) {
1734
+ const el = document.getElementById('projExpand-' + idx);
1735
+ if (!el) return;
1736
+ if (el.classList.contains('open')) { el.classList.remove('open'); return; }
1737
+ // Close others
1738
+ document.querySelectorAll('.proj-expand.open').forEach(e => e.classList.remove('open'));
1739
+ el.classList.add('open');
1740
+ const p = projectsData[idx];
1741
+ const cached = _getCached(p.path);
1742
+ if (cached) { _renderDetails(el, p, cached.health, cached.facts); return; }
1743
+
1744
+ el.innerHTML = '<div style="color:var(--text2);font-size:12px">Loading details...</div>';
1745
+ busyStart('Loading ' + p.name);
1746
+ try {
1747
+ const [health, facts] = await Promise.all([
1748
+ api('/api/projects/health?path=' + encodeURIComponent(p.path)),
1749
+ api('/api/projects/facts?path=' + encodeURIComponent(p.path) + '&limit=10'),
1750
+ ]);
1751
+ _setCache(p.path, health, facts);
1752
+ _renderDetails(el, p, health, facts);
1753
+ } catch (e) {
1754
+ el.innerHTML = `<div style="color:var(--red);font-size:12px">Error loading details: ${esc(e.message)}</div>`;
1755
+ toast('Details failed', e.message, 'error');
1756
+ } finally {
1757
+ busyEnd();
1758
+ }
1759
+ }
1760
+
1761
+ async function reviewProject(idx) {
1762
+ const p = projectsData[idx];
1763
+ if (!p) return;
1764
+ _invalidateCache(p.path);
1765
+ await tracked('Review: ' + p.name, async () => {
1766
+ const result = await api('/api/projects/review', { method: 'POST', body: { path: p.path } });
1767
+ const issues = (result.issues || []).length;
1768
+ toast('Review: ' + p.name,
1769
+ `Health: ${result.status || '?'}` + (issues ? ` \u2014 ${issues} issue(s)` : ' \u2014 no issues'),
1770
+ result.status === 'ok' ? 'success' : result.status === 'error' ? 'error' : 'warning', 4000);
1771
+ }, { silent: true });
1772
+ loadProjects();
1773
+ }
1774
+
1775
+ async function reviewAllProjects() {
1776
+ const total = projectsData.length;
1777
+ if (!total) return;
1778
+ projectsData.forEach(p => _invalidateCache(p.path));
1779
+ const progressEl = document.getElementById('reviewAllProgress');
1780
+ const barEl = document.getElementById('reviewAllBar');
1781
+ const labelEl = document.getElementById('reviewAllLabel');
1782
+ const countEl = document.getElementById('reviewAllCount');
1783
+ progressEl.style.display = '';
1784
+ barEl.style.width = '0%';
1785
+ countEl.textContent = `0 / ${total}`;
1786
+ labelEl.textContent = 'Reviewing...';
1787
+ busyStart('Reviewing all projects');
1788
+ let done = 0, errors = 0;
1789
+ for (const p of projectsData) {
1790
+ labelEl.textContent = `Reviewing ${p.name}...`;
1791
+ try {
1792
+ await api('/api/projects/review', { method: 'POST', body: { path: p.path } });
1793
+ } catch { errors++; }
1794
+ done++;
1795
+ barEl.style.width = Math.round((done / total) * 100) + '%';
1796
+ countEl.textContent = `${done} / ${total}`;
1797
+ }
1798
+ busyEnd();
1799
+ barEl.style.background = errors ? 'var(--yellow)' : 'var(--green)';
1800
+ labelEl.textContent = errors ? `Done with ${errors} error(s)` : 'All projects reviewed';
1801
+ toast('Review All', `${done} project(s) reviewed` + (errors ? `, ${errors} failed` : ''), errors ? 'warning' : 'success');
1802
+ loadProjects();
1803
+ setTimeout(() => { progressEl.style.display = 'none'; barEl.style.background = 'var(--accent)'; }, 4000);
1804
+ }
1805
+
1806
+ async function scanProjects() {
1807
+ await tracked('Scanning projects', async () => {
1808
+ await api('/api/projects/scan', { method: 'POST' });
1809
+ }, { successMsg: 'Projects rescanned' });
1810
+ loadProjects();
1811
+ }
1812
+
1813
+ // ═══════════════════════════════════════════════════════════
1814
+ // ── Insights ──
1815
+ // ═══════════════════════════════════════════════════════════
1816
+ async function loadInsights() {
1817
+ try {
1818
+ const data = await api('/api/insights');
1819
+ renderInsights(data.insights || []);
1820
+ } catch { renderInsights([]); }
1821
+ }
1822
+
1823
+ function renderInsights(insights) {
1824
+ const el = document.getElementById('insightsList');
1825
+ const empty = document.getElementById('insightsEmpty');
1826
+ if (!insights.length) { el.innerHTML = ''; empty.style.display = ''; return; }
1827
+ empty.style.display = 'none';
1828
+ el.innerHTML = insights.map(i => `
1829
+ <div class="card">
1830
+ <div class="card-header">
1831
+ <span class="insight-type type-${i.type||'pattern'}">${i.type||'pattern'}</span>
1832
+ <span class="card-meta">confidence: ${(i.confidence||0).toFixed(2)}</span>
1833
+ </div>
1834
+ <p style="font-size:13px;line-height:1.5">${esc(i.text)}</p>
1835
+ <div style="margin-top:6px;font-size:11px;color:var(--text2)">
1836
+ Projects: ${(i.source_projects||[]).map(p => esc(p.split(/[/\\]/).pop())).join(', ')}
1837
+ ${i.tags?.length ? ' \u00b7 Tags: ' + i.tags.join(', ') : ''}
1838
+ </div>
1839
+ <div style="margin-top:8px"><button class="btn btn-ghost btn-sm" onclick="dismissInsight('${i.id}')">Dismiss</button></div>
1840
+ </div>
1841
+ `).join('');
1842
+ }
1843
+
1844
+ async function generateInsights() {
1845
+ const btn = document.getElementById('btnGenInsights');
1846
+ btn.disabled = true;
1847
+ await tracked('Generating insights', async () => {
1848
+ const result = await api('/api/insights/generate', { method: 'POST' });
1849
+ if (result.error) throw new Error(result.error);
1850
+ loadInsights();
1851
+ return result;
1852
+ }, { successMsg: 'Insights generated' }).catch(() => {});
1853
+ btn.disabled = false;
1854
+ }
1855
+
1856
+ async function dismissInsight(id) {
1857
+ await tracked('Dismiss insight', async () => {
1858
+ await api('/api/insights/dismiss', { method: 'POST', body: { id } });
1859
+ loadInsights();
1860
+ }, { successMsg: 'Insight dismissed', silent: false });
1861
+ }
1862
+
1863
+ // ═══════════════════════════════════════════════════════════
1864
+ // ── Suggestions ──
1865
+ // ═══════════════════════════════════════════════════════════
1866
+ async function loadSuggestions() {
1867
+ try {
1868
+ const data = await api('/api/suggestions');
1869
+ renderSuggestions(data);
1870
+ } catch { renderSuggestions([]); }
1871
+ }
1872
+
1873
+ function renderSuggestions(suggestions) {
1874
+ const el = document.getElementById('suggestionsList');
1875
+ const empty = document.getElementById('suggestionsEmpty');
1876
+ if (!suggestions.length) { el.innerHTML = ''; empty.style.display = ''; return; }
1877
+ empty.style.display = 'none';
1878
+ el.innerHTML = suggestions.map(s => `
1879
+ <div class="card suggestion">
1880
+ <div class="card-header">
1881
+ <span class="card-title">${esc(s.type)} \u2014 ${esc(s.project_path?.split(/[/\\]/).pop() || '?')}</span>
1882
+ <span class="card-meta">${timeAgo(new Date(s.created_at).getTime())}</span>
1883
+ </div>
1884
+ <pre style="font-size:11px;color:var(--text2);white-space:pre-wrap;max-height:120px;overflow:auto">${esc(JSON.stringify(s.data, null, 2))}</pre>
1885
+ <div class="suggestion-actions">
1886
+ <button class="btn btn-primary btn-sm" onclick="approveSuggestion('${s.id}')">Approve</button>
1887
+ <button class="btn btn-ghost btn-sm" onclick="dismissSuggestion('${s.id}')">Dismiss</button>
1888
+ </div>
1889
+ </div>
1890
+ `).join('');
1891
+ }
1892
+
1893
+ async function approveSuggestion(id) {
1894
+ await tracked('Approving suggestion', async () => {
1895
+ const result = await api('/api/suggestions/approve', { method: 'POST', body: { id } });
1896
+ if (result.error) throw new Error(result.error);
1897
+ loadSuggestions();
1898
+ }, { successMsg: 'Suggestion applied to project memory' });
1899
+ }
1900
+
1901
+ async function dismissSuggestion(id) {
1902
+ await tracked('Dismiss suggestion', async () => {
1903
+ await api('/api/suggestions/dismiss', { method: 'POST', body: { id } });
1904
+ loadSuggestions();
1905
+ }, { successMsg: 'Suggestion dismissed' });
1906
+ }
1907
+
1908
+ // ═══════════════════════════════════════════════════════════
1909
+ // ── Settings & Agents ──
1910
+ // ═══════════════════════════════════════════════════════════
1911
+ window.oracleConfig = {};
1912
+ window.oracleAgents = [];
1913
+
1914
+ async function loadSettings() {
1915
+ try {
1916
+ const [cfg, ollama] = await Promise.all([api('/api/config'), api('/api/ollama/status')]);
1917
+ window.oracleConfig = cfg;
1918
+ window.oracleAgents = cfg.agents || [];
1919
+ renderAgents();
1920
+
1921
+ document.getElementById('cfgOllamaUrl').value = cfg.ollama_base_url || '';
1922
+ const keyInput = document.getElementById('cfgApiKey');
1923
+ const keyCheck = document.getElementById('cfgApiKeyEdit');
1924
+ const hasKey = !!cfg.ollama_api_key;
1925
+ keyInput.value = hasKey ? cfg.ollama_api_key : '';
1926
+ keyInput.placeholder = hasKey ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (key set)' : 'OLLAMA_API_KEY (or set env var)';
1927
+ keyInput.disabled = hasKey;
1928
+ keyCheck.checked = false;
1929
+ keyCheck.parentElement.style.display = hasKey ? '' : 'none';
1930
+ document.getElementById('cfgHubUrl').value = cfg.hub_url || '';
1931
+ document.getElementById('cfgInterval').value = Math.round((cfg.review_interval_seconds || 1800) / 60);
1932
+ applyTheme(cfg.theme || 'dark');
1933
+ // Hub link
1934
+ const hubUrl = cfg.hub_url || 'http://localhost:3330';
1935
+ const hubEl = document.getElementById('hubLink');
1936
+ hubEl.href = hubUrl;
1937
+ hubEl.style.display = '';
1938
+
1939
+ const modelInput = document.getElementById('cfgModel');
1940
+ const datalist = document.getElementById('cfgModelList');
1941
+ datalist.innerHTML = '';
1942
+ const models = ollama.models || [];
1943
+ // Always include current model in suggestions
1944
+ const currentModel = cfg.model || 'gemma4:31b-cloud';
1945
+ if (currentModel && !models.includes(currentModel)) models.unshift(currentModel);
1946
+ models.forEach(m => {
1947
+ const opt = document.createElement('option');
1948
+ opt.value = m;
1949
+ datalist.appendChild(opt);
1950
+ });
1951
+ modelInput.value = currentModel;
1952
+ } catch { /* ignore */ }
1953
+ }
1954
+
1955
+ function toggleApiKeyEdit() {
1956
+ const keyInput = document.getElementById('cfgApiKey');
1957
+ const checked = document.getElementById('cfgApiKeyEdit').checked;
1958
+ keyInput.disabled = !checked;
1959
+ if (checked) {
1960
+ keyInput.value = '';
1961
+ keyInput.placeholder = 'Enter new API key';
1962
+ keyInput.focus();
1963
+ } else {
1964
+ // Revert — reload will restore the masked value
1965
+ loadSettings();
1966
+ }
1967
+ }
1968
+
1969
+ async function saveSettings() {
1970
+ const keyInput = document.getElementById('cfgApiKey');
1971
+ const keyEditing = document.getElementById('cfgApiKeyEdit').checked;
1972
+ const apiKeyVal = keyEditing ? keyInput.value : '';
1973
+ const cfg = {
1974
+ model: document.getElementById('cfgModel').value,
1975
+ ollama_base_url: document.getElementById('cfgOllamaUrl').value,
1976
+ hub_url: document.getElementById('cfgHubUrl').value,
1977
+ review_interval_seconds: parseInt(document.getElementById('cfgInterval').value) * 60,
1978
+ agents: window.oracleAgents
1979
+ };
1980
+ if (apiKeyVal) cfg.ollama_api_key = apiKeyVal;
1981
+ await tracked('Saving settings', async () => {
1982
+ await api('/api/config', { method: 'POST', body: cfg });
1983
+ window.oracleConfig = cfg;
1984
+ // Update hub link
1985
+ const hubEl = document.getElementById('hubLink');
1986
+ hubEl.href = cfg.hub_url;
1987
+ refreshHeader();
1988
+ }, { successMsg: 'Configuration saved' });
1989
+ }
1990
+
1991
+ async function testConnection() {
1992
+ await tracked('Testing Ollama', async () => {
1993
+ const result = await api('/api/ollama/test', { method: 'POST' });
1994
+ const resp = result.response || 'No response';
1995
+ toast('Ollama response', resp, resp.includes('No response') ? 'warning' : 'success', 6000);
1996
+ }, { silent: true });
1997
+ }
1998
+
1999
+ // ═══════════════════════════════════════════════════════════
2000
+ // ── Utilities ──
2001
+ // ═══════════════════════════════════════════════════════════
2002
+ function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
2003
+ function timeAgo(ts) {
2004
+ const diff = Date.now() - ts;
2005
+ if (diff < 60000) return 'just now';
2006
+ if (diff < 3600000) return Math.floor(diff/60000) + 'm ago';
2007
+ if (diff < 86400000) return Math.floor(diff/3600000) + 'h ago';
2008
+ return Math.floor(diff/86400000) + 'd ago';
2009
+ }
2010
+
2011
+ // ═══════════════════════════════════════════════════════════
2012
+ // ── Chat system ──
2013
+ // ═══════════════════════════════════════════════════════════
2014
+ let chatCurrentConvId = null;
2015
+ let chatStreaming = false;
2016
+ let chatAbortController = null;
2017
+ let chatUserScrolled = false;
2018
+
2019
+ // ── Markdown renderer (marked.js + highlight.js) ──
2020
+ const _mdRenderer = new marked.Renderer();
2021
+ _mdRenderer.code = function(code, lang) {
2022
+ const language = (lang || '').trim();
2023
+ let highlighted;
2024
+ try {
2025
+ highlighted = language && hljs.getLanguage(language)
2026
+ ? hljs.highlight(code, { language }).value
2027
+ : hljs.highlightAuto(code).value;
2028
+ } catch { highlighted = esc(code); }
2029
+ const label = language || 'text';
2030
+ return '<div class="code-block-wrap">'
2031
+ + '<div class="code-block-header"><span>' + esc(label) + '</span>'
2032
+ + '<button class="code-copy-btn" onclick="chatCopyCode(this)" data-code="' + encodeURIComponent(code) + '">Copy</button></div>'
2033
+ + '<pre><code class="hljs">' + highlighted + '</code></pre></div>';
2034
+ };
2035
+ marked.setOptions({ renderer: _mdRenderer, gfm: true, breaks: true, pedantic: false });
2036
+
2037
+ function renderMarkdown(text) {
2038
+ if (!text) return '';
2039
+ try { return marked.parse(text); }
2040
+ catch { return '<pre>' + esc(text) + '</pre>'; }
2041
+ }
2042
+
2043
+ // ── Conversation list ──
2044
+ async function chatLoadConversations() {
2045
+ try {
2046
+ const data = await api('/api/chat/conversations');
2047
+ const list = document.getElementById('convList');
2048
+ const convs = data.conversations || [];
2049
+ if (convs.length === 0) {
2050
+ list.innerHTML = '<div class="empty" style="padding:20px;font-size:11px">No conversations yet</div>';
2051
+ return;
2052
+ }
2053
+ let html = '';
2054
+ let lastGroup = '';
2055
+ convs.forEach(c => {
2056
+ const group = _convDateGroup(c.updated);
2057
+ if (group !== lastGroup) {
2058
+ html += `<div class="conv-group-label">${esc(group)}</div>`;
2059
+ lastGroup = group;
2060
+ }
2061
+ html += `
2062
+ <div class="conv-item ${c.id === chatCurrentConvId ? 'active' : ''}" onclick="chatLoadConversation('${c.id}')">
2063
+ <span class="conv-title">${esc(c.title || 'Untitled')}</span>
2064
+ <span class="conv-time">${c.updated ? timeAgo(new Date(c.updated).getTime()) : (c.message_count || 0) + ' msg'}</span>
2065
+ <button class="conv-rename" onclick="event.stopPropagation();chatRenameConversation('${c.id}',this)" title="Rename">&#9998;</button>
2066
+ <button class="conv-del" onclick="event.stopPropagation();chatDeleteConversation('${c.id}')" title="Delete">\u2715</button>
2067
+ </div>`;
2068
+ });
2069
+ list.innerHTML = html;
2070
+ } catch (e) {
2071
+ console.error('Failed to load conversations:', e);
2072
+ }
2073
+ }
2074
+
2075
+ function _convDateGroup(isoStr) {
2076
+ if (!isoStr) return 'Older';
2077
+ const d = new Date(isoStr);
2078
+ const now = new Date();
2079
+ const diffMs = now - d;
2080
+ const diffDays = Math.floor(diffMs / 86400000);
2081
+ const isToday = d.toDateString() === now.toDateString();
2082
+ if (isToday) return 'Today';
2083
+ const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
2084
+ if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
2085
+ if (diffDays < 7) return 'This Week';
2086
+ return 'Older';
2087
+ }
2088
+
2089
+ function chatFilterConversations() {
2090
+ const q = (document.getElementById('convSearch').value || '').toLowerCase();
2091
+ document.querySelectorAll('#convList .conv-item').forEach(el => {
2092
+ const title = (el.querySelector('.conv-title')?.textContent || '').toLowerCase();
2093
+ el.style.display = !q || title.includes(q) ? '' : 'none';
2094
+ });
2095
+ // Hide empty group labels
2096
+ document.querySelectorAll('#convList .conv-group-label').forEach(label => {
2097
+ let next = label.nextElementSibling;
2098
+ let hasVisible = false;
2099
+ while (next && !next.classList.contains('conv-group-label')) {
2100
+ if (next.classList.contains('conv-item') && next.style.display !== 'none') hasVisible = true;
2101
+ next = next.nextElementSibling;
2102
+ }
2103
+ label.style.display = hasVisible ? '' : 'none';
2104
+ });
2105
+ }
2106
+
2107
+ async function chatRenameConversation(convId, btn) {
2108
+ const item = btn.closest('.conv-item');
2109
+ const titleEl = item.querySelector('.conv-title');
2110
+ const origTitle = titleEl.textContent;
2111
+ const input = document.createElement('input');
2112
+ input.className = 'conv-title-input';
2113
+ input.value = origTitle;
2114
+ titleEl.replaceWith(input);
2115
+ input.focus();
2116
+ input.select();
2117
+
2118
+ const finish = async (save) => {
2119
+ if (input._done) return; input._done = true;
2120
+ const newTitle = input.value.trim();
2121
+ if (save && newTitle && newTitle !== origTitle) {
2122
+ try {
2123
+ await api(`/api/chat/conversations/${convId}/title`, { method: 'PUT', body: { title: newTitle } });
2124
+ } catch (e) { chatShowToast('Rename failed: ' + e.message, 'error'); }
2125
+ }
2126
+ chatLoadConversations();
2127
+ };
2128
+ input.addEventListener('keydown', (e) => {
2129
+ if (e.key === 'Enter') { e.preventDefault(); finish(true); }
2130
+ if (e.key === 'Escape') finish(false);
2131
+ });
2132
+ input.addEventListener('blur', () => finish(true));
2133
+ }
2134
+
2135
+ async function chatNewConversation() {
2136
+ try {
2137
+ const data = await api('/api/chat/conversations', { method: 'POST', body: {} });
2138
+ chatCurrentConvId = data.id;
2139
+ chatClearMessages();
2140
+ chatUpdateStatePills({});
2141
+ chatLoadConversations();
2142
+ } catch (e) {
2143
+ toast('Error', 'Failed to create conversation', 'error');
2144
+ }
2145
+ }
2146
+
2147
+ async function chatLoadConversation(convId) {
2148
+ chatCurrentConvId = convId;
2149
+ chatLoadConversations();
2150
+ try {
2151
+ const [convData, stateData] = await Promise.all([
2152
+ api(`/api/chat/conversations/${convId}`),
2153
+ api(`/api/chat/conversations/${convId}/state`).catch(() => ({ state: {} })),
2154
+ ]);
2155
+ chatClearMessages();
2156
+ chatUpdateStatePills(stateData.state || {});
2157
+ const msgs = convData.messages || [];
2158
+ for (const msg of msgs) {
2159
+ if (msg.role === 'user') chatAppendUserMsg(msg.content, msg.timestamp);
2160
+ else if (msg.role === 'assistant') chatAppendAssistantMsg(msg.content, msg.timestamp, msg.metadata);
2161
+ else if (msg.role === 'tool_call') chatAppendToolCall(JSON.parse(msg.content || '{}'), msg.tool_id);
2162
+ else if (msg.role === 'tool_result') chatAppendToolResult(msg.tool_name, msg.content, msg.tool_id);
2163
+ }
2164
+ chatScrollToBottom(true);
2165
+ chatShowToolbar();
2166
+ chatUpdateToolbarInfo(convId);
2167
+ } catch (e) {
2168
+ toast('Error', 'Failed to load conversation', 'error');
2169
+ }
2170
+ }
2171
+
2172
+ async function chatDeleteConversation(convId) {
2173
+ try {
2174
+ await api(`/api/chat/conversations/${convId}`, { method: 'DELETE' });
2175
+ if (convId === chatCurrentConvId) {
2176
+ chatCurrentConvId = null;
2177
+ chatClearMessages();
2178
+ }
2179
+ chatLoadConversations();
2180
+ } catch (e) {
2181
+ toast('Error', 'Failed to delete conversation', 'error');
2182
+ }
2183
+ }
2184
+
2185
+ function formatMsgTime(isoStr) {
2186
+ if (!isoStr) return '';
2187
+ const d = new Date(isoStr);
2188
+ const now = new Date();
2189
+ const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
2190
+ if (d.toDateString() === now.toDateString()) return hm;
2191
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ', ' + hm;
2192
+ }
2193
+
2194
+ // ── Message rendering ──
2195
+ function chatClearMessages() {
2196
+ const container = document.getElementById('chatMessages');
2197
+ container.innerHTML = `
2198
+ <div class="chat-welcome" id="chatWelcome">
2199
+ <div style="font-size:40px;opacity:.3">&#128300;</div>
2200
+ <h2>Oracle Chat</h2>
2201
+ <p>Ask about your projects, memory patterns, health, or cross-project insights. Type <code>/</code> for commands.</p>
2202
+ <div class="chat-suggestions">
2203
+ <button onclick="chatSendSuggested('What projects do I have?')">&#128193; List my projects</button>
2204
+ <button onclick="chatSendSuggested('Show memory health across all projects')">&#128153; Memory health overview</button>
2205
+ <button onclick="chatSendSuggested('Find patterns and insights across my projects')">&#128270; Cross-project patterns</button>
2206
+ <button onclick="chatSendSuggested('Which projects have stale or duplicate facts?')">&#128214; Stale/duplicate facts</button>
2207
+ </div>
2208
+ <div style="font-size:11px;color:var(--text2);margin-top:12px">Enter to send &middot; Shift+Enter for newline &middot; / for commands</div>
2209
+ </div>
2210
+ `;
2211
+ }
2212
+
2213
+ function chatHideWelcome() {
2214
+ const welcome = document.getElementById('chatWelcome');
2215
+ if (welcome) welcome.remove();
2216
+ }
2217
+
2218
+ const _svgCopy = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>';
2219
+ const _svgRetry = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>';
2220
+
2221
+ function _msgFooter(timestamp, opts = {}) {
2222
+ const ts = timestamp ? formatMsgTime(timestamp) : '';
2223
+ const actions = [];
2224
+ actions.push(`<button class="msg-footer-btn" onclick="chatCopyMsg(this)" title="Copy">${_svgCopy} Copy</button>`);
2225
+ if (opts.retry) {
2226
+ actions.push(`<button class="msg-footer-btn" onclick="chatRetryLast()" title="Retry">${_svgRetry} Retry</button>`);
2227
+ }
2228
+ return `<div class="msg-footer">
2229
+ <span class="msg-footer-ts">${ts ? esc(ts) : ''}</span>
2230
+ <span class="msg-footer-actions">${actions.join('')}</span>
2231
+ </div>`;
2232
+ }
2233
+
2234
+ function chatAppendUserMsg(text, timestamp) {
2235
+ chatHideWelcome();
2236
+ const container = document.getElementById('chatMessages');
2237
+ const div = document.createElement('div');
2238
+ div.className = 'msg msg-user';
2239
+ div.dataset.text = text;
2240
+ div.innerHTML = `<div class="msg-bubble">${esc(text)}</div>${_msgFooter(timestamp)}`;
2241
+ container.appendChild(div);
2242
+ }
2243
+
2244
+ function _fmtTokens(n) {
2245
+ if (!n && n !== 0) return '';
2246
+ n = Number(n);
2247
+ if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
2248
+ return String(n);
2249
+ }
2250
+
2251
+ function _fmtDuration(ms) {
2252
+ if (!ms && ms !== 0) return '';
2253
+ ms = Number(ms);
2254
+ if (ms < 1000) return ms + 'ms';
2255
+ const s = ms / 1000;
2256
+ if (s < 60) return s.toFixed(s < 10 ? 2 : 1) + 's';
2257
+ const m = Math.floor(s / 60);
2258
+ const rem = Math.round(s - m * 60);
2259
+ return `${m}m${rem}s`;
2260
+ }
2261
+
2262
+ function _msgMeta(_meta) {
2263
+ // Per-message metadata footer disabled — stats are available via the stats panel.
2264
+ return '';
2265
+ }
2266
+
2267
+ function chatAppendAssistantMsg(text, timestamp, metadata) {
2268
+ chatHideWelcome();
2269
+ const container = document.getElementById('chatMessages');
2270
+ const existing = container.querySelector('.msg-streaming');
2271
+ if (existing) existing.remove();
2272
+
2273
+ const div = document.createElement('div');
2274
+ div.className = 'msg msg-assistant';
2275
+ div.dataset.text = text;
2276
+ div.innerHTML = `<div class="msg-bubble"><div class="msg-content">${renderMarkdown(text)}</div></div>${_msgMeta(metadata)}${_msgFooter(timestamp, { retry: true })}`;
2277
+ container.appendChild(div);
2278
+ }
2279
+
2280
+ // Accumulated text for the current streaming turn
2281
+ window.__oracleStreamVersion = 'v2-live-trail-2026-04-11';
2282
+ console.log('[oracle] stream UI', window.__oracleStreamVersion);
2283
+ let _streamThinkingText = '';
2284
+ let _streamResponseText = '';
2285
+ let _streamInContentThink = false; // tracks <think> tags embedded in content field
2286
+ let _streamStartTime = 0;
2287
+ let _streamTicker = null;
2288
+ let _streamPendingRoundSep = false; // set on tool_call, flushed on next thinking chunk
2289
+ let _streamRenderScheduled = false;
2290
+ let _streamThinkRenderScheduled = false;
2291
+
2292
+ function _scheduleStreamRender() {
2293
+ if (_streamRenderScheduled) return;
2294
+ _streamRenderScheduled = true;
2295
+ requestAnimationFrame(() => {
2296
+ _streamRenderScheduled = false;
2297
+ _renderStreamNow();
2298
+ });
2299
+ }
2300
+
2301
+ function _renderStreamNow() {
2302
+ const container = document.getElementById('chatMessages');
2303
+ const el = container && container.querySelector('.msg-streaming');
2304
+ if (!el) return;
2305
+ const responseEl = el.querySelector('.response-content');
2306
+ if (!responseEl) return;
2307
+ // Strip in-progress <tool_call> blocks so users don't see raw JSON.
2308
+ const displayText = _streamResponseText
2309
+ .replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '')
2310
+ .replace(/<tool_call>[\s\S]*$/, '');
2311
+ const lastLen = parseInt(responseEl.dataset.rendered || '0', 10);
2312
+ if (displayText.length === lastLen) return;
2313
+ if (!displayText.length) {
2314
+ responseEl.innerHTML = '';
2315
+ responseEl.dataset.rendered = '0';
2316
+ return;
2317
+ }
2318
+ responseEl.innerHTML = renderMarkdown(displayText);
2319
+ responseEl.dataset.rendered = String(displayText.length);
2320
+ chatScrollToBottom();
2321
+ }
2322
+
2323
+ function _scheduleThinkRender() {
2324
+ if (_streamThinkRenderScheduled) return;
2325
+ _streamThinkRenderScheduled = true;
2326
+ requestAnimationFrame(() => {
2327
+ _streamThinkRenderScheduled = false;
2328
+ _renderThinkNow();
2329
+ });
2330
+ }
2331
+
2332
+ function _renderThinkNow() {
2333
+ const container = document.getElementById('chatMessages');
2334
+ const el = container && container.querySelector('.msg-streaming');
2335
+ if (!el) return;
2336
+ const thinkBlock = el.querySelector('.thinking-block');
2337
+ if (!thinkBlock) return;
2338
+ thinkBlock.style.display = '';
2339
+ thinkBlock.setAttribute('open', '');
2340
+ const contentEl = thinkBlock.querySelector('.thinking-content');
2341
+ if (contentEl && contentEl.textContent !== _streamThinkingText) {
2342
+ contentEl.textContent = _streamThinkingText;
2343
+ contentEl.scrollTop = contentEl.scrollHeight;
2344
+ }
2345
+ const summaryEl = thinkBlock.querySelector('summary');
2346
+ if (summaryEl) summaryEl.textContent = 'Thinking \u00b7 ' + _fmtChars(_streamThinkingText.length) + ' chars';
2347
+ chatScrollToBottom();
2348
+ }
2349
+
2350
+ function _fmtElapsed(ms) {
2351
+ const s = ms / 1000;
2352
+ if (s < 60) return s.toFixed(1) + 's';
2353
+ const m = Math.floor(s / 60);
2354
+ const rem = Math.floor(s % 60);
2355
+ return m + ':' + String(rem).padStart(2, '0');
2356
+ }
2357
+
2358
+ function _updateStreamElapsed() {
2359
+ const container = document.getElementById('chatMessages');
2360
+ const el = container && container.querySelector('.msg-streaming');
2361
+ if (!el || !_streamStartTime) return;
2362
+ const ticker = el.querySelector('.stream-elapsed');
2363
+ if (!ticker) return;
2364
+ const parts = [_fmtElapsed(performance.now() - _streamStartTime)];
2365
+ if (_streamResponseText.length) parts.push(_fmtChars(_streamResponseText.length) + ' chars');
2366
+ else if (_streamThinkingText.length) parts.push(_fmtChars(_streamThinkingText.length) + ' think');
2367
+ ticker.textContent = parts.join(' \u00b7 ');
2368
+ }
2369
+
2370
+ function _startStreamTicker() {
2371
+ if (_streamTicker) return;
2372
+ _streamStartTime = performance.now();
2373
+ _streamTicker = setInterval(_updateStreamElapsed, 100);
2374
+ }
2375
+
2376
+ function _stopStreamTicker() {
2377
+ if (_streamTicker) { clearInterval(_streamTicker); _streamTicker = null; }
2378
+ }
2379
+
2380
+ function _chatPushStreamActivity(text, cls) {
2381
+ const container = document.getElementById('chatMessages');
2382
+ const el = container && container.querySelector('.msg-streaming');
2383
+ if (!el) return;
2384
+ const trail = el.querySelector('.stream-activity');
2385
+ if (!trail) return;
2386
+ // Mark previous active row as done
2387
+ const prev = trail.querySelector('.stream-activity-row.sa-active');
2388
+ if (prev) { prev.classList.remove('sa-active'); prev.classList.add('sa-done'); }
2389
+ const row = document.createElement('div');
2390
+ row.className = 'stream-activity-row sa-active' + (cls ? ' ' + cls : '');
2391
+ const elapsed = _streamStartTime ? _fmtElapsed(performance.now() - _streamStartTime) : '0.0s';
2392
+ row.innerHTML = '<span class="sa-time">' + esc(elapsed) + '</span><span class="sa-text">' + esc(text) + '</span>';
2393
+ trail.appendChild(row);
2394
+ trail.scrollTop = trail.scrollHeight;
2395
+ }
2396
+
2397
+ function _chatFinishStreamActivity() {
2398
+ const container = document.getElementById('chatMessages');
2399
+ const el = container && container.querySelector('.msg-streaming');
2400
+ if (!el) return;
2401
+ const trail = el.querySelector('.stream-activity');
2402
+ if (!trail) return;
2403
+ const prev = trail.querySelector('.stream-activity-row.sa-active');
2404
+ if (prev) { prev.classList.remove('sa-active'); prev.classList.add('sa-done'); }
2405
+ }
2406
+
2407
+ function _ensureStreamingEl() {
2408
+ const container = document.getElementById('chatMessages');
2409
+ let el = container.querySelector('.msg-streaming');
2410
+ if (!el) {
2411
+ _streamThinkingText = '';
2412
+ _streamResponseText = '';
2413
+ _streamInContentThink = false;
2414
+ _streamPendingRoundSep = false;
2415
+ el = document.createElement('div');
2416
+ el.className = 'msg msg-assistant msg-streaming';
2417
+ el.innerHTML = '<div class="msg-bubble"><div class="msg-content">'
2418
+ + '<details class="thinking-block streaming" style="display:none" open>'
2419
+ + '<summary>Thinking</summary><div class="thinking-content"></div></details>'
2420
+ + '<div class="stream-status" data-state="connecting">'
2421
+ + '<span class="stream-phase">Connecting</span>'
2422
+ + '<span class="stream-detail">Connecting to model...</span>'
2423
+ + '<span class="stream-elapsed">0.0s</span>'
2424
+ + '</div>'
2425
+ + '<div class="stream-activity"></div>'
2426
+ + '<div class="response-content"></div>'
2427
+ + '<span class="streaming-cursor"></span>'
2428
+ + '</div></div>';
2429
+ container.appendChild(el);
2430
+ _startStreamTicker();
2431
+ }
2432
+ return el;
2433
+ }
2434
+
2435
+ function chatStreamState(text) {
2436
+ const value = (text || '').toLowerCase();
2437
+ if (value.includes('thinking')) return 'thinking';
2438
+ if (value.includes('tool') || value.includes('executing')) return 'tool';
2439
+ if (value.includes('retry')) return 'retry';
2440
+ if (value.includes('writing') || value.includes('response')) return 'writing';
2441
+ return 'connecting';
2442
+ }
2443
+
2444
+ function chatStreamLabel(text, state) {
2445
+ if (!text) return '';
2446
+ if (state === 'thinking') return 'Thinking';
2447
+ if (state === 'tool') return 'Using tools';
2448
+ if (state === 'retry') return 'Retrying';
2449
+ if (state === 'writing') return text.includes('Writing') ? 'Writing' : 'Generating';
2450
+ const lower = text.toLowerCase();
2451
+ if (lower.includes('preparing')) return 'Preparing';
2452
+ if (lower.includes('context ready')) return 'Ready';
2453
+ if (lower.includes('connected')) return 'Connected';
2454
+ if (lower.includes('finaliz')) return 'Finalizing';
2455
+ return 'Connecting';
2456
+ }
2457
+
2458
+ function chatSetStreamPhase(text) {
2459
+ chatHideWelcome();
2460
+ chatRemoveTypingIndicator();
2461
+ const el = _ensureStreamingEl();
2462
+ const statusEl = el.querySelector('.stream-status');
2463
+ if (statusEl) {
2464
+ const state = chatStreamState(text);
2465
+ statusEl.dataset.state = state;
2466
+ statusEl.classList.toggle('hidden', !text);
2467
+ const phaseEl = statusEl.querySelector('.stream-phase');
2468
+ const detailEl = statusEl.querySelector('.stream-detail');
2469
+ if (phaseEl) phaseEl.textContent = chatStreamLabel(text, state);
2470
+ if (detailEl) detailEl.textContent = text || '';
2471
+ }
2472
+ chatScrollToBottom();
2473
+ }
2474
+
2475
+ function chatUpdateThinking(chunk) {
2476
+ chatHideWelcome();
2477
+ chatRemoveTypingIndicator();
2478
+ _ensureStreamingEl();
2479
+ chatSetStreamPhase('Thinking live...');
2480
+ // Flush a deferred round separator only now that real new thinking
2481
+ // content is arriving — avoids empty "--- next round ---" markers.
2482
+ if (_streamPendingRoundSep) {
2483
+ _streamThinkingText += '\n\n--- next round ---\n\n';
2484
+ _streamPendingRoundSep = false;
2485
+ }
2486
+ _streamThinkingText += chunk;
2487
+ _scheduleThinkRender();
2488
+ }
2489
+
2490
+ function chatFinishThinking() {
2491
+ const container = document.getElementById('chatMessages');
2492
+ const el = container.querySelector('.msg-streaming');
2493
+ if (!el) return;
2494
+ const thinkBlock = el.querySelector('.thinking-block');
2495
+ if (thinkBlock) {
2496
+ thinkBlock.classList.remove('streaming');
2497
+ thinkBlock.removeAttribute('open');
2498
+ }
2499
+ }
2500
+
2501
+ function chatUpdateStreamingMsg(text) {
2502
+ chatHideWelcome();
2503
+ chatRemoveTypingIndicator();
2504
+ const el = _ensureStreamingEl();
2505
+
2506
+ // Handle <think> tags embedded in content (models without thinking field)
2507
+ let remaining = text;
2508
+ while (remaining) {
2509
+ if (_streamInContentThink) {
2510
+ const closeIdx = remaining.indexOf('</think>');
2511
+ if (closeIdx === -1) {
2512
+ // Still in thinking — send entire chunk to thinking
2513
+ chatUpdateThinking(remaining);
2514
+ remaining = '';
2515
+ } else {
2516
+ // Thinking ends mid-chunk
2517
+ chatUpdateThinking(remaining.slice(0, closeIdx));
2518
+ chatFinishThinking();
2519
+ _streamInContentThink = false;
2520
+ remaining = remaining.slice(closeIdx + 8);
2521
+ }
2522
+ } else {
2523
+ const openIdx = remaining.indexOf('<think>');
2524
+ if (openIdx === -1) {
2525
+ // Normal response text
2526
+ _streamResponseText += remaining;
2527
+ remaining = '';
2528
+ } else {
2529
+ // Response text before <think>, then enter thinking
2530
+ if (openIdx > 0) _streamResponseText += remaining.slice(0, openIdx);
2531
+ _streamInContentThink = true;
2532
+ remaining = remaining.slice(openIdx + 7);
2533
+ }
2534
+ }
2535
+ }
2536
+
2537
+ // Once real response text has arrived, hide the stream-status overlay so
2538
+ // the user never sees a stale "Connecting" label sitting on top of an
2539
+ // empty bubble. Also do a synchronous render as a safety net — rAF can
2540
+ // be deferred indefinitely in background tabs or during heavy work.
2541
+ if (_streamResponseText) {
2542
+ const statusEl = el.querySelector('.stream-status');
2543
+ if (statusEl) statusEl.classList.add('hidden');
2544
+ _renderStreamNow();
2545
+ }
2546
+
2547
+ // Coalesced markdown re-render for the next frame — handles any trailing
2548
+ // chunks that arrive in the same tick.
2549
+ _scheduleStreamRender();
2550
+ }
2551
+
2552
+ function chatFinalizeStreamingMsg(text) {
2553
+ const container = document.getElementById('chatMessages');
2554
+ const el = container.querySelector('.msg-streaming');
2555
+ if (el) {
2556
+ el.classList.remove('msg-streaming');
2557
+ el.dataset.text = text;
2558
+ // Add metadata footer (model, time, tokens) before the actions footer.
2559
+ // Source of truth: _lastTurnMeta from the `done` event, with model name
2560
+ // falling back to _lastStreamModel captured on the `meta` event.
2561
+ if (!el.querySelector('.msg-meta')) {
2562
+ const meta = { ...(_lastTurnMeta || {}) };
2563
+ if (!meta.model && _lastStreamModel) meta.model = _lastStreamModel;
2564
+ const metaHtml = _msgMeta(meta);
2565
+ if (metaHtml) {
2566
+ const wrap = document.createElement('div');
2567
+ wrap.innerHTML = metaHtml;
2568
+ el.appendChild(wrap.firstElementChild);
2569
+ }
2570
+ }
2571
+ // Add footer (timestamp + actions) if not already present
2572
+ if (!el.querySelector('.msg-footer')) {
2573
+ const footer = document.createElement('div');
2574
+ footer.innerHTML = _msgFooter(new Date().toISOString(), { retry: true });
2575
+ el.appendChild(footer.firstElementChild);
2576
+ }
2577
+ // Finalize thinking block — collapse now that streaming is done
2578
+ const thinkBlock = el.querySelector('.thinking-block');
2579
+ if (thinkBlock) {
2580
+ thinkBlock.classList.remove('streaming');
2581
+ if (!_streamThinkingText) {
2582
+ thinkBlock.style.display = 'none';
2583
+ } else {
2584
+ thinkBlock.removeAttribute('open'); // collapse on finalize
2585
+ const summary = thinkBlock.querySelector('summary');
2586
+ if (summary) {
2587
+ const chars = _streamThinkingText.length;
2588
+ summary.textContent = `Thinking \u00b7 ${_fmtChars(chars)} chars`;
2589
+ }
2590
+ }
2591
+ }
2592
+ // Render response as markdown and remove cursor
2593
+ _stopStreamTicker();
2594
+ _chatFinishStreamActivity();
2595
+ const responseEl = el.querySelector('.response-content');
2596
+ const statusEl = el.querySelector('.stream-status');
2597
+ if (statusEl) statusEl.remove();
2598
+ const activityEl = el.querySelector('.stream-activity');
2599
+ if (activityEl) activityEl.remove();
2600
+ let cleanText = text
2601
+ .replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '')
2602
+ .replace(/<think>[\s\S]*?<\/think>/g, '')
2603
+ .trim();
2604
+ if (!cleanText) cleanText = 'No visible response was returned.';
2605
+ el.dataset.text = cleanText;
2606
+ if (responseEl) responseEl.innerHTML = renderMarkdown(cleanText);
2607
+ const cursor = el.querySelector('.streaming-cursor');
2608
+ if (cursor) cursor.remove();
2609
+ // Reset accumulators
2610
+ _streamThinkingText = '';
2611
+ _streamResponseText = '';
2612
+ _streamInContentThink = false;
2613
+ _streamPendingRoundSep = false;
2614
+ // Append activity summary if tool calls were made
2615
+ chatAppendActivitySummary(el);
2616
+ }
2617
+ }
2618
+
2619
+ function _toolCallSummary(call) {
2620
+ const name = call.name || 'tool';
2621
+ const args = call.args || {};
2622
+ // Extract the most meaningful arg value for a one-line summary
2623
+ const keyPriority = ['query', 'project_path', 'path', 'action', 'fact_ids'];
2624
+ for (const k of keyPriority) {
2625
+ if (args[k]) {
2626
+ const v = typeof args[k] === 'string' ? args[k] : JSON.stringify(args[k]);
2627
+ const short = v.length > 50 ? v.slice(0, 47) + '...' : v;
2628
+ return short;
2629
+ }
2630
+ }
2631
+ // Fallback: first string arg
2632
+ for (const v of Object.values(args)) {
2633
+ if (typeof v === 'string' && v.length > 0) {
2634
+ return v.length > 50 ? v.slice(0, 47) + '...' : v;
2635
+ }
2636
+ }
2637
+ return 'running...';
2638
+ }
2639
+
2640
+ function chatRetryLast() {
2641
+ if (chatStreaming) return;
2642
+ // Find the last user message
2643
+ const userMsgs = document.querySelectorAll('#chatMessages .msg-user');
2644
+ if (!userMsgs.length) return;
2645
+ const lastUser = userMsgs[userMsgs.length - 1];
2646
+ const text = lastUser.dataset.text;
2647
+ if (!text) return;
2648
+ const input = document.getElementById('chatInput');
2649
+ input.value = text;
2650
+ chatSendMessage();
2651
+ }
2652
+
2653
+ function chatInsertTypingIndicator() {
2654
+ chatRemoveTypingIndicator();
2655
+ const container = document.getElementById('chatMessages');
2656
+ const el = document.createElement('div');
2657
+ el.className = 'msg msg-assistant msg-typing';
2658
+ el.style.animation = 'none'; // don't fade-in the dots
2659
+ el.innerHTML = '<div class="msg-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>';
2660
+ container.appendChild(el);
2661
+ chatScrollToBottom(true);
2662
+ }
2663
+
2664
+ function chatRemoveTypingIndicator() {
2665
+ const el = document.querySelector('.msg-typing');
2666
+ if (el) el.remove();
2667
+ }
2668
+
2669
+ function chatAppendToolCall(call, toolId) {
2670
+ chatHideWelcome();
2671
+ const container = document.getElementById('chatMessages');
2672
+ const div = document.createElement('div');
2673
+ div.className = 'msg msg-tool';
2674
+ div.id = `tool-${toolId}`;
2675
+ const argsStr = JSON.stringify(call.args || {}, null, 2);
2676
+ div.innerHTML = `
2677
+ <div class="tool-header" onclick="this.nextElementSibling.classList.toggle('open');this.querySelector('.tool-chevron').classList.toggle('open')">
2678
+ <span class="tool-badge">${esc(call.name || 'tool')}</span>
2679
+ <span class="tool-label">${esc(_toolCallSummary(call))}<span class="tool-spinner"> \u25CF</span></span>
2680
+ <span class="tool-chevron open">\u25B6</span>
2681
+ </div>
2682
+ <div class="tool-body open">
2683
+ <div class="tool-section-label">Arguments</div>
2684
+ <pre>${esc(argsStr)}</pre>
2685
+ <div class="tool-result-area" id="tool-result-${toolId}"></div>
2686
+ </div>
2687
+ `;
2688
+ container.appendChild(div);
2689
+ chatScrollToBottom();
2690
+ }
2691
+
2692
+ function chatAppendToolResult(name, resultStr, toolId, durLabel = '') {
2693
+ // Try to find the tool call element and add result
2694
+ const area = document.getElementById(`tool-result-${toolId}`);
2695
+ const displayStr = typeof resultStr === 'string' ? resultStr : JSON.stringify(resultStr, null, 2);
2696
+ if (area) {
2697
+ const toolEl = area.closest('.msg-tool');
2698
+ const label = toolEl.querySelector('.tool-label');
2699
+ if (label) label.innerHTML = `<span class="tool-status-ok">\u2713</span> Done${esc(durLabel)}`;
2700
+ area.innerHTML = `
2701
+ <div class="tool-section-label" style="margin-top:8px">Result</div>
2702
+ <pre>${esc(displayStr)}</pre>
2703
+ `;
2704
+ // Keep the body open if it contains a sub-agent activity block so the
2705
+ // user can review what the agent streamed. Otherwise collapse.
2706
+ const hasAgent = toolEl.querySelector('.agent-activity');
2707
+ const body = toolEl.querySelector('.tool-body');
2708
+ const chevron = toolEl.querySelector('.tool-chevron');
2709
+ if (!hasAgent) {
2710
+ if (body) body.classList.remove('open');
2711
+ if (chevron) chevron.classList.remove('open');
2712
+ }
2713
+ } else {
2714
+ // Fallback: append as standalone
2715
+ const container = document.getElementById('chatMessages');
2716
+ const div = document.createElement('div');
2717
+ div.className = 'msg msg-tool';
2718
+ div.innerHTML = `
2719
+ <div class="tool-header">
2720
+ <span class="tool-badge">${esc(name || 'tool')}</span>
2721
+ <span class="tool-label"><span class="tool-status-ok">\u2713</span> Result${esc(durLabel)}</span>
2722
+ </div>
2723
+ <div class="tool-body open">
2724
+ <pre>${esc(displayStr)}</pre>
2725
+ </div>
2726
+ `;
2727
+ container.appendChild(div);
2728
+ }
2729
+ chatScrollToBottom();
2730
+ }
2731
+
2732
+ function chatAppendError(msg) {
2733
+ const container = document.getElementById('chatMessages');
2734
+ const div = document.createElement('div');
2735
+ div.className = 'msg-error';
2736
+ div.textContent = msg;
2737
+ container.appendChild(div);
2738
+ chatScrollToBottom();
2739
+ }
2740
+
2741
+ // ── Sub-agent activity (nested inside delegate_task tool blocks) ──
2742
+
2743
+ function chatAgentEnsureBlock(toolId, agentId) {
2744
+ const toolEl = document.getElementById(`tool-${toolId}`);
2745
+ if (!toolEl) return null;
2746
+ const body = toolEl.querySelector('.tool-body');
2747
+ if (!body) return null;
2748
+ let block = body.querySelector('.agent-activity');
2749
+ if (!block) {
2750
+ block = document.createElement('div');
2751
+ block.className = 'agent-activity';
2752
+ if (agentId) block.dataset.agentId = agentId;
2753
+ block.innerHTML = `
2754
+ <div class="agent-header">
2755
+ <span class="agent-badge">${esc(agentId || 'agent')}</span>
2756
+ <span class="agent-round"></span>
2757
+ <span class="agent-spinner">\u25CF</span>
2758
+ </div>
2759
+ <details class="thinking-block agent-thinking streaming" style="display:none" open>
2760
+ <summary>Thinking</summary>
2761
+ <div class="thinking-content"></div>
2762
+ </details>
2763
+ <div class="agent-tools"></div>
2764
+ <div class="agent-response"></div>
2765
+ <div class="agent-footer"></div>
2766
+ `;
2767
+ // Ensure the tool body is open so users see live activity
2768
+ body.classList.add('open');
2769
+ const chevron = toolEl.querySelector('.tool-chevron');
2770
+ if (chevron) chevron.classList.add('open');
2771
+ body.appendChild(block);
2772
+ }
2773
+ return block;
2774
+ }
2775
+
2776
+ function chatAgentStart(event) {
2777
+ const block = chatAgentEnsureBlock(event.tool_id, event.agent_id);
2778
+ if (!block) return;
2779
+ block.dataset.text = '';
2780
+ block.dataset.thinkingText = '';
2781
+ const label = document.querySelector(`#tool-${event.tool_id} .tool-label`);
2782
+ if (label) label.innerHTML = `Delegating to <strong>${esc(event.agent_id || 'agent')}</strong><span class="tool-spinner"> \u25CF</span>`;
2783
+ chatScrollToBottom();
2784
+ }
2785
+
2786
+ function chatAgentRound(event) {
2787
+ const block = chatAgentEnsureBlock(event.tool_id);
2788
+ if (!block) return;
2789
+ const roundEl = block.querySelector('.agent-round');
2790
+ if (roundEl) roundEl.textContent = `round ${event.round}`;
2791
+ }
2792
+
2793
+ function chatAgentThinking(event) {
2794
+ const block = chatAgentEnsureBlock(event.tool_id);
2795
+ if (!block) return;
2796
+ const thinkBlock = block.querySelector('.thinking-block');
2797
+ if (!thinkBlock) return;
2798
+ thinkBlock.style.display = '';
2799
+ thinkBlock.setAttribute('open', '');
2800
+ const contentEl = thinkBlock.querySelector('.thinking-content');
2801
+ const prev = block.dataset.thinkingText || '';
2802
+ const next = prev + (event.content || '');
2803
+ block.dataset.thinkingText = next;
2804
+ contentEl.textContent = next;
2805
+ contentEl.scrollTop = contentEl.scrollHeight;
2806
+ const summary = thinkBlock.querySelector('summary');
2807
+ if (summary) summary.textContent = 'Thinking \u00b7 ' + _fmtChars(next.length) + ' chars';
2808
+ chatScrollToBottom();
2809
+ }
2810
+
2811
+ function chatAgentText(event) {
2812
+ const block = chatAgentEnsureBlock(event.tool_id);
2813
+ if (!block) return;
2814
+ const respEl = block.querySelector('.agent-response');
2815
+ if (!respEl) return;
2816
+ const prev = block.dataset.text || '';
2817
+ const next = prev + (event.content || '');
2818
+ block.dataset.text = next;
2819
+ // Strip any tool_call/think wrappers for live preview
2820
+ const clean = next
2821
+ .replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '\u2026')
2822
+ .replace(/<think>[\s\S]*?<\/think>/g, '');
2823
+ respEl.textContent = clean;
2824
+ chatScrollToBottom();
2825
+ }
2826
+
2827
+ function chatAgentToolCall(event) {
2828
+ const block = chatAgentEnsureBlock(event.tool_id);
2829
+ if (!block) return;
2830
+ const toolsEl = block.querySelector('.agent-tools');
2831
+ if (!toolsEl) return;
2832
+ const subId = event.sub_tool_id;
2833
+ const argsStr = JSON.stringify(event.args || {}, null, 2);
2834
+ const card = document.createElement('div');
2835
+ card.className = 'agent-sub-tool';
2836
+ card.id = `agent-sub-${subId}`;
2837
+ card.innerHTML = `
2838
+ <div class="agent-sub-header" onclick="this.nextElementSibling.classList.toggle('open')">
2839
+ <span class="agent-sub-badge">${esc(event.name || 'tool')}</span>
2840
+ <span class="agent-sub-label">${esc(_toolCallSummary({ name: event.name, args: event.args }))}<span class="tool-spinner"> \u25CF</span></span>
2841
+ </div>
2842
+ <div class="agent-sub-body">
2843
+ <div class="tool-section-label">Arguments</div>
2844
+ <pre>${esc(argsStr)}</pre>
2845
+ <div class="agent-sub-result" id="agent-sub-result-${subId}"></div>
2846
+ </div>
2847
+ `;
2848
+ toolsEl.appendChild(card);
2849
+ chatScrollToBottom();
2850
+ }
2851
+
2852
+ function chatAgentToolResult(event) {
2853
+ const subId = event.sub_tool_id;
2854
+ const card = document.getElementById(`agent-sub-${subId}`);
2855
+ if (!card) return;
2856
+ const label = card.querySelector('.agent-sub-label');
2857
+ const durLabel = event.duration_ms ? ` (${event.duration_ms}ms)` : '';
2858
+ const hasError = event.result && typeof event.result === 'object' && 'error' in event.result;
2859
+ if (label) {
2860
+ label.innerHTML = hasError
2861
+ ? `<span class="tool-status-err">\u2717</span> Error${esc(durLabel)}`
2862
+ : `<span class="tool-status-ok">\u2713</span> Done${esc(durLabel)}`;
2863
+ }
2864
+ const resultArea = document.getElementById(`agent-sub-result-${subId}`);
2865
+ if (resultArea) {
2866
+ const display = typeof event.result === 'string' ? event.result : JSON.stringify(event.result, null, 2);
2867
+ resultArea.innerHTML = `<div class="tool-section-label" style="margin-top:6px">Result</div><pre>${esc(display)}</pre>`;
2868
+ }
2869
+ }
2870
+
2871
+ function chatAgentDone(event) {
2872
+ const block = chatAgentEnsureBlock(event.tool_id, event.agent_id);
2873
+ if (!block) return;
2874
+ const spinner = block.querySelector('.agent-spinner');
2875
+ if (spinner) spinner.remove();
2876
+ const footer = block.querySelector('.agent-footer');
2877
+ if (footer) {
2878
+ const parts = [];
2879
+ if (event.rounds) parts.push(`${event.rounds} round${event.rounds > 1 ? 's' : ''}`);
2880
+ if (event.result_chars) parts.push(`${_fmtChars(event.result_chars)} chars`);
2881
+ if (event.duration_ms) parts.push(_fmtDuration(event.duration_ms));
2882
+ if (event.error) parts.push(`\u26A0 ${event.error}`);
2883
+ footer.innerHTML = parts.map(p => `<span>${esc(p)}</span>`).join('<span class="msg-meta-sep">\u00b7</span>');
2884
+ }
2885
+ // Finalize thinking block — collapse it
2886
+ const thinkBlock = block.querySelector('.thinking-block');
2887
+ if (thinkBlock) {
2888
+ thinkBlock.classList.remove('streaming');
2889
+ thinkBlock.removeAttribute('open');
2890
+ }
2891
+ block.classList.add('done');
2892
+ }
2893
+
2894
+ // ── Status bar (activity trail) ──
2895
+ let _statusTrailSteps = [];
2896
+
2897
+ function chatShowStatus(msg, detail = '') {
2898
+ const bar = document.getElementById('chatStatusBar');
2899
+ bar.classList.add('active');
2900
+ const trail = document.getElementById('chatStatusTrail');
2901
+
2902
+ // Deduplicate consecutive identical messages
2903
+ if (_statusTrailSteps.length && _statusTrailSteps[_statusTrailSteps.length - 1] === msg) {
2904
+ document.getElementById('chatStatusDetail').textContent = detail;
2905
+ return;
2906
+ }
2907
+
2908
+ _statusTrailSteps.push(msg);
2909
+
2910
+ // Rebuild trail: previous steps faded, current step highlighted
2911
+ let html = '';
2912
+ const visible = _statusTrailSteps.length > 6 ? _statusTrailSteps.slice(-6) : _statusTrailSteps;
2913
+ visible.forEach((s, i) => {
2914
+ if (i > 0) html += '<span class="chat-status-sep">\u203A</span>';
2915
+ html += `<span class="chat-status-step${i === visible.length - 1 ? ' active' : ''}">${esc(s)}</span>`;
2916
+ });
2917
+ trail.innerHTML = html;
2918
+ document.getElementById('chatStatusDetail').textContent = detail;
2919
+ }
2920
+
2921
+ function chatHideStatus() {
2922
+ document.getElementById('chatStatusBar').classList.remove('active');
2923
+ document.getElementById('chatStatusTrail').innerHTML = '';
2924
+ document.getElementById('chatStatusStats').innerHTML = '';
2925
+ _statusTrailSteps = [];
2926
+ }
2927
+
2928
+ function chatShowStats(stats) {
2929
+ const el = document.getElementById('chatStatusStats');
2930
+ if (!stats) { el.innerHTML = ''; return; }
2931
+ const parts = [];
2932
+ if (stats.total_ms) parts.push(`${(stats.total_ms / 1000).toFixed(1)}s`);
2933
+ // Token breakdown: thinking + response
2934
+ if (stats.eval_tokens) {
2935
+ parts.push(`${stats.eval_tokens} tok`);
2936
+ }
2937
+ if (stats.thinking_chars) {
2938
+ parts.push(`${_fmtChars(stats.thinking_chars)} thinking`);
2939
+ }
2940
+ if (stats.response_chars) {
2941
+ parts.push(`${_fmtChars(stats.response_chars)} response`);
2942
+ }
2943
+ if (stats.prompt_tokens) {
2944
+ parts.push(`${stats.prompt_tokens} prompt`);
2945
+ }
2946
+ if (stats.tokens_per_sec) {
2947
+ parts.push(`${stats.tokens_per_sec} tok/s`);
2948
+ }
2949
+ if (stats.tool_calls) parts.push(`${stats.tool_calls} tool${stats.tool_calls > 1 ? 's' : ''}`);
2950
+ if (stats.rounds > 1) parts.push(`${stats.rounds} rounds`);
2951
+ el.innerHTML = parts.map(p => `<span>${esc(p)}</span>`).join('');
2952
+ }
2953
+
2954
+ function _fmtChars(n) {
2955
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
2956
+ return String(n);
2957
+ }
2958
+
2959
+ // ── Copy & Export utilities ──
2960
+ function chatCopyCode(btn) {
2961
+ const code = decodeURIComponent(btn.dataset.code);
2962
+ navigator.clipboard.writeText(code);
2963
+ btn.textContent = 'Copied!';
2964
+ setTimeout(() => btn.textContent = 'Copy', 1500);
2965
+ }
2966
+
2967
+ function chatCopyMsg(btn) {
2968
+ const msg = btn.closest('.msg');
2969
+ const text = msg ? (msg.dataset.text || msg.innerText) : '';
2970
+ navigator.clipboard.writeText(text);
2971
+ btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg> Copied`;
2972
+ setTimeout(() => {
2973
+ btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> Copy`;
2974
+ }, 1500);
2975
+ }
2976
+
2977
+ function chatCopyAll() {
2978
+ const msgs = document.querySelectorAll('#chatMessages .msg');
2979
+ const parts = [];
2980
+ msgs.forEach(m => {
2981
+ const text = m.dataset.text || m.innerText || '';
2982
+ if (!text.trim()) return;
2983
+ const isUser = m.classList.contains('msg-user');
2984
+ const isTool = m.classList.contains('msg-tool');
2985
+ if (isTool) {
2986
+ const badge = m.querySelector('.tool-badge');
2987
+ parts.push('**Tool: ' + (badge ? badge.textContent : 'tool') + '**\n' + text);
2988
+ } else {
2989
+ parts.push('## ' + (isUser ? 'User' : 'Assistant') + '\n\n' + text);
2990
+ }
2991
+ });
2992
+ if (!parts.length) return;
2993
+ navigator.clipboard.writeText(parts.join('\n\n---\n\n'));
2994
+ chatShowToast('Conversation copied to clipboard');
2995
+ }
2996
+
2997
+ function chatToggleExport() {
2998
+ document.getElementById('exportMenu').classList.toggle('open');
2999
+ }
3000
+
3001
+ async function chatExport(format) {
3002
+ document.getElementById('exportMenu').classList.remove('open');
3003
+ if (!chatCurrentConvId) { chatShowToast('No conversation selected', 'error'); return; }
3004
+ try {
3005
+ const msgs = document.querySelectorAll('#chatMessages .msg');
3006
+ if (format === 'json') {
3007
+ const turns = [];
3008
+ msgs.forEach(m => {
3009
+ const text = m.dataset.text || m.innerText || '';
3010
+ if (!text.trim()) return;
3011
+ turns.push({ role: m.classList.contains('msg-user') ? 'user' : 'assistant', text });
3012
+ });
3013
+ const blob = new Blob([JSON.stringify({ conversation_id: chatCurrentConvId, turns }, null, 2)], { type: 'application/json' });
3014
+ const url = URL.createObjectURL(blob);
3015
+ const a = document.createElement('a'); a.href = url; a.download = 'conversation-' + chatCurrentConvId.slice(0, 8) + '.json'; a.click();
3016
+ URL.revokeObjectURL(url);
3017
+ } else {
3018
+ const parts = ['# Conversation ' + chatCurrentConvId.slice(0, 8), ''];
3019
+ msgs.forEach(m => {
3020
+ const text = m.dataset.text || m.innerText || '';
3021
+ if (!text.trim()) return;
3022
+ const isUser = m.classList.contains('msg-user');
3023
+ parts.push('## ' + (isUser ? 'User' : 'Assistant'), '', text, '', '---', '');
3024
+ });
3025
+ const blob = new Blob([parts.join('\n')], { type: 'text/markdown' });
3026
+ const url = URL.createObjectURL(blob);
3027
+ const a = document.createElement('a'); a.href = url; a.download = 'conversation-' + chatCurrentConvId.slice(0, 8) + '.md'; a.click();
3028
+ URL.revokeObjectURL(url);
3029
+ }
3030
+ chatShowToast('Exported as ' + format);
3031
+ } catch (e) { chatShowToast('Export failed: ' + e.message, 'error'); }
3032
+ }
3033
+
3034
+ // Close export dropdown on outside click
3035
+ document.addEventListener('mousedown', function(e) {
3036
+ const menu = document.getElementById('exportMenu');
3037
+ if (menu && !e.target.closest('.export-dd')) menu.classList.remove('open');
3038
+ });
3039
+
3040
+ // ── In-conversation search ──
3041
+ let _chatSearchMatches = [];
3042
+ let _chatSearchIdx = -1;
3043
+
3044
+ let _chatSearchTimer = null;
3045
+ function chatToolbarSearch(query) {
3046
+ clearTimeout(_chatSearchTimer);
3047
+ _chatSearchTimer = setTimeout(() => _chatToolbarSearchExec(query), 150);
3048
+ }
3049
+
3050
+ function _chatToolbarSearchExec(query) {
3051
+ // Clear previous highlights and merge fragmented text nodes
3052
+ document.querySelectorAll('#chatMessages .search-highlight').forEach(el => {
3053
+ el.replaceWith(el.textContent);
3054
+ });
3055
+ document.querySelectorAll('#chatMessages .msg-content, #chatMessages .msg-bubble').forEach(el => {
3056
+ el.normalize();
3057
+ });
3058
+ _chatSearchMatches = [];
3059
+ _chatSearchIdx = -1;
3060
+ const countEl = document.getElementById('toolbarSearchCount');
3061
+
3062
+ if (!query || query.length < 2) { countEl.textContent = ''; return; }
3063
+
3064
+ const msgs = document.querySelectorAll('#chatMessages .msg');
3065
+ const escapedQ = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3066
+ const re = new RegExp('(' + escapedQ + ')', 'gi');
3067
+
3068
+ msgs.forEach(m => {
3069
+ const content = m.querySelector('.msg-content') || m.querySelector('.msg-bubble');
3070
+ if (!content) return;
3071
+ const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
3072
+ const textNodes = [];
3073
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
3074
+
3075
+ textNodes.forEach(node => {
3076
+ if (!re.test(node.textContent)) return;
3077
+ re.lastIndex = 0;
3078
+ // Build DOM nodes directly — innerHTML would corrupt content with < > & chars
3079
+ const parts = node.textContent.split(re);
3080
+ const frag = document.createDocumentFragment();
3081
+ for (let i = 0; i < parts.length; i++) {
3082
+ if (i % 2 === 0) {
3083
+ if (parts[i]) frag.appendChild(document.createTextNode(parts[i]));
3084
+ } else {
3085
+ const mark = document.createElement('mark');
3086
+ mark.className = 'search-highlight';
3087
+ mark.textContent = parts[i];
3088
+ frag.appendChild(mark);
3089
+ }
3090
+ }
3091
+ node.replaceWith(frag);
3092
+ });
3093
+ });
3094
+
3095
+ _chatSearchMatches = document.querySelectorAll('#chatMessages .search-highlight');
3096
+ countEl.textContent = _chatSearchMatches.length ? '0/' + _chatSearchMatches.length : 'No results';
3097
+ if (_chatSearchMatches.length) chatToolbarSearchNav(1);
3098
+ }
3099
+
3100
+ function chatToolbarSearchNav(dir) {
3101
+ if (!_chatSearchMatches.length) return;
3102
+ // Remove active highlight from previous
3103
+ if (_chatSearchIdx >= 0 && _chatSearchMatches[_chatSearchIdx]) {
3104
+ _chatSearchMatches[_chatSearchIdx].style.background = 'rgba(234,179,8,.25)';
3105
+ }
3106
+ _chatSearchIdx = (_chatSearchIdx + dir + _chatSearchMatches.length) % _chatSearchMatches.length;
3107
+ const el = _chatSearchMatches[_chatSearchIdx];
3108
+ if (el) {
3109
+ el.style.background = 'rgba(234,179,8,.55)';
3110
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
3111
+ }
3112
+ document.getElementById('toolbarSearchCount').textContent = (_chatSearchIdx + 1) + '/' + _chatSearchMatches.length;
3113
+ }
3114
+
3115
+ // ── Activity summary for assistant messages ──
3116
+ let _chatPendingToolCalls = [];
3117
+
3118
+ // Module-scoped state shared between chatSendMessage stream loop and
3119
+ // chatFinalizeStreamingMsg — captures meta/done events so we can paint a
3120
+ // metadata footer on the assistant bubble at finalization time.
3121
+ let _lastTurnMeta = null; // stats dict from the `done` event
3122
+ let _lastStreamModel = null; // model name from the `meta` event
3123
+
3124
+ function chatAppendActivitySummary(msgEl) {
3125
+ if (!_chatPendingToolCalls.length) return;
3126
+ const calls = [..._chatPendingToolCalls];
3127
+ _chatPendingToolCalls = [];
3128
+
3129
+ const bubble = msgEl.querySelector('.msg-bubble');
3130
+ if (!bubble) return;
3131
+
3132
+ // Build summary
3133
+ const counts = {};
3134
+ const files = new Set();
3135
+ const filePat = /(?:[\w./-]+\/)*[\w.-]+\.\w{1,6}/g;
3136
+ calls.forEach(c => {
3137
+ const name = c.name || 'tool';
3138
+ counts[name] = (counts[name] || 0) + 1;
3139
+ const m = (JSON.stringify(c.args || '') || '').match(filePat);
3140
+ if (m) m.forEach(f => { if (f.length > 3 && f.includes('.')) files.add(f); });
3141
+ });
3142
+
3143
+ const summaryId = 'activity-' + Date.now();
3144
+ const completed = calls.filter(c => c.status !== 'running').length;
3145
+ const failed = calls.filter(c => c.status === 'error').length;
3146
+ const totalMs = calls.reduce((sum, c) => sum + (Number(c.duration_ms) || 0), 0);
3147
+ const toolBadges = Object.entries(counts).sort((a, b) => b[1] - a[1])
3148
+ .map(([name, ct]) => `<span class="msg-activity-badge neutral">${esc(name)}${ct > 1 ? ' x' + ct : ''}</span>`)
3149
+ .join(' ');
3150
+
3151
+ const detailRows = calls.slice(0, 20).map(c => {
3152
+ const args = typeof c.args === 'string' ? c.args : JSON.stringify(c.args || {});
3153
+ const truncArgs = args.length > 120 ? args.slice(0, 120) + '...' : args;
3154
+ const meta = [c.status || 'running'];
3155
+ const statusClass = c.status === 'error' ? 'err' : (c.status === 'ok' ? 'ok' : 'neutral');
3156
+ if (c.duration_ms) meta.push(c.duration_ms + 'ms');
3157
+ return `<div class="activity-tool-row"><span class="activity-tool-name">${esc(c.name || '?')}</span><span class="activity-tool-args">${esc(truncArgs)}</span><span class="msg-activity-badge ${statusClass}">${esc(meta.join(' \u00b7 '))}</span></div>`;
3158
+ }).join('');
3159
+
3160
+ const filesBadges = [...files].slice(0, 10).map(f => `<span class="activity-file-badge">${esc(f)}</span>`).join('');
3161
+
3162
+ const div = document.createElement('div');
3163
+ div.className = 'msg-activity-summary';
3164
+ div.innerHTML = `
3165
+ <div class="msg-activity-header" onclick="this.querySelector('.chevron').classList.toggle('open');document.getElementById('${summaryId}').classList.toggle('open')">
3166
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
3167
+ Agent Activity
3168
+ <span class="msg-activity-badge ${failed ? 'err' : 'ok'}">${completed}/${calls.length}</span>
3169
+ ${totalMs ? `<span class="msg-activity-badge neutral">${totalMs}ms</span>` : ''}
3170
+ <span class="chevron">\u25B6</span>
3171
+ </div>
3172
+ <div class="msg-activity-body" id="${summaryId}">
3173
+ <div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px">${toolBadges}</div>
3174
+ ${detailRows}
3175
+ ${calls.length > 20 ? '<div class="activity-summary-line">... and ' + (calls.length - 20) + ' more</div>' : ''}
3176
+ ${filesBadges ? '<div class="activity-files" style="margin-top:6px"><span style="color:var(--text2);font-size:10px;margin-right:4px">Files:</span>' + filesBadges + '</div>' : ''}
3177
+ <div class="activity-summary-line">Used ${calls.length} tool${calls.length > 1 ? 's' : ''}: ${Object.entries(counts).map(([n, c]) => n + (c > 1 ? ' x' + c : '')).join(', ')}${failed ? ' \u00b7 ' + failed + ' failed' : ''}${totalMs ? ' \u00b7 ' + totalMs + 'ms total' : ''}</div>
3178
+ </div>
3179
+ `;
3180
+ // Insert between bubble and footer for clean layout
3181
+ const footer = msgEl.querySelector('.msg-footer');
3182
+ if (footer) {
3183
+ msgEl.insertBefore(div, footer);
3184
+ } else {
3185
+ msgEl.appendChild(div);
3186
+ }
3187
+ }
3188
+
3189
+ function chatShowToast(message, type = 'info') {
3190
+ const existing = document.querySelector('.chat-toast');
3191
+ if (existing) existing.remove();
3192
+ const div = document.createElement('div');
3193
+ div.className = 'chat-toast';
3194
+ div.textContent = message;
3195
+ if (type === 'error') div.style.borderColor = 'var(--red)';
3196
+ document.body.appendChild(div);
3197
+ setTimeout(() => div.remove(), 3000);
3198
+ }
3199
+
3200
+ // ── Toolbar visibility ──
3201
+ function chatShowToolbar() {
3202
+ const tb = document.getElementById('chatToolbar');
3203
+ if (tb) tb.style.display = 'flex';
3204
+ }
3205
+
3206
+ function chatUpdateToolbarInfo(convId) {
3207
+ const srcEl = document.getElementById('toolbarSource');
3208
+ const cntEl = document.getElementById('toolbarTurnCount');
3209
+ if (srcEl) srcEl.textContent = 'Oracle';
3210
+ const msgs = document.querySelectorAll('#chatMessages .msg');
3211
+ if (cntEl) cntEl.textContent = msgs.length + ' messages';
3212
+ }
3213
+
3214
+ // ── Scroll management ──
3215
+ function chatScrollToBottom(force = false) {
3216
+ const container = document.getElementById('chatMessages');
3217
+ if (force || !chatUserScrolled) {
3218
+ container.scrollTop = container.scrollHeight;
3219
+ }
3220
+ }
3221
+
3222
+ document.getElementById('chatMessages').addEventListener('scroll', function() {
3223
+ const el = this;
3224
+ chatUserScrolled = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
3225
+ });
3226
+
3227
+ // ── Slash command system ──
3228
+ let chatCmdRegistry = {};
3229
+ let chatCmdOverlayIdx = -1;
3230
+ let chatConvState = {};
3231
+
3232
+ async function chatLoadCommands() {
3233
+ try {
3234
+ const data = await api('/api/chat/commands');
3235
+ chatCmdRegistry = data.commands || {};
3236
+ } catch { /* use empty */ }
3237
+ }
3238
+
3239
+ function chatOnInput(el) {
3240
+ chatAutoResize(el);
3241
+ chatUpdateCmdOverlay();
3242
+ chatUpdateGhostHint();
3243
+ }
3244
+
3245
+ function chatAutoResize(el) {
3246
+ el.style.height = 'auto';
3247
+ el.style.height = Math.min(el.scrollHeight, 140) + 'px';
3248
+ }
3249
+
3250
+ function chatUpdateCmdOverlay() {
3251
+ const input = document.getElementById('chatInput');
3252
+ const overlay = document.getElementById('chatCmdOverlay');
3253
+ const text = input.value;
3254
+
3255
+ if (!text.startsWith('/') || text.includes('\n') || chatStreaming) {
3256
+ overlay.classList.remove('open');
3257
+ chatCmdOverlayIdx = -1;
3258
+ return;
3259
+ }
3260
+
3261
+ const typed = text.slice(1).split(' ')[0].toLowerCase();
3262
+ const hasSpace = text.indexOf(' ') > 0;
3263
+
3264
+ // If command is fully typed with space (entering args), hide overlay
3265
+ if (hasSpace && chatCmdRegistry[typed]) {
3266
+ overlay.classList.remove('open');
3267
+ chatCmdOverlayIdx = -1;
3268
+ return;
3269
+ }
3270
+
3271
+ // Build list of all items (commands + team)
3272
+ const allItems = [];
3273
+
3274
+ // Standard commands
3275
+ Object.entries(chatCmdRegistry).forEach(([name, info]) => {
3276
+ if (!typed || name.startsWith(typed)) {
3277
+ allItems.push({ type: 'cmd', name, info });
3278
+ }
3279
+ });
3280
+
3281
+ // Team agents
3282
+ if (window.oracleAgents) {
3283
+ window.oracleAgents.forEach(a => {
3284
+ if (a.active && (!typed || a.id.startsWith(typed) || a.name.toLowerCase().startsWith(typed))) {
3285
+ allItems.push({ type: 'agent', name: a.id, info: { args: '', desc: 'Delegate to ' + a.name } });
3286
+ }
3287
+ });
3288
+ }
3289
+
3290
+ if (allItems.length === 0) {
3291
+ overlay.classList.remove('open');
3292
+ chatCmdOverlayIdx = -1;
3293
+ return;
3294
+ }
3295
+
3296
+ // Sort and render with groups
3297
+ allItems.sort((a, b) => a.name.localeCompare(b.name));
3298
+
3299
+ let html = '';
3300
+ let lastType = null;
3301
+ allItems.forEach((item, i) => {
3302
+ if (item.type !== lastType) {
3303
+ const label = item.type === 'cmd' ? 'Commands' : 'Active Team';
3304
+ html += `<div class="cmd-group">${label}</div>`;
3305
+ lastType = item.type;
3306
+ }
3307
+ html += `
3308
+ <div class="cmd-row ${i === chatCmdOverlayIdx ? 'active' : ''}"
3309
+ onmousedown="chatSelectCmd('${item.name}', '${item.type}')"
3310
+ onmouseenter="chatCmdOverlayIdx=${i};chatUpdateCmdOverlay()">
3311
+ <span class="cmd-name">/${item.name}</span>
3312
+ <span class="cmd-args">${esc(item.info.args || '')}</span>
3313
+ <span class="cmd-desc">${esc(item.info.desc || '')}</span>
3314
+ </div>
3315
+ `;
3316
+ });
3317
+
3318
+ overlay.innerHTML = html;
3319
+ overlay.classList.add('open');
3320
+ }
3321
+
3322
+ function chatSelectCmd(name, type = 'cmd') {
3323
+ const input = document.getElementById('chatInput');
3324
+ if (type === 'agent') {
3325
+ input.value = `Ask ${name} to `;
3326
+ } else {
3327
+ const info = chatCmdRegistry[name];
3328
+ input.value = '/' + name + (info && info.args ? ' ' : '');
3329
+ }
3330
+ input.focus();
3331
+ chatUpdateCmdOverlay();
3332
+ chatUpdateGhostHint();
3333
+ }
3334
+
3335
+ function chatUpdateGhostHint() {
3336
+ const input = document.getElementById('chatInput');
3337
+ const ghost = document.getElementById('chatGhost');
3338
+ const text = input.value;
3339
+
3340
+ if (!text.startsWith('/') || !text.includes(' ')) {
3341
+ ghost.innerHTML = '';
3342
+ return;
3343
+ }
3344
+
3345
+ const parts = text.split(' ');
3346
+ const cmdName = parts[0].slice(1).toLowerCase();
3347
+ const info = chatCmdRegistry[cmdName];
3348
+ if (!info || !info.args) {
3349
+ ghost.innerHTML = '';
3350
+ return;
3351
+ }
3352
+
3353
+ const argTyped = parts.slice(1).join(' ');
3354
+ if (argTyped.length > 0) {
3355
+ ghost.innerHTML = '';
3356
+ return;
3357
+ }
3358
+
3359
+ // Show ghost hint: invisible text matching input + faint hint
3360
+ ghost.innerHTML = `<span class="chat-ghost-visible">${esc(text)}</span><span class="chat-ghost-hint">${esc(info.args)}</span>`;
3361
+ }
3362
+
3363
+ function chatInputKeydown(e) {
3364
+ const overlay = document.getElementById('chatCmdOverlay');
3365
+ const isOpen = overlay.classList.contains('open');
3366
+
3367
+ if (isOpen) {
3368
+ const rows = overlay.querySelectorAll('.cmd-row');
3369
+ if (e.key === 'ArrowDown') {
3370
+ e.preventDefault();
3371
+ chatCmdOverlayIdx = Math.min(chatCmdOverlayIdx + 1, rows.length - 1);
3372
+ chatUpdateCmdOverlay();
3373
+ return;
3374
+ }
3375
+ if (e.key === 'ArrowUp') {
3376
+ e.preventDefault();
3377
+ chatCmdOverlayIdx = Math.max(chatCmdOverlayIdx - 1, 0);
3378
+ chatUpdateCmdOverlay();
3379
+ return;
3380
+ }
3381
+ if ((e.key === 'Enter' || e.key === 'Tab') && chatCmdOverlayIdx >= 0 && chatCmdOverlayIdx < rows.length) {
3382
+ e.preventDefault();
3383
+ const row = rows[chatCmdOverlayIdx];
3384
+ const name = row.querySelector('.cmd-name').textContent.slice(1);
3385
+
3386
+ // Determine type from context
3387
+ let type = 'cmd';
3388
+ let prev = row.previousElementSibling;
3389
+ while (prev) {
3390
+ if (prev.classList.contains('cmd-group')) {
3391
+ if (prev.textContent.includes('Team')) type = 'agent';
3392
+ break;
3393
+ }
3394
+ prev = prev.previousElementSibling;
3395
+ }
3396
+
3397
+ chatSelectCmd(name, type);
3398
+ return;
3399
+ }
3400
+ if (e.key === 'Escape') {
3401
+ overlay.classList.remove('open');
3402
+ chatCmdOverlayIdx = -1;
3403
+ return;
3404
+ }
3405
+ }
3406
+
3407
+ if (e.key === 'Enter' && !e.shiftKey) {
3408
+ e.preventDefault();
3409
+ chatSendMessage();
3410
+ }
3411
+ }
3412
+
3413
+ // ── State pills ──
3414
+ function chatUpdateStatePills(state) {
3415
+ chatConvState = state || {};
3416
+ const bar = document.getElementById('chatStateBar');
3417
+ const pills = [];
3418
+
3419
+ const focused = state.focused_projects || [];
3420
+ if (focused.length > 0) {
3421
+ const names = focused.map(p => esc(p.name)).join(', ');
3422
+ pills.push(`<span class="state-pill state-pill-accent"><span class="pill-label">project</span> ${names}</span>`);
3423
+ }
3424
+ if (state.model) {
3425
+ pills.push(`<span class="state-pill"><span class="pill-label">model</span> ${esc(state.model)}</span>`);
3426
+ }
3427
+ const depth = state.depth || 'normal';
3428
+ const depthNext = { brief: 'normal', normal: 'deep', deep: 'brief' };
3429
+ pills.push(`<span class="state-pill" style="cursor:pointer" onclick="chatCycleDepth('${depthNext[depth] || 'normal'}')" title="Click to cycle depth">
3430
+ <span class="pill-label">depth</span> ${esc(depth)}
3431
+ </span>`);
3432
+
3433
+ if (pills.length > 0) {
3434
+ bar.innerHTML = pills.join('');
3435
+ bar.classList.add('active');
3436
+ } else {
3437
+ bar.innerHTML = '';
3438
+ bar.classList.remove('active');
3439
+ }
3440
+ }
3441
+
3442
+ async function chatCycleDepth(depth) {
3443
+ if (!chatCurrentConvId) { chatShowToast('Start a conversation first', 'error'); return; }
3444
+ try {
3445
+ const result = await api('/api/chat/command', {
3446
+ method: 'POST',
3447
+ body: { conversation_id: chatCurrentConvId, command: '/depth ' + depth },
3448
+ });
3449
+ if (result.state) chatUpdateStatePills(result.state);
3450
+ chatShowToast('Depth set to ' + depth);
3451
+ } catch (e) {
3452
+ chatShowToast('Failed: ' + e.message, 'error');
3453
+ }
3454
+ }
3455
+
3456
+ // ── Command result rendering ──
3457
+ function chatAppendCommandResult(result) {
3458
+ chatHideWelcome();
3459
+ const container = document.getElementById('chatMessages');
3460
+
3461
+ if (result.command === 'health' && result.results) {
3462
+ // Render health cards
3463
+ const div = document.createElement('div');
3464
+ div.className = 'msg msg-command';
3465
+ let html = '<div class="cmd-title">/health</div><div class="cmd-body">';
3466
+ for (const r of result.results) {
3467
+ const name = r.project_path ? r.project_path.split(/[\\/]/).pop() : '?';
3468
+ const statusColor = r.status === 'ok' ? 'var(--green)' : r.status === 'warning' ? 'var(--yellow)' : 'var(--red)';
3469
+ const issues = (r.issues || []).filter(i => i.severity !== 'info');
3470
+ html += `<div class="health-card">
3471
+ <div class="health-card-header">
3472
+ <span style="width:8px;height:8px;border-radius:50%;background:${statusColor};display:inline-block"></span>
3473
+ <span class="health-card-name">${esc(name)}</span>
3474
+ <span class="health-card-status" style="color:${statusColor}">${esc(r.status || 'unknown')}</span>
3475
+ </div>
3476
+ <div style="font-size:11px;color:var(--text2)">Facts: ${r.fact_stats?.total || 0} &middot; Edges: ${r.graph_stats?.total_edges || 0}</div>
3477
+ ${issues.length ? '<div class="health-card-issues">' + issues.map(i => `<div>&bull; ${esc(i.message)}</div>`).join('') + '</div>' : ''}
3478
+ </div>`;
3479
+ }
3480
+ html += '</div>';
3481
+ div.innerHTML = html;
3482
+ container.appendChild(div);
3483
+ } else if (result.command === 'clear' && result.ok) {
3484
+ chatClearMessages();
3485
+ if (result.state) chatUpdateStatePills(result.state);
3486
+ chatScrollToBottom(true);
3487
+ return;
3488
+ } else {
3489
+ const div = document.createElement('div');
3490
+ div.className = 'msg msg-command';
3491
+ const icon = result.ok ? '\u2713' : '\u2717';
3492
+ const color = result.ok ? 'var(--green)' : 'var(--red)';
3493
+ div.innerHTML = `
3494
+ <div class="cmd-title" style="color:${color}">${icon} /${esc(result.command || '?')}</div>
3495
+ <div class="cmd-body">${renderMarkdown(result.message || '')}</div>
3496
+ `;
3497
+ container.appendChild(div);
3498
+ }
3499
+
3500
+ if (result.state) chatUpdateStatePills(result.state);
3501
+ chatScrollToBottom(true);
3502
+ }
3503
+
3504
+ function chatSendSuggested(text) {
3505
+ document.getElementById('chatInput').value = text;
3506
+ chatSendMessage();
3507
+ }
3508
+
3509
+ // ── Main send + SSE consumer ──
3510
+ async function chatSendMessage() {
3511
+ const input = document.getElementById('chatInput');
3512
+ const text = input.value.trim();
3513
+ if (!text || chatStreaming) return;
3514
+
3515
+ // Hide overlay and ghost
3516
+ document.getElementById('chatCmdOverlay').classList.remove('open');
3517
+ document.getElementById('chatGhost').innerHTML = '';
3518
+
3519
+ // Slash command interception
3520
+ if (text.startsWith('/')) {
3521
+ chatAppendUserMsg(text);
3522
+ input.value = '';
3523
+ input.style.height = 'auto';
3524
+ try {
3525
+ const result = await api('/api/chat/command', {
3526
+ method: 'POST',
3527
+ body: { conversation_id: chatCurrentConvId, command: text },
3528
+ });
3529
+ chatAppendCommandResult(result);
3530
+ if (result.conv_id) chatCurrentConvId = result.conv_id;
3531
+ } catch (e) {
3532
+ chatAppendError('Command failed: ' + e.message);
3533
+ }
3534
+ return;
3535
+ }
3536
+
3537
+ chatStreaming = true;
3538
+ chatUserScrolled = false;
3539
+ _chatPendingToolCalls = [];
3540
+ chatShowToolbar();
3541
+ const sendBtn = document.getElementById('chatSendBtn');
3542
+ sendBtn.textContent = 'Stop';
3543
+ sendBtn.className = 'chat-stop';
3544
+ sendBtn.onclick = () => { if (chatAbortController) chatAbortController.abort(); };
3545
+
3546
+ chatAppendUserMsg(text);
3547
+ input.value = '';
3548
+ input.style.height = 'auto';
3549
+ chatScrollToBottom(true);
3550
+ chatHideStatus();
3551
+
3552
+ // Clean up any stale streaming element from a previous send BEFORE
3553
+ // creating the new one — otherwise chatSetStreamPhase creates a fresh
3554
+ // bubble and this line immediately removes it, leaving the user with
3555
+ // no visible feedback during slow model first-byte wait.
3556
+ const staleStreaming = document.getElementById('chatMessages').querySelector('.msg-streaming');
3557
+ if (staleStreaming) staleStreaming.remove();
3558
+ _stopStreamTicker();
3559
+
3560
+ chatShowStatus('Connecting', 'Sending to Oracle...');
3561
+ chatSetStreamPhase('Connecting to Oracle...');
3562
+
3563
+ chatAbortController = new AbortController();
3564
+ let assistantText = '';
3565
+ let buffer = '';
3566
+ let lastStats = null;
3567
+ let gotResponse = false;
3568
+
3569
+ try {
3570
+ const resp = await fetch(API + '/api/chat', {
3571
+ method: 'POST',
3572
+ headers: { 'Content-Type': 'application/json' },
3573
+ body: JSON.stringify({ conversation_id: chatCurrentConvId, message: text }),
3574
+ signal: chatAbortController.signal,
3575
+ });
3576
+
3577
+ if (!resp.ok) {
3578
+ const err = await resp.json().catch(() => ({}));
3579
+ throw new Error(err.error || `Server error: HTTP ${resp.status}`);
3580
+ }
3581
+
3582
+ const reader = resp.body.getReader();
3583
+ const decoder = new TextDecoder();
3584
+
3585
+ while (true) {
3586
+ const { done, value } = await reader.read();
3587
+ if (done) {
3588
+ buffer += decoder.decode(); // flush remaining bytes
3589
+ } else {
3590
+ buffer += decoder.decode(value, { stream: true });
3591
+ }
3592
+
3593
+ while (buffer.includes('\n\n')) {
3594
+ const idx = buffer.indexOf('\n\n');
3595
+ const line = buffer.slice(0, idx).trim();
3596
+ buffer = buffer.slice(idx + 2);
3597
+
3598
+ if (line === 'data: [DONE]') continue;
3599
+ if (!line.startsWith('data: ')) continue;
3600
+
3601
+ let event;
3602
+ try { event = JSON.parse(line.slice(6)); } catch { continue; }
3603
+
3604
+ switch (event.type) {
3605
+ case 'meta':
3606
+ if (event.conv_id && !chatCurrentConvId) {
3607
+ chatCurrentConvId = event.conv_id;
3608
+ }
3609
+ if (event.state) chatUpdateStatePills(event.state);
3610
+ if (event.model) _lastStreamModel = event.model;
3611
+ _lastTurnMeta = null; // reset for new turn
3612
+ chatShowStatus('Connected', event.model ? `Model: ${event.model}` : '');
3613
+ chatSetStreamPhase(event.model ? `Connected to ${event.model}` : 'Connected to model');
3614
+ _chatPushStreamActivity(event.model ? `Connected to ${event.model}` : 'Connected to model');
3615
+ break;
3616
+ case 'status': {
3617
+ const statusMsg = event.message || '';
3618
+ const statusDetail = event.detail || '';
3619
+ chatShowStatus(statusMsg, statusDetail);
3620
+ if (statusMsg.startsWith('Preparing')) chatSetStreamPhase('Preparing context...');
3621
+ else if (statusMsg.startsWith('Context ready')) chatSetStreamPhase('Context ready. Waiting for model...');
3622
+ else if (statusMsg.startsWith('Streaming')) chatSetStreamPhase('Generating response live...');
3623
+ else if (statusMsg.startsWith('Retrying')) chatSetStreamPhase('Retrying visible response...');
3624
+ else if (statusMsg.startsWith('Executing')) chatSetStreamPhase(statusMsg + '...');
3625
+ else if (statusMsg.startsWith('Continuing')) chatSetStreamPhase('Continuing response generation...');
3626
+ if (statusMsg) {
3627
+ const trailText = statusDetail ? `${statusMsg} \u2014 ${statusDetail}` : statusMsg;
3628
+ _chatPushStreamActivity(trailText);
3629
+ }
3630
+ break;
3631
+ }
3632
+ case 'thinking':
3633
+ chatUpdateThinking(event.content);
3634
+ gotResponse = true;
3635
+ break;
3636
+ case 'text':
3637
+ chatRemoveTypingIndicator();
3638
+ chatSetStreamPhase('Writing response...');
3639
+ assistantText += event.content;
3640
+ chatUpdateStreamingMsg(event.content);
3641
+ gotResponse = true;
3642
+ break;
3643
+ case 'tool_call':
3644
+ // Keep the streaming bubble across rounds — accumulate thinking,
3645
+ // but reset response text since the prior round's text was just
3646
+ // the tool call wrapper. The final round's text will fill it.
3647
+ {
3648
+ const bubble = document.getElementById('chatMessages').querySelector('.msg-streaming');
3649
+ if (bubble) {
3650
+ const responseEl = bubble.querySelector('.response-content');
3651
+ if (responseEl) {
3652
+ responseEl.textContent = '';
3653
+ // Also reset the memoization counter — otherwise the next
3654
+ // round's tokens fall into the else-branch with a stale
3655
+ // lastLen and `slice(staleN)` stays empty until text grows
3656
+ // past the prior length, making streaming invisible.
3657
+ responseEl.dataset.rendered = '0';
3658
+ }
3659
+ // Defer the round separator — only insert it if the next
3660
+ // round actually emits new thinking tokens. Otherwise we
3661
+ // end up with empty "--- next round ---" markers when a
3662
+ // round produces only the tool_call wrapper and no
3663
+ // visible reasoning.
3664
+ if (_streamThinkingText) _streamPendingRoundSep = true;
3665
+ }
3666
+ }
3667
+ _streamResponseText = '';
3668
+ _streamInContentThink = false;
3669
+ assistantText = '';
3670
+ gotResponse = true;
3671
+ chatRemoveTypingIndicator();
3672
+ _chatPendingToolCalls.push({ name: event.name || event.tool, args: event.args, tool_id: event.tool_id, status: 'running' });
3673
+ chatSetStreamPhase(`Using tool: ${event.name || event.tool || 'tool'}...`);
3674
+ _chatPushStreamActivity(`Tool call: ${event.name || event.tool || 'tool'}`, 'sa-tool');
3675
+ chatAppendToolCall(event, event.tool_id);
3676
+ break;
3677
+ case 'tool_result': {
3678
+ const pendingCall = _chatPendingToolCalls.find(c => c.tool_id === event.tool_id);
3679
+ if (pendingCall) {
3680
+ pendingCall.duration_ms = event.duration_ms || 0;
3681
+ pendingCall.status = event.result && event.result.error ? 'error' : 'ok';
3682
+ }
3683
+ chatSetStreamPhase(`Tool complete: ${event.name || 'tool'}`);
3684
+ const _trDur = event.duration_ms ? ` (${event.duration_ms}ms)` : '';
3685
+ _chatPushStreamActivity(`Tool result: ${event.name || 'tool'}${_trDur}`, 'sa-tool');
3686
+ const durLabel = event.duration_ms ? ` (${event.duration_ms}ms)` : '';
3687
+ chatAppendToolResult(
3688
+ event.name,
3689
+ typeof event.result === 'string' ? event.result : JSON.stringify(event.result, null, 2),
3690
+ event.tool_id,
3691
+ durLabel
3692
+ );
3693
+ break;
3694
+ }
3695
+ case 'agent_start':
3696
+ chatAgentStart(event);
3697
+ chatSetStreamPhase(`Sub-agent ${event.agent_id || ''} starting...`);
3698
+ break;
3699
+ case 'agent_round':
3700
+ chatAgentRound(event);
3701
+ break;
3702
+ case 'agent_thinking':
3703
+ chatAgentThinking(event);
3704
+ break;
3705
+ case 'agent_text':
3706
+ chatAgentText(event);
3707
+ chatSetStreamPhase(`Sub-agent ${event.agent_id || ''} responding...`);
3708
+ break;
3709
+ case 'agent_tool_call':
3710
+ chatAgentToolCall(event);
3711
+ break;
3712
+ case 'agent_tool_result':
3713
+ chatAgentToolResult(event);
3714
+ break;
3715
+ case 'agent_done':
3716
+ chatAgentDone(event);
3717
+ break;
3718
+ case 'error':
3719
+ chatRemoveTypingIndicator();
3720
+ _chatPushStreamActivity(event.message || 'Error', 'sa-err');
3721
+ chatAppendError(event.message || 'Unknown error');
3722
+ gotResponse = true;
3723
+ break;
3724
+ case 'done':
3725
+ if (event.conv_id) chatCurrentConvId = event.conv_id;
3726
+ lastStats = event.stats || null;
3727
+ _lastTurnMeta = event.stats || null;
3728
+ break;
3729
+ }
3730
+ }
3731
+
3732
+ if (done) break;
3733
+ }
3734
+ } catch (e) {
3735
+ chatRemoveTypingIndicator();
3736
+ _stopStreamTicker();
3737
+ if (e.name !== 'AbortError') {
3738
+ chatAppendError('Connection error: ' + e.message);
3739
+ }
3740
+ }
3741
+
3742
+ chatRemoveTypingIndicator(); // safety cleanup
3743
+ _stopStreamTicker(); // safety cleanup
3744
+
3745
+ // Finalize streaming message
3746
+ if (assistantText) {
3747
+ chatFinalizeStreamingMsg(assistantText);
3748
+ } else if (_streamThinkingText) {
3749
+ // Model produced only thinking with no text response — finalize the bubble
3750
+ chatFinalizeStreamingMsg('');
3751
+ } else if (!gotResponse) {
3752
+ chatAppendError('No response received \u2014 the model may be unavailable.');
3753
+ }
3754
+
3755
+ // Annotate the last thinking block with token-level stats
3756
+ if (lastStats && (lastStats.thinking_chars || lastStats.eval_tokens)) {
3757
+ const msgs = document.getElementById('chatMessages');
3758
+ const lastAssistant = msgs.querySelector('.msg-assistant:last-of-type .thinking-block summary');
3759
+ if (lastAssistant) {
3760
+ const parts = [];
3761
+ if (lastStats.thinking_chars) parts.push(_fmtChars(lastStats.thinking_chars) + ' chars');
3762
+ if (lastStats.eval_tokens) parts.push(lastStats.eval_tokens + ' tok');
3763
+ if (lastStats.tokens_per_sec) parts.push(lastStats.tokens_per_sec + ' tok/s');
3764
+ lastAssistant.textContent = 'Thinking \u00b7 ' + parts.join(' \u00b7 ');
3765
+ }
3766
+ }
3767
+
3768
+ // Show final stats briefly, then hide
3769
+ if (lastStats) {
3770
+ chatShowStatus('Complete', '');
3771
+ chatShowStats(lastStats);
3772
+ // Stats persist until next send (chatHideStatus called at start of chatSendMessage)
3773
+ } else {
3774
+ chatHideStatus();
3775
+ }
3776
+
3777
+ chatStreaming = false;
3778
+ chatAbortController = null;
3779
+ sendBtn.textContent = 'Send';
3780
+ sendBtn.className = 'chat-send';
3781
+ sendBtn.onclick = chatSendMessage;
3782
+ chatLoadConversations();
3783
+ chatScrollToBottom(true);
3784
+ chatUpdateToolbarInfo(chatCurrentConvId);
3785
+ }
3786
+
3787
+ // ═══════════════════════════════════════════════════════════
3788
+ // ── Agent Management ──
3789
+ // ═══════════════════════════════════════════════════════════
3790
+ function renderAgents() {
3791
+ const container = document.getElementById('agentsList');
3792
+ const empty = document.getElementById('agentsEmpty');
3793
+ const agents = window.oracleAgents || [];
3794
+
3795
+ if (agents.length === 0) {
3796
+ container.innerHTML = '';
3797
+ empty.style.display = 'block';
3798
+ return;
3799
+ }
3800
+
3801
+ empty.style.display = 'none';
3802
+ container.innerHTML = agents.map(a => `
3803
+ <div class="card" style="display:flex;justify-content:space-between;align-items:center;border-color:${a.active ? 'var(--accent)' : 'var(--border)'};opacity:${a.active ? '1' : '0.6'}">
3804
+ <div style="flex:1;min-width:0;margin-right:16px">
3805
+ <div style="font-weight:600;font-size:14px;margin-bottom:4px;display:flex;align-items:center;gap:8px">
3806
+ ${esc(a.name)}
3807
+ <span style="font-size:10px;font-family:monospace;background:var(--bg3);padding:2px 6px;border-radius:4px;color:var(--text2)">${esc(a.model)}</span>
3808
+ </div>
3809
+ <div style="font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${esc(a.description)}">${esc(a.description)}</div>
3810
+ </div>
3811
+ <div style="display:flex;gap:8px;align-items:center;flex-shrink:0">
3812
+ <button class="btn btn-ghost btn-sm" onclick="editAgent('${esc(a.id)}')">Edit</button>
3813
+ <button class="btn btn-danger btn-sm" onclick="deleteAgent('${esc(a.id)}')">Delete</button>
3814
+ </div>
3815
+ </div>
3816
+ `).join('');
3817
+ }
3818
+
3819
+ function openAgentModal(id = null) {
3820
+ const m = document.getElementById('agentModal');
3821
+ const title = document.getElementById('agentModalTitle');
3822
+ const nameInput = document.getElementById('agentName');
3823
+ const descInput = document.getElementById('agentDesc');
3824
+ const promptInput = document.getElementById('agentPrompt');
3825
+ const modelInput = document.getElementById('agentModel');
3826
+ const activeInput = document.getElementById('agentActive');
3827
+ const idInput = document.getElementById('agentId');
3828
+
3829
+ if (id) {
3830
+ const a = window.oracleAgents.find(x => x.id === id);
3831
+ if (!a) return;
3832
+ title.textContent = 'Edit Agent';
3833
+ idInput.value = a.id;
3834
+ nameInput.value = a.name || '';
3835
+ descInput.value = a.description || '';
3836
+ promptInput.value = a.system_prompt || '';
3837
+ modelInput.value = a.model || '';
3838
+ activeInput.checked = a.active !== false;
3839
+ } else {
3840
+ title.textContent = 'Add Custom Agent';
3841
+ idInput.value = 'agent_' + Math.random().toString(36).substr(2, 6);
3842
+ nameInput.value = '';
3843
+ descInput.value = '';
3844
+ promptInput.value = '';
3845
+ modelInput.value = window.oracleConfig.model || 'gemma4:31b-cloud';
3846
+ activeInput.checked = true;
3847
+ }
3848
+
3849
+ m.style.display = 'flex';
3850
+ }
3851
+
3852
+ function closeAgentModal() {
3853
+ document.getElementById('agentModal').style.display = 'none';
3854
+ }
3855
+
3856
+ function saveAgent() {
3857
+ const id = document.getElementById('agentId').value;
3858
+ const name = document.getElementById('agentName').value.trim();
3859
+ if (!name) return alert('Name is required');
3860
+
3861
+ const agent = {
3862
+ id: id,
3863
+ name: name,
3864
+ description: document.getElementById('agentDesc').value.trim(),
3865
+ system_prompt: document.getElementById('agentPrompt').value.trim(),
3866
+ model: document.getElementById('agentModel').value.trim(),
3867
+ active: document.getElementById('agentActive').checked
3868
+ };
3869
+
3870
+ const idx = window.oracleAgents.findIndex(x => x.id === id);
3871
+ if (idx >= 0) window.oracleAgents[idx] = agent;
3872
+ else window.oracleAgents.push(agent);
3873
+
3874
+ renderAgents();
3875
+ closeAgentModal();
3876
+ saveSettings();
3877
+ }
3878
+
3879
+ function deleteAgent(id) {
3880
+ if (!confirm('Are you sure you want to delete this agent?')) return;
3881
+ window.oracleAgents = window.oracleAgents.filter(x => x.id !== id);
3882
+ renderAgents();
3883
+ saveSettings();
3884
+ }
3885
+
3886
+ function editAgent(id) {
3887
+ openAgentModal(id);
3888
+ }
3889
+
3890
+ // ═══════════════════════════════════════════════════════════
3891
+ // ── Init ──
3892
+ // ═══════════════════════════════════════════════════════════
3893
+ (async function init() {
3894
+ await Promise.all([refreshHeader(), loadProjects(), loadInsights(), loadSuggestions(), loadSettings(), chatLoadConversations(), chatLoadCommands()]);
3895
+ // Auto-refresh every 60s
3896
+ setInterval(() => { refreshHeader(); loadProjects(); }, 60000);
3897
+ })();
3898
+ </script>
3899
+ </body>
3900
+ </html>