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
@@ -0,0 +1,758 @@
1
+ // ─── Chat Panel ───────────────────────────────────────
2
+ const BUILD_TIME_CHAT = "2026-04-10";
3
+
4
+ // ─── Markdown renderer setup ──────────────────────────
5
+ const chatRenderer = new marked.Renderer();
6
+ chatRenderer.code = function (code, lang) {
7
+ const language = (lang || '').trim();
8
+ let highlighted;
9
+ try {
10
+ highlighted = language && hljs.getLanguage(language)
11
+ ? hljs.highlight(code, { language }).value
12
+ : hljs.highlightAuto(code).value;
13
+ } catch { highlighted = code.replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
14
+ const label = language || 'text';
15
+ return '<div class="chat-code-block" data-lang="' + label + '">'
16
+ + '<div class="chat-code-header"><span>' + label + '</span>'
17
+ + '<button class="chat-code-copy" data-code="' + encodeURIComponent(code) + '">Copy</button></div>'
18
+ + '<pre><code class="hljs">' + highlighted + '</code></pre></div>';
19
+ };
20
+ marked.setOptions({ renderer: chatRenderer, gfm: true, breaks: true, pedantic: false });
21
+
22
+ const renderMarkdown = (text) => {
23
+ if (!text) return '';
24
+ try { return marked.parse(text); }
25
+ catch { return '<pre>' + text + '</pre>'; }
26
+ };
27
+
28
+ const highlightSearchInHtml = (html, query) => {
29
+ if (!query || query.length < 2) return html;
30
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ return html.replace(new RegExp('(' + escaped + ')', 'gi'),
32
+ '<mark style="background:#ffb22450;color:inherit;padding:1px 2px;border-radius:2px">$1</mark>');
33
+ };
34
+
35
+ // ─── Utility helpers ──────────────────────────────────
36
+ const sourceColors = { claude: '#22c55e', gemini: '#3b82f6', imports: '#a855f7', manual: '#8b949e', api: '#f59e0b' };
37
+ const getSourceColor = (src) => sourceColors[(src || '').toLowerCase()] || '#8b949e';
38
+
39
+ const extractFilesFromTools = (toolCalls) => {
40
+ const files = new Set();
41
+ const pat = /(?:[\w./-]+\/)*[\w.-]+\.\w{1,6}/g;
42
+ for (const tc of (toolCalls || [])) {
43
+ const m = (tc.args || '').match(pat);
44
+ if (m) m.forEach(f => { if (f.length > 3 && f.includes('.')) files.add(f); });
45
+ }
46
+ return [...files].slice(0, 12);
47
+ };
48
+
49
+ const toolSummary = (toolCalls) => {
50
+ const counts = {};
51
+ for (const tc of (toolCalls || [])) {
52
+ const n = tc.tool || 'unknown';
53
+ counts[n] = (counts[n] || 0) + 1;
54
+ }
55
+ return Object.entries(counts).sort((a, b) => b[1] - a[1]);
56
+ };
57
+
58
+ const truncate = (s, max) => s && s.length > max ? s.slice(0, max) + '...' : s;
59
+
60
+ // ─── ChatAgentActivity ───────────────────────────────
61
+ function ChatAgentActivity({ toolCalls, expanded, onToggle }) {
62
+ if (!toolCalls || !toolCalls.length) return null;
63
+ const summary = React.useMemo(() => toolSummary(toolCalls), [toolCalls]);
64
+ const files = React.useMemo(() => extractFilesFromTools(toolCalls), [toolCalls]);
65
+
66
+ return React.createElement('div', {
67
+ style: { marginTop: 8, borderTop: '1px solid ' + T.border, paddingTop: 6 }
68
+ },
69
+ React.createElement('div', {
70
+ onClick: onToggle,
71
+ style: {
72
+ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer',
73
+ fontSize: 11, color: T.textMuted, padding: '4px 0', userSelect: 'none'
74
+ }
75
+ },
76
+ React.createElement(I, { name: 'wrench', size: 12, color: T.textMuted }),
77
+ React.createElement('span', null, 'Agent Activity'),
78
+ React.createElement(Badge, { label: String(toolCalls.length), color: T.accent }),
79
+ React.createElement(I, {
80
+ name: 'chevron', size: 12, color: T.textMuted,
81
+ style: { transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform .15s' }
82
+ })
83
+ ),
84
+ expanded && React.createElement('div', {
85
+ style: { padding: '6px 0 2px 18px', fontSize: 11, lineHeight: 1.7 }
86
+ },
87
+ // Tool call list
88
+ React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 } },
89
+ summary.map(([name, count]) =>
90
+ React.createElement(Badge, {
91
+ key: name,
92
+ label: name + (count > 1 ? ' x' + count : ''),
93
+ color: (typeof toolColors !== 'undefined' && toolColors[name]) || T.textMuted
94
+ })
95
+ )
96
+ ),
97
+ // Detailed tool calls
98
+ toolCalls.slice(0, 20).map((tc, i) =>
99
+ React.createElement('div', {
100
+ key: i,
101
+ style: { display: 'flex', gap: 6, alignItems: 'baseline', color: T.textDim, marginBottom: 2 }
102
+ },
103
+ React.createElement('span', {
104
+ style: {
105
+ fontFamily: "'JetBrains Mono', monospace", fontSize: 10,
106
+ color: (typeof toolColors !== 'undefined' && toolColors[tc.tool]) || T.accent,
107
+ flexShrink: 0, minWidth: 70
108
+ }
109
+ }, tc.tool || '?'),
110
+ React.createElement('span', {
111
+ style: { fontFamily: "'JetBrains Mono', monospace", fontSize: 10, opacity: 0.7, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
112
+ }, truncate(tc.args || '', 120))
113
+ )
114
+ ),
115
+ toolCalls.length > 20 && React.createElement('div', { style: { color: T.textDim, fontSize: 10, marginTop: 4 } },
116
+ '... and ' + (toolCalls.length - 20) + ' more tool calls'
117
+ ),
118
+ // Files referenced
119
+ files.length > 0 && React.createElement('div', {
120
+ style: { marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center' }
121
+ },
122
+ React.createElement('span', { style: { color: T.textMuted, fontSize: 10, marginRight: 2 } }, 'Files:'),
123
+ files.map(f => React.createElement('span', {
124
+ key: f,
125
+ style: {
126
+ fontFamily: "'JetBrains Mono', monospace", fontSize: 10, color: T.blue,
127
+ background: T.blue + '12', padding: '1px 5px', borderRadius: 3
128
+ }
129
+ }, f))
130
+ )
131
+ )
132
+ );
133
+ }
134
+
135
+ // ─── ChatMessage ─────────────────────────────────────
136
+ const ChatMessage = React.memo(function ChatMessage({ turn, isUser, onCopy, copiedId, expanded, onToggleActivity, searchHighlight }) {
137
+ const msgRef = React.useRef(null);
138
+ const [hovered, setHovered] = React.useState(false);
139
+
140
+ const renderedHtml = React.useMemo(() => {
141
+ if (isUser) return null;
142
+ let html = renderMarkdown(turn.text || '');
143
+ if (searchHighlight) html = highlightSearchInHtml(html, searchHighlight);
144
+ return html;
145
+ }, [turn.text, isUser, searchHighlight]);
146
+
147
+ const handleClick = React.useCallback((e) => {
148
+ const btn = e.target.closest('.chat-code-copy');
149
+ if (btn) {
150
+ e.preventDefault();
151
+ const code = decodeURIComponent(btn.dataset.code);
152
+ navigator.clipboard.writeText(code);
153
+ btn.textContent = 'Copied!';
154
+ setTimeout(() => btn.textContent = 'Copy', 1500);
155
+ }
156
+ }, []);
157
+
158
+ const turnId = turn.id || turn.ts || turn.seq;
159
+ const isCopied = copiedId === turnId;
160
+
161
+ const bubbleStyle = isUser ? {
162
+ maxWidth: '80%', marginLeft: 'auto',
163
+ padding: '10px 14px', borderRadius: '12px 12px 4px 12px',
164
+ background: T.accent + '12', border: '1px solid ' + T.accent + '20',
165
+ color: T.text, fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
166
+ position: 'relative'
167
+ } : {
168
+ maxWidth: '88%',
169
+ padding: '10px 14px', borderRadius: '12px 12px 12px 4px',
170
+ background: T.surface, border: '1px solid ' + T.border,
171
+ color: T.text, fontSize: 13, lineHeight: 1.6, position: 'relative'
172
+ };
173
+
174
+ return React.createElement('div', {
175
+ ref: msgRef,
176
+ 'data-turn-id': turnId,
177
+ style: { marginBottom: 10, display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start' },
178
+ onMouseEnter: () => setHovered(true),
179
+ onMouseLeave: () => setHovered(false)
180
+ },
181
+ // Role label
182
+ React.createElement('div', {
183
+ style: { fontSize: 10, color: T.textMuted, marginBottom: 3, paddingLeft: isUser ? 0 : 2, paddingRight: isUser ? 2 : 0 }
184
+ }, isUser ? 'You' : 'Assistant',
185
+ turn.ts && React.createElement('span', {
186
+ style: { marginLeft: 8, fontSize: 10, color: T.textDim }
187
+ }, typeof timeAgo === 'function' ? timeAgo(turn.ts) : '')
188
+ ),
189
+ // Bubble
190
+ React.createElement('div', { style: bubbleStyle, onClick: !isUser ? handleClick : undefined },
191
+ // Copy button on hover
192
+ hovered && React.createElement('button', {
193
+ onClick: (e) => { e.stopPropagation(); onCopy(turnId, turn.text); },
194
+ style: {
195
+ position: 'absolute', top: 6, right: 6, background: T.surfaceAlt,
196
+ border: '1px solid ' + T.border, borderRadius: 4, padding: '2px 6px',
197
+ cursor: 'pointer', fontSize: 10, color: T.textMuted, display: 'flex', alignItems: 'center', gap: 3, zIndex: 2
198
+ },
199
+ title: 'Copy message'
200
+ },
201
+ React.createElement(I, { name: isCopied ? 'check' : 'copy', size: 10, color: isCopied ? T.accent : T.textMuted }),
202
+ isCopied ? 'Copied' : 'Copy'
203
+ ),
204
+ // Content
205
+ isUser
206
+ ? React.createElement('span', null, searchHighlight ? React.createElement('span', { dangerouslySetInnerHTML: { __html: highlightSearchInHtml((turn.text || '').replace(/</g, '&lt;'), searchHighlight) } }) : (turn.text || ''))
207
+ : React.createElement('div', { className: 'chat-md', dangerouslySetInnerHTML: { __html: renderedHtml } }),
208
+ // Agent activity
209
+ !isUser && turn.tool_calls && turn.tool_calls.length > 0 &&
210
+ React.createElement(ChatAgentActivity, { toolCalls: turn.tool_calls, expanded: expanded, onToggle: onToggleActivity })
211
+ ),
212
+ // Token count if available
213
+ turn.tokens && React.createElement('div', {
214
+ style: { fontSize: 9, color: T.textDim, marginTop: 2, paddingLeft: isUser ? 0 : 2 }
215
+ }, turn.tokens.toLocaleString() + ' tokens')
216
+ );
217
+ }, (prev, next) => {
218
+ return prev.turn === next.turn && prev.copiedId === next.copiedId
219
+ && prev.expanded === next.expanded && prev.searchHighlight === next.searchHighlight;
220
+ });
221
+
222
+ // ─── ChatToolbar ─────────────────────────────────────
223
+ function ChatToolbar({ session, turns, toolbarSearch, onSearchChange, matchCount, matchIdx, onNext, onPrev, onCopyAll, onExport, showJump, onJumpBottom }) {
224
+ const [exportOpen, setExportOpen] = React.useState(false);
225
+ const exportRef = React.useRef(null);
226
+
227
+ // Close export dropdown on outside click
228
+ React.useEffect(() => {
229
+ if (!exportOpen) return;
230
+ const handler = (e) => { if (exportRef.current && !exportRef.current.contains(e.target)) setExportOpen(false); };
231
+ document.addEventListener('mousedown', handler);
232
+ return () => document.removeEventListener('mousedown', handler);
233
+ }, [exportOpen]);
234
+
235
+ const turnCount = (session && session.turns) || (turns && turns.length) || 0;
236
+ const source = (session && session.source) || '?';
237
+
238
+ return React.createElement('div', {
239
+ style: {
240
+ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px',
241
+ borderBottom: '1px solid ' + T.border, background: T.bg, flexShrink: 0, flexWrap: 'wrap'
242
+ }
243
+ },
244
+ // Session info
245
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginRight: 'auto', minWidth: 0 } },
246
+ React.createElement(Badge, { label: source, color: getSourceColor(source) }),
247
+ React.createElement('span', { style: { fontSize: 11, color: T.textMuted } }, turnCount + ' turns'),
248
+ session && session.started && React.createElement('span', {
249
+ style: { fontSize: 10, color: T.textDim }
250
+ }, typeof localDate === 'function' ? localDate(session.started) : session.started)
251
+ ),
252
+ // In-conversation search
253
+ React.createElement('div', {
254
+ style: { display: 'flex', alignItems: 'center', gap: 4, background: T.surface, borderRadius: 6, padding: '3px 8px', border: '1px solid ' + T.border }
255
+ },
256
+ React.createElement(I, { name: 'search', size: 12, color: T.textMuted }),
257
+ React.createElement('input', {
258
+ type: 'text', value: toolbarSearch, onChange: (e) => onSearchChange(e.target.value),
259
+ placeholder: 'Search in conversation...',
260
+ style: {
261
+ background: 'none', border: 'none', outline: 'none', color: T.text,
262
+ fontSize: 11, width: 140, fontFamily: 'inherit'
263
+ }
264
+ }),
265
+ matchCount > 0 && React.createElement(React.Fragment, null,
266
+ React.createElement('span', { style: { fontSize: 10, color: T.textMuted, whiteSpace: 'nowrap' } },
267
+ (matchIdx + 1) + '/' + matchCount),
268
+ React.createElement('button', {
269
+ onClick: onPrev, style: { background: 'none', border: 'none', cursor: 'pointer', padding: 0 }
270
+ }, React.createElement(I, { name: 'chevron', size: 12, color: T.textMuted, style: { transform: 'rotate(-90deg)' } })),
271
+ React.createElement('button', {
272
+ onClick: onNext, style: { background: 'none', border: 'none', cursor: 'pointer', padding: 0 }
273
+ }, React.createElement(I, { name: 'chevron', size: 12, color: T.textMuted, style: { transform: 'rotate(90deg)' } }))
274
+ )
275
+ ),
276
+ // Copy all
277
+ React.createElement(Btn, { ghost: true, onClick: onCopyAll, style: { padding: '4px 8px', fontSize: 11 } },
278
+ React.createElement(I, { name: 'copy', size: 12 }), ' Copy All'
279
+ ),
280
+ // Export dropdown
281
+ React.createElement('div', { ref: exportRef, style: { position: 'relative' } },
282
+ React.createElement(Btn, {
283
+ ghost: true, onClick: () => setExportOpen(!exportOpen),
284
+ style: { padding: '4px 8px', fontSize: 11 }
285
+ }, React.createElement(I, { name: 'download', size: 12 }), ' Export'),
286
+ exportOpen && React.createElement('div', {
287
+ style: {
288
+ position: 'absolute', top: '100%', right: 0, marginTop: 4, background: T.surface,
289
+ border: '1px solid ' + T.border, borderRadius: 6, overflow: 'hidden', zIndex: 20, minWidth: 130
290
+ }
291
+ },
292
+ ['markdown', 'json'].map(fmt =>
293
+ React.createElement('div', {
294
+ key: fmt,
295
+ onClick: () => { onExport(fmt); setExportOpen(false); },
296
+ style: {
297
+ padding: '8px 14px', fontSize: 12, cursor: 'pointer', color: T.text,
298
+ borderBottom: fmt === 'markdown' ? '1px solid ' + T.border : 'none'
299
+ },
300
+ onMouseEnter: (e) => e.currentTarget.style.background = T.surfaceAlt,
301
+ onMouseLeave: (e) => e.currentTarget.style.background = 'transparent'
302
+ }, 'Export as ' + fmt.charAt(0).toUpperCase() + fmt.slice(1))
303
+ )
304
+ )
305
+ ),
306
+ // Jump to bottom
307
+ showJump && React.createElement(Btn, {
308
+ ghost: true, onClick: onJumpBottom, style: { padding: '4px 8px', fontSize: 11 }
309
+ }, React.createElement(I, { name: 'arrowDown', size: 12 }))
310
+ );
311
+ }
312
+
313
+ // ─── ChatSessionCard ─────────────────────────────────
314
+ function ChatSessionCard({ session, selected, onClick }) {
315
+ const [hovered, setHovered] = React.useState(false);
316
+ const title = session.title || session.session_id || 'Untitled';
317
+ const source = session.source || '?';
318
+ const turns = session.turns || 0;
319
+ const started = session.started;
320
+
321
+ return React.createElement('div', {
322
+ onClick: onClick,
323
+ onMouseEnter: () => setHovered(true),
324
+ onMouseLeave: () => setHovered(false),
325
+ style: {
326
+ padding: '10px 12px', cursor: 'pointer',
327
+ borderLeft: selected ? '3px solid ' + T.accent : '3px solid transparent',
328
+ background: selected ? T.accent + '08' : hovered ? T.surfaceAlt : 'transparent',
329
+ borderBottom: '1px solid ' + T.border,
330
+ transition: 'background .12s'
331
+ }
332
+ },
333
+ React.createElement('div', {
334
+ style: {
335
+ fontSize: 12, fontWeight: 500, color: T.text, marginBottom: 4,
336
+ overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical'
337
+ }
338
+ }, title),
339
+ React.createElement('div', {
340
+ style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, color: T.textMuted }
341
+ },
342
+ React.createElement('span', {
343
+ style: {
344
+ display: 'inline-block', padding: '1px 5px', borderRadius: 3, fontSize: 9,
345
+ background: getSourceColor(source) + '18', color: getSourceColor(source),
346
+ border: '1px solid ' + getSourceColor(source) + '30'
347
+ }
348
+ }, source),
349
+ React.createElement('span', null, turns + ' turns'),
350
+ started && React.createElement('span', { style: { marginLeft: 'auto', color: T.textDim } },
351
+ typeof timeAgo === 'function' ? timeAgo(started) : started)
352
+ )
353
+ );
354
+ }
355
+
356
+ // ─── ChatSessionList ─────────────────────────────────
357
+ function ChatSessionList({ sessions, selectedId, onSelect, loading, searchQuery, onSearchChange, filterSource, onFilterChange, onSync, syncing, stats, searchResults, onSearchSelect }) {
358
+ const displayList = searchResults || sessions;
359
+
360
+ return React.createElement('div', {
361
+ style: {
362
+ width: 310, minWidth: 310, height: '100%', display: 'flex', flexDirection: 'column',
363
+ borderRight: '1px solid ' + T.border, background: T.bg
364
+ }
365
+ },
366
+ // Header controls
367
+ React.createElement('div', {
368
+ style: { padding: '8px 10px', borderBottom: '1px solid ' + T.border, display: 'flex', flexDirection: 'column', gap: 6 }
369
+ },
370
+ // Search row
371
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
372
+ React.createElement('div', {
373
+ style: {
374
+ flex: 1, display: 'flex', alignItems: 'center', gap: 6,
375
+ background: T.surface, borderRadius: 6, padding: '5px 8px', border: '1px solid ' + T.border
376
+ }
377
+ },
378
+ React.createElement(I, { name: 'search', size: 13, color: T.textMuted }),
379
+ React.createElement('input', {
380
+ type: 'text', value: searchQuery, onChange: (e) => onSearchChange(e.target.value),
381
+ placeholder: 'Search conversations...',
382
+ style: { background: 'none', border: 'none', outline: 'none', color: T.text, fontSize: 12, width: '100%', fontFamily: 'inherit' }
383
+ }),
384
+ searchQuery && React.createElement('button', {
385
+ onClick: () => onSearchChange(''),
386
+ style: { background: 'none', border: 'none', cursor: 'pointer', padding: 0 }
387
+ }, React.createElement(I, { name: 'xSmall', size: 12, color: T.textMuted }))
388
+ ),
389
+ React.createElement(Btn, {
390
+ ghost: true, onClick: onSync, disabled: syncing,
391
+ style: { padding: '5px 8px', fontSize: 11, whiteSpace: 'nowrap', opacity: syncing ? 0.5 : 1 }
392
+ }, React.createElement(I, { name: 'refresh', size: 12, style: syncing ? { animation: 'spin 1s linear infinite' } : {} }), ' Sync')
393
+ ),
394
+ // Filter row
395
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
396
+ React.createElement('select', {
397
+ value: filterSource, onChange: (e) => onFilterChange(e.target.value),
398
+ style: {
399
+ background: T.surface, border: '1px solid ' + T.border, borderRadius: 4,
400
+ color: T.text, fontSize: 11, padding: '3px 6px', outline: 'none', cursor: 'pointer'
401
+ }
402
+ },
403
+ React.createElement('option', { value: '' }, 'All sources'),
404
+ React.createElement('option', { value: 'claude' }, 'Claude'),
405
+ React.createElement('option', { value: 'gemini' }, 'Gemini'),
406
+ React.createElement('option', { value: 'imports' }, 'Imports')
407
+ ),
408
+ stats && React.createElement('span', { style: { fontSize: 10, color: T.textDim, marginLeft: 'auto' } },
409
+ (stats.total_sessions || 0) + ' sessions')
410
+ )
411
+ ),
412
+ // Session list
413
+ React.createElement('div', { style: { flex: 1, overflowY: 'auto' } },
414
+ loading
415
+ ? [1, 2, 3, 4, 5].map(i => React.createElement('div', {
416
+ key: i,
417
+ style: { padding: '12px', borderBottom: '1px solid ' + T.border }
418
+ },
419
+ React.createElement('div', { style: { height: 14, background: T.surface, borderRadius: 4, marginBottom: 8, animation: 'pulse 1.5s infinite', width: '80%' } }),
420
+ React.createElement('div', { style: { height: 10, background: T.surface, borderRadius: 3, animation: 'pulse 1.5s infinite', width: '50%' } })
421
+ ))
422
+ : searchResults
423
+ ? searchResults.length === 0
424
+ ? React.createElement('div', { style: { padding: 20, textAlign: 'center', color: T.textMuted, fontSize: 12 } }, 'No results found')
425
+ : searchResults.map((r, i) => React.createElement('div', {
426
+ key: i,
427
+ onClick: () => onSearchSelect(r),
428
+ style: {
429
+ padding: '8px 12px', cursor: 'pointer', borderBottom: '1px solid ' + T.border,
430
+ fontSize: 11
431
+ },
432
+ onMouseEnter: (e) => e.currentTarget.style.background = T.surfaceAlt,
433
+ onMouseLeave: (e) => e.currentTarget.style.background = 'transparent'
434
+ },
435
+ React.createElement('div', { style: { fontWeight: 500, color: T.text, marginBottom: 3 } }, r.session_title || r.session_id),
436
+ React.createElement('div', { style: { color: T.textMuted, fontSize: 10 } }, truncate(r.snippet || r.text || '', 120))
437
+ ))
438
+ : displayList.map(s => React.createElement(ChatSessionCard, {
439
+ key: s.session_id, session: s, selected: s.session_id === selectedId,
440
+ onClick: () => onSelect(s.session_id)
441
+ }))
442
+ ),
443
+ // Stats footer
444
+ stats && !searchResults && React.createElement('div', {
445
+ style: {
446
+ padding: '6px 12px', borderTop: '1px solid ' + T.border,
447
+ fontSize: 10, color: T.textDim, display: 'flex', gap: 10
448
+ }
449
+ },
450
+ React.createElement('span', null, (stats.total_sessions || 0) + ' sessions'),
451
+ React.createElement('span', null, (stats.total_turns || 0) + ' turns')
452
+ )
453
+ );
454
+ }
455
+
456
+ // ─── ChatConversation ────────────────────────────────
457
+ function ChatConversation({ turns, turnsLoading, session, onLoadMore, hasMore, scrollToTurnId }) {
458
+ const scrollRef = React.useRef(null);
459
+ const [toolbarSearch, setToolbarSearch] = React.useState('');
460
+ const [copiedId, setCopiedId] = React.useState(null);
461
+ const [expandedActivity, setExpandedActivity] = React.useState({});
462
+ const [showJump, setShowJump] = React.useState(false);
463
+ const [copyAllFlash, setCopyAllFlash] = React.useState(false);
464
+
465
+ // Search matching
466
+ const { matchIndices, matchIdx, setMatchIdx } = (() => {
467
+ const [idx, setIdx] = React.useState(0);
468
+ const indices = React.useMemo(() => {
469
+ if (!toolbarSearch || toolbarSearch.length < 2) return [];
470
+ const q = toolbarSearch.toLowerCase();
471
+ return turns.reduce((acc, t, i) => {
472
+ if ((t.text || '').toLowerCase().includes(q)) acc.push(i);
473
+ return acc;
474
+ }, []);
475
+ }, [turns, toolbarSearch]);
476
+ return { matchIndices: indices, matchIdx: Math.min(idx, Math.max(0, indices.length - 1)), setMatchIdx: setIdx };
477
+ })();
478
+
479
+ // Auto-scroll to bottom on session change
480
+ React.useEffect(() => {
481
+ if (scrollRef.current && turns.length > 0) {
482
+ setTimeout(() => {
483
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
484
+ }, 50);
485
+ }
486
+ }, [session && session.session_id]);
487
+
488
+ // Scroll to search match
489
+ React.useEffect(() => {
490
+ if (matchIndices.length > 0 && scrollRef.current) {
491
+ const turnIdx = matchIndices[matchIdx];
492
+ const el = scrollRef.current.querySelector('[data-turn-id]');
493
+ const allEls = scrollRef.current.querySelectorAll('[data-turn-id]');
494
+ if (allEls[turnIdx]) allEls[turnIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
495
+ }
496
+ }, [matchIdx, matchIndices]);
497
+
498
+ // Scroll to specific turn (from cross-session search)
499
+ React.useEffect(() => {
500
+ if (scrollToTurnId && scrollRef.current) {
501
+ const el = scrollRef.current.querySelector('[data-turn-id="' + scrollToTurnId + '"]');
502
+ if (el) setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'center' }), 100);
503
+ }
504
+ }, [scrollToTurnId]);
505
+
506
+ // Track scroll position for jump button
507
+ const handleScroll = React.useCallback(() => {
508
+ if (!scrollRef.current) return;
509
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
510
+ setShowJump(scrollHeight - scrollTop - clientHeight > 200);
511
+ }, []);
512
+
513
+ const handleCopy = React.useCallback((turnId, text) => {
514
+ navigator.clipboard.writeText(text || '');
515
+ setCopiedId(turnId);
516
+ setTimeout(() => setCopiedId(null), 2000);
517
+ }, []);
518
+
519
+ const handleCopyAll = React.useCallback(() => {
520
+ if (!turns.length) return;
521
+ const text = turns.map(t => {
522
+ const role = t.role === 'user' ? 'User' : 'Assistant';
523
+ let msg = '## ' + role + '\n\n' + (t.text || '');
524
+ if (t.tool_calls && t.tool_calls.length) {
525
+ msg += '\n\nTool calls:\n' + t.tool_calls.map(tc => '- ' + (tc.tool || '?') + ': ' + (tc.args || '')).join('\n');
526
+ }
527
+ return msg;
528
+ }).join('\n\n---\n\n');
529
+ navigator.clipboard.writeText(text);
530
+ setCopyAllFlash(true);
531
+ setTimeout(() => setCopyAllFlash(false), 2000);
532
+ }, [turns]);
533
+
534
+ const handleExport = React.useCallback(async (fmt) => {
535
+ if (!session) return;
536
+ try {
537
+ const data = await api.get('/api/conversations/' + session.session_id + '/export?format=' + fmt);
538
+ let blob, ext;
539
+ if (fmt === 'json') {
540
+ blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
541
+ ext = '.json';
542
+ } else {
543
+ blob = new Blob([data.markdown || ''], { type: 'text/markdown' });
544
+ ext = '.md';
545
+ }
546
+ const url = URL.createObjectURL(blob);
547
+ const a = document.createElement('a');
548
+ a.href = url;
549
+ a.download = (data.title || session.session_id || 'conversation').replace(/[^a-zA-Z0-9_-]/g, '_') + ext;
550
+ a.click();
551
+ URL.revokeObjectURL(url);
552
+ } catch (e) { console.error('Export error:', e); }
553
+ }, [session]);
554
+
555
+ const toggleActivity = React.useCallback((turnId) => {
556
+ setExpandedActivity(prev => ({ ...prev, [turnId]: !prev[turnId] }));
557
+ }, []);
558
+
559
+ const jumpToBottom = React.useCallback(() => {
560
+ if (scrollRef.current) scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
561
+ }, []);
562
+
563
+ if (!session) {
564
+ return React.createElement('div', {
565
+ style: {
566
+ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
567
+ color: T.textMuted, gap: 12
568
+ }
569
+ },
570
+ React.createElement(I, { name: 'messageSquare', size: 48, color: T.textDim }),
571
+ React.createElement('div', { style: { fontSize: 16, fontWeight: 500 } }, 'Select a conversation'),
572
+ React.createElement('div', { style: { fontSize: 12, color: T.textDim } }, 'Choose a session from the list or sync new transcripts')
573
+ );
574
+ }
575
+
576
+ return React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 } },
577
+ // Toolbar
578
+ React.createElement(ChatToolbar, {
579
+ session, turns, toolbarSearch, onSearchChange: setToolbarSearch,
580
+ matchCount: matchIndices.length, matchIdx,
581
+ onNext: () => setMatchIdx(i => (i + 1) % matchIndices.length),
582
+ onPrev: () => setMatchIdx(i => (i - 1 + matchIndices.length) % matchIndices.length),
583
+ onCopyAll: handleCopyAll, onExport: handleExport, showJump, onJumpBottom: jumpToBottom
584
+ }),
585
+ // Copy all flash
586
+ copyAllFlash && React.createElement('div', {
587
+ style: { padding: '4px 12px', background: T.accent + '15', color: T.accent, fontSize: 11, textAlign: 'center' }
588
+ }, 'Conversation copied to clipboard'),
589
+ // Messages
590
+ React.createElement('div', {
591
+ ref: scrollRef, onScroll: handleScroll,
592
+ style: { flex: 1, overflowY: 'auto', padding: '16px 20px' }
593
+ },
594
+ // Load more
595
+ hasMore && React.createElement('div', { style: { textAlign: 'center', marginBottom: 12 } },
596
+ React.createElement(Btn, { ghost: true, onClick: onLoadMore, style: { fontSize: 11, padding: '4px 12px' } },
597
+ 'Load earlier messages')
598
+ ),
599
+ turnsLoading
600
+ ? [1, 2, 3].map(i => React.createElement('div', {
601
+ key: i,
602
+ style: {
603
+ marginBottom: 16, padding: 14, borderRadius: 12,
604
+ background: T.surface, border: '1px solid ' + T.border, maxWidth: i % 2 === 0 ? '80%' : '85%',
605
+ marginLeft: i % 2 === 0 ? 'auto' : 0
606
+ }
607
+ },
608
+ React.createElement('div', { style: { height: 12, background: T.surfaceAlt, borderRadius: 4, marginBottom: 8, width: '60%', animation: 'pulse 1.5s infinite' } }),
609
+ React.createElement('div', { style: { height: 12, background: T.surfaceAlt, borderRadius: 4, width: '90%', animation: 'pulse 1.5s infinite' } }),
610
+ React.createElement('div', { style: { height: 12, background: T.surfaceAlt, borderRadius: 4, marginTop: 6, width: '40%', animation: 'pulse 1.5s infinite' } })
611
+ ))
612
+ : turns.map((t, i) => {
613
+ const turnId = t.id || t.ts || t.seq || i;
614
+ return React.createElement(ChatMessage, {
615
+ key: turnId, turn: t, isUser: t.role === 'user',
616
+ onCopy: handleCopy, copiedId,
617
+ expanded: !!expandedActivity[turnId],
618
+ onToggleActivity: () => toggleActivity(turnId),
619
+ searchHighlight: toolbarSearch.length >= 2 ? toolbarSearch : null
620
+ });
621
+ }),
622
+ turns.length === 0 && !turnsLoading && React.createElement('div', {
623
+ style: { textAlign: 'center', color: T.textDim, padding: 40, fontSize: 12 }
624
+ }, 'No messages in this conversation')
625
+ )
626
+ );
627
+ }
628
+
629
+ // ─── ChatPanel (top-level) ───────────────────────────
630
+ function ChatPanel() {
631
+ const [sessions, setSessions] = React.useState([]);
632
+ const [selectedId, setSelectedId] = React.useState(null);
633
+ const [turns, setTurns] = React.useState([]);
634
+ const [loading, setLoading] = React.useState(true);
635
+ const [turnsLoading, setTurnsLoading] = React.useState(false);
636
+ const [searchQuery, setSearchQuery] = React.useState('');
637
+ const [searchResults, setSearchResults] = React.useState(null);
638
+ const [filterSource, setFilterSource] = React.useState('');
639
+ const [syncing, setSyncing] = React.useState(false);
640
+ const [stats, setStats] = React.useState(null);
641
+ const [hasMore, setHasMore] = React.useState(false);
642
+ const [turnOffset, setTurnOffset] = React.useState(0);
643
+ const [scrollToTurnId, setScrollToTurnId] = React.useState(null);
644
+ const searchTimerRef = React.useRef(null);
645
+ const PAGE_SIZE = 50;
646
+
647
+ // Load sessions on mount
648
+ React.useEffect(() => {
649
+ const load = async () => {
650
+ try {
651
+ setLoading(true);
652
+ const [sessData, statsData] = await Promise.all([
653
+ api.get('/api/conversations?limit=100'),
654
+ api.get('/api/conversations/stats')
655
+ ]);
656
+ setSessions(sessData || []);
657
+ setStats(statsData || null);
658
+ } catch (e) { console.error('Failed to load conversations:', e); }
659
+ finally { setLoading(false); }
660
+ };
661
+ load();
662
+ }, []);
663
+
664
+ // Load turns when session selected
665
+ React.useEffect(() => {
666
+ if (!selectedId) { setTurns([]); return; }
667
+ const load = async () => {
668
+ setTurnsLoading(true);
669
+ setTurnOffset(0);
670
+ try {
671
+ const data = await api.get('/api/conversations/' + selectedId + '?limit=' + PAGE_SIZE);
672
+ const arr = Array.isArray(data) ? data : (data.turns || []);
673
+ setTurns(arr);
674
+ setHasMore(arr.length >= PAGE_SIZE);
675
+ } catch (e) { console.error('Failed to load turns:', e); setTurns([]); }
676
+ finally { setTurnsLoading(false); }
677
+ };
678
+ load();
679
+ }, [selectedId]);
680
+
681
+ // Load more (earlier) turns
682
+ const loadMore = React.useCallback(async () => {
683
+ if (!selectedId) return;
684
+ const newOffset = turnOffset + PAGE_SIZE;
685
+ try {
686
+ const data = await api.get('/api/conversations/' + selectedId + '?offset=' + newOffset + '&limit=' + PAGE_SIZE);
687
+ const arr = Array.isArray(data) ? data : (data.turns || []);
688
+ setTurns(prev => [...arr, ...prev]);
689
+ setTurnOffset(newOffset);
690
+ setHasMore(arr.length >= PAGE_SIZE);
691
+ } catch (e) { console.error('Load more error:', e); }
692
+ }, [selectedId, turnOffset]);
693
+
694
+ // Debounced search
695
+ const handleSearchChange = React.useCallback((q) => {
696
+ setSearchQuery(q);
697
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
698
+ if (!q || q.length < 2) { setSearchResults(null); return; }
699
+ searchTimerRef.current = setTimeout(async () => {
700
+ try {
701
+ const data = await api.get('/api/conversations/search?q=' + encodeURIComponent(q) + '&limit=30');
702
+ setSearchResults(data || []);
703
+ } catch (e) { setSearchResults([]); }
704
+ }, 300);
705
+ }, []);
706
+
707
+ // Sync transcripts
708
+ const handleSync = React.useCallback(async () => {
709
+ setSyncing(true);
710
+ try {
711
+ await api.get('/api/conversations/sync');
712
+ const [sessData, statsData] = await Promise.all([
713
+ api.get('/api/conversations?limit=100'),
714
+ api.get('/api/conversations/stats')
715
+ ]);
716
+ setSessions(sessData || []);
717
+ setStats(statsData || null);
718
+ setSearchResults(null);
719
+ setSearchQuery('');
720
+ } catch (e) { console.error('Sync error:', e); }
721
+ finally { setSyncing(false); }
722
+ }, []);
723
+
724
+ // Filter sessions by source
725
+ const filteredSessions = React.useMemo(() => {
726
+ if (!filterSource) return sessions;
727
+ return sessions.filter(s => (s.source || '').toLowerCase() === filterSource);
728
+ }, [sessions, filterSource]);
729
+
730
+ // Handle search result click
731
+ const handleSearchSelect = React.useCallback((result) => {
732
+ const sid = result.session_id;
733
+ setSelectedId(sid);
734
+ setSearchResults(null);
735
+ setSearchQuery('');
736
+ if (result.turn_id) setScrollToTurnId(result.turn_id);
737
+ }, []);
738
+
739
+ const selectedSession = React.useMemo(() =>
740
+ sessions.find(s => s.session_id === selectedId) || null
741
+ , [sessions, selectedId]);
742
+
743
+ return React.createElement('div', {
744
+ style: { display: 'flex', height: '100%', overflow: 'hidden' }
745
+ },
746
+ React.createElement(ChatSessionList, {
747
+ sessions: filteredSessions, selectedId, onSelect: setSelectedId,
748
+ loading, searchQuery, onSearchChange: handleSearchChange,
749
+ filterSource, onFilterChange: setFilterSource,
750
+ onSync: handleSync, syncing, stats, searchResults,
751
+ onSearchSelect: handleSearchSelect
752
+ }),
753
+ React.createElement(ChatConversation, {
754
+ turns, turnsLoading, session: selectedSession,
755
+ onLoadMore: loadMore, hasMore, scrollToTurnId
756
+ })
757
+ );
758
+ }