Krishna172912 commited on
Commit
e280ef7
Β·
unverified Β·
1 Parent(s): d5402f8

Create app.js

Browse files
Files changed (1) hide show
  1. front_end/app.js +695 -0
front_end/app.js ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ─────────────────────────────────────────────
2
+ // DOM REFS
3
+ // ─────────────────────────────────────────────
4
+ const $ = (id) => document.getElementById(id);
5
+
6
+ const els = {
7
+ repoInput: $('repoInput'),
8
+ analyseBtn: $('analyseBtn'),
9
+ landingPage: $('landingPage'),
10
+ processingPage: $('processingPage'),
11
+ chatPage: $('chatPage'),
12
+ stepsContainer: $('stepsContainer'),
13
+ procSubtitle: $('procSubtitle'),
14
+ footer: $('footer'),
15
+ chatInput: $('chatInput'),
16
+ sendBtn: $('sendBtn'),
17
+ chatMessages: $('chatMessages'),
18
+ warningBanner: $('warningBanner'),
19
+ dismissWarning: $('dismissWarning'),
20
+ statusDot: $('statusDot'),
21
+ statusLabel: $('statusLabel'),
22
+ };
23
+
24
+ let IS_BIG_REPO = false;
25
+ let activeStepBox = null;
26
+ let hadError = false;
27
+ let hadWarning = false;
28
+
29
+ // ─────────────────────────────────────────────
30
+ // MARKED.JS SETUP
31
+ // ─────────────────────────────────────────────
32
+ if (typeof marked !== 'undefined') {
33
+ marked.setOptions({ breaks: true, gfm: true });
34
+ }
35
+
36
+ // ─────────────────────────────────────────────
37
+ // UTILITIES
38
+ // ─────────────────────────────────────────────
39
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
40
+
41
+ function setStatus(mode, label) {
42
+ els.statusDot.className = 'status-dot ' + mode;
43
+ els.statusLabel.textContent = label;
44
+ }
45
+
46
+ function showPage(pageEl) {
47
+ ['landingPage', 'processingPage', 'chatPage'].forEach((id) => {
48
+ $(id).classList.remove('active');
49
+ });
50
+ pageEl.classList.add('active');
51
+ }
52
+
53
+ function formatTime(d) {
54
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
55
+ }
56
+
57
+ // ─────────────────────────────────────────────
58
+ // AUTO-GROW TEXTAREA
59
+ // ─────────────────────────────────────────────
60
+ els.chatInput.addEventListener('input', () => {
61
+ els.chatInput.style.height = 'auto';
62
+ els.chatInput.style.height = Math.min(els.chatInput.scrollHeight, 160) + 'px';
63
+ });
64
+
65
+ // ─────────────────────────────────────────────
66
+ // COLLAPSE A COMPLETED STEP BOX
67
+ // ─────────────────────────────────────────────
68
+ function collapseBox(box, status) {
69
+ return new Promise((resolve) => {
70
+ // Lock height at current value so we can animate down
71
+ const currentH = box.offsetHeight;
72
+ box.style.height = currentH + 'px';
73
+ box.style.overflow = 'hidden';
74
+
75
+ // Badge text
76
+ const badge = document.createElement('span');
77
+ badge.className = 'step-collapsed-badge';
78
+ badge.textContent = status === 'ERROR' ? 'Β· Error' : status === 'WARNING' ? 'Β· Warning' : 'Β· Done';
79
+ box.querySelector('.step-title').appendChild(badge);
80
+
81
+ // Hide sub-items right away so they don't flash during resize
82
+ const subItems = box.querySelector('.sub-items');
83
+ if (subItems) subItems.style.opacity = '0';
84
+
85
+ // Animate to collapsed height next frame
86
+ requestAnimationFrame(() => {
87
+ requestAnimationFrame(() => {
88
+ box.style.height = '46px';
89
+ box.style.paddingTop = '0';
90
+ box.style.paddingBottom = '0';
91
+ });
92
+ });
93
+
94
+ box.addEventListener('transitionend', function handler(e) {
95
+ if (e.propertyName !== 'height') return;
96
+ box.removeEventListener('transitionend', handler);
97
+ // Reveal the badge now that height is settled
98
+ const b = box.querySelector('.step-collapsed-badge');
99
+ if (b) b.classList.add('visible');
100
+ resolve();
101
+ });
102
+ });
103
+ }
104
+
105
+ // ─────────────────────────────────────────────
106
+ // LANDING β†’ PROCESSING
107
+ // ─────────────────────────────────────────────
108
+ els.analyseBtn.addEventListener('click', startAnalysis);
109
+ els.repoInput.addEventListener('keydown', (e) => {
110
+ if (e.key === 'Enter') startAnalysis();
111
+ });
112
+
113
+ // Welcome chip clicks
114
+ document.querySelectorAll('.welcome-chip').forEach((chip) => {
115
+ chip.addEventListener('click', () => {
116
+ els.chatInput.value = chip.textContent;
117
+ els.chatInput.focus();
118
+ });
119
+ });
120
+
121
+ function startAnalysis() {
122
+ const repoUrl = els.repoInput.value.trim();
123
+ if (!repoUrl) return;
124
+
125
+ setStatus('active', 'analysing');
126
+ showPage(els.processingPage);
127
+ els.stepsContainer.innerHTML = '';
128
+ activeStepBox = null;
129
+ IS_BIG_REPO = false;
130
+ hadError = false;
131
+ hadWarning = false;
132
+
133
+ connectToBackend(repoUrl);
134
+ }
135
+
136
+ // ─────────────────────────────────────────────
137
+ // BACKEND: INIT-REPO STREAM
138
+ // ─────────────────────────────────────────────
139
+ async function connectToBackend(repoUrl) {
140
+ try {
141
+ const response = await fetch('http://localhost:8000/init-repo', {
142
+ method: 'POST',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify({ url: repoUrl }),
145
+ });
146
+
147
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
148
+
149
+ const reader = response.body.getReader();
150
+ const decoder = new TextDecoder();
151
+ let buffer = '';
152
+
153
+ while (true) {
154
+ const { value, done } = await reader.read();
155
+ if (done) break;
156
+
157
+ buffer += decoder.decode(value, { stream: true });
158
+ const lines = buffer.split('\n\n');
159
+ buffer = lines.pop();
160
+
161
+ for (const event of lines) {
162
+ if (!event.trim()) continue;
163
+ const dataLine = event.split('\n')[0];
164
+ if (!dataLine.startsWith('data: ')) continue;
165
+ try {
166
+ const data = JSON.parse(dataLine.slice(6).trim());
167
+ await processBackendEvent(data);
168
+ } catch (e) {
169
+ console.error('Parse error:', e);
170
+ }
171
+ }
172
+ }
173
+ } catch (error) {
174
+ console.error('Fetch error:', error);
175
+
176
+ if (activeStepBox) {
177
+ await finishActiveBox(activeStepBox, 'ERROR');
178
+ } else {
179
+ const errorBox = createNewStepBox(`Error: ${error.message}`);
180
+ await finishActiveBox(errorBox, 'ERROR');
181
+ }
182
+
183
+ showProcessingAction('error');
184
+ }
185
+ }
186
+
187
+ async function processBackendEvent(data) {
188
+ const { status, task } = data;
189
+
190
+ if (status === 'FINISHED') {
191
+ if (activeStepBox) await finishActiveBox(activeStepBox, 'SUCCESS');
192
+
193
+ if (hadError) {
194
+ showProcessingAction('error');
195
+ } else if (hadWarning) {
196
+ showProcessingAction('warning');
197
+ } else {
198
+ await delay(700);
199
+ setStatus('chat', 'ready');
200
+ transitionToChat();
201
+ }
202
+ return;
203
+ }
204
+
205
+ if (status === 'START') {
206
+ if (activeStepBox) await finishActiveBox(activeStepBox, 'SUCCESS');
207
+ activeStepBox = createNewStepBox(task);
208
+ if (els.procSubtitle) els.procSubtitle.textContent = task;
209
+ }
210
+
211
+ if (status === 'WARNING' && task && task.includes('Repo is large')) {
212
+ IS_BIG_REPO = true;
213
+ }
214
+
215
+ if (status === 'WARNING') hadWarning = true;
216
+ if (status === 'ERROR') hadError = true;
217
+
218
+ if (status === 'SUCCESS' || status === 'WARNING' || status === 'ERROR') {
219
+ if (activeStepBox) {
220
+ const titleText = activeStepBox.querySelector('.title-text');
221
+ if (titleText) titleText.textContent = task;
222
+ await finishActiveBox(activeStepBox, status);
223
+ activeStepBox = null;
224
+ } else {
225
+ const box = createNewStepBox(task);
226
+ await finishActiveBox(box, status);
227
+ }
228
+ }
229
+ }
230
+
231
+ // ─────────────────────────────────────────────
232
+ // PROCESSING ACTION BUTTON (error / warning gate)
233
+ // ─────────────────────────────────────────────
234
+ function showProcessingAction(type) {
235
+ // Remove any existing action card
236
+ const existing = els.stepsContainer.querySelector('.proc-action-card');
237
+ if (existing) existing.remove();
238
+
239
+ const card = document.createElement('div');
240
+ card.className = `proc-action-card ${type === 'error' ? 'proc-action-error' : 'proc-action-warning'}`;
241
+
242
+ if (type === 'error') {
243
+ card.innerHTML = `
244
+ <div class="proc-action-icon">βœ•</div>
245
+ <div class="proc-action-body">
246
+ <p class="proc-action-title">Analysis failed</p>
247
+ <p class="proc-action-desc">An error occurred while analysing this repository. Please check the URL and try again.</p>
248
+ <button class="proc-action-btn proc-btn-error" id="procBackBtn">← Try a Different Repo</button>
249
+ </div>`;
250
+ } else {
251
+ card.innerHTML = `
252
+ <div class="proc-action-icon">⚠</div>
253
+ <div class="proc-action-body">
254
+ <p class="proc-action-title">Analysis complete with warnings</p>
255
+ <p class="proc-action-desc">Some steps raised warnings. Results may be partial, but you can still explore the repository.</p>
256
+ <button class="proc-action-btn proc-btn-warning" id="procContinueBtn">Continue to Chat β†’</button>
257
+ </div>`;
258
+ }
259
+
260
+ els.stepsContainer.appendChild(card);
261
+ card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
262
+
263
+ if (type === 'error') {
264
+ document.getElementById('procBackBtn').addEventListener('click', () => {
265
+ showPage(els.landingPage);
266
+ setStatus('', 'idle');
267
+ });
268
+ } else {
269
+ document.getElementById('procContinueBtn').addEventListener('click', () => {
270
+ setStatus('chat', 'ready');
271
+ transitionToChat();
272
+ });
273
+ }
274
+ }
275
+
276
+ // ─────────────────────────────────────────────
277
+ // STEP BOX HELPERS
278
+ // ─────────────────────────────────────────────
279
+ function createNewStepBox(message) {
280
+ const box = document.createElement('div');
281
+ box.className = 'step-box';
282
+
283
+ box.innerHTML = `
284
+ <div class="step-title">
285
+ <span class="step-icon-wrap">
286
+ <span class="step-ring-spinner"></span>
287
+ </span>
288
+ <span class="title-text">${escapeHtml(message)}</span>
289
+ </div>
290
+ <div class="sub-items"></div>`;
291
+
292
+ els.stepsContainer.appendChild(box);
293
+ box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
294
+ return box;
295
+ }
296
+
297
+ async function finishActiveBox(box, status) {
298
+ // Swap spinner for status icon
299
+ const iconWrap = box.querySelector('.step-icon-wrap');
300
+ if (iconWrap) {
301
+ const spinner = iconWrap.querySelector('.step-ring-spinner');
302
+ if (spinner) spinner.remove();
303
+
304
+ const icon = document.createElement('span');
305
+ icon.className = 'step-status-icon';
306
+
307
+ if (status === 'WARNING') {
308
+ icon.textContent = '⚠';
309
+ icon.classList.add('warn');
310
+ } else if (status === 'ERROR') {
311
+ icon.textContent = 'βœ•';
312
+ icon.classList.add('err');
313
+ } else {
314
+ icon.textContent = 'βœ“';
315
+ icon.classList.add('green');
316
+ }
317
+
318
+ iconWrap.appendChild(icon);
319
+ }
320
+
321
+ // Apply status colour class
322
+ if (status === 'WARNING') {
323
+ box.classList.add('warn-yellow');
324
+ } else if (status === 'ERROR') {
325
+ box.classList.add('err-red');
326
+ } else {
327
+ box.classList.add('done-green');
328
+ }
329
+
330
+ // Brief pause so the colour is visible, then animate collapsed
331
+ await delay(320);
332
+ await collapseBox(box, status);
333
+ }
334
+
335
+ // ─────────────────────────────────────────────
336
+ // PROCESSING β†’ CHAT
337
+ // ─────────────────────────────────────────────
338
+ function transitionToChat() {
339
+ if (IS_BIG_REPO) els.warningBanner.classList.remove('hidden');
340
+
341
+ showPage(els.chatPage);
342
+ renderWelcome();
343
+ els.footer.classList.remove('hidden');
344
+ els.chatInput.focus();
345
+ }
346
+
347
+ els.dismissWarning.addEventListener('click', () => {
348
+ els.warningBanner.classList.add('hidden');
349
+ });
350
+
351
+ // ─────────────────────────────────────────────
352
+ // WELCOME TO CHAT STATE
353
+ // ─────────────────────────────────────────────
354
+ function renderWelcome() {
355
+ els.chatMessages.innerHTML = '';
356
+
357
+ const welcome = document.createElement('div');
358
+ welcome.className = 'chat-welcome';
359
+ welcome.innerHTML = `
360
+ <div class="welcome-icon">βŽ”</div>
361
+ <div class="welcome-title">Where do you want to start?</div>
362
+ <div class="welcome-sub">Ask anything about this repository</div>
363
+ <div class="welcome-chips">
364
+ <span class="welcome-chip">Give me an overview of this repo</span>
365
+ <span class="welcome-chip">What are the main dependencies?</span>
366
+ <span class="welcome-chip">How do I run this locally?</span>
367
+ <span class="welcome-chip">What design patterns are used?</span>
368
+ </div>`;
369
+
370
+ els.chatMessages.appendChild(welcome);
371
+
372
+ welcome.querySelectorAll('.welcome-chip').forEach((chip) => {
373
+ chip.addEventListener('click', () => {
374
+ els.chatInput.value = chip.dataset.q || chip.textContent;
375
+ els.chatInput.focus();
376
+ sendMessage();
377
+ });
378
+ });
379
+ }
380
+
381
+ // ─────────────────────────────────────────────
382
+ // CHAT: SEND
383
+ // ─────────────────────────────────────────────
384
+ els.sendBtn.addEventListener('click', sendMessage);
385
+ els.chatInput.addEventListener('keydown', (e) => {
386
+ if (e.key === 'Enter' && !e.shiftKey) {
387
+ e.preventDefault();
388
+ sendMessage();
389
+ }
390
+ });
391
+
392
+ async function sendMessage() {
393
+ const text = els.chatInput.value.trim();
394
+ if (!text) return;
395
+
396
+ // Remove welcome block if present
397
+ const welcome = els.chatMessages.querySelector('.chat-welcome');
398
+ if (welcome) welcome.remove();
399
+
400
+ els.chatInput.value = '';
401
+ els.chatInput.style.height = 'auto';
402
+
403
+ // User bubble
404
+ appendUserMessage(text);
405
+
406
+ // Bot group container
407
+ const botGroup = document.createElement('div');
408
+ botGroup.className = 'msg-group bot';
409
+ els.chatMessages.appendChild(botGroup);
410
+
411
+ // Thinking UI (collapsible)
412
+ let thinkingWrap = null;
413
+ let stepsInner = null;
414
+ let lastStepEl = null;
415
+ let stepCount = 0;
416
+ let finalBubble = null;
417
+ let fullMarkdown = '';
418
+
419
+ try {
420
+ const response = await fetch('http://localhost:8000/chat', {
421
+ method: 'POST',
422
+ headers: { 'Content-Type': 'application/json' },
423
+ body: JSON.stringify({ message: text }),
424
+ });
425
+
426
+ if (!response.ok) throw new Error('Backend connection failed');
427
+
428
+ const reader = response.body.getReader();
429
+ const decoder = new TextDecoder();
430
+ let buffer = '';
431
+
432
+ while (true) {
433
+ const { value, done } = await reader.read();
434
+
435
+ if (value) {
436
+ buffer += decoder.decode(value, { stream: !done });
437
+ const lines = buffer.split('\n\n');
438
+ buffer = lines.pop();
439
+
440
+ for (const event of lines) {
441
+ if (!event.trim()) continue;
442
+ const dataLine = event.split('\n')[0];
443
+ if (!dataLine.startsWith('data: ')) continue;
444
+
445
+ try {
446
+ const data = JSON.parse(dataLine.slice(6).trim());
447
+
448
+ if (data.type === 'tool' || data.type === 'thinking') {
449
+ // Build thinking UI on first thought
450
+ if (!thinkingWrap) {
451
+ const ui = createThinkingUI(botGroup);
452
+ thinkingWrap = ui.wrap;
453
+ stepsInner = ui.inner;
454
+ }
455
+ lastStepEl = addThinkingStep(stepsInner, data.text, lastStepEl);
456
+ stepCount++;
457
+ updateThinkingToggleLabel(thinkingWrap, stepCount, false);
458
+ }
459
+
460
+ else if (data.type === 'message') {
461
+ // Mark last step done
462
+ if (lastStepEl) lastStepEl.classList.add('done-step');
463
+
464
+ // Finalise thinking toggle
465
+ if (thinkingWrap) {
466
+ updateThinkingToggleLabel(thinkingWrap, stepCount, true);
467
+ closeThinkingPanel(thinkingWrap);
468
+ }
469
+
470
+ // Accumulate markdown text
471
+ fullMarkdown += data.text;
472
+
473
+ // Create or update the answer bubble
474
+ if (!finalBubble) {
475
+ finalBubble = createBotBubble(botGroup);
476
+ }
477
+ renderMarkdownToBubble(finalBubble, fullMarkdown);
478
+ finalBubble.scrollIntoView({ behavior: 'smooth', block: 'end' });
479
+ }
480
+
481
+ } catch (e) {
482
+ console.error('Parse error:', e);
483
+ }
484
+ }
485
+ }
486
+
487
+ if (done) break;
488
+ console.log("End has come!!")
489
+ }
490
+
491
+ // Add copy-response button after streaming is done
492
+ if (finalBubble && fullMarkdown) {
493
+ const copyBtn = buildCopyResponseBtn(fullMarkdown, finalBubble);
494
+ botGroup.appendChild(copyBtn);
495
+ }
496
+
497
+ // Add timestamp after response is complete
498
+ const meta = document.createElement('div');
499
+ meta.className = 'msg-meta';
500
+ meta.textContent = 'WHATREPO Β· ' + formatTime(new Date());
501
+ botGroup.appendChild(meta);
502
+
503
+ } catch (error) {
504
+ const errBubble = createBotBubble(botGroup);
505
+ errBubble.innerHTML = `<span style="color:var(--red)">Error: ${escapeHtml(error.message)}</span>`;
506
+ }
507
+ }
508
+
509
+ // ─────────────────────────────────────────────
510
+ // MARKDOWN RENDERING
511
+ // ─────────────────────────────────────────────
512
+ function renderMarkdownToBubble(bubble, mdText) {
513
+ if (typeof marked === 'undefined') {
514
+ // Fallback: plain text with line breaks
515
+ bubble.textContent = mdText;
516
+ return;
517
+ }
518
+
519
+ const html = marked.parse(mdText);
520
+ bubble.innerHTML = html;
521
+
522
+ // Post-process: wrap <pre> blocks with header + copy button
523
+ bubble.querySelectorAll('pre').forEach((pre) => {
524
+ if (pre.parentElement && pre.parentElement.classList.contains('code-block-wrap')) return; // already wrapped
525
+
526
+ const codeEl = pre.querySelector('code');
527
+ const rawLang = codeEl ? codeEl.className.replace(/^language-/, '').trim() : '';
528
+ const lang = rawLang || 'code';
529
+
530
+ const wrap = document.createElement('div');
531
+ wrap.className = 'code-block-wrap';
532
+
533
+ const header = document.createElement('div');
534
+ header.className = 'code-block-header';
535
+
536
+ const langLabel = document.createElement('span');
537
+ langLabel.className = 'code-lang-label';
538
+ langLabel.textContent = lang;
539
+
540
+ const copyBtn = document.createElement('button');
541
+ copyBtn.className = 'code-copy-btn';
542
+ copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 11V3h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Copy</span>`;
543
+
544
+ copyBtn.addEventListener('click', () => {
545
+ const content = codeEl ? codeEl.textContent : pre.textContent;
546
+ navigator.clipboard.writeText(content).then(() => {
547
+ copyBtn.querySelector('span').textContent = 'Copied!';
548
+ setTimeout(() => { copyBtn.querySelector('span').textContent = 'Copy'; }, 1800);
549
+ }).catch(() => {});
550
+ });
551
+
552
+ header.appendChild(langLabel);
553
+ header.appendChild(copyBtn);
554
+
555
+ pre.parentNode.insertBefore(wrap, pre);
556
+ wrap.appendChild(header);
557
+ wrap.appendChild(pre);
558
+ });
559
+ }
560
+
561
+ function buildCopyResponseBtn(markdownText, bubble) {
562
+ const btn = document.createElement('button');
563
+ btn.className = 'msg-copy-btn';
564
+ btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 11V3h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
565
+
566
+ btn.addEventListener('click', () => {
567
+ // Copy plain text from the rendered bubble
568
+ navigator.clipboard.writeText(bubble.innerText).then(() => {
569
+ btn.querySelector('span').textContent = 'Copied!';
570
+ setTimeout(() => { btn.querySelector('span').textContent = 'Copy response'; }, 1800);
571
+ }).catch(() => {});
572
+ });
573
+
574
+ return btn;
575
+ }
576
+
577
+ // ─────────────────────────────────────────────
578
+ // MESSAGE UI HELPERS
579
+ // ─────────────────────────────────────────────
580
+ function appendUserMessage(text) {
581
+ const group = document.createElement('div');
582
+ group.className = 'msg-group user';
583
+ group.innerHTML = `
584
+ <div class="msg-sender">You</div>
585
+ <div class="msg-bubble user">${escapeHtml(text)}</div>
586
+ <div class="msg-meta">${formatTime(new Date())}</div>`;
587
+ els.chatMessages.appendChild(group);
588
+ group.scrollIntoView({ behavior: 'smooth', block: 'end' });
589
+ }
590
+
591
+ function createBotBubble(container) {
592
+ const bubble = document.createElement('div');
593
+ bubble.className = 'msg-bubble bot';
594
+ container.appendChild(bubble);
595
+ return bubble;
596
+ }
597
+
598
+ // ─────────────────────────────────────────────
599
+ // THINKING UI β€” Collapsible (Gemini-style)
600
+ // ─────────────────────────────────────────────
601
+ function createThinkingUI(container) {
602
+ const wrap = document.createElement('div');
603
+ wrap.className = 'thinking-wrap';
604
+
605
+ wrap.innerHTML = `
606
+ <button class="thinking-toggle active open" aria-expanded="true">
607
+ <span class="thinking-spinner">
608
+ <svg width="12" height="12" viewBox="0 0 50 50">
609
+ <circle cx="25" cy="25" r="20" fill="none" stroke="var(--accent)" stroke-width="5"
610
+ stroke-dasharray="80 45" stroke-linecap="round"/>
611
+ </svg>
612
+ </span>
613
+ <span class="thinking-label">Thinking…</span>
614
+ <span class="thinking-chevron">β–Ύ</span>
615
+ </button>
616
+ <div class="thinking-steps-panel open">
617
+ <div class="thinking-steps-inner"></div>
618
+ </div>`;
619
+
620
+ container.appendChild(wrap);
621
+
622
+ // Toggle click
623
+ const toggle = wrap.querySelector('.thinking-toggle');
624
+ const panel = wrap.querySelector('.thinking-steps-panel');
625
+
626
+ toggle.addEventListener('click', () => {
627
+ const isOpen = panel.classList.contains('open');
628
+ panel.classList.toggle('open', !isOpen);
629
+ toggle.classList.toggle('open', !isOpen);
630
+ toggle.setAttribute('aria-expanded', String(!isOpen));
631
+ });
632
+
633
+ return {
634
+ wrap,
635
+ toggle,
636
+ panel,
637
+ inner: wrap.querySelector('.thinking-steps-inner'),
638
+ };
639
+ }
640
+
641
+ function addThinkingStep(inner, text, previousStepEl) {
642
+ // Mark previous as done
643
+ if (previousStepEl) {
644
+ previousStepEl.classList.remove('current');
645
+ previousStepEl.classList.add('done-step');
646
+ }
647
+
648
+ const step = document.createElement('div');
649
+ step.className = 'thinking-step current';
650
+
651
+ // Preserve newlines from backend
652
+ const formatted = escapeHtml(text).replace(/\n/g, '<br>');
653
+ step.innerHTML = `<span class="thinking-step-dot"></span><span class="thinking-step-text">${formatted}</span>`;
654
+ inner.appendChild(step);
655
+
656
+ // Scroll the panel
657
+ inner.scrollTop = inner.scrollHeight;
658
+
659
+ return step;
660
+ }
661
+
662
+ function updateThinkingToggleLabel(wrap, count, isDone) {
663
+ const label = wrap.querySelector('.thinking-label');
664
+ const spinner = wrap.querySelector('.thinking-spinner');
665
+ const toggle = wrap.querySelector('.thinking-toggle');
666
+
667
+ if (isDone) {
668
+ label.textContent = `Thought for ${count} step${count !== 1 ? 's' : ''}`;
669
+ spinner.classList.add('done');
670
+ spinner.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4" fill="var(--green)"/></svg>`;
671
+ toggle.classList.remove('active');
672
+ } else {
673
+ label.textContent = `Thinking… (${count} step${count !== 1 ? 's' : ''})`;
674
+ }
675
+ }
676
+
677
+ function closeThinkingPanel(wrap) {
678
+ const panel = wrap.querySelector('.thinking-steps-panel');
679
+ const toggle = wrap.querySelector('.thinking-toggle');
680
+ // Auto-collapse once done so the focus is on the answer
681
+ panel.classList.remove('open');
682
+ toggle.classList.remove('open');
683
+ toggle.setAttribute('aria-expanded', 'false');
684
+ }
685
+
686
+ // ─────────────────────────────────────────────
687
+ // ESCAPE
688
+ // ─────────────────────────────────────────────
689
+ function escapeHtml(str) {
690
+ return String(str)
691
+ .replace(/&/g, '&amp;')
692
+ .replace(/</g, '&lt;')
693
+ .replace(/>/g, '&gt;')
694
+ .replace(/"/g, '&quot;');
695
+ }