browser-assistant 0.0.0-stage → 2.8.2

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.
@@ -0,0 +1,2765 @@
1
+ // Browser Assistant Content Script v3.0 — High Accuracy Edition
2
+ (function () {
3
+ // Abort on pages without a body (about:blank, PDFs, XML docs, etc.)
4
+ if (!document.body)
5
+ return;
6
+ const extensionVersion = chrome.runtime.getManifest().version;
7
+ const lifecycle = new AbortController();
8
+ const pendingTimeouts = new Set();
9
+ const scheduleTimeout = window.setTimeout.bind(window);
10
+ let disposed = false;
11
+ const setTimeout = (fn, delay) => {
12
+ if (disposed)
13
+ return 0;
14
+ const id = scheduleTimeout(() => { pendingTimeouts.delete(id); if (!disposed)
15
+ fn(); }, delay);
16
+ pendingTimeouts.add(id);
17
+ return id;
18
+ };
19
+ const navigationCleanup = [];
20
+ console.log("Browser Assistant v3.0 loaded.");
21
+ const SERVER = 'http://localhost:8000';
22
+ const root = document.createElement('div');
23
+ root.id = 'assistant-root';
24
+ document.body.appendChild(root);
25
+ // ── Move panel ───────────────────────────────────────────────────────────
26
+ // Drag the header to put the panel anywhere on the current page. The saved
27
+ // location is restored after a refresh on that site.
28
+ const POSITION_KEY = 'browserAssistantPosition';
29
+ let dragState = { dragging: false, moved: false, startX: 0, startY: 0, origX: 0, origY: 0 };
30
+ let suppressNextClick = false;
31
+ function clampPanelPosition(x, y) {
32
+ return {
33
+ x: Math.max(0, Math.min(window.innerWidth - root.offsetWidth, x)),
34
+ y: Math.max(0, Math.min(window.innerHeight - root.offsetHeight, y)),
35
+ };
36
+ }
37
+ function setPanelPosition(x, y) {
38
+ const position = clampPanelPosition(x, y);
39
+ root.style.right = 'auto';
40
+ root.style.bottom = 'auto';
41
+ root.style.left = `${position.x}px`;
42
+ root.style.top = `${position.y}px`;
43
+ return position;
44
+ }
45
+ function restorePanelPosition() {
46
+ try {
47
+ const saved = JSON.parse(localStorage.getItem(POSITION_KEY));
48
+ if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
49
+ requestAnimationFrame(() => setPanelPosition(saved.x, saved.y));
50
+ }
51
+ }
52
+ catch { /* Position persistence is optional. */ }
53
+ }
54
+ function initDrag() {
55
+ root.addEventListener('pointerdown', (event) => {
56
+ // The expanded panel moves by its header; the collapsed round badge
57
+ // itself is also draggable.
58
+ const isHeader = event.target.closest('.assistant-header');
59
+ const isCollapsedBadge = event.target.closest('.assistant-tab.collapsed');
60
+ if ((!isHeader && !isCollapsedBadge) || (event.target.closest('button') && !isCollapsedBadge))
61
+ return;
62
+ const rect = root.getBoundingClientRect();
63
+ dragState = {
64
+ dragging: true,
65
+ moved: false,
66
+ collapsedBadge: Boolean(isCollapsedBadge),
67
+ startX: event.clientX,
68
+ startY: event.clientY,
69
+ origX: rect.left,
70
+ origY: rect.top,
71
+ };
72
+ root.setPointerCapture?.(event.pointerId);
73
+ root.classList.add('assistant-dragging');
74
+ event.preventDefault();
75
+ });
76
+ root.addEventListener('pointermove', (event) => {
77
+ if (!dragState.dragging)
78
+ return;
79
+ if (Math.abs(event.clientX - dragState.startX) > 4 || Math.abs(event.clientY - dragState.startY) > 4) {
80
+ dragState.moved = true;
81
+ }
82
+ setPanelPosition(dragState.origX + event.clientX - dragState.startX, dragState.origY + event.clientY - dragState.startY);
83
+ });
84
+ const stopDragging = () => {
85
+ if (!dragState.dragging)
86
+ return;
87
+ suppressNextClick = dragState.moved;
88
+ dragState.dragging = false;
89
+ root.classList.remove('assistant-dragging');
90
+ try {
91
+ const rect = root.getBoundingClientRect();
92
+ localStorage.setItem(POSITION_KEY, JSON.stringify({ x: rect.left, y: rect.top }));
93
+ }
94
+ catch { /* Position persistence is optional. */ }
95
+ // Some browsers do not emit a click after a pointer drag. Clear the
96
+ // guard shortly afterwards so the user's next deliberate click on
97
+ // the badge always opens the panel.
98
+ if (suppressNextClick) {
99
+ setTimeout(() => { suppressNextClick = false; }, 250);
100
+ }
101
+ // Pointer capture retargets the eventual click to the root, so a
102
+ // stationary badge press must open here rather than lose its click.
103
+ if (!dragState.moved && dragState.collapsedBadge)
104
+ openPanel();
105
+ };
106
+ root.addEventListener('pointerup', stopDragging);
107
+ root.addEventListener('pointercancel', stopDragging);
108
+ root.addEventListener('click', (event) => {
109
+ if (!suppressNextClick)
110
+ return;
111
+ suppressNextClick = false;
112
+ event.preventDefault();
113
+ event.stopImmediatePropagation();
114
+ }, true);
115
+ window.addEventListener('resize', () => {
116
+ const rect = root.getBoundingClientRect();
117
+ if (root.style.left)
118
+ setPanelPosition(rect.left, rect.top);
119
+ }, { signal: lifecycle.signal });
120
+ }
121
+ initDrag();
122
+ restorePanelPosition();
123
+ let isCollapsed = true;
124
+ // Scan type and visible tab are intentionally separate. Switching away
125
+ // from QA Control must never stop an authorized automation run.
126
+ let selectedMode = 'auto'; // 'auto', 'mcq', 'aptitude', 'code'
127
+ let selectedTab = 'auto'; // 'auto', 'mcq', 'aptitude', 'code', 'qa'
128
+ let isMinimalMode = false;
129
+ let lastResultSummary = "";
130
+ let isHidden = false;
131
+ let serverOnline = null; // null = unknown, true/false = checked
132
+ let isScanning = false;
133
+ let extensionAlive = true; // false after extension reload invalidates context
134
+ let historyItems = [];
135
+ let isHistoryOpen = false;
136
+ const automationPolicy = window.BrowserAssistantAutomationPolicy;
137
+ const automation = {
138
+ state: 'IDLE', running: false, paused: false, runId: 0,
139
+ processed: new Set(), completed: 0, error: '', currentSignature: '',
140
+ };
141
+ const updateListener = (request, _sender, sendResponse) => {
142
+ const busy = automation.running || isScanning || Boolean(window.BrowserAssistantJournalBusy);
143
+ if (request.action === 'assistantUpdateStatus') {
144
+ sendResponse({ busy });
145
+ return false;
146
+ }
147
+ if (request.action !== 'assistantPrepareUpdate')
148
+ return false;
149
+ if (busy) {
150
+ sendResponse({ ready: false });
151
+ return false;
152
+ }
153
+ try {
154
+ sessionStorage.setItem('browserAssistantReopenAfterUpdate', isCollapsed ? 'false' : 'true');
155
+ }
156
+ catch { }
157
+ disposed = true;
158
+ extensionAlive = false;
159
+ automation.runId++;
160
+ lifecycle.abort();
161
+ for (const cleanup of navigationCleanup)
162
+ cleanup();
163
+ for (const timer of pendingTimeouts)
164
+ window.clearTimeout(timer);
165
+ pendingTimeouts.clear();
166
+ root.remove();
167
+ chrome.runtime.onMessage.removeListener(updateListener);
168
+ sendResponse({ ready: true });
169
+ return false;
170
+ };
171
+ chrome.runtime.onMessage.addListener(updateListener);
172
+ document.addEventListener('assistant-journal-update', updateAutomationUI, { signal: lifecycle.signal });
173
+ document.addEventListener('assistant-journal-ready', () => {
174
+ if (!window.BrowserAssistantJournal?.matches())
175
+ return;
176
+ selectedTab = 'qa';
177
+ isCollapsed = false;
178
+ isHidden = false;
179
+ root.style.display = 'block';
180
+ render();
181
+ }, { signal: lifecycle.signal });
182
+ function getAutomationContext() {
183
+ const target = automationPolicy?.resolveTarget(window.location, document) || null;
184
+ const pageType = target?.pageTypes?.[0] || null;
185
+ const context = {
186
+ authorizationVerified: Boolean(target),
187
+ target: target ? { ...target, authorized: true } : { authorized: false },
188
+ page: { type: pageType, authorized: Boolean(target && pageType) },
189
+ };
190
+ context.mode = automationPolicy?.determineMode(context) || 'ASSIST';
191
+ return context;
192
+ }
193
+ function getAutomationAdapter(context = getAutomationContext()) {
194
+ return context.mode === 'FULL' ? automationPolicy?.getAdapter(context.target.adapter) || null : null;
195
+ }
196
+ function normalizeAutomationText(value) {
197
+ return (value || '').replace(/\s+/g, ' ').trim();
198
+ }
199
+ function questionSignature(questionText, options) {
200
+ return `${normalizeAutomationText(questionText)}|${options.map(option => normalizeAutomationText(option.text)).join('|')}`;
201
+ }
202
+ function hasSameQuestionOptions(before, after) {
203
+ if (!before || !after || before.options.length !== after.options.length)
204
+ return false;
205
+ if (normalizeAutomationText(before.identityText || before.text) !== normalizeAutomationText(after.identityText || after.text))
206
+ return false;
207
+ return before.options.every((option, index) => normalizeAutomationText(option.identityText || option.text) === normalizeAutomationText(after.options[index]?.identityText || after.options[index]?.text));
208
+ }
209
+ function getAuthorizedQuestion(context = getAutomationContext()) {
210
+ // The dashboard contains mentor feedback radios, not quiz answers.
211
+ if (window.location.origin === 'https://learning.ccbp.in' && window.location.pathname === '/')
212
+ return null;
213
+ // Problem-list filters are checkboxes, not an MCQ question.
214
+ if (window.location.pathname.replace(/\/$/, '') === '/problems-set')
215
+ return null;
216
+ const adapter = getAutomationAdapter(context);
217
+ if (!adapter || !automationPolicy.canExecute('detect', context) || !automationPolicy.canExecute('read', context))
218
+ return null;
219
+ const coding = window.BrowserAssistantCoding?.getQuestion(document);
220
+ if (coding) {
221
+ // CCBP re-renders description content while an answer is being
222
+ // generated (images, examples and status UI can change). Its
223
+ // CCBP's UUID route uniquely identifies the problem. Its title can
224
+ // disappear when React replaces the description, so neither the
225
+ // title nor a prefix of mutable example text is an identity there.
226
+ const stableRoute = window.location.origin === 'https://learning.ccbp.in'
227
+ && /^\/question\/[a-f0-9-]{36}\/?$/i.test(window.location.pathname);
228
+ const identity = stableRoute ? '' : (normalizeAutomationText(coding.title)
229
+ || normalizeAutomationText(coding.text).slice(0, 240));
230
+ return { ...coding, signature: `code:${window.location.pathname}:${coding.language}:${identity}` };
231
+ }
232
+ const question = adapter.getQuestion(document);
233
+ if (!question || !normalizeAutomationText(question.text) || (question.type !== 'short-answer' && question.options.length < 2))
234
+ return null;
235
+ return { ...question, text: normalizeAutomationText(question.text), options: question.options.map((option, index) => ({
236
+ ...option, index, text: normalizeAutomationText(option.text),
237
+ })), signature: questionSignature(question.identityText || question.text, question.options)
238
+ + (window.BrowserAssistantQuestionVisuals?.nodes(question).length ? `|visual:${window.BrowserAssistantQuestionVisuals.fingerprint(question)}` : '') };
239
+ }
240
+ const AUTO_RESUME_KEY = 'browserAssistantResumeAuthorizedQa';
241
+ function questionDetectionError() {
242
+ const codingPage = window.location.origin === 'https://learning.ccbp.in'
243
+ && /^\/question\//.test(window.location.pathname);
244
+ if (codingPage)
245
+ return window.BrowserAssistantCoding?.getDetectionError()
246
+ || 'The coding question/editor has not finished loading.';
247
+ return 'The question/options did not finish loading or could not be identified. Restart on the current question.';
248
+ }
249
+ async function waitForReadableQuestion(runId, timeoutMs = 15000) {
250
+ let deadline = Date.now() + timeoutMs;
251
+ while (Date.now() < deadline && automation.running && automation.runId === runId) {
252
+ const pausedAt = Date.now();
253
+ if (!(await waitWhilePaused(runId)))
254
+ return null;
255
+ deadline += Date.now() - pausedAt;
256
+ const context = getAutomationContext();
257
+ if (context.mode !== 'FULL' || context.target.id !== automation.targetId)
258
+ return null;
259
+ const question = getAuthorizedQuestion(context);
260
+ if (question)
261
+ return question;
262
+ automation.state = 'WAITING_FOR_QUESTION';
263
+ updateAutomationUI();
264
+ await new Promise(resolve => setTimeout(resolve, 100));
265
+ }
266
+ return null;
267
+ }
268
+ let lastObservedAutomationUrl = window.location.href;
269
+ function revalidateAutomationAfterNavigation() {
270
+ const currentUrl = window.location.href;
271
+ const navigated = currentUrl !== lastObservedAutomationUrl;
272
+ lastObservedAutomationUrl = currentUrl;
273
+ if (navigated && automation.running) {
274
+ const context = getAutomationContext();
275
+ if (automation.pendingNavigation && context.mode === 'FULL' && context.target.id === automation.targetId) {
276
+ automation.pendingNavigation = false;
277
+ updateAutomationUI();
278
+ return;
279
+ }
280
+ stopAutomation('NAVIGATED');
281
+ return;
282
+ }
283
+ if (!automation.running) {
284
+ updateAutomationUI();
285
+ return;
286
+ }
287
+ const context = getAutomationContext();
288
+ if (context.mode !== 'FULL' || context.target.id !== automation.targetId) {
289
+ stopAutomation('UNAUTHORIZED');
290
+ return;
291
+ }
292
+ updateAutomationUI();
293
+ }
294
+ function watchSpaNavigation() {
295
+ const check = () => setTimeout(revalidateAutomationAfterNavigation, 0);
296
+ window.addEventListener('popstate', check, { signal: lifecycle.signal });
297
+ window.addEventListener('hashchange', check, { signal: lifecycle.signal });
298
+ for (const method of ['pushState', 'replaceState']) {
299
+ const original = history[method];
300
+ const wrapped = function (...args) {
301
+ const result = original.apply(this, args);
302
+ check();
303
+ return result;
304
+ };
305
+ history[method] = wrapped;
306
+ navigationCleanup.push(() => { if (history[method] === wrapped)
307
+ history[method] = original; });
308
+ }
309
+ const navigationTimer = setInterval(() => {
310
+ if (window.location.href !== lastObservedAutomationUrl)
311
+ revalidateAutomationAfterNavigation();
312
+ }, 500);
313
+ navigationCleanup.push(() => clearInterval(navigationTimer));
314
+ }
315
+ watchSpaNavigation();
316
+ function answerIndexesFromResult(results, question) {
317
+ const answer = String(results?.[0]?.answer ?? '').replace(/[*`]/g, '').trim();
318
+ const numericValue = answer.replace(/^(?:the\s+)?(?:correct\s+)?(?:answer|result)\s*(?:is\s*|:\s*)?/i, '').trim();
319
+ if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(numericValue)) {
320
+ const matches = question.options.filter(option => option.text.trim() === numericValue);
321
+ return matches.length === 1 ? [matches[0].index] : [];
322
+ }
323
+ const letterMatches = [...answer.matchAll(/\bOption\s+([A-Z])\b/gi)]
324
+ .map((match) => match[1].toUpperCase().charCodeAt(0) - 65)
325
+ .filter((index) => index >= 0 && index < question.options.length);
326
+ const standaloneLetter = answer.match(/(?:^|\n|(?:answer|correct(?:\s+answer)?)\s*(?:is|:)?\s*)\(?([A-Z])\)?(?:[.:\-\s]|$)/i)?.[1];
327
+ const standaloneIndex = standaloneLetter ? standaloneLetter.toUpperCase().charCodeAt(0) - 65 : -1;
328
+ const numericMatches = [...answer.matchAll(/option\s*(?:number|no\.?|#)?\s*([1-9])\b/gi)]
329
+ .map((match) => Number(match[1]) - 1)
330
+ .filter((index) => index >= 0 && index < question.options.length);
331
+ const optionIndexes = [...new Set([
332
+ ...letterMatches,
333
+ ...(standaloneIndex >= 0 && standaloneIndex < question.options.length ? [standaloneIndex] : []),
334
+ ...numericMatches,
335
+ ])];
336
+ if (optionIndexes.length)
337
+ return question.selectionType === 'multiple' ? optionIndexes : [optionIndexes[0]];
338
+ const answerText = answer.toLowerCase();
339
+ const compact = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, '');
340
+ const normalizedAnswer = answerText
341
+ .replace(/^(?:the\s+)?(?:correct\s+)?(?:answer|result)\s*(?:is|:)?\s*/i, '')
342
+ .replace(/^option\s+[a-z]\s*[:.)-]?\s*/i, '')
343
+ .trim();
344
+ // Numeric MCQ options such as 0, 2, 9, and 3 need an exact match;
345
+ // substring matching would be unsafe for values inside code/reasoning.
346
+ const exactValue = question.options.find((option) => option.text.trim().toLowerCase() === normalizedAnswer
347
+ || (!/^[+-]?[\d.]+$/.test(option.text.trim()) && compact(option.text) === compact(normalizedAnswer)));
348
+ if (exactValue)
349
+ return [exactValue.index];
350
+ const textMatches = question.options
351
+ .filter((option) => {
352
+ const optionText = compact(option.text);
353
+ const modelText = compact(answerText);
354
+ return optionText.length > 2 && (modelText.includes(optionText) || optionText.includes(modelText));
355
+ })
356
+ .map((option) => option.index);
357
+ return question.selectionType === 'multiple' ? textMatches : textMatches.slice(0, 1);
358
+ }
359
+ async function recoverAnswerIndexes(question, previousAnswer) {
360
+ const format = question.selectionType === 'multiple'
361
+ ? 'Return every correct choice strictly as "Option A; Option C".'
362
+ : 'Return exactly one choice strictly as "Option A", "Option B", "Option C", or "Option D".';
363
+ const response = await apiRequest('/solve', 'POST', {
364
+ text: `OPTION-MAPPING RECOVERY\n${format}\nDo not explain.\n\nQuestion:\n${question.text}\n\nOptions:\n${question.options.map((option, index) => `${String.fromCharCode(65 + index)}. ${option.text}`).join('\n')}\n\nPrevious model response:\n${String(previousAnswer || '')}`,
365
+ url: window.location.href,
366
+ practice_type: 'mcq',
367
+ });
368
+ if (!response?.ok)
369
+ return [];
370
+ try {
371
+ const recovered = JSON.parse(response.data.results);
372
+ return answerIndexesFromResult(recovered, question);
373
+ }
374
+ catch {
375
+ return [];
376
+ }
377
+ }
378
+ function updateAutomationUI() {
379
+ if (disposed)
380
+ return;
381
+ const status = document.getElementById('assistant-automation-status');
382
+ const statusBadge = document.getElementById('assistant-automation-state');
383
+ const page = document.getElementById('assistant-automation-page');
384
+ const completed = document.getElementById('assistant-automation-completed');
385
+ const question = document.getElementById('assistant-automation-question');
386
+ const progress = document.getElementById('assistant-automation-progress');
387
+ const latest = document.getElementById('assistant-automation-latest');
388
+ const start = document.getElementById('assistant-auto-start-btn');
389
+ const pause = document.getElementById('assistant-auto-pause-btn');
390
+ const stop = document.getElementById('assistant-auto-stop-btn');
391
+ const journal = window.BrowserAssistantJournal;
392
+ if (journal?.matches()) {
393
+ const current = journal.getState();
394
+ journal.mount(document.getElementById('assistant-journal-slot'));
395
+ if (status)
396
+ status.textContent = `Build ${extensionVersion} · Reflective Journal · ${current.state}`;
397
+ if (statusBadge) {
398
+ statusBadge.textContent = current.state;
399
+ statusBadge.dataset.state = current.state.toLowerCase();
400
+ }
401
+ if (page)
402
+ page.textContent = 'Aurora · Reflective Journal';
403
+ if (completed)
404
+ completed.textContent = String(current.completed || 0);
405
+ if (question)
406
+ question.textContent = current.title || 'Select an ongoing reflective journal';
407
+ if (progress)
408
+ progress.textContent = `${current.completed || 0} / 1`;
409
+ if (latest) {
410
+ latest.textContent = current.message;
411
+ latest.classList.remove('has-error');
412
+ }
413
+ if (start) {
414
+ start.disabled = current.running;
415
+ start.textContent = '▶ Start journal automation';
416
+ }
417
+ if (pause) {
418
+ pause.disabled = !current.running || current.submitting;
419
+ pause.textContent = current.paused ? 'Resume' : 'Pause';
420
+ }
421
+ if (stop)
422
+ stop.disabled = !current.running;
423
+ const diagnostic = document.getElementById('assistant-export-question-btn');
424
+ if (diagnostic)
425
+ diagnostic.hidden = true;
426
+ return;
427
+ }
428
+ if (start)
429
+ start.textContent = '▶ Start automation';
430
+ const diagnostic = document.getElementById('assistant-export-question-btn');
431
+ if (diagnostic)
432
+ diagnostic.hidden = false;
433
+ const context = getAutomationContext();
434
+ const authorized = context.mode === 'FULL';
435
+ const currentQuestion = automation.batch?.title || (automation.running && automation.codingQuestionText)
436
+ || (authorized ? getAuthorizedQuestion(context)?.text : '');
437
+ const pageName = authorized ? context.target.id : window.location.hostname;
438
+ if (status) {
439
+ status.textContent = authorized
440
+ ? `Build ${extensionVersion} · ${context.target.id}: ${automation.state} · Completed: ${automation.completed}${automation.error ? ` · ${automation.error}` : ''}`
441
+ : `Assist Mode — ${window.location.origin} is not authorized by target policy.`;
442
+ }
443
+ if (statusBadge) {
444
+ statusBadge.textContent = automation.state;
445
+ statusBadge.dataset.state = automation.state.toLowerCase();
446
+ }
447
+ if (page)
448
+ page.textContent = pageName;
449
+ if (completed)
450
+ completed.textContent = String(automation.completed);
451
+ if (question)
452
+ question.textContent = currentQuestion || '—';
453
+ if (progress)
454
+ progress.textContent = `${automation.completed} / —`;
455
+ if (latest) {
456
+ latest.textContent = automation.error
457
+ ? `⚠ ${automation.error}`
458
+ : authorized
459
+ ? `Current status: ${automation.state.replace(/_/g, ' ').toLowerCase()}.`
460
+ : 'Automation is available only on an authorized QA page.';
461
+ latest.classList.toggle('has-error', Boolean(automation.error));
462
+ }
463
+ if (start)
464
+ start.disabled = !authorized || automation.running;
465
+ if (pause) {
466
+ pause.disabled = !automation.running;
467
+ pause.textContent = automation.paused ? 'Resume' : 'Pause';
468
+ }
469
+ if (stop)
470
+ stop.disabled = !automation.running;
471
+ }
472
+ function pauseOrResumeAutomation() {
473
+ if (window.BrowserAssistantJournal?.matches()) {
474
+ window.BrowserAssistantJournal.pause();
475
+ updateAutomationUI();
476
+ return;
477
+ }
478
+ if (!automation.running)
479
+ return;
480
+ automation.paused = !automation.paused;
481
+ automation.state = automation.paused ? 'PAUSED' : 'DETECTING';
482
+ updateAutomationUI();
483
+ }
484
+ function stopAutomation(reason = 'STOPPED') {
485
+ if (window.BrowserAssistantJournal?.matches()) {
486
+ window.BrowserAssistantJournal.stop();
487
+ updateAutomationUI();
488
+ return;
489
+ }
490
+ try {
491
+ sessionStorage.removeItem('browserAssistantCodingBatch');
492
+ }
493
+ catch { /* Stop must work when site storage is blocked. */ }
494
+ automation.batch = null;
495
+ automation.runId += 1;
496
+ automation.running = false;
497
+ automation.paused = false;
498
+ automation.pendingNavigation = false;
499
+ automation.state = reason;
500
+ updateAutomationUI();
501
+ }
502
+ async function waitWhilePaused(runId) {
503
+ while (automation.running && automation.paused && automation.runId === runId) {
504
+ await new Promise(resolve => setTimeout(resolve, 100));
505
+ }
506
+ return automation.running && automation.runId === runId;
507
+ }
508
+ async function waitForOptionSelection(question, index, runId) {
509
+ const started = Date.now();
510
+ let deadline = started + 2500;
511
+ let settledSince = null;
512
+ while (Date.now() < deadline && automation.running && automation.runId === runId) {
513
+ if (!(await waitWhilePaused(runId)))
514
+ return null;
515
+ const context = getAutomationContext();
516
+ if (context.mode !== 'FULL' || !automationPolicy.canExecute('validate', context))
517
+ return null;
518
+ const fresh = getAuthorizedQuestion(context);
519
+ // Never trust detached pre-click nodes, or confirmation on a
520
+ // different question that arrived during an asynchronous render.
521
+ if (fresh && !hasSameQuestionOptions(question, fresh))
522
+ return null;
523
+ if (getAutomationAdapter(context)?.getSubmittedAdvance?.(document)) {
524
+ return { ...(fresh || question), submitted: true };
525
+ }
526
+ const option = fresh?.options[index];
527
+ const saving = option?.getSaveState?.() === 'saving' || getAutomationAdapter(context)?.isSaving?.(document);
528
+ if (saving) {
529
+ deadline = started + 30000;
530
+ settledSince = null;
531
+ automation.state = 'WAITING_FOR_SAVE';
532
+ updateAutomationUI();
533
+ }
534
+ else if (option && (option.isSelected?.() || option.input?.checked)) {
535
+ if (settledSince === null)
536
+ settledSince = Date.now();
537
+ if (Date.now() - settledSince >= 150)
538
+ return fresh;
539
+ }
540
+ else
541
+ settledSince = null;
542
+ await new Promise(resolve => setTimeout(resolve, 50));
543
+ }
544
+ return null;
545
+ }
546
+ async function waitForQuestionSubmit(question, runId, adapter, answerIndexes, answerText) {
547
+ let deadline = Date.now() + 15000;
548
+ while (Date.now() < deadline && automation.running && automation.runId === runId) {
549
+ const pausedAt = Date.now();
550
+ if (!(await waitWhilePaused(runId)))
551
+ return null;
552
+ deadline += Date.now() - pausedAt;
553
+ const context = getAutomationContext();
554
+ if (context.mode !== 'FULL' || context.target.id !== automation.targetId
555
+ || getAutomationAdapter(context)?.id !== adapter.id
556
+ || !automationPolicy.canExecute('submit', context)) {
557
+ throw new Error('Target policy does not permit submission on this page.');
558
+ }
559
+ const fresh = getAuthorizedQuestion(context);
560
+ if (fresh && fresh.signature !== question.signature) {
561
+ throw new Error('Question changed before submission. No Submit was clicked.');
562
+ }
563
+ if (adapter.isSaving?.(document)) {
564
+ automation.state = 'WAITING_FOR_SAVE';
565
+ updateAutomationUI();
566
+ }
567
+ else if (adapter.getSubmittedAdvance?.(document)) {
568
+ return { submitted: true };
569
+ }
570
+ else if (fresh) {
571
+ if ((question.type === 'short-answer' && fresh.input?.value !== answerText)
572
+ || (answerIndexes && question.type !== 'short-answer' && question.type !== 'ordering'
573
+ && fresh.options.some(option => Boolean(option.isSelected?.() || option.input?.checked) !== answerIndexes.includes(option.index)))) {
574
+ throw new Error('The selected answer changed while waiting. No Submit was clicked.');
575
+ }
576
+ const button = adapter.getSubmit(document, fresh);
577
+ if (button?.isConnected && button.getClientRects().length && !button.disabled
578
+ && button.getAttribute('aria-disabled') !== 'true')
579
+ return { button };
580
+ automation.state = 'WAITING_FOR_SUBMIT';
581
+ updateAutomationUI();
582
+ }
583
+ await new Promise(resolve => setTimeout(resolve, 100));
584
+ }
585
+ if (automation.running && automation.runId === runId) {
586
+ throw new Error('The portal did not enable a unique Submit button within 15 seconds. The selected answer was kept.');
587
+ }
588
+ return null;
589
+ }
590
+ async function waitForPracticeTransition(previousSignature, runId) {
591
+ let deadline = Date.now() + 15000;
592
+ while (Date.now() < deadline && automation.running && automation.runId === runId) {
593
+ if (!(await waitWhilePaused(runId)))
594
+ return false;
595
+ const context = getAutomationContext();
596
+ const adapter = getAutomationAdapter(context);
597
+ if (context.mode !== 'FULL')
598
+ return false;
599
+ const blocked = adapter?.getBlockingReason?.(document);
600
+ if (blocked)
601
+ return { blocked };
602
+ if (adapter?.isComplete(document))
603
+ return 'complete';
604
+ if (adapter?.isWaitingForParticipants?.(document)) {
605
+ deadline = Date.now() + 15000;
606
+ automation.state = 'WAITING_FOR_CLASS';
607
+ updateAutomationUI();
608
+ await new Promise(resolve => setTimeout(resolve, 250));
609
+ continue;
610
+ }
611
+ const next = getAuthorizedQuestion(context);
612
+ if (next && next.signature !== previousSignature
613
+ && (previousSignature !== null || !adapter?.getSubmittedAdvance?.(document)))
614
+ return 'next';
615
+ await new Promise(resolve => setTimeout(resolve, 100));
616
+ }
617
+ return false;
618
+ }
619
+ async function submitCompletedQuiz(button, adapter, runId) {
620
+ if (!automationPolicy.canExecute('submit', getAutomationContext()))
621
+ return false;
622
+ automation.state = 'SUBMITTING_QUIZ';
623
+ automation.pendingNavigation = true;
624
+ updateAutomationUI();
625
+ button.click();
626
+ const deadline = Date.now() + 20000;
627
+ let confirmed = false;
628
+ while (Date.now() < deadline && automation.running && automation.runId === runId) {
629
+ if (!(await waitWhilePaused(runId)))
630
+ return false;
631
+ const context = getAutomationContext();
632
+ if (context.mode !== 'FULL' || getAutomationAdapter(context)?.id !== adapter.id)
633
+ return false;
634
+ if (adapter.isComplete(document))
635
+ return true;
636
+ const confirmation = adapter.getQuizConfirmation?.(document);
637
+ if (confirmation && !confirmed && automationPolicy.canExecute('submit', context)) {
638
+ confirmed = true;
639
+ confirmation.click();
640
+ }
641
+ await new Promise(resolve => setTimeout(resolve, 100));
642
+ }
643
+ return false;
644
+ }
645
+ async function waitForAdvanceOrTransition(previousSignature, runId, adapter) {
646
+ let deadline = Date.now() + 15000;
647
+ while (Date.now() < deadline && automation.running && automation.runId === runId) {
648
+ if (!(await waitWhilePaused(runId)))
649
+ return false;
650
+ const context = getAutomationContext();
651
+ if (context.mode !== 'FULL' || getAutomationAdapter(context)?.id !== adapter.id)
652
+ return false;
653
+ const blocked = adapter.getBlockingReason?.(document);
654
+ if (blocked)
655
+ return { blocked };
656
+ if (adapter.isComplete(document))
657
+ return 'complete';
658
+ const confirmation = adapter.getQuizConfirmation?.(document);
659
+ if (confirmation)
660
+ return { advance: confirmation, context };
661
+ if (adapter.isWaitingForParticipants?.(document)) {
662
+ deadline = Date.now() + 15000;
663
+ automation.state = 'WAITING_FOR_CLASS';
664
+ updateAutomationUI();
665
+ await new Promise(resolve => setTimeout(resolve, 250));
666
+ continue;
667
+ }
668
+ const proceed = adapter.getCompletionProceed?.(document);
669
+ if (proceed)
670
+ return { proceed, context };
671
+ const nextQuestion = getAuthorizedQuestion(context);
672
+ if (nextQuestion && nextQuestion.signature !== previousSignature)
673
+ return 'next';
674
+ const advance = adapter.getAdvance?.(document);
675
+ if (advance)
676
+ return { advance, context };
677
+ await new Promise(resolve => setTimeout(resolve, 100));
678
+ }
679
+ return false;
680
+ }
681
+ async function waitForNextPracticeQuestion(previousUrl, runId) {
682
+ const deadline = Date.now() + 12000;
683
+ while (Date.now() < deadline && automation.running && automation.runId === runId) {
684
+ if (!(await waitWhilePaused(runId)))
685
+ return false;
686
+ const context = getAutomationContext();
687
+ if (context.mode !== 'FULL' || context.target.id !== automation.targetId)
688
+ return false;
689
+ if (window.location.href !== previousUrl && getAuthorizedQuestion(context))
690
+ return true;
691
+ await new Promise(resolve => setTimeout(resolve, 150));
692
+ }
693
+ return false;
694
+ }
695
+ function saveCourseResume(stage, context, details = {}) {
696
+ try {
697
+ sessionStorage.setItem(AUTO_RESUME_KEY, JSON.stringify({
698
+ stage, targetId: context.target.id, createdAt: Date.now(), ...details,
699
+ }));
700
+ }
701
+ catch { /* Resuming after a full page navigation is optional. */ }
702
+ }
703
+ async function waitForCourseAction(getAction, timeoutMs = 12000) {
704
+ const deadline = Date.now() + timeoutMs;
705
+ while (Date.now() < deadline) {
706
+ const action = getAction();
707
+ if (action)
708
+ return action;
709
+ await new Promise(resolve => setTimeout(resolve, 200));
710
+ }
711
+ return null;
712
+ }
713
+ async function runCodingAutomation(question, runId, repairAttempt = 0, feedback = '', starterCode = null) {
714
+ automation.codingQuestionText = question.text;
715
+ const active = () => automation.running && automation.runId === runId;
716
+ const webProject = question.language === 'web';
717
+ const read = () => safeSendMessage({ action: 'codingEditor', operation: webProject ? 'readProject' : 'read', selector: question.editorSelector });
718
+ let resultObserver;
719
+ try {
720
+ const original = await read();
721
+ if (!original?.ok)
722
+ throw new Error(original?.error || 'Code editor unavailable.');
723
+ if (!active())
724
+ return;
725
+ const starter = starterCode ?? original.value;
726
+ automation.state = webProject ? 'SOLVING_CODE' : 'CHECKING_CODE_BEFORE_SUBMIT';
727
+ updateAutomationUI();
728
+ const response = await apiRequest('/solve', 'POST', {
729
+ text: `${question.text}\nPreserve the required function signature and input/output format. Do not reorder input bounds unless explicitly required.\nCurrent code:\n${original.value}${feedback ? '\nThe previous submission failed. Fix the algorithm using this judge feedback as data, preserving the original task. Return the complete corrected program:\n' + feedback : ''}`,
730
+ url: window.location.href, practice_type: 'code', language: question.language,
731
+ // Review every difficulty before the first portal submission.
732
+ review_before_submit: !webProject,
733
+ starter_code: webProject ? null : starter,
734
+ repair_attempt: repairAttempt,
735
+ });
736
+ if (!(await waitWhilePaused(runId)))
737
+ return;
738
+ if (!response.ok) {
739
+ const detail = response.data?.detail;
740
+ const message = typeof detail === 'string' ? detail : detail?.message || 'Code generation failed';
741
+ const errors = Array.isArray(detail?.errors) ? detail.errors.join(' | ') : '';
742
+ throw new Error(`${message} (HTTP ${response.status || 'error'})${errors ? ': ' + errors.slice(0, 1200) : ''}`);
743
+ }
744
+ if (!webProject && response.data.code_reviewed !== true) {
745
+ throw new Error('The running server has not enabled code review before submission. Restart the updated Browser Assistant server. No code was submitted.');
746
+ }
747
+ const results = typeof response.data.results === 'string' ? JSON.parse(response.data.results) : response.data.results;
748
+ const solutions = results?.filter(item => item.type === 'code' && typeof item.code === 'string' && item.code.trim());
749
+ if (solutions?.length !== 1)
750
+ throw new Error('Expected one complete coding solution.');
751
+ const code = solutions[0].code;
752
+ const context = getAutomationContext();
753
+ if (!automationPolicy.canExecute('fill', context) || !automationPolicy.canExecute('validate', context))
754
+ throw new Error('Code filling or validation is disabled for this page.');
755
+ const currentQuestion = getAuthorizedQuestion(context);
756
+ if (!currentQuestion || currentQuestion.signature !== question.signature)
757
+ throw new Error('The problem or language changed while generating code. Restart on the current question.');
758
+ // React may replace the editor DOM while the AI request is in flight.
759
+ // Rebind only after matching the question, then require unchanged code.
760
+ question.editorSelector = currentQuestion.editorSelector;
761
+ const refreshed = await read();
762
+ if (!(await waitWhilePaused(runId)))
763
+ return;
764
+ if (!refreshed?.ok || refreshed.value !== original.value)
765
+ throw new Error('Starter code changed while generating the solution. Restart to use the current code.');
766
+ if (getAuthorizedQuestion()?.signature !== question.signature)
767
+ throw new Error('Question changed before code insertion.');
768
+ automation.state = 'FILLING_CODE';
769
+ updateAutomationUI();
770
+ const filled = await safeSendMessage({ action: 'codingEditor', operation: webProject ? 'writeProject' : 'write', selector: question.editorSelector, expected: original.value, value: code });
771
+ if (!active())
772
+ return;
773
+ if (!filled?.ok)
774
+ throw new Error(filled?.error || 'Code insertion failed.');
775
+ if (!(await waitWhilePaused(runId)))
776
+ return;
777
+ const verified = await read();
778
+ if (!active())
779
+ return;
780
+ const current = getAutomationContext();
781
+ if (!verified?.ok || verified.value !== code || getAuthorizedQuestion(current)?.signature !== question.signature)
782
+ throw new Error('Editor or question changed before submission.');
783
+ const submit = window.BrowserAssistantCoding.getSubmit(document);
784
+ if (!automationPolicy.canExecute('submit', current) || !submit)
785
+ throw new Error('Code filled. No unique enabled Submit button found.');
786
+ const previousResult = window.BrowserAssistantCoding.getResult(document);
787
+ let resultChanged = false;
788
+ if (webProject && typeof MutationObserver !== 'undefined') {
789
+ const result = document.querySelector('#submitResultContainer');
790
+ if (result?.parentElement) {
791
+ resultObserver = new MutationObserver(records => {
792
+ if (records.some(record => record.target.closest?.('#submitResultContainer')
793
+ || [...record.addedNodes, ...record.removedNodes].some(node => node.id === 'submitResultContainer')))
794
+ resultChanged = true;
795
+ });
796
+ resultObserver.observe(result.parentElement, { childList: true, subtree: true, characterData: true });
797
+ }
798
+ }
799
+ const submissionUrl = window.location.href;
800
+ automation.state = 'SUBMITTING';
801
+ updateAutomationUI();
802
+ submit.click();
803
+ automation.state = 'WAITING_FOR_RESULT';
804
+ updateAutomationUI();
805
+ const deadline = Date.now() + 60000;
806
+ let sawPending = !previousResult;
807
+ while (Date.now() < deadline) {
808
+ if (!(await waitWhilePaused(runId)))
809
+ return;
810
+ if (window.location.href !== submissionUrl)
811
+ throw new Error('Page changed while waiting for the coding result.');
812
+ const afterSubmit = getAuthorizedQuestion();
813
+ // Submissions replaces Description on this portal. Result detection
814
+ // must continue even when the original prompt is no longer mounted.
815
+ if (afterSubmit && afterSubmit.signature !== question.signature)
816
+ throw new Error('Question changed while waiting for the coding result.');
817
+ const status = window.BrowserAssistantCoding.getResult(document);
818
+ if (!status)
819
+ sawPending = true;
820
+ if (sawPending || resultChanged || status !== previousResult) {
821
+ if (status === 'failed') {
822
+ if (repairAttempt >= 2)
823
+ throw new Error('Tests still fail after two automatic repairs. Review the latest judge feedback.');
824
+ const feedback = window.BrowserAssistantCoding.getFailureFeedback(document);
825
+ if (!feedback)
826
+ throw new Error('Tests failed, but readable judge feedback was not available.');
827
+ automation.state = 'REPAIRING_CODE';
828
+ updateAutomationUI();
829
+ const description = window.BrowserAssistantCoding.getDescriptionTab(document);
830
+ description?.click();
831
+ const repairDeadline = Date.now() + 15000;
832
+ while (Date.now() < repairDeadline) {
833
+ if (!(await waitWhilePaused(runId)))
834
+ return;
835
+ if (window.location.href !== submissionUrl)
836
+ throw new Error('Page changed before repair.');
837
+ const restored = getAuthorizedQuestion();
838
+ if (restored?.signature === question.signature) {
839
+ return await runCodingAutomation(restored, runId, repairAttempt + 1, feedback, starter);
840
+ }
841
+ await new Promise(resolve => setTimeout(resolve, 250));
842
+ }
843
+ throw new Error('Could not reopen the original description for code repair.');
844
+ }
845
+ if (status === 'passed') {
846
+ automation.completed += 1;
847
+ if (!automation.batch)
848
+ stopAutomation('COMPLETED');
849
+ return true;
850
+ }
851
+ }
852
+ await new Promise(resolve => setTimeout(resolve, 250));
853
+ }
854
+ throw new Error('Code submitted; success was not confirmed. Check the judge result.');
855
+ }
856
+ catch (error) {
857
+ if (!active())
858
+ return;
859
+ automation.error = error.message;
860
+ stopAutomation('PAUSED');
861
+ }
862
+ finally {
863
+ resultObserver?.disconnect();
864
+ }
865
+ }
866
+ const CODING_BATCH_KEY = 'browserAssistantCodingBatch';
867
+ function codingQuestionPath(value, baseUrl) {
868
+ if (!value)
869
+ return '';
870
+ try {
871
+ return new URL(value, baseUrl).pathname.replace(/\/$/, '');
872
+ }
873
+ catch {
874
+ return '';
875
+ }
876
+ }
877
+ function rememberCompletedCodingQuestion(batch) {
878
+ batch.done ||= [];
879
+ batch.doneUrls ||= [];
880
+ if (batch.title && !batch.done.includes(batch.title))
881
+ batch.done.push(batch.title);
882
+ const path = codingQuestionPath(batch.questionUrl, batch.listUrl);
883
+ if (path && !batch.doneUrls.includes(path))
884
+ batch.doneUrls.push(path);
885
+ }
886
+ function wasCompletedCodingQuestion(batch, row) {
887
+ const titleSeen = (batch.done || []).includes(row.title);
888
+ const rowPath = codingQuestionPath(row.href, batch.listUrl);
889
+ const urlSeen = Boolean(rowPath && (batch.doneUrls || []).includes(rowPath));
890
+ return titleSeen || urlSeen;
891
+ }
892
+ async function startCodingBatch(saved = null) {
893
+ const context = getAutomationContext();
894
+ if (context.mode !== 'FULL' || !automationPolicy.canExecute('navigate', context))
895
+ return;
896
+ const batch = saved || { origin: location.origin, listUrl: location.href, phase: 'list', done: [], doneUrls: [], completed: 0 };
897
+ batch.done ||= [];
898
+ batch.doneUrls ||= [];
899
+ if (batch.origin !== location.origin)
900
+ return;
901
+ automation.runId += 1;
902
+ const runId = automation.runId;
903
+ automation.running = true;
904
+ automation.paused = false;
905
+ automation.error = '';
906
+ automation.targetId = context.target.id;
907
+ automation.batch = batch;
908
+ automation.completed = batch.completed;
909
+ isCollapsed = false;
910
+ selectedTab = 'qa';
911
+ render();
912
+ const persist = phase => {
913
+ batch.phase = phase;
914
+ batch.updatedAt = Date.now();
915
+ sessionStorage.setItem(CODING_BATCH_KEY, JSON.stringify(batch));
916
+ };
917
+ const waitFor = async (predicate, timeout = 20000) => {
918
+ const deadline = Date.now() + timeout;
919
+ while (Date.now() < deadline) {
920
+ if (!(await waitWhilePaused(runId)))
921
+ return null;
922
+ const value = predicate();
923
+ if (value)
924
+ return value;
925
+ // CCBP navigates client-side; short polling keeps the batch
926
+ // moving as soon as its list or next question is rendered.
927
+ await new Promise(resolve => setTimeout(resolve, 125));
928
+ }
929
+ return null;
930
+ };
931
+ const visited = new Set();
932
+ let filterChanges = 0;
933
+ try {
934
+ if (batch.phase === 'busy')
935
+ throw new Error('Page reloaded during solving/submission. Check the judge result, then restart from the problem list.');
936
+ while (await waitWhilePaused(runId)) {
937
+ if (location.origin !== batch.origin || getAutomationContext().mode !== 'FULL')
938
+ throw new Error('Batch left its starting website.');
939
+ if (batch.phase === 'question' || batch.phase === 'return') {
940
+ if (batch.phase === 'question') {
941
+ automation.state = 'LOADING_QUESTION';
942
+ updateAutomationUI();
943
+ const question = await waitFor(() => {
944
+ const found = getAuthorizedQuestion();
945
+ const matches = window.BrowserAssistantCodingBatch.matchesQuestion?.(found, batch, location.href)
946
+ ?? (found?.type === 'code' && normalizeAutomationText(found.text).toLowerCase().includes(batch.title.toLowerCase()));
947
+ return matches ? found : null;
948
+ });
949
+ if (!automation.running || automation.runId !== runId)
950
+ return;
951
+ if (!question)
952
+ throw new Error(window.BrowserAssistantCoding.getDetectionError() || 'Selected coding question did not load or its title did not match.');
953
+ persist('busy');
954
+ const passed = await runCodingAutomation(question, runId);
955
+ if (!passed || !automation.running || automation.runId !== runId)
956
+ return;
957
+ // Keep both title and URL. CCBP may leave a newly accepted
958
+ // item marked "In Progress" briefly after returning to the
959
+ // list, so status alone is not enough to prevent a retry.
960
+ rememberCompletedCodingQuestion(batch);
961
+ batch.completed = automation.completed;
962
+ }
963
+ persist('list');
964
+ automation.state = 'RETURNING_TO_LIST';
965
+ automation.pendingNavigation = true;
966
+ updateAutomationUI();
967
+ const back = window.BrowserAssistantCodingBatch.getBackControl(document, batch.listUrl);
968
+ if (!back)
969
+ throw new Error('Accepted. Question Bank Back button was not found; return to the list to continue.');
970
+ back.click();
971
+ let saveConfirmed = false;
972
+ const returned = await waitFor(() => {
973
+ if (location.pathname.replace(/\/$/, '') === new URL(batch.listUrl).pathname.replace(/\/$/, '')
974
+ && window.BrowserAssistantCodingBatch.pageRange(document))
975
+ return true;
976
+ const yes = window.BrowserAssistantCodingBatch.getSaveBeforeLeaving?.(document);
977
+ if (yes && !saveConfirmed) {
978
+ saveConfirmed = true;
979
+ automation.state = 'SAVING_BEFORE_LEAVING';
980
+ updateAutomationUI();
981
+ yes.click();
982
+ }
983
+ return false;
984
+ });
985
+ if (!automation.running || automation.runId !== runId)
986
+ return;
987
+ if (!returned)
988
+ throw new Error('Accepted. Back was clicked, but the problem list did not load.');
989
+ visited.clear();
990
+ batch.title = '';
991
+ persist('list');
992
+ continue;
993
+ }
994
+ automation.state = 'FINDING_UNSOLVED';
995
+ updateAutomationUI();
996
+ const listReady = await waitFor(() => location.pathname.replace(/\/$/, '') === new URL(batch.listUrl).pathname.replace(/\/$/, '')
997
+ && window.BrowserAssistantCodingBatch.pageRange(document));
998
+ if (!automation.running || automation.runId !== runId)
999
+ return;
1000
+ if (!listReady)
1001
+ throw new Error('Problem list or pagination count did not load.');
1002
+ const filterStep = window.BrowserAssistantCodingBatch.courseFilterStep?.(document);
1003
+ if (filterStep?.error)
1004
+ throw new Error(filterStep.error);
1005
+ if (filterStep?.control) {
1006
+ if (++filterChanges > 20)
1007
+ throw new Error('Course filters are not updating. Check the filter selection and restart.');
1008
+ automation.state = 'FILTERING_COURSES';
1009
+ updateAutomationUI();
1010
+ filterStep.control.click();
1011
+ await new Promise(resolve => setTimeout(resolve, 750));
1012
+ continue;
1013
+ }
1014
+ const rows = window.BrowserAssistantCodingBatch.rows(document);
1015
+ const next = rows.find(row => !wasCompletedCodingQuestion(batch, row));
1016
+ if (next) {
1017
+ batch.title = next.title;
1018
+ batch.questionUrl = next.href || null;
1019
+ persist('question');
1020
+ automation.state = 'OPENING_QUESTION';
1021
+ automation.pendingNavigation = true;
1022
+ updateAutomationUI();
1023
+ next.action.click();
1024
+ continue;
1025
+ }
1026
+ const key = window.BrowserAssistantCodingBatch.pageKey(document);
1027
+ if (visited.has(key))
1028
+ throw new Error('Pagination repeated the same problem list.');
1029
+ visited.add(key);
1030
+ const range = window.BrowserAssistantCodingBatch.pageRange(document);
1031
+ const nextPage = window.BrowserAssistantCodingBatch.nextPage(document);
1032
+ if (!nextPage) {
1033
+ if (range && range.end >= range.total) {
1034
+ stopAutomation('COMPLETED');
1035
+ return;
1036
+ }
1037
+ throw new Error('No Not Attempted or In Progress questions on this page; the next-page control could not be identified.');
1038
+ }
1039
+ persist('list');
1040
+ automation.state = 'NEXT_LIST_PAGE';
1041
+ automation.pendingNavigation = true;
1042
+ updateAutomationUI();
1043
+ nextPage.click();
1044
+ const changed = await waitFor(() => {
1045
+ const updated = window.BrowserAssistantCodingBatch.pageRange(document);
1046
+ return updated && updated.start !== range.start;
1047
+ });
1048
+ if (!automation.running || automation.runId !== runId)
1049
+ return;
1050
+ if (!changed)
1051
+ throw new Error('Next problem-list page did not load.');
1052
+ }
1053
+ }
1054
+ catch (error) {
1055
+ if (automation.runId !== runId)
1056
+ return;
1057
+ automation.error = error.message;
1058
+ stopAutomation('PAUSED');
1059
+ }
1060
+ }
1061
+ async function resumeCodingBatch() {
1062
+ let saved;
1063
+ try {
1064
+ saved = JSON.parse(sessionStorage.getItem(CODING_BATCH_KEY) || 'null');
1065
+ }
1066
+ catch {
1067
+ return false;
1068
+ }
1069
+ if (!saved)
1070
+ return false;
1071
+ if (saved.origin !== location.origin || Date.now() - saved.updatedAt > 120000) {
1072
+ sessionStorage.removeItem(CODING_BATCH_KEY);
1073
+ return false;
1074
+ }
1075
+ await startCodingBatch(saved);
1076
+ return true;
1077
+ }
1078
+ async function runOnlineIdeAutomation(question) {
1079
+ if (question.solved) {
1080
+ stopAutomation('ALREADY_SOLVED');
1081
+ return;
1082
+ }
1083
+ const url = location.href;
1084
+ automation.runId += 1;
1085
+ const runId = automation.runId;
1086
+ automation.running = true;
1087
+ automation.paused = false;
1088
+ automation.completed = 0;
1089
+ automation.error = '';
1090
+ automation.codingQuestionText = question.title;
1091
+ automation.state = 'PREPARING_APP_JS';
1092
+ updateAutomationUI();
1093
+ const current = () => location.href === url && automation.runId === runId;
1094
+ async function openOnlineIde() {
1095
+ let ide = await safeSendMessage({ action: 'focusOnlineIde' });
1096
+ if (!(await waitWhilePaused(runId)) || !current())
1097
+ return;
1098
+ if (!ide?.found) {
1099
+ const fresh = window.BrowserAssistantCoding.getOnlineIdeQuestion(document, location);
1100
+ if (!fresh || fresh.text !== question.text)
1101
+ throw new Error('The IDE question changed before opening.');
1102
+ fresh.button.click();
1103
+ const deadline = Date.now() + 10000;
1104
+ do {
1105
+ if (!(await waitWhilePaused(runId)) || !current())
1106
+ return;
1107
+ ide = await safeSendMessage({ action: 'focusOnlineIde' });
1108
+ if (ide?.found || Date.now() >= deadline)
1109
+ break;
1110
+ await new Promise(resolve => setTimeout(resolve, 500));
1111
+ } while (!ide?.found && Date.now() < deadline);
1112
+ }
1113
+ return ide;
1114
+ }
1115
+ try {
1116
+ const [ide, response] = await Promise.all([openOnlineIde(), apiRequest('/solve', 'POST', {
1117
+ url, practice_type: 'code', language: 'javascript', review_before_submit: true,
1118
+ text: `${question.text}\n\nGenerate the complete app.js for the online Node.js IDE. Use the stated database, route methods, response bodies and module format. Use parameterized SQL where applicable. Return source code for app.js only.`,
1119
+ })]);
1120
+ if (!(await waitWhilePaused(runId)) || !current())
1121
+ return;
1122
+ if (!response?.ok)
1123
+ throw window.BrowserAssistantAnswerRequests.failure(response);
1124
+ const solutions = JSON.parse(response.data.results);
1125
+ if (!Array.isArray(solutions) || solutions.length !== 1 || solutions[0].type !== 'code'
1126
+ || typeof solutions[0].code !== 'string' || !solutions[0].code.trim()) {
1127
+ throw new Error('The server did not return a complete app.js solution.');
1128
+ }
1129
+ const solution = solutions[0];
1130
+ let instructions = ide?.found
1131
+ ? 'Copy into app.js in the IDE tab after the workspace is ready, then save.'
1132
+ : 'Use the portal Open IDE button, then copy into app.js and save.';
1133
+ const context = getAutomationContext();
1134
+ if (ide?.found && question.workspace && response.data.code_reviewed === true
1135
+ && automationPolicy.canExecute('fill', context) && automationPolicy.canExecute('submit', context)) {
1136
+ const token = crypto.randomUUID();
1137
+ const target = { workspace: question.workspace, token };
1138
+ automation.state = 'WAITING_FOR_IDE';
1139
+ updateAutomationUI();
1140
+ const deadline = Date.now() + 120000;
1141
+ let launched;
1142
+ do {
1143
+ if (!(await waitWhilePaused(runId)) || !current())
1144
+ return;
1145
+ launched = await safeSendMessage({ action: 'runOnlineIde', ...target, code: solution.code });
1146
+ if (!launched?.retry)
1147
+ break;
1148
+ await new Promise(resolve => setTimeout(resolve, 1000));
1149
+ } while (Date.now() < deadline);
1150
+ if (!current())
1151
+ return;
1152
+ if (launched?.alreadyPassed) {
1153
+ stopAutomation('ALREADY_SOLVED');
1154
+ return;
1155
+ }
1156
+ if (launched?.started) {
1157
+ automation.state = 'WAITING_FOR_IDE_GRADING';
1158
+ updateAutomationUI();
1159
+ const resultDeadline = Date.now() + 240000;
1160
+ while (current() && Date.now() < resultDeadline) {
1161
+ if (!(await waitWhilePaused(runId)) || !current())
1162
+ return;
1163
+ const result = await safeSendMessage({ action: 'onlineIdeStatus', ...target });
1164
+ if (!current())
1165
+ return;
1166
+ if (result?.done) {
1167
+ if (result.passed)
1168
+ automation.completed = 1;
1169
+ else
1170
+ automation.error = result.submitted ? 'Grading finished. Review the test results in the IDE.' : 'The IDE command finished without a confirmed submission. Check the terminal.';
1171
+ stopAutomation(result.passed ? 'COMPLETED' : 'CHECK_IDE_RESULT');
1172
+ return;
1173
+ }
1174
+ if (result?.ok === false && !result.retry)
1175
+ break;
1176
+ await new Promise(resolve => setTimeout(resolve, 1000));
1177
+ }
1178
+ if (current()) {
1179
+ automation.error = 'The command was sent. Check its result in the IDE terminal.';
1180
+ stopAutomation('CHECK_IDE_RESULT');
1181
+ }
1182
+ return;
1183
+ }
1184
+ if (launched?.error)
1185
+ instructions = `${launched.error} ${instructions}`;
1186
+ }
1187
+ stopAutomation('CODE_READY');
1188
+ displayResults([{ ...solution, title: 'app.js', approach: [instructions, solution.approach].filter(Boolean).join(' ') }], response.data.provider);
1189
+ }
1190
+ catch (error) {
1191
+ if (current()) {
1192
+ automation.error = error.message;
1193
+ stopAutomation('ERROR');
1194
+ }
1195
+ }
1196
+ finally {
1197
+ if (automation.runId === runId)
1198
+ stopAutomation('STOPPED');
1199
+ }
1200
+ }
1201
+ async function startPracticeAutomation() {
1202
+ if (window.BrowserAssistantJournal?.matches()) {
1203
+ await window.BrowserAssistantJournal.start();
1204
+ updateAutomationUI();
1205
+ return;
1206
+ }
1207
+ if (window.location.origin === 'https://learning.ccbp.in' && window.location.pathname === '/') {
1208
+ automation.state = 'OPEN_PRACTICE';
1209
+ automation.error = 'Open a practice or quiz question first. Mentor feedback on the Home page is not a quiz.';
1210
+ updateAutomationUI();
1211
+ return;
1212
+ }
1213
+ // A question opened by the user uses the one-question solver below.
1214
+ // Only an explicit Start on the problem list begins a filtered batch.
1215
+ // Do not enter the batch's return phase before solving this question.
1216
+ if (window.location.pathname.replace(/\/$/, '') === '/problems-set') {
1217
+ await startCodingBatch();
1218
+ return;
1219
+ }
1220
+ const initialContext = getAutomationContext();
1221
+ const adapter = getAutomationAdapter(initialContext);
1222
+ if (initialContext.mode !== 'FULL' || !adapter) {
1223
+ automation.state = 'UNAUTHORIZED';
1224
+ automation.error = 'This origin or page is not authorized.';
1225
+ updateAutomationUI();
1226
+ return;
1227
+ }
1228
+ const onlineIde = window.BrowserAssistantCoding?.getOnlineIdeQuestion?.(document, location);
1229
+ if (onlineIde && automationPolicy.canExecute('navigate', initialContext)) {
1230
+ await runOnlineIdeAutomation(onlineIde);
1231
+ return;
1232
+ }
1233
+ // Course instruction pages have no options yet. Start the selected
1234
+ // practice first, then resume when its first question is available.
1235
+ if (!getAuthorizedQuestion(initialContext)) {
1236
+ const startPractice = adapter.getStartPractice?.(document);
1237
+ if (startPractice && automationPolicy.canExecute('navigate', initialContext)) {
1238
+ automation.state = 'STARTING_PRACTICE';
1239
+ automation.targetId = initialContext.target.id;
1240
+ updateAutomationUI();
1241
+ saveCourseResume('solve', initialContext);
1242
+ startPractice.click();
1243
+ setTimeout(() => { void resumeAuthorizedQaAfterNavigation(); }, 900);
1244
+ return;
1245
+ }
1246
+ }
1247
+ automation.runId += 1;
1248
+ const runId = automation.runId;
1249
+ automation.running = true;
1250
+ automation.paused = false;
1251
+ automation.error = '';
1252
+ automation.completed = 0;
1253
+ automation.targetId = initialContext.target.id;
1254
+ automation.pendingNavigation = false;
1255
+ automation.currentSignature = null;
1256
+ automation.processed.clear();
1257
+ updateAutomationUI();
1258
+ while (automation.running && automation.runId === runId) {
1259
+ if (!(await waitWhilePaused(runId)))
1260
+ break;
1261
+ const context = getAutomationContext();
1262
+ const currentAdapter = getAutomationAdapter(context);
1263
+ if (context.mode !== 'FULL' || currentAdapter?.id !== adapter.id) {
1264
+ stopAutomation('UNAUTHORIZED');
1265
+ break;
1266
+ }
1267
+ const blocked = currentAdapter.getBlockingReason?.(document);
1268
+ if (blocked) {
1269
+ automation.error = blocked;
1270
+ stopAutomation('PAUSED');
1271
+ break;
1272
+ }
1273
+ if (currentAdapter.isSaving?.(document)) {
1274
+ automation.state = 'WAITING_FOR_SAVE';
1275
+ updateAutomationUI();
1276
+ await new Promise(resolve => setTimeout(resolve, 100));
1277
+ continue;
1278
+ }
1279
+ const finalSubmit = currentAdapter.getFinalQuizSubmit?.(document);
1280
+ if (finalSubmit) {
1281
+ const submitted = await submitCompletedQuiz(finalSubmit, currentAdapter, runId);
1282
+ if (!automation.running || automation.runId !== runId)
1283
+ break;
1284
+ if (!submitted)
1285
+ automation.error = 'Final Submit was clicked; the portal did not show a submission confirmation. Check its status.';
1286
+ stopAutomation(submitted ? 'COMPLETED' : 'PAUSED');
1287
+ break;
1288
+ }
1289
+ if (currentAdapter.isWaitingForParticipants?.(document)) {
1290
+ automation.state = 'WAITING_FOR_CLASS';
1291
+ updateAutomationUI();
1292
+ await new Promise(resolve => setTimeout(resolve, 250));
1293
+ continue;
1294
+ }
1295
+ const submittedAdvance = currentAdapter.getSubmittedAdvance?.(document);
1296
+ if (submittedAdvance) {
1297
+ if (!automationPolicy.canExecute('click', context)) {
1298
+ automation.error = 'Target policy does not permit Next.';
1299
+ stopAutomation('UNAUTHORIZED');
1300
+ break;
1301
+ }
1302
+ const previousSignature = getAuthorizedQuestion(context)?.signature || automation.currentSignature;
1303
+ automation.state = 'OPENING_NEXT_QUESTION';
1304
+ updateAutomationUI();
1305
+ submittedAdvance.click();
1306
+ const transition = await waitForPracticeTransition(previousSignature, runId);
1307
+ if (!automation.running || automation.runId !== runId)
1308
+ break;
1309
+ if (transition === 'next' || transition === 'complete')
1310
+ automation.completed += 1;
1311
+ if (transition === 'complete') {
1312
+ stopAutomation('COMPLETED');
1313
+ break;
1314
+ }
1315
+ if (transition !== 'next') {
1316
+ automation.error = transition?.blocked || 'Next question did not appear after the submitted answer.';
1317
+ stopAutomation('PAUSED');
1318
+ break;
1319
+ }
1320
+ continue;
1321
+ }
1322
+ if (currentAdapter.isComplete(document)) {
1323
+ const nextPractice = currentAdapter.getNextPractice?.(document);
1324
+ if (!nextPractice) {
1325
+ stopAutomation('COMPLETED');
1326
+ break;
1327
+ }
1328
+ if (!automationPolicy.canExecute('navigate', context) && !automationPolicy.canExecute('click', context)) {
1329
+ stopAutomation('COMPLETED');
1330
+ break;
1331
+ }
1332
+ automation.state = 'OPENING_NEXT_PRACTICE';
1333
+ automation.pendingNavigation = true;
1334
+ updateAutomationUI();
1335
+ const previousUrl = window.location.href;
1336
+ try {
1337
+ sessionStorage.setItem(AUTO_RESUME_KEY, JSON.stringify({ targetId: automation.targetId, fromUrl: previousUrl, createdAt: Date.now() }));
1338
+ }
1339
+ catch { /* Resuming after a full page navigation is optional. */ }
1340
+ nextPractice.click();
1341
+ if (!(await waitForNextPracticeQuestion(previousUrl, runId))) {
1342
+ automation.error = 'Next practice did not load in time.';
1343
+ stopAutomation('PAUSED');
1344
+ break;
1345
+ }
1346
+ continue;
1347
+ }
1348
+ automation.state = 'DETECTING';
1349
+ updateAutomationUI();
1350
+ let question = getAuthorizedQuestion(context);
1351
+ if (!question)
1352
+ question = await waitForReadableQuestion(runId);
1353
+ if (!automation.running || automation.runId !== runId)
1354
+ break;
1355
+ if (!question) {
1356
+ automation.error = questionDetectionError();
1357
+ stopAutomation('PAUSED');
1358
+ break;
1359
+ }
1360
+ if (question.type === 'code') {
1361
+ await runCodingAutomation(question, runId);
1362
+ break;
1363
+ }
1364
+ if (automation.processed.has(question.signature)) {
1365
+ automation.error = 'Repeated question detected.';
1366
+ stopAutomation('PAUSED');
1367
+ break;
1368
+ }
1369
+ automation.currentSignature = question.signature;
1370
+ automation.state = 'SOLVING';
1371
+ updateAutomationUI();
1372
+ let response;
1373
+ try {
1374
+ let requestText = question.text;
1375
+ if (location.origin === 'https://learning.ccbp.in' && /^\/primitive-coding\/practice\//.test(location.pathname)
1376
+ && document.querySelector('.ace_editor,.monaco-editor,.CodeMirror')) {
1377
+ const snapshot = await readMcqSnippetSnapshot(question, runId);
1378
+ if (!snapshot?.ok)
1379
+ throw new Error(snapshot?.error || 'Full code snippet could not be read.');
1380
+ if (!(await waitWhilePaused(runId)))
1381
+ break;
1382
+ const fresh = getAuthorizedQuestion();
1383
+ if (!hasSameQuestionOptions(question, fresh))
1384
+ throw new Error('Question changed while reading the code.');
1385
+ // The snapshot fills option code from full models, not the
1386
+ // rows visible when this question was initially detected.
1387
+ question = fresh;
1388
+ automation.currentSignature = question.signature;
1389
+ question.codeSnapshot = JSON.stringify(snapshot.editorSources || snapshot.sources);
1390
+ if (snapshot.sources.length)
1391
+ requestText = `${question.identityText || question.text}\nComplete code snippets:\n${snapshot.sources.join('\n\n')}`;
1392
+ }
1393
+ const requestBody = {
1394
+ text: `${requestText}\nQuestion format: ${question.type === 'short-answer' ? 'Free response. Return only the exact value to enter in the JSON answer field, without an option label, explanation, or Markdown.' : question.selectionType === 'multiple' ? 'select all that apply; return every correct option as "Option A: ...; Option B: ...".' : 'select one answer.'}\n${question.options.map((option, index) => `${String.fromCharCode(65 + index)}. ${option.text}`).join('\n')}`,
1395
+ ...(question.type === 'ordering' ? { text: `${question.text}\nReorder ALL items. Return the complete permutation as a JSON array of 1-based item numbers in the answer field (example: [2,1,3]). Check every item appears exactly once.\n${question.options.map((option, index) => `${index + 1}. ${option.text}`).join('\n')}` } : {}),
1396
+ url: window.location.href, practice_type: 'mcq',
1397
+ };
1398
+ const visuals = window.BrowserAssistantQuestionVisuals;
1399
+ if (visuals?.nodes(question).length) {
1400
+ automation.state = 'READING_CHART';
1401
+ updateAutomationUI();
1402
+ await visuals.requireBackend(apiRequest, visuals.nodes(question).length);
1403
+ const snapshot = await visuals.capture(question, safeSendMessage);
1404
+ if (!(await waitWhilePaused(runId)))
1405
+ break;
1406
+ const fresh = getAuthorizedQuestion();
1407
+ if (!hasSameQuestionOptions(question, fresh) || visuals.fingerprint(fresh) !== snapshot.fingerprint) {
1408
+ throw new Error('Question or illustration changed while loading. Please restart.');
1409
+ }
1410
+ question.visualSnapshot = snapshot.fingerprint;
1411
+ automation.state = 'SOLVING';
1412
+ updateAutomationUI();
1413
+ response = await requestPracticeAnswer(question, runId, '/solve-vision', {
1414
+ image: snapshot.images[0], images: snapshot.images.slice(1),
1415
+ text: requestBody.text, url: requestBody.url, practice_type: 'mcq',
1416
+ });
1417
+ }
1418
+ else {
1419
+ response = await requestPracticeAnswer(question, runId, '/solve', requestBody);
1420
+ }
1421
+ }
1422
+ catch (error) {
1423
+ if (!automation.running || automation.runId !== runId)
1424
+ break;
1425
+ automation.error = error.message || 'Local AI server unavailable.';
1426
+ stopAutomation('ERROR');
1427
+ break;
1428
+ }
1429
+ if (!automation.running || automation.runId !== runId || !response)
1430
+ break;
1431
+ automation.error = '';
1432
+ automation.state = 'SOLVING';
1433
+ updateAutomationUI();
1434
+ let results;
1435
+ try {
1436
+ results = JSON.parse(response.data.results);
1437
+ }
1438
+ catch {
1439
+ automation.error = 'AI returned an invalid answer.';
1440
+ stopAutomation('ERROR');
1441
+ break;
1442
+ }
1443
+ if (question.codeSnapshot) {
1444
+ let snapshot;
1445
+ try {
1446
+ snapshot = await readMcqSnippetSnapshot(question, runId);
1447
+ }
1448
+ catch {
1449
+ snapshot = null;
1450
+ }
1451
+ if (!automation.running || automation.runId !== runId)
1452
+ break;
1453
+ if (!snapshot?.ok || JSON.stringify(snapshot.editorSources || snapshot.sources) !== question.codeSnapshot) {
1454
+ automation.error = 'The full code snippet changed while solving. Restart on the current question.';
1455
+ stopAutomation('PAUSED');
1456
+ break;
1457
+ }
1458
+ }
1459
+ const shortAnswer = question.type === 'short-answer';
1460
+ const ordering = question.type === 'ordering';
1461
+ const rawAnswer = results?.[0]?.answer;
1462
+ const answerText = typeof rawAnswer === 'string' || typeof rawAnswer === 'number' ? String(rawAnswer).trim() : '';
1463
+ let answerIndexes = shortAnswer ? (answerText ? [0] : []) : answerIndexesFromResult(results, question);
1464
+ if (ordering) {
1465
+ try {
1466
+ const order = Array.isArray(rawAnswer) ? rawAnswer : JSON.parse(answerText);
1467
+ answerIndexes = Array.isArray(order) && order.length === question.options.length && new Set(order).size === order.length
1468
+ && order.every(value => Number.isInteger(value) && value >= 1 && value <= order.length) ? order.map(value => value - 1) : [];
1469
+ }
1470
+ catch {
1471
+ answerIndexes = [];
1472
+ }
1473
+ }
1474
+ if (!answerIndexes.length && !shortAnswer && !ordering && !question.visualSnapshot) {
1475
+ automation.state = 'RECOVERING_ANSWER';
1476
+ updateAutomationUI();
1477
+ try {
1478
+ answerIndexes = await recoverAnswerIndexes(question, results?.[0]?.answer);
1479
+ }
1480
+ catch {
1481
+ answerIndexes = [];
1482
+ }
1483
+ }
1484
+ const currentContext = getAutomationContext();
1485
+ let current = getAuthorizedQuestion(currentContext);
1486
+ if (question.visualSnapshot && (!current || window.BrowserAssistantQuestionVisuals.fingerprint(current) !== question.visualSnapshot)) {
1487
+ automation.error = 'Question illustration changed while solving. No answer was selected.';
1488
+ stopAutomation('PAUSED');
1489
+ break;
1490
+ }
1491
+ // Syntax highlighting can rewrite a visible code block while the
1492
+ // AI call is in flight. The ordered option set is the action
1493
+ // target, so keep the safety check focused on it rather than on
1494
+ // transient code-block markup.
1495
+ if (!answerIndexes.length || !current || !hasSameQuestionOptions(question, current)) {
1496
+ automation.error = !answerIndexes.length ? 'AI answer did not match a unique option.' : !current ? 'Question controls disappeared while solving.' : 'Question text or options changed while solving. Please retry.';
1497
+ stopAutomation('PAUSED');
1498
+ break;
1499
+ }
1500
+ if (!automationPolicy.canExecute('select', currentContext) || !automationPolicy.canExecute('validate', currentContext)) {
1501
+ automation.error = 'Target policy does not permit selection or validation.';
1502
+ stopAutomation('UNAUTHORIZED');
1503
+ break;
1504
+ }
1505
+ automation.state = 'SELECTING';
1506
+ updateAutomationUI();
1507
+ if (ordering) {
1508
+ const active = () => automation.running && automation.runId === runId && getAutomationContext().mode === 'FULL';
1509
+ const nativeDrag = (prompt, options, from, to) => safeSendMessage({ action: 'orderingDrag', prompt, options, from, to });
1510
+ if (!(await currentAdapter.applyOrder(document, current, answerIndexes, active, nativeDrag))) {
1511
+ automation.error = 'Drag-and-drop order could not be verified. This page may need a different drag adapter.';
1512
+ stopAutomation('PAUSED');
1513
+ break;
1514
+ }
1515
+ const reordered = getAuthorizedQuestion();
1516
+ if (!reordered || reordered.text !== question.text || !answerIndexes.every((index, position) => reordered.options[position]?.text === question.options[index].text)) {
1517
+ automation.error = 'Reordered question changed unexpectedly.';
1518
+ stopAutomation('PAUSED');
1519
+ break;
1520
+ }
1521
+ question.signature = reordered.signature;
1522
+ }
1523
+ if (shortAnswer) {
1524
+ if (current.type !== 'short-answer' || !automationPolicy.canExecute('fill', currentContext)) {
1525
+ automation.error = 'Answer field changed or filling is disabled.';
1526
+ stopAutomation('PAUSED');
1527
+ break;
1528
+ }
1529
+ const field = current.input;
1530
+ const prototype = field.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
1531
+ Object.getOwnPropertyDescriptor(prototype, 'value').set.call(field, answerText);
1532
+ field.dispatchEvent(new Event('input', { bubbles: true }));
1533
+ field.dispatchEvent(new Event('change', { bubbles: true }));
1534
+ if (field.value !== answerText) {
1535
+ automation.error = 'Answer entry could not be verified.';
1536
+ stopAutomation('PAUSED');
1537
+ break;
1538
+ }
1539
+ }
1540
+ if (!shortAnswer && !ordering && current.selectionType === 'multiple') {
1541
+ for (const option of current.options) {
1542
+ if (!answerIndexes.includes(option.index) && (option.isSelected?.() || option.input?.checked))
1543
+ (option.input || option.control)?.click();
1544
+ }
1545
+ }
1546
+ for (const answerIndex of shortAnswer || ordering ? [] : answerIndexes) {
1547
+ const option = current.options[answerIndex];
1548
+ if (!option || (!option.isSelected?.() && !option.input?.checked))
1549
+ (option?.input || option?.control)?.click();
1550
+ const selected = await waitForOptionSelection(question, answerIndex, runId);
1551
+ if (!automation.running || automation.runId !== runId)
1552
+ break;
1553
+ if (!selected) {
1554
+ automation.error = 'Selection could not be verified.';
1555
+ stopAutomation('PAUSED');
1556
+ break;
1557
+ }
1558
+ current = selected;
1559
+ if (current.submitted)
1560
+ break;
1561
+ }
1562
+ if (!automation.running)
1563
+ break;
1564
+ if (current.submitted || currentAdapter.getSubmittedAdvance?.(document))
1565
+ continue;
1566
+ if (!shortAnswer && !ordering && current.selectionType === 'multiple' && current.options.some(option => Boolean(option.isSelected?.() || option.input?.checked) !== answerIndexes.includes(option.index))) {
1567
+ automation.error = 'Multiple-answer selection could not be verified.';
1568
+ stopAutomation('PAUSED');
1569
+ break;
1570
+ }
1571
+ let ready;
1572
+ try {
1573
+ ready = await waitForQuestionSubmit(question, runId, currentAdapter, answerIndexes, answerText);
1574
+ }
1575
+ catch (error) {
1576
+ automation.error = error.message;
1577
+ stopAutomation('PAUSED');
1578
+ break;
1579
+ }
1580
+ if (!ready || !automation.running || automation.runId !== runId)
1581
+ break;
1582
+ if (ready.submitted)
1583
+ continue;
1584
+ const submit = ready.button;
1585
+ automation.state = 'SUBMITTING';
1586
+ updateAutomationUI();
1587
+ automation.processed.add(question.signature);
1588
+ submit.click();
1589
+ automation.state = 'WAITING_FOR_NEXT';
1590
+ updateAutomationUI();
1591
+ let transition = await waitForAdvanceOrTransition(question.signature, runId, currentAdapter);
1592
+ if (transition?.proceed) {
1593
+ automation.state = 'OPENING_COURSE';
1594
+ automation.pendingNavigation = true;
1595
+ updateAutomationUI();
1596
+ saveCourseResume('course', transition.context, {
1597
+ currentTitle: currentAdapter.getCurrentPracticeTitle?.(document) || '',
1598
+ });
1599
+ transition.proceed.click();
1600
+ return;
1601
+ }
1602
+ if (transition?.advance) {
1603
+ if (!automationPolicy.canExecute('click', transition.context)) {
1604
+ automation.error = 'Target policy does not permit Next.';
1605
+ stopAutomation('UNAUTHORIZED');
1606
+ break;
1607
+ }
1608
+ transition.advance.click();
1609
+ transition = await waitForPracticeTransition(question.signature, runId);
1610
+ }
1611
+ if (transition === 'complete') {
1612
+ automation.completed += 1;
1613
+ stopAutomation('COMPLETED');
1614
+ break;
1615
+ }
1616
+ if (transition !== 'next') {
1617
+ automation.error = transition?.blocked || 'Next question did not appear in time.';
1618
+ stopAutomation('PAUSED');
1619
+ break;
1620
+ }
1621
+ automation.completed += 1;
1622
+ }
1623
+ }
1624
+ async function readMcqSnippetSnapshot(question, runId) {
1625
+ let lastError = 'Full code snippet could not be read.';
1626
+ for (let attempt = 0; attempt < 3; attempt++) {
1627
+ if (!(await waitWhilePaused(runId)))
1628
+ return null;
1629
+ if (!hasSameQuestionOptions(question, getAuthorizedQuestion()))
1630
+ throw new Error('Question changed while reading the full code snippet.');
1631
+ let snapshot;
1632
+ try {
1633
+ snapshot = await safeSendMessage({ action: 'mcqSnippets' });
1634
+ }
1635
+ catch (error) {
1636
+ lastError = error.message;
1637
+ }
1638
+ if (snapshot?.ok && snapshot.sources?.length) {
1639
+ if (typeof browserAssistantCacheMcqSnippets === 'function')
1640
+ browserAssistantCacheMcqSnippets(snapshot);
1641
+ return snapshot;
1642
+ }
1643
+ if (snapshot?.ok && !snapshot.sources?.length)
1644
+ lastError = 'The full snippet editor is still loading; no source was returned.';
1645
+ if (snapshot?.error)
1646
+ lastError = snapshot.error;
1647
+ // Some pages block MAIN-world injection. The isolated reader can
1648
+ // still recover an Ace snippet if its DOM proves every row exists.
1649
+ if (typeof browserAssistantReadMcqSnippets === 'function') {
1650
+ const fallback = browserAssistantReadMcqSnippets();
1651
+ if (fallback.ok && fallback.sources.length) {
1652
+ if (typeof browserAssistantCacheMcqSnippets === 'function')
1653
+ browserAssistantCacheMcqSnippets(fallback);
1654
+ return fallback;
1655
+ }
1656
+ }
1657
+ if (attempt < 2)
1658
+ await new Promise(resolve => setTimeout(resolve, attempt === 0 ? 200 : 500));
1659
+ }
1660
+ throw new Error(lastError);
1661
+ }
1662
+ async function requestPracticeAnswer(question, runId, path, body) {
1663
+ return window.BrowserAssistantAnswerRequests.run({
1664
+ request: () => apiRequest(path, 'POST', body),
1665
+ active: () => automation.running && automation.runId === runId,
1666
+ ready: () => waitWhilePaused(runId),
1667
+ current: () => {
1668
+ if (location.href !== body.url)
1669
+ return false;
1670
+ const fresh = getAuthorizedQuestion();
1671
+ return hasSameQuestionOptions(question, fresh) && (!question.visualSnapshot
1672
+ || window.BrowserAssistantQuestionVisuals.fingerprint(fresh) === question.visualSnapshot);
1673
+ },
1674
+ onRetry: ({ attempt, attempts, message }) => {
1675
+ automation.state = 'RETRYING_REQUEST';
1676
+ automation.error = `Temporary answer service issue. Retrying ${attempt}/${attempts}: ${message}`;
1677
+ updateAutomationUI();
1678
+ },
1679
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
1680
+ });
1681
+ }
1682
+ /**
1683
+ * Safely send a message to the background script.
1684
+ * Handles:
1685
+ * - Extension context invalidated (after reload/update)
1686
+ * - No response from background (unknown action)
1687
+ * - Timeout (background hung)
1688
+ */
1689
+ function safeSendMessage(msg, timeoutMs = 10000) {
1690
+ if (!extensionAlive) {
1691
+ return Promise.reject(new Error("Extension was reloaded — please refresh the page"));
1692
+ }
1693
+ return new Promise((resolve, reject) => {
1694
+ const timer = setTimeout(() => {
1695
+ reject(new Error("Background script did not respond (timeout)"));
1696
+ }, timeoutMs);
1697
+ try {
1698
+ chrome.runtime.sendMessage(msg, (response) => {
1699
+ clearTimeout(timer);
1700
+ if (chrome.runtime.lastError) {
1701
+ const errMsg = chrome.runtime.lastError.message || "";
1702
+ if (errMsg.includes("Extension context invalidated")) {
1703
+ extensionAlive = false;
1704
+ }
1705
+ reject(new Error(errMsg || "Message failed"));
1706
+ }
1707
+ else {
1708
+ resolve(response);
1709
+ }
1710
+ });
1711
+ }
1712
+ catch (e) {
1713
+ clearTimeout(timer);
1714
+ if (e.message && e.message.includes("Extension context invalidated")) {
1715
+ extensionAlive = false;
1716
+ }
1717
+ reject(e);
1718
+ }
1719
+ });
1720
+ }
1721
+ // Route local API traffic through the extension service worker instead of
1722
+ // the page context, so visited sites cannot directly access the API.
1723
+ async function apiRequest(path, method = 'POST', body) {
1724
+ const response = await safeSendMessage({ action: 'apiRequest', path, method, body }, 370000);
1725
+ if (!response)
1726
+ throw new Error('Local server did not respond');
1727
+ if (response.error)
1728
+ throw new Error(response.error);
1729
+ return response;
1730
+ }
1731
+ async function saveScanHistory(results, provider, scanType, language = null) {
1732
+ try {
1733
+ const response = await apiRequest('/history', 'POST', {
1734
+ scan_type: scanType,
1735
+ url: window.location.href,
1736
+ provider: provider || 'unknown',
1737
+ results: JSON.stringify(results),
1738
+ language,
1739
+ });
1740
+ if (!response.ok)
1741
+ throw new Error('History could not be saved');
1742
+ }
1743
+ catch (error) {
1744
+ // Answers remain available even if optional history persistence fails.
1745
+ console.warn('History save failed:', error.message);
1746
+ }
1747
+ }
1748
+ async function openHistory() {
1749
+ isHistoryOpen = true;
1750
+ render();
1751
+ renderHistoryLoading();
1752
+ try {
1753
+ const response = await apiRequest('/history', 'GET');
1754
+ if (!response.ok)
1755
+ throw new Error(response.data?.detail || 'Could not load history');
1756
+ historyItems = response.data?.items || [];
1757
+ renderHistory();
1758
+ }
1759
+ catch (error) {
1760
+ renderHistoryError(error.message);
1761
+ }
1762
+ }
1763
+ async function clearHistory() {
1764
+ const button = document.getElementById('assistant-clear-history-btn');
1765
+ if (button) {
1766
+ button.disabled = true;
1767
+ button.textContent = 'Clearing…';
1768
+ }
1769
+ try {
1770
+ const response = await apiRequest('/history', 'DELETE');
1771
+ if (!response.ok)
1772
+ throw new Error('Could not clear history');
1773
+ historyItems = [];
1774
+ renderHistory();
1775
+ }
1776
+ catch (error) {
1777
+ renderHistoryError(error.message);
1778
+ }
1779
+ }
1780
+ // ── Server health check ───────────────────────────────────────────────────
1781
+ async function checkServer() {
1782
+ try {
1783
+ const res = await apiRequest('/health', 'GET');
1784
+ serverOnline = res.ok;
1785
+ }
1786
+ catch {
1787
+ serverOnline = false;
1788
+ }
1789
+ // Re-render if panel is open to show status
1790
+ if (!isCollapsed)
1791
+ render();
1792
+ }
1793
+ // Check once on load
1794
+ checkServer();
1795
+ // ── Smart text extraction ────────────────────────────────────────────────
1796
+ function extractSmartText() {
1797
+ const textLimit = ['code', 'mcq'].includes(detectPracticeType()) ? 60000 : 12000;
1798
+ /**
1799
+ * Instead of grabbing ALL page text (which includes navigation, ads,
1800
+ * footers, etc.), this function extracts only question-relevant content.
1801
+ * Falls back to full body text if smart extraction finds nothing.
1802
+ */
1803
+ // A user-highlighted question is the strongest possible signal.
1804
+ const selectedText = window.getSelection?.().toString().trim();
1805
+ if (selectedText && selectedText.length >= 10) {
1806
+ console.log(`Using selected text: ${selectedText.length} chars`);
1807
+ return selectedText.substring(0, textLimit);
1808
+ }
1809
+ // Priority 1: Try to find the main content area
1810
+ const contentSelectors = [
1811
+ // Common question/problem containers
1812
+ '[class*="question"]',
1813
+ '[class*="problem"]',
1814
+ '[class*="challenge"]',
1815
+ '[class*="exercise"]',
1816
+ '[class*="quiz"]',
1817
+ '[class*="test-"]',
1818
+ '[class*="exam"]',
1819
+ '[class*="mcq"]',
1820
+ '[class*="task-"]',
1821
+ '[id*="question"]',
1822
+ '[id*="problem"]',
1823
+ '[id*="challenge"]',
1824
+ // Code editors and output areas
1825
+ '[class*="editor"]',
1826
+ '[class*="code-"]',
1827
+ '[class*="CodeMirror"]',
1828
+ '[class*="monaco"]',
1829
+ '[class*="ace_editor"]',
1830
+ // Common content wrappers
1831
+ 'main',
1832
+ 'article',
1833
+ '[role="main"]',
1834
+ '[class*="content-area"]',
1835
+ '[class*="main-content"]',
1836
+ '[class*="practice"]',
1837
+ '[class*="assessment"]',
1838
+ ];
1839
+ let smartText = '';
1840
+ const seen = new Set();
1841
+ for (const selector of contentSelectors) {
1842
+ try {
1843
+ const elements = document.querySelectorAll(selector);
1844
+ elements.forEach(el => {
1845
+ // Skip our own assistant UI
1846
+ if (el.closest('#assistant-root'))
1847
+ return;
1848
+ const text = el.innerText?.trim();
1849
+ if (text && text.length > 20 && !seen.has(text)) {
1850
+ seen.add(text);
1851
+ smartText += text + '\n\n';
1852
+ }
1853
+ });
1854
+ }
1855
+ catch (e) {
1856
+ // Selector might be invalid, skip
1857
+ }
1858
+ }
1859
+ // If smart extraction got meaningful content, use it
1860
+ if (smartText.length > 100) {
1861
+ console.log(`Smart extraction: ${smartText.length} chars from targeted selectors`);
1862
+ return smartText.substring(0, textLimit);
1863
+ }
1864
+ // Fallback: Use full body text but strip common noise
1865
+ console.log("Smart extraction fallback: using cleaned body text");
1866
+ return cleanBodyText(textLimit);
1867
+ }
1868
+ function cleanBodyText(textLimit = 12000) {
1869
+ /**
1870
+ * Extract body text while removing common noise elements
1871
+ * (navigation, footer, ads, sidebars, etc.)
1872
+ */
1873
+ const noiseSelectors = [
1874
+ 'script', 'style', 'noscript', 'template', 'input[type="password"]',
1875
+ 'nav', 'footer', 'header',
1876
+ '[class*="sidebar"]', '[class*="nav"]',
1877
+ '[class*="footer"]', '[class*="header"]',
1878
+ '[class*="menu"]', '[class*="toolbar"]',
1879
+ '[class*="cookie"]', '[class*="banner"]',
1880
+ '[class*="advertisement"]', '[class*="ad-"]',
1881
+ '[class*="social"]', '[class*="share"]',
1882
+ '[role="navigation"]', '[role="banner"]',
1883
+ '[role="complementary"]',
1884
+ '#assistant-root',
1885
+ ];
1886
+ // Clone body to avoid modifying the real DOM
1887
+ const clone = document.body.cloneNode(true);
1888
+ // Remove noise elements from the clone
1889
+ noiseSelectors.forEach(sel => {
1890
+ try {
1891
+ clone.querySelectorAll(sel).forEach(el => el.remove());
1892
+ }
1893
+ catch (e) { /* skip invalid selectors */ }
1894
+ });
1895
+ // textContent works on detached nodes; innerText requires layout
1896
+ const text = (clone.textContent || '').replace(/\s+/g, ' ').trim();
1897
+ return text.substring(0, textLimit);
1898
+ }
1899
+ // ── Panel open/close helpers (idempotent) ──────────────────────────────
1900
+ /**
1901
+ * OPEN the panel. Idempotent: if already open, does nothing.
1902
+ * Does NOT trigger a scan — just shows the UI.
1903
+ */
1904
+ function openPanel() {
1905
+ if (!isCollapsed && !isHidden)
1906
+ return; // already open — no-op
1907
+ isHidden = false;
1908
+ isCollapsed = false;
1909
+ root.style.display = 'block';
1910
+ render();
1911
+ }
1912
+ /**
1913
+ * CLOSE the panel. Idempotent: if already closed, does nothing.
1914
+ * Collapses to badge AND hides it completely.
1915
+ */
1916
+ function closePanel() {
1917
+ if (isCollapsed && isHidden)
1918
+ return; // already closed — no-op
1919
+ isCollapsed = true;
1920
+ isHidden = true;
1921
+ root.style.display = 'none';
1922
+ }
1923
+ function handleAutomationShortcut(e) {
1924
+ if (e.repeat || e.isComposing || e.defaultPrevented || !e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey
1925
+ || (e.code !== 'KeyA' && String(e.key).toLowerCase() !== 'a'))
1926
+ return false;
1927
+ e.preventDefault();
1928
+ e.stopPropagation();
1929
+ selectedTab = 'qa';
1930
+ isHidden = false;
1931
+ isCollapsed = false;
1932
+ root.style.display = 'block';
1933
+ render();
1934
+ const journal = window.BrowserAssistantJournal;
1935
+ if (journal?.matches() && journal.getState().running) {
1936
+ journal.pause();
1937
+ updateAutomationUI();
1938
+ }
1939
+ else if (automation.running) {
1940
+ pauseOrResumeAutomation();
1941
+ }
1942
+ else {
1943
+ void startPracticeAutomation().catch(error => {
1944
+ automation.error = error.message || 'Automation could not start.';
1945
+ stopAutomation('ERROR');
1946
+ });
1947
+ }
1948
+ return true;
1949
+ }
1950
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────
1951
+ document.addEventListener('keydown', (e) => {
1952
+ // Block held-key auto-repeat to prevent rapid-fire race conditions
1953
+ if (e.repeat || e.isComposing)
1954
+ return;
1955
+ // Explicit automation chord also works while the code editor has focus.
1956
+ if (handleAutomationShortcut(e))
1957
+ return;
1958
+ const active = document.activeElement;
1959
+ const isTyping = active instanceof HTMLInputElement ||
1960
+ active instanceof HTMLTextAreaElement ||
1961
+ active instanceof HTMLSelectElement ||
1962
+ active?.isContentEditable ||
1963
+ active?.getAttribute?.('role') === 'textbox';
1964
+ if (isTyping)
1965
+ return;
1966
+ switch (e.key) {
1967
+ // ── ` = OPEN panel ──
1968
+ case '`':
1969
+ e.preventDefault();
1970
+ e.stopPropagation();
1971
+ openPanel();
1972
+ break;
1973
+ // ── ] = CLOSE panel ──
1974
+ case ']':
1975
+ e.preventDefault();
1976
+ e.stopPropagation();
1977
+ closePanel();
1978
+ break;
1979
+ // ── \ = Vision scan (opens + scans screenshot) ──
1980
+ case '\\':
1981
+ e.preventDefault();
1982
+ e.stopPropagation();
1983
+ openPanel();
1984
+ visionScan();
1985
+ break;
1986
+ // ── [ = Toggle minimal mode ──
1987
+ case '[':
1988
+ e.preventDefault();
1989
+ e.stopPropagation();
1990
+ isMinimalMode = !isMinimalMode;
1991
+ render();
1992
+ break;
1993
+ }
1994
+ }, { capture: true, signal: lifecycle.signal });
1995
+ // ── Vision scan ───────────────────────────────────────────────────────────
1996
+ async function visionScan(continueExistingScan = false, requestedLanguage = null, practiceType = selectedMode) {
1997
+ if (isScanning && !continueExistingScan)
1998
+ return;
1999
+ const scanWasAlreadyRunning = isScanning;
2000
+ isScanning = true;
2001
+ isCollapsed = false;
2002
+ render();
2003
+ setLoading("Capturing screen…");
2004
+ let timeoutId;
2005
+ let panelVisibility = '';
2006
+ try {
2007
+ // Do not include our own floating panel in the captured screenshot.
2008
+ panelVisibility = root.style.visibility;
2009
+ root.style.visibility = 'hidden';
2010
+ const context = await prepareVisionQuestionContext();
2011
+ const visuals = window.BrowserAssistantQuestionVisuals;
2012
+ let images;
2013
+ if (context.question && visuals?.nodes(context.question).length) {
2014
+ setLoading('Reading complete question illustrations…');
2015
+ const snapshot = await visuals.capture(context.question, safeSendMessage);
2016
+ images = snapshot.images;
2017
+ context.visualSnapshot = snapshot.fingerprint;
2018
+ }
2019
+ else {
2020
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
2021
+ const response = await safeSendMessage({ action: "captureVisibleTab" });
2022
+ if (!response?.dataUrl || response.error)
2023
+ throw new Error(response?.error || 'Screenshot capture failed');
2024
+ images = [response.dataUrl];
2025
+ }
2026
+ root.style.visibility = panelVisibility;
2027
+ await assertVisionQuestionCurrent(context);
2028
+ setLoading("Analyzing and cross-checking…");
2029
+ const res = await apiRequest('/solve-vision', 'POST', {
2030
+ image: images[0], images: images.slice(1), text: context.text,
2031
+ url: context.url,
2032
+ practice_type: context.question ? 'mcq' : practiceType,
2033
+ language: requestedLanguage
2034
+ });
2035
+ if (!res.ok) {
2036
+ const err = res.data || {};
2037
+ const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || err.message || 'Vision server error');
2038
+ throw new Error(msg);
2039
+ }
2040
+ const data = res.data;
2041
+ await assertVisionQuestionCurrent(context);
2042
+ let parsed;
2043
+ try {
2044
+ parsed = JSON.parse(data.results);
2045
+ }
2046
+ catch (e) {
2047
+ throw new Error('Vision returned an invalid answer format. Please scan a clear, complete question again.');
2048
+ }
2049
+ if (!Array.isArray(parsed) || !parsed.length)
2050
+ throw new Error('The complete question could not be read. No guessed answer was returned.');
2051
+ displayResults(parsed, data.provider);
2052
+ }
2053
+ catch (error) {
2054
+ if (timeoutId)
2055
+ clearTimeout(timeoutId);
2056
+ console.error("Vision scan error:", error);
2057
+ const msg = error.name === 'AbortError'
2058
+ ? "Request timed out — server may be busy, please try again"
2059
+ : error.message;
2060
+ showError(`Vision Error: ${msg}`);
2061
+ }
2062
+ finally {
2063
+ root.style.visibility = panelVisibility;
2064
+ // Text Scan can hand off to Vision without a visible error. In that
2065
+ // case, leave its in-progress state for the caller to clear.
2066
+ if (!scanWasAlreadyRunning) {
2067
+ isScanning = false;
2068
+ updateScanControls();
2069
+ }
2070
+ }
2071
+ }
2072
+ async function prepareVisionQuestionContext() {
2073
+ const context = { url: location.href, text: '', question: getAuthorizedQuestion() };
2074
+ if (!context.question || context.question.type === 'code') {
2075
+ context.question = null;
2076
+ return context;
2077
+ }
2078
+ let sources = [];
2079
+ if (location.origin === 'https://learning.ccbp.in' && document.querySelector('.ace_editor,.monaco-editor,.CodeMirror')) {
2080
+ let snapshot = await safeSendMessage({ action: 'mcqSnippets' });
2081
+ if (!snapshot?.ok && typeof browserAssistantReadMcqSnippets === 'function')
2082
+ snapshot = browserAssistantReadMcqSnippets();
2083
+ if (!snapshot?.ok || !snapshot.sources?.length)
2084
+ throw new Error('Complete option code could not be read. No answer was guessed from partial lines.');
2085
+ if (typeof browserAssistantCacheMcqSnippets === 'function')
2086
+ browserAssistantCacheMcqSnippets(snapshot);
2087
+ const fresh = getAuthorizedQuestion();
2088
+ if (!hasSameQuestionOptions(context.question, fresh))
2089
+ throw new Error('Question changed while loading. Please scan again.');
2090
+ context.question = fresh;
2091
+ context.codeSnapshot = JSON.stringify(snapshot.editorSources || snapshot.sources);
2092
+ sources = snapshot.sources;
2093
+ }
2094
+ const question = context.question;
2095
+ context.text = `${question.identityText || question.text}\n${sources.length ? 'Complete code snippets:\n' + sources.join('\n\n') + '\n' : ''}`
2096
+ + (question.type === 'short-answer' ? 'Free response: return only the exact value.' : question.selectionType === 'multiple' ? 'Select ALL correct options.' : 'Select one answer.')
2097
+ + '\n' + question.options.map((option, index) => `${String.fromCharCode(65 + index)}. ${option.text}`).join('\n');
2098
+ return context;
2099
+ }
2100
+ async function assertVisionQuestionCurrent(context) {
2101
+ if (context.url !== location.href)
2102
+ throw new Error('Page changed while scanning. Please scan the current question again.');
2103
+ if (!context.question)
2104
+ return;
2105
+ const fresh = getAuthorizedQuestion();
2106
+ if (!hasSameQuestionOptions(context.question, fresh)
2107
+ || (context.visualSnapshot && window.BrowserAssistantQuestionVisuals.fingerprint(fresh) !== context.visualSnapshot)) {
2108
+ throw new Error('Question or illustration changed while scanning. Please scan again.');
2109
+ }
2110
+ if (context.codeSnapshot) {
2111
+ const snapshot = await safeSendMessage({ action: 'mcqSnippets' });
2112
+ if (!snapshot?.ok || JSON.stringify(snapshot.editorSources || snapshot.sources) !== context.codeSnapshot) {
2113
+ throw new Error('The complete code changed while scanning. Please scan again.');
2114
+ }
2115
+ }
2116
+ }
2117
+ // ── Page text scan ────────────────────────────────────────────────────────
2118
+ async function scanPage() {
2119
+ if (isScanning)
2120
+ return;
2121
+ isScanning = true;
2122
+ // Show server-offline warning early
2123
+ if (serverOnline === false) {
2124
+ showError("Server offline. Start the backend with: python main.py");
2125
+ isScanning = false;
2126
+ return;
2127
+ }
2128
+ isCollapsed = false;
2129
+ render();
2130
+ setLoading("Solving…");
2131
+ const practiceType = detectPracticeType();
2132
+ console.log("Scanning… mode:", practiceType);
2133
+ let timeoutId;
2134
+ try {
2135
+ // Use smart extraction instead of raw body text
2136
+ const pageText = extractSmartText();
2137
+ console.log(`Sending ${pageText.length} chars to server`);
2138
+ const question = getAuthorizedQuestion();
2139
+ const visuals = window.BrowserAssistantQuestionVisuals;
2140
+ let res;
2141
+ if (question && question.type !== 'code' && visuals?.nodes(question).length) {
2142
+ setLoading('Reading complete question illustrations…');
2143
+ await visuals.requireBackend(apiRequest, visuals.nodes(question).length);
2144
+ const snapshot = await visuals.capture(question, safeSendMessage);
2145
+ if (!hasSameQuestionOptions(question, getAuthorizedQuestion()) || visuals.fingerprint(getAuthorizedQuestion()) !== snapshot.fingerprint) {
2146
+ throw new Error('Question illustration changed while loading. Please scan again.');
2147
+ }
2148
+ res = await apiRequest('/solve-vision', 'POST', {
2149
+ image: snapshot.images[0], images: snapshot.images.slice(1),
2150
+ text: `${question.text}\n${question.selectionType === 'multiple' ? 'Select ALL correct options.' : 'Select one answer.'}\n${question.options.map((option, index) => `${String.fromCharCode(65 + index)}. ${option.text}`).join('\n')}`,
2151
+ url: location.href, practice_type: 'mcq',
2152
+ });
2153
+ }
2154
+ else {
2155
+ res = await apiRequest('/solve', 'POST', {
2156
+ text: pageText, url: window.location.href, practice_type: practiceType,
2157
+ });
2158
+ }
2159
+ if (!res.ok) {
2160
+ const err = res.data || {};
2161
+ if (res.status === 422 && err.detail?.code === 'no_readable_question') {
2162
+ console.info('No usable page text; automatically falling back to Vision scan.');
2163
+ setLoading("Text isn't readable — analyzing the visible screen…");
2164
+ await visionScan(true, err.detail?.language, practiceType);
2165
+ return;
2166
+ }
2167
+ const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || err.message || 'Backend error');
2168
+ throw new Error(msg);
2169
+ }
2170
+ const data = res.data;
2171
+ serverOnline = true;
2172
+ let parsed;
2173
+ try {
2174
+ parsed = JSON.parse(data.results);
2175
+ }
2176
+ catch (e) {
2177
+ const raw = data.results || "";
2178
+ const optMatch = raw.match(/Option\s+([A-D])\s*[:\s]+(.+?)(?:\.|$)/i);
2179
+ if (optMatch) {
2180
+ parsed = [{ type: "mcq", question: "Question", reasoning: "", answer: `Option ${optMatch[1].toUpperCase()}: ${optMatch[2].trim()}` }];
2181
+ }
2182
+ else {
2183
+ parsed = [{ type: "mcq", question: "Result", reasoning: "", answer: raw.substring(0, 200) }];
2184
+ }
2185
+ }
2186
+ displayResults(parsed, data.provider);
2187
+ }
2188
+ catch (error) {
2189
+ if (timeoutId)
2190
+ clearTimeout(timeoutId);
2191
+ console.error("Scan error:", error);
2192
+ if (error.name === 'AbortError') {
2193
+ showError("Request timed out — server may be busy, please try again");
2194
+ }
2195
+ else if (error instanceof TypeError && error.message.includes('fetch')) {
2196
+ serverOnline = false;
2197
+ showError(`Error: ${error.message}`);
2198
+ }
2199
+ else {
2200
+ showError(`Error: ${error.message}`);
2201
+ }
2202
+ }
2203
+ finally {
2204
+ isScanning = false;
2205
+ updateScanControls();
2206
+ }
2207
+ }
2208
+ // ── Helpers ───────────────────────────────────────────────────────────────
2209
+ function setLoading(msg) {
2210
+ const c = document.getElementById('assistant-results');
2211
+ if (c) {
2212
+ c.innerHTML = `<div class="assistant-loading"><div class="assistant-spinner"></div>${escapeHtml(msg)}</div>`;
2213
+ }
2214
+ else {
2215
+ lastResultSummary = "WAIT";
2216
+ render();
2217
+ }
2218
+ }
2219
+ // Result rendering happens before the async scan finishes. Do not re-render
2220
+ // the panel afterwards (that would erase the result); just unlock its
2221
+ // buttons so the next Text or Vision scan works immediately.
2222
+ function updateScanControls() {
2223
+ for (const id of ['assistant-scan-btn', 'assistant-vision-btn']) {
2224
+ const button = document.getElementById(id);
2225
+ if (button)
2226
+ button.disabled = isScanning;
2227
+ }
2228
+ }
2229
+ function showError(msg) {
2230
+ const c = document.getElementById('assistant-results');
2231
+ if (c) {
2232
+ const errorDiv = document.createElement('div');
2233
+ errorDiv.className = 'assistant-error';
2234
+ errorDiv.textContent = msg;
2235
+ c.innerHTML = '';
2236
+ c.appendChild(errorDiv);
2237
+ }
2238
+ else {
2239
+ lastResultSummary = "ERR";
2240
+ render();
2241
+ }
2242
+ }
2243
+ function renderHistoryLoading() {
2244
+ const container = document.getElementById('assistant-results');
2245
+ if (container) {
2246
+ container.innerHTML = '<div class="assistant-loading"><div class="assistant-spinner"></div>Loading scan history…</div>';
2247
+ }
2248
+ }
2249
+ function renderHistoryError(message) {
2250
+ const container = document.getElementById('assistant-results');
2251
+ if (!container)
2252
+ return;
2253
+ container.innerHTML = '';
2254
+ const error = document.createElement('div');
2255
+ error.className = 'assistant-error';
2256
+ error.textContent = `History Error: ${message}`;
2257
+ const retry = document.createElement('button');
2258
+ retry.className = 'assistant-history-btn';
2259
+ retry.textContent = 'Retry history';
2260
+ retry.onclick = openHistory;
2261
+ container.append(error, retry);
2262
+ }
2263
+ function renderHistory() {
2264
+ const container = document.getElementById('assistant-results');
2265
+ if (!container || !isHistoryOpen)
2266
+ return;
2267
+ container.innerHTML = '';
2268
+ const header = document.createElement('div');
2269
+ header.className = 'assistant-history-header';
2270
+ const title = document.createElement('strong');
2271
+ title.textContent = 'Recent scans';
2272
+ const actions = document.createElement('div');
2273
+ const back = document.createElement('button');
2274
+ back.className = 'assistant-history-action';
2275
+ back.textContent = 'Back';
2276
+ back.onclick = () => { isHistoryOpen = false; render(); };
2277
+ actions.appendChild(back);
2278
+ if (historyItems.length) {
2279
+ const clear = document.createElement('button');
2280
+ clear.className = 'assistant-history-action danger';
2281
+ clear.id = 'assistant-clear-history-btn';
2282
+ clear.textContent = 'Clear';
2283
+ clear.onclick = clearHistory;
2284
+ actions.appendChild(clear);
2285
+ }
2286
+ header.append(title, actions);
2287
+ container.appendChild(header);
2288
+ if (!historyItems.length) {
2289
+ const empty = document.createElement('div');
2290
+ empty.className = 'assistant-placeholder';
2291
+ empty.textContent = 'No saved scans yet. Run Text Scan or Vision Scan to build your history.';
2292
+ container.appendChild(empty);
2293
+ return;
2294
+ }
2295
+ for (const item of historyItems) {
2296
+ const card = document.createElement('button');
2297
+ card.className = 'assistant-history-item';
2298
+ card.type = 'button';
2299
+ let answer = 'Saved result';
2300
+ try {
2301
+ const parsed = JSON.parse(item.results);
2302
+ answer = parsed?.[0]?.answer || parsed?.[0]?.title || answer;
2303
+ }
2304
+ catch { /* The server validates saved results; retain a safe fallback. */ }
2305
+ const when = new Date(item.created_at);
2306
+ const timestamp = Number.isNaN(when.getTime()) ? 'Saved scan' : when.toLocaleString();
2307
+ card.innerHTML = `<span class="assistant-history-type">${escapeHtml(item.scan_type || 'scan')}</span><span class="assistant-history-answer">${escapeHtml(answer)}</span><span class="assistant-history-time">${escapeHtml(timestamp)}</span>`;
2308
+ card.onclick = () => showHistoryItem(item);
2309
+ container.appendChild(card);
2310
+ }
2311
+ }
2312
+ function showHistoryItem(item) {
2313
+ try {
2314
+ const results = JSON.parse(item.results);
2315
+ if (!Array.isArray(results) || !results.length)
2316
+ throw new Error('Saved result is invalid');
2317
+ isHistoryOpen = false;
2318
+ isCollapsed = false;
2319
+ render();
2320
+ renderFullResults(document.getElementById('assistant-results'), results, `${item.provider} · history`);
2321
+ }
2322
+ catch (error) {
2323
+ renderHistoryError('This saved result could not be opened.');
2324
+ }
2325
+ }
2326
+ function detectPracticeType() {
2327
+ if (selectedMode !== 'auto')
2328
+ return selectedMode;
2329
+ const hasInputs = document.querySelectorAll('input[type="radio"], input[type="checkbox"]').length > 0;
2330
+ // textContent avoids expensive layout reflow (B12)
2331
+ const upperText = (document.body.textContent || '').substring(0, 2000).toUpperCase();
2332
+ const title = document.title.toUpperCase();
2333
+ // Detect aptitude/math pages FIRST (before generic MCQ detection)
2334
+ const aptitudeKeywords = ["APTITUDE", "QUANTITATIVE", "PERCENTAGE", "PROFIT AND LOSS", "PROFIT & LOSS",
2335
+ "SIMPLE INTEREST", "COMPOUND INTEREST", "TIME AND WORK", "TIME & WORK", "TIME AND DISTANCE",
2336
+ "TIME & DISTANCE", "RATIO AND PROPORTION", "RATIO & PROPORTION", "AVERAGE", "MIXTURE",
2337
+ "ALLIGATION", "PROBABILITY", "PERMUTATION", "COMBINATION", "LOGARITHM", "ARITHMETIC",
2338
+ "NUMBER SYSTEM", "MENSURATION", "ALGEBRA", "TRIGONOMETRY", "GEOMETRY", "BOATS AND STREAMS",
2339
+ "TRAINS", "PIPES AND CISTERN", "AGES", "CLOCK", "CALENDAR"];
2340
+ const isAptitude = aptitudeKeywords.some(kw => upperText.includes(kw) || title.includes(kw));
2341
+ if (isAptitude)
2342
+ return "aptitude";
2343
+ // Generic MCQ detection
2344
+ if (hasInputs)
2345
+ return "mcq";
2346
+ if (upperText.includes("MCQ PRACTICE") || upperText.includes("QUIZ") || upperText.includes("MCQ"))
2347
+ return "mcq";
2348
+ if (upperText.includes("CODING PRACTICE") || upperText.includes("CODING"))
2349
+ return "code";
2350
+ if (title.includes("MCQ"))
2351
+ return "mcq";
2352
+ if (title.includes("CODING"))
2353
+ return "code";
2354
+ return "auto";
2355
+ }
2356
+ function selectTab(tab) {
2357
+ selectedTab = tab;
2358
+ isHistoryOpen = false;
2359
+ if (tab !== 'qa')
2360
+ selectedMode = tab;
2361
+ render();
2362
+ }
2363
+ function bindTabKeyboardNavigation() {
2364
+ const tabs = [...root.querySelectorAll('[role="tab"]')];
2365
+ tabs.forEach((tab, index) => {
2366
+ tab.addEventListener('keydown', (event) => {
2367
+ let nextIndex = null;
2368
+ if (event.key === 'ArrowRight')
2369
+ nextIndex = (index + 1) % tabs.length;
2370
+ if (event.key === 'ArrowLeft')
2371
+ nextIndex = (index - 1 + tabs.length) % tabs.length;
2372
+ if (event.key === 'Home')
2373
+ nextIndex = 0;
2374
+ if (event.key === 'End')
2375
+ nextIndex = tabs.length - 1;
2376
+ if (nextIndex === null)
2377
+ return;
2378
+ event.preventDefault();
2379
+ tabs[nextIndex].click();
2380
+ requestAnimationFrame(() => root.querySelectorAll('[role="tab"]')[nextIndex]?.focus());
2381
+ });
2382
+ });
2383
+ }
2384
+ function scanPanelMarkup() {
2385
+ return `
2386
+ <section class="assistant-scan-panel" id="assistant-scan-panel" role="tabpanel" aria-labelledby="mode-${selectedTab}">
2387
+ <div class="assistant-btn-row">
2388
+ <button class="assistant-btn" id="assistant-scan-btn">⚡ Scan (Text)</button>
2389
+ <button class="assistant-btn secondary" id="assistant-vision-btn">⌁ Scan (Vision)</button>
2390
+ </div>
2391
+ <div class="assistant-body" id="assistant-results">
2392
+ <div class="assistant-placeholder">No results yet.<br><small>Press <kbd>\`</kbd> to open or <kbd>]</kbd> to close.</small></div>
2393
+ </div>
2394
+ </section>
2395
+ `;
2396
+ }
2397
+ function qaControlMarkup() {
2398
+ return `
2399
+ <section class="assistant-qa-panel" id="assistant-qa-panel" role="tabpanel" aria-labelledby="mode-qa">
2400
+ <div class="assistant-qa-heading">
2401
+ <div>
2402
+ <p class="assistant-eyebrow">AUTHORIZED AUTOMATION</p>
2403
+ <h4>QA Control</h4>
2404
+ </div>
2405
+ <span class="assistant-qa-state" id="assistant-automation-state" aria-live="polite">${automation.state}</span>
2406
+ </div>
2407
+ <div class="assistant-automation-status" id="assistant-automation-status" aria-live="polite"></div>
2408
+ <dl class="assistant-qa-details">
2409
+ <div class="assistant-qa-wide"><dt>Current page</dt><dd id="assistant-automation-page">—</dd></div>
2410
+ <div><dt>Completed</dt><dd id="assistant-automation-completed">0</dd></div>
2411
+ <div><dt>Progress</dt><dd id="assistant-automation-progress">0 / —</dd></div>
2412
+ <div class="assistant-qa-wide"><dt>Current question</dt><dd id="assistant-automation-question">—</dd></div>
2413
+ </dl>
2414
+ <div class="assistant-qa-latest" id="assistant-automation-latest" aria-live="polite"></div>
2415
+ <div id="assistant-journal-slot"></div>
2416
+ <div class="assistant-automation-actions assistant-qa-actions">
2417
+ <button class="assistant-auto-btn start" id="assistant-auto-start-btn" aria-keyshortcuts="Alt+Shift+A" title="Start automation (Alt + Shift + A)">▶ Start automation</button>
2418
+ <button class="assistant-auto-btn" id="assistant-auto-pause-btn" aria-keyshortcuts="Alt+Shift+A" title="Pause or resume (Alt + Shift + A)" disabled>Pause</button>
2419
+ <button class="assistant-auto-btn stop" id="assistant-auto-stop-btn" disabled>■ Stop</button>
2420
+ </div>
2421
+ <p class="assistant-automation-status">Alt + Shift + A: Start / Pause / Resume</p>
2422
+ <button class="assistant-auto-btn" id="assistant-export-question-btn">Export question details</button>
2423
+ </section>
2424
+ `;
2425
+ }
2426
+ function buildQuestionDiagnostic(doc) {
2427
+ const text = node => (node.textContent || '').replace(/\s+/g, ' ').trim();
2428
+ const prompts = [...doc.querySelectorAll('p,h1,h2,h3,h4,div,span')].filter(node => !node.closest('#assistant-root') && /rearrange|drag and drop to order/i.test(text(node))
2429
+ && ![...node.children].some(child => /rearrange|drag and drop to order/i.test(text(child))));
2430
+ for (const prompt of prompts) {
2431
+ for (let region = prompt.parentElement; region && region !== doc.body; region = region.parentElement) {
2432
+ if (!/submit/i.test(text(region)) || region.querySelector('#assistant-root'))
2433
+ continue;
2434
+ const clone = region.cloneNode(true);
2435
+ clone.querySelectorAll('script,style,iframe,img,video,canvas,#assistant-root').forEach(node => node.remove());
2436
+ for (const node of [clone, ...clone.querySelectorAll('*')]) {
2437
+ for (const attribute of [...node.attributes]) {
2438
+ if (!/^(class|id|role|tabindex|draggable|type|disabled|aria-[\w-]+|data-(rbd|rfd|dnd|sortable)[\w-]*)$/.test(attribute.name))
2439
+ node.removeAttribute(attribute.name);
2440
+ }
2441
+ if (node.matches('input,textarea')) {
2442
+ node.removeAttribute('value');
2443
+ node.textContent = '';
2444
+ }
2445
+ }
2446
+ return clone.outerHTML;
2447
+ }
2448
+ }
2449
+ return null;
2450
+ }
2451
+ function exportQuestionDiagnostic() {
2452
+ const html = buildQuestionDiagnostic(document);
2453
+ if (!html) {
2454
+ automation.error = 'Open the drag-and-drop question before exporting details.';
2455
+ updateAutomationUI();
2456
+ return;
2457
+ }
2458
+ const url = URL.createObjectURL(new Blob([html], { type: 'text/plain' }));
2459
+ const link = document.createElement('a');
2460
+ link.href = url;
2461
+ link.download = 'question-controls.txt';
2462
+ link.click();
2463
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
2464
+ }
2465
+ // ── Render ────────────────────────────────────────────────────────────────
2466
+ function render() {
2467
+ if (disposed)
2468
+ return;
2469
+ window.BrowserAssistantJournal?.detach();
2470
+ if (isCollapsed) {
2471
+ const content = lastResultSummary || "";
2472
+ // Escape for safe attribute injection (B7)
2473
+ const safeContent = content.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
2474
+ root.innerHTML = `
2475
+ <button class="assistant-tab collapsed ${content ? 'has-answer' : ''}" id="assistant-trigger" type="button" aria-label="Open Browser Assistant" data-content="${safeContent}">
2476
+ ${isMinimalMode ? '<div class="assistant-mode-indicator">MIN</div>' : ''}
2477
+ </button>
2478
+ `;
2479
+ document.getElementById('assistant-trigger').onclick = () => {
2480
+ lastResultSummary = "";
2481
+ openPanel();
2482
+ };
2483
+ }
2484
+ else {
2485
+ const statusDot = serverOnline === false
2486
+ ? '<span class="status-dot offline" title="Server offline"></span>'
2487
+ : serverOnline === true
2488
+ ? '<span class="status-dot online" title="Server online"></span>'
2489
+ : '<span class="status-dot unknown" title="Checking server…"></span>';
2490
+ root.innerHTML = `
2491
+ <div class="assistant-tab" id="assistant-panel">
2492
+ <div class="assistant-header">
2493
+ <div class="assistant-title-row">
2494
+ ${statusDot}
2495
+ <h3 class="assistant-title">Browser Assistant</h3>
2496
+ </div>
2497
+ <div class="assistant-header-actions">
2498
+ <button class="assistant-icon-btn" id="assistant-history-btn" type="button" aria-label="Open Scan History" title="Scan History">
2499
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7M3 4v5h5M12 7v5l3.5 2"/></svg>
2500
+ </button>
2501
+ <button class="assistant-close" id="assistant-close-btn" type="button" aria-label="Close Browser Assistant" title="Close">&times;</button>
2502
+ </div>
2503
+ </div>
2504
+ <div class="assistant-content">
2505
+ <div class="assistant-mode-switcher" role="tablist" aria-label="Browser Assistant modes">
2506
+ <button class="mode-btn ${selectedTab === 'auto' ? 'active' : ''}" id="mode-auto" type="button" role="tab" aria-selected="${selectedTab === 'auto'}" aria-controls="assistant-scan-panel" tabindex="${selectedTab === 'auto' ? '0' : '-1'}">AUTO</button>
2507
+ <button class="mode-btn ${selectedTab === 'mcq' ? 'active' : ''}" id="mode-mcq" type="button" role="tab" aria-selected="${selectedTab === 'mcq'}" aria-controls="assistant-scan-panel" tabindex="${selectedTab === 'mcq' ? '0' : '-1'}">MCQ</button>
2508
+ <button class="mode-btn ${selectedTab === 'aptitude' ? 'active' : ''}" id="mode-apt" type="button" role="tab" aria-selected="${selectedTab === 'aptitude'}" aria-controls="assistant-scan-panel" tabindex="${selectedTab === 'aptitude' ? '0' : '-1'}">MATH</button>
2509
+ <button class="mode-btn ${selectedTab === 'code' ? 'active' : ''}" id="mode-code" type="button" role="tab" aria-selected="${selectedTab === 'code'}" aria-controls="assistant-scan-panel" tabindex="${selectedTab === 'code' ? '0' : '-1'}">CODE</button>
2510
+ <button class="mode-btn qa-tab ${selectedTab === 'qa' ? 'active' : ''}" id="mode-qa" type="button" role="tab" aria-label="QA Control" aria-selected="${selectedTab === 'qa'}" aria-controls="assistant-qa-panel" tabindex="${selectedTab === 'qa' ? '0' : '-1'}">QA CONTROL</button>
2511
+ </div>
2512
+ ${isHistoryOpen
2513
+ ? '<div class="assistant-body" id="assistant-results" aria-live="polite"></div>'
2514
+ : selectedTab === 'qa' ? qaControlMarkup() : scanPanelMarkup()}
2515
+ </div>
2516
+ </div>
2517
+ `;
2518
+ document.getElementById('assistant-close-btn').onclick = closePanel;
2519
+ document.getElementById('mode-auto').onclick = () => selectTab('auto');
2520
+ document.getElementById('mode-mcq').onclick = () => selectTab('mcq');
2521
+ document.getElementById('mode-apt').onclick = () => selectTab('aptitude');
2522
+ document.getElementById('mode-code').onclick = () => selectTab('code');
2523
+ document.getElementById('mode-qa').onclick = () => selectTab('qa');
2524
+ bindTabKeyboardNavigation();
2525
+ document.getElementById('assistant-history-btn').onclick = openHistory;
2526
+ document.getElementById('assistant-scan-btn')?.addEventListener('click', scanPage);
2527
+ document.getElementById('assistant-vision-btn')?.addEventListener('click', visionScan);
2528
+ document.getElementById('assistant-auto-start-btn')?.addEventListener('click', startPracticeAutomation);
2529
+ document.getElementById('assistant-export-question-btn')?.addEventListener('click', exportQuestionDiagnostic);
2530
+ document.getElementById('assistant-auto-pause-btn')?.addEventListener('click', pauseOrResumeAutomation);
2531
+ document.getElementById('assistant-auto-stop-btn')?.addEventListener('click', () => stopAutomation());
2532
+ updateAutomationUI();
2533
+ // Disable scan buttons while a scan is in progress (B3: prevent double-click)
2534
+ if (isScanning) {
2535
+ const textScan = document.getElementById('assistant-scan-btn');
2536
+ const visionScanButton = document.getElementById('assistant-vision-btn');
2537
+ if (textScan)
2538
+ textScan.disabled = true;
2539
+ if (visionScanButton)
2540
+ visionScanButton.disabled = true;
2541
+ }
2542
+ }
2543
+ }
2544
+ // ── Display results ───────────────────────────────────────────────────────
2545
+ function displayResults(results, provider) {
2546
+ isHistoryOpen = false;
2547
+ // A scan may be launched with a keyboard shortcut while QA Control is
2548
+ // visible. Show that result in the active scan surface without touching
2549
+ // the separate automation state.
2550
+ if (selectedTab === 'qa')
2551
+ selectedTab = selectedMode;
2552
+ if (results && results.length > 0) {
2553
+ const scanType = provider?.includes('vision') ? 'vision' : 'text';
2554
+ void saveScanHistory(results, provider, scanType);
2555
+ }
2556
+ // Minimal mode: show answer letter on the badge
2557
+ if (isMinimalMode) {
2558
+ if (results && results.length > 0) {
2559
+ const first = results[0];
2560
+ if (first.type === 'mcq') {
2561
+ const match = (first.answer || "").match(/^Option\s*([A-E])/i)
2562
+ || (first.answer || "").match(/\(([A-E])\)/i)
2563
+ || (first.answer || "").match(/^([A-E])[:\s.-]/i)
2564
+ || (first.answer || "").match(/\b([A-E])\b/i);
2565
+ lastResultSummary = match ? match[1].toUpperCase() : "Ans";
2566
+ }
2567
+ else {
2568
+ lastResultSummary = "OK";
2569
+ }
2570
+ }
2571
+ else {
2572
+ lastResultSummary = "None";
2573
+ }
2574
+ render();
2575
+ return;
2576
+ }
2577
+ if (results && results.length > 0) {
2578
+ isCollapsed = false;
2579
+ render();
2580
+ const container = document.getElementById('assistant-results');
2581
+ if (container)
2582
+ renderFullResults(container, results, provider);
2583
+ }
2584
+ else {
2585
+ const container = document.getElementById('assistant-results');
2586
+ if (container)
2587
+ container.innerHTML = '<div class="assistant-placeholder">No results found.</div>';
2588
+ }
2589
+ }
2590
+ function renderFullResults(container, results, provider) {
2591
+ // Show provider with verified badge if applicable
2592
+ provider = provider || "unknown";
2593
+ const isVerified = provider.includes('[verified]');
2594
+ const safeProvider = escapeHtml(provider.toUpperCase());
2595
+ const providerBadge = isVerified
2596
+ ? `<div class="assistant-provider-badge verified">✅ via ${safeProvider}</div>`
2597
+ : `<div class="assistant-provider-badge">via ${safeProvider}</div>`;
2598
+ container.innerHTML = providerBadge;
2599
+ results.forEach((res, index) => {
2600
+ if (res.type === 'code') {
2601
+ const codeContent = res.code || res.answer || "";
2602
+ if (!codeContent)
2603
+ return;
2604
+ const language = (res.language || 'text').toLowerCase().replace(/[^a-z0-9-]/g, '');
2605
+ const codeBox = document.createElement('div');
2606
+ codeBox.className = 'assistant-code-container';
2607
+ const approach = res.approach
2608
+ ? `<div class="assistant-code-approach">${escapeHtml(res.approach)}</div>`
2609
+ : '';
2610
+ const complexity = [res.time_complexity, res.space_complexity]
2611
+ .filter(Boolean)
2612
+ .map(escapeHtml)
2613
+ .join(' · ');
2614
+ const complexityHtml = complexity
2615
+ ? `<div class="assistant-code-complexity">${complexity}</div>`
2616
+ : '';
2617
+ codeBox.innerHTML = `
2618
+ <div class="assistant-code-header">
2619
+ <span>${escapeHtml(res.title || 'Solution')} — Language: ${escapeHtml(formatLanguageName(res.language))}</span>
2620
+ <button class="assistant-copy-btn" id="copy-btn-${index}">Copy</button>
2621
+ </div>
2622
+ ${approach}${complexityHtml}
2623
+ <pre class="assistant-code-block"><code class="language-${language}">${escapeHtml(codeContent)}</code></pre>
2624
+ `;
2625
+ container.appendChild(codeBox);
2626
+ document.getElementById(`copy-btn-${index}`).onclick = () => copyToClipboard(codeContent, `copy-btn-${index}`);
2627
+ }
2628
+ else {
2629
+ const item = document.createElement('div');
2630
+ item.className = 'assistant-answer';
2631
+ // Show reasoning if available (collapsible)
2632
+ const reasoningHtml = res.reasoning
2633
+ ? `<details class="assistant-reasoning">
2634
+ <summary>Show reasoning</summary>
2635
+ <div class="assistant-reasoning-text">${escapeHtml(res.reasoning)}</div>
2636
+ </details>`
2637
+ : '';
2638
+ item.innerHTML = `
2639
+ <div class="assistant-question-text">${escapeHtml(res.question || 'Question')}</div>
2640
+ <div class="assistant-answer-text">${escapeHtml(res.answer || 'No answer found')}</div>
2641
+ ${reasoningHtml}
2642
+ `;
2643
+ container.appendChild(item);
2644
+ }
2645
+ });
2646
+ }
2647
+ function escapeHtml(text) {
2648
+ const div = document.createElement('div');
2649
+ div.textContent = text;
2650
+ return div.innerHTML;
2651
+ }
2652
+ function formatLanguageName(language) {
2653
+ const names = {
2654
+ python: 'Python',
2655
+ cpp: 'C++',
2656
+ java: 'Java',
2657
+ javascript: 'JavaScript',
2658
+ c: 'C',
2659
+ csharp: 'C#',
2660
+ go: 'Go',
2661
+ rust: 'Rust',
2662
+ kotlin: 'Kotlin',
2663
+ php: 'PHP',
2664
+ sql: 'SQL'
2665
+ };
2666
+ const key = (language || '').toLowerCase();
2667
+ return names[key] || language || 'code';
2668
+ }
2669
+ async function copyToClipboard(text, btnId) {
2670
+ try {
2671
+ await navigator.clipboard.writeText(text);
2672
+ const btn = document.getElementById(btnId);
2673
+ if (!btn)
2674
+ return; // Button may have been destroyed by re-render
2675
+ btn.innerText = 'Copied!';
2676
+ btn.classList.add('copied');
2677
+ setTimeout(() => {
2678
+ const b = document.getElementById(btnId);
2679
+ if (b) {
2680
+ b.innerText = 'Copy';
2681
+ b.classList.remove('copied');
2682
+ }
2683
+ }, 2000);
2684
+ }
2685
+ catch (err) {
2686
+ console.error('Clipboard error:', err);
2687
+ }
2688
+ }
2689
+ async function resumeAuthorizedQaAfterNavigation() {
2690
+ let resume;
2691
+ try {
2692
+ resume = JSON.parse(sessionStorage.getItem(AUTO_RESUME_KEY) || 'null');
2693
+ sessionStorage.removeItem(AUTO_RESUME_KEY);
2694
+ }
2695
+ catch {
2696
+ return;
2697
+ }
2698
+ const context = getAutomationContext();
2699
+ const isFresh = Number.isFinite(resume?.createdAt) && Date.now() - resume.createdAt < 120000;
2700
+ if (!isFresh || resume.targetId !== context.target?.id || context.mode !== 'FULL')
2701
+ return;
2702
+ isCollapsed = false;
2703
+ render();
2704
+ const adapter = getAutomationAdapter(context);
2705
+ if (!adapter)
2706
+ return;
2707
+ if (resume.stage === 'course') {
2708
+ const nextPractice = await waitForCourseAction(() => adapter.getNextCoursePractice?.(document, resume.currentTitle));
2709
+ if (!nextPractice) {
2710
+ automation.state = 'PAUSED';
2711
+ automation.error = 'Next MCQ practice was not found.';
2712
+ updateAutomationUI();
2713
+ return;
2714
+ }
2715
+ automation.state = 'OPENING_NEXT_PRACTICE';
2716
+ updateAutomationUI();
2717
+ saveCourseResume('start', context);
2718
+ nextPractice.scrollIntoView({ block: 'center', behavior: 'smooth' });
2719
+ setTimeout(() => nextPractice.click(), 250);
2720
+ setTimeout(() => { void resumeAuthorizedQaAfterNavigation(); }, 900);
2721
+ return;
2722
+ }
2723
+ if (resume.stage === 'start') {
2724
+ const startPractice = await waitForCourseAction(() => adapter.getStartPractice?.(document));
2725
+ if (!startPractice) {
2726
+ automation.state = 'PAUSED';
2727
+ automation.error = 'Start Practice button was not found.';
2728
+ updateAutomationUI();
2729
+ return;
2730
+ }
2731
+ automation.state = 'STARTING_PRACTICE';
2732
+ updateAutomationUI();
2733
+ saveCourseResume('solve', context);
2734
+ startPractice.click();
2735
+ setTimeout(() => { void resumeAuthorizedQaAfterNavigation(); }, 900);
2736
+ return;
2737
+ }
2738
+ if (resume.stage === 'solve') {
2739
+ const question = await waitForCourseAction(() => getAuthorizedQuestion(context));
2740
+ if (!question) {
2741
+ automation.state = 'PAUSED';
2742
+ automation.error = 'New MCQ question did not load.';
2743
+ updateAutomationUI();
2744
+ return;
2745
+ }
2746
+ }
2747
+ void startPracticeAutomation();
2748
+ }
2749
+ let reopenedAfterUpdate = false;
2750
+ try {
2751
+ const reopen = sessionStorage.getItem('browserAssistantReopenAfterUpdate');
2752
+ reopenedAfterUpdate = reopen !== null;
2753
+ sessionStorage.removeItem('browserAssistantReopenAfterUpdate');
2754
+ if (reopen === 'true') {
2755
+ isCollapsed = false;
2756
+ selectedTab = 'qa';
2757
+ automation.state = 'UPDATED';
2758
+ }
2759
+ }
2760
+ catch { }
2761
+ render();
2762
+ if (!reopenedAfterUpdate)
2763
+ void resumeCodingBatch().then(resumed => { if (!resumed)
2764
+ void resumeAuthorizedQaAfterNavigation(); });
2765
+ })();