@in-the-loop-labs/pair-review 1.3.2 → 1.4.0

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,1071 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Index Page Manager
4
+ *
5
+ * Handles the main index page functionality including:
6
+ * - Theme management (light/dark toggle)
7
+ * - Help modal
8
+ * - PR review start flow
9
+ * - Local review start flow
10
+ * - Recent reviews listing with pagination
11
+ * - Local review sessions listing with pagination and deletion
12
+ * - Tab switching (PR / Local)
13
+ */
14
+ (function () {
15
+ 'use strict';
16
+
17
+ // ─── Theme Management ───────────────────────────────────────────────────────
18
+
19
+ function initTheme() {
20
+ const savedTheme = localStorage.getItem('theme');
21
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
22
+ const theme = savedTheme || (prefersDark ? 'dark' : 'light');
23
+ document.documentElement.setAttribute('data-theme', theme);
24
+ }
25
+
26
+ function toggleTheme() {
27
+ const currentTheme = document.documentElement.getAttribute('data-theme');
28
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
29
+ document.documentElement.setAttribute('data-theme', newTheme);
30
+ localStorage.setItem('theme', newTheme);
31
+ }
32
+
33
+ // Initialize theme on page load
34
+ initTheme();
35
+
36
+ // Set up theme toggle button
37
+ document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
38
+
39
+ // ─── Help Modal ─────────────────────────────────────────────────────────────
40
+
41
+ function openHelpModal() {
42
+ const overlay = document.getElementById('help-modal-overlay');
43
+ overlay.classList.add('visible');
44
+ document.body.style.overflow = 'hidden';
45
+ }
46
+
47
+ function closeHelpModal() {
48
+ const overlay = document.getElementById('help-modal-overlay');
49
+ overlay.classList.remove('visible');
50
+ document.body.style.overflow = '';
51
+ }
52
+
53
+ // Set up help button
54
+ document.getElementById('help-btn').addEventListener('click', openHelpModal);
55
+
56
+ // Set up close button
57
+ document.getElementById('help-modal-close').addEventListener('click', closeHelpModal);
58
+
59
+ // Close on overlay click (but not modal click)
60
+ document.getElementById('help-modal-overlay').addEventListener('click', function (e) {
61
+ if (e.target === this) {
62
+ closeHelpModal();
63
+ }
64
+ });
65
+
66
+ // Close on Escape key
67
+ document.addEventListener('keydown', function (e) {
68
+ if (e.key === 'Escape') {
69
+ const overlay = document.getElementById('help-modal-overlay');
70
+ if (overlay.classList.contains('visible')) {
71
+ closeHelpModal();
72
+ }
73
+ }
74
+ });
75
+
76
+ // Listen for system theme changes
77
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
78
+ if (!localStorage.getItem('theme')) {
79
+ document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
80
+ }
81
+ });
82
+
83
+ // ─── Shared Utilities ───────────────────────────────────────────────────────
84
+
85
+ /** localStorage key for persisting the active tab */
86
+ const TAB_STORAGE_KEY = 'pair-review-active-tab';
87
+
88
+ /**
89
+ * Format a relative time string from a date
90
+ * @param {string} dateString - ISO date string
91
+ * @returns {string} Human-readable relative time
92
+ */
93
+ function formatRelativeTime(dateString) {
94
+ const date = new Date(dateString);
95
+ const now = new Date();
96
+ const diffMs = now - date;
97
+ const diffSecs = Math.floor(diffMs / 1000);
98
+ const diffMins = Math.floor(diffSecs / 60);
99
+ const diffHours = Math.floor(diffMins / 60);
100
+ const diffDays = Math.floor(diffHours / 24);
101
+
102
+ if (diffSecs < 60) {
103
+ return 'Just now';
104
+ } else if (diffMins < 60) {
105
+ return diffMins + ' minute' + (diffMins !== 1 ? 's' : '') + ' ago';
106
+ } else if (diffHours < 24) {
107
+ return diffHours + ' hour' + (diffHours !== 1 ? 's' : '') + ' ago';
108
+ } else if (diffDays < 7) {
109
+ return diffDays + ' day' + (diffDays !== 1 ? 's' : '') + ' ago';
110
+ } else if (diffDays < 30) {
111
+ const weeks = Math.floor(diffDays / 7);
112
+ return weeks + ' week' + (weeks !== 1 ? 's' : '') + ' ago';
113
+ } else {
114
+ const months = Math.floor(diffDays / 30);
115
+ return months + ' month' + (months !== 1 ? 's' : '') + ' ago';
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Escape HTML special characters
121
+ * @param {string} text - Text to escape
122
+ * @returns {string} Escaped text
123
+ */
124
+ function escapeHtml(text) {
125
+ const div = document.createElement('div');
126
+ div.textContent = text || '';
127
+ return div.innerHTML;
128
+ }
129
+
130
+ /**
131
+ * Set loading state for a tab's form
132
+ * @param {string} tab - 'pr' or 'local'
133
+ * @param {boolean} loading - Whether to show loading state
134
+ * @param {string} [text] - Optional loading text
135
+ */
136
+ function setFormLoading(tab, loading, text) {
137
+ const ids = tab === 'pr'
138
+ ? { input: 'pr-url-input', btn: 'start-review-btn', loadingEl: 'start-review-loading-pr', loadingText: 'start-review-loading-text-pr', errorEl: 'start-review-error-pr', btnLabel: 'Start Review' }
139
+ : { input: 'local-path-input', btn: 'start-local-btn', loadingEl: 'start-review-loading-local', loadingText: 'start-review-loading-text-local', errorEl: 'start-review-error-local', btnLabel: 'Review Local' };
140
+
141
+ const inputEl = document.getElementById(ids.input);
142
+ const btnEl = document.getElementById(ids.btn);
143
+ const loadingEl = document.getElementById(ids.loadingEl);
144
+ const loadingTextEl = document.getElementById(ids.loadingText);
145
+ const errorEl = document.getElementById(ids.errorEl);
146
+
147
+ if (loading) {
148
+ if (inputEl) inputEl.disabled = true;
149
+ if (btnEl) { btnEl.disabled = true; btnEl.textContent = 'Starting...'; }
150
+ if (loadingEl) loadingEl.classList.add('visible');
151
+ if (loadingTextEl && text) loadingTextEl.textContent = text;
152
+ if (errorEl) errorEl.classList.remove('visible', 'info');
153
+ } else {
154
+ if (inputEl) inputEl.disabled = false;
155
+ if (btnEl) { btnEl.disabled = false; btnEl.textContent = ids.btnLabel; }
156
+ if (loadingEl) loadingEl.classList.remove('visible');
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Show error message for a specific tab's form
162
+ * @param {string} tab - 'pr' or 'local'
163
+ * @param {string} message - Error message to display
164
+ */
165
+ function showError(tab, message) {
166
+ const elId = tab === 'pr' ? 'start-review-error-pr' : 'start-review-error-local';
167
+ const errorEl = document.getElementById(elId);
168
+ if (errorEl) {
169
+ errorEl.textContent = message;
170
+ errorEl.classList.remove('info');
171
+ errorEl.classList.add('visible');
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Show an informational (non-error) message to the user.
177
+ * Uses the same element as showError but with neutral info styling.
178
+ * @param {string} tab - 'pr' or 'local'
179
+ * @param {string} message - Informational message to display
180
+ */
181
+ function showInfo(tab, message) {
182
+ const elId = tab === 'pr' ? 'start-review-error-pr' : 'start-review-error-local';
183
+ const errorEl = document.getElementById(elId);
184
+ if (errorEl) {
185
+ errorEl.textContent = message;
186
+ errorEl.classList.add('visible', 'info');
187
+ }
188
+ }
189
+
190
+ // ─── Tab Switching ──────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Generic tab switching handler
194
+ * Activates the clicked tab and its corresponding pane
195
+ * @param {HTMLElement} tabBar - The tab bar container
196
+ * @param {HTMLElement} clickedBtn - The clicked tab button
197
+ * @param {Function} [onActivate] - Optional callback when a tab is activated
198
+ */
199
+ function switchTab(tabBar, clickedBtn, onActivate) {
200
+ const tabId = clickedBtn.dataset.tab;
201
+ if (!tabId) return;
202
+
203
+ // Deactivate all tabs in this bar
204
+ tabBar.querySelectorAll('.tab-btn').forEach(function (btn) { btn.classList.remove('active'); });
205
+ clickedBtn.classList.add('active');
206
+
207
+ // Find the parent container that holds the tab panes
208
+ const container = tabBar.closest('.recent-reviews-section');
209
+ if (!container) return;
210
+
211
+ // Hide all panes, show the target
212
+ container.querySelectorAll('.tab-pane').forEach(function (pane) { pane.classList.remove('active'); });
213
+ const targetPane = container.querySelector('#' + tabId);
214
+ if (targetPane) {
215
+ targetPane.classList.add('active');
216
+ }
217
+
218
+ if (onActivate) onActivate(tabId);
219
+ }
220
+
221
+ // ─── Local Reviews ──────────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * Render a single local review session table row
225
+ * @param {Object} session - Session data
226
+ * @returns {string} HTML string for the table row
227
+ */
228
+ function renderLocalReviewRow(session) {
229
+ const link = '/local/' + session.id;
230
+ const relativeTime = formatRelativeTime(session.updated_at);
231
+ const pathDisplay = session.local_path || '';
232
+ const sha = session.local_head_sha
233
+ ? session.local_head_sha.substring(0, 7)
234
+ : '';
235
+ const hasName = !!session.name;
236
+ const nameDisplay = hasName ? escapeHtml(session.name) : '<em>Untitled</em>';
237
+
238
+ // Build repo settings link if repository looks like owner/repo
239
+ var settingsHtml = '';
240
+ if (session.repository && session.repository.includes('/')) {
241
+ var repoParts = session.repository.split('/');
242
+ var settingsLink = '/repo-settings.html?owner=' + encodeURIComponent(repoParts[0]) + '&repo=' + encodeURIComponent(repoParts[1]);
243
+ settingsHtml =
244
+ '<a href="' + settingsLink + '" class="btn-repo-settings" title="Repository settings">' +
245
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
246
+ '<path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>' +
247
+ '</svg>' +
248
+ '</a>';
249
+ }
250
+
251
+ return '' +
252
+ '<tr data-session-id="' + session.id + '">' +
253
+ '<td class="col-local-name" title="' + escapeHtml(session.name || 'Untitled') + '"><a href="' + link + '">' + nameDisplay + '</a></td>' +
254
+ '<td class="col-local-sha" title="' + escapeHtml(session.local_head_sha || '') + '">' + escapeHtml(sha) + '</td>' +
255
+ '<td class="col-local-path" title="' + escapeHtml(pathDisplay) + '">' + escapeHtml(pathDisplay) + '</td>' +
256
+ '<td class="col-repo">' + escapeHtml(session.repository || '') + '</td>' +
257
+ '<td class="col-time">' + relativeTime + '</td>' +
258
+ '<td class="col-actions">' +
259
+ settingsHtml +
260
+ '<button' +
261
+ ' class="btn-delete-session"' +
262
+ ' data-session-id="' + session.id + '"' +
263
+ ' data-session-path="' + escapeHtml(pathDisplay) + '"' +
264
+ ' title="Delete session"' +
265
+ '>' +
266
+ '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">' +
267
+ '<path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19a1.75 1.75 0 001.741-1.575l.66-6.6a.75.75 0 00-1.492-.15l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>' +
268
+ '</svg>' +
269
+ '</button>' +
270
+ '</td>' +
271
+ '</tr>';
272
+ }
273
+
274
+ /** Pagination state for the local reviews list */
275
+ const localReviewsPagination = {
276
+ lastTimestamp: null,
277
+ pageSize: 10,
278
+ hasMore: false,
279
+ loaded: false
280
+ };
281
+
282
+ /**
283
+ * Fetch and display local review sessions (initial load).
284
+ */
285
+ async function loadLocalReviews() {
286
+ const container = document.getElementById('local-reviews-container');
287
+ if (!container) return;
288
+
289
+ // Reset pagination
290
+ localReviewsPagination.lastTimestamp = null;
291
+ localReviewsPagination.hasMore = false;
292
+
293
+ try {
294
+ const response = await fetch('/api/local/sessions?limit=' + localReviewsPagination.pageSize);
295
+ if (!response.ok) throw new Error('Failed to fetch local sessions');
296
+
297
+ const data = await response.json();
298
+
299
+ if (!data.success || !data.sessions || data.sessions.length === 0) {
300
+ container.innerHTML =
301
+ '<div class="recent-reviews-empty">' +
302
+ '<p>No local review sessions yet. Enter a directory path above or run <code>pair-review --local</code> from the CLI.</p>' +
303
+ '</div>';
304
+ container.classList.remove('recent-reviews-loading');
305
+ return;
306
+ }
307
+
308
+ // Update pagination
309
+ localReviewsPagination.lastTimestamp = data.sessions[data.sessions.length - 1].updated_at;
310
+ localReviewsPagination.hasMore = !!data.hasMore;
311
+
312
+ container.innerHTML =
313
+ '<table class="recent-reviews-table local-table">' +
314
+ '<thead>' +
315
+ '<tr>' +
316
+ '<th>Name</th>' +
317
+ '<th>Head</th>' +
318
+ '<th>Path</th>' +
319
+ '<th>Repo</th>' +
320
+ '<th>Last Updated</th>' +
321
+ '<th></th>' +
322
+ '</tr>' +
323
+ '</thead>' +
324
+ '<tbody id="local-reviews-tbody">' +
325
+ data.sessions.map(renderLocalReviewRow).join('') +
326
+ '</tbody>' +
327
+ '</table>' +
328
+ (data.hasMore
329
+ ? '<div class="show-more-container" id="local-show-more-container">' +
330
+ '<button class="btn-show-more" id="btn-local-show-more" type="button">' +
331
+ '<span class="btn-show-more-text">Show more</span>' +
332
+ '<span class="spinner"></span>' +
333
+ '</button>' +
334
+ '</div>'
335
+ : '');
336
+ container.classList.remove('recent-reviews-loading');
337
+ localReviewsPagination.loaded = true;
338
+
339
+ } catch (error) {
340
+ console.error('Error loading local reviews:', error);
341
+ container.innerHTML =
342
+ '<div class="recent-reviews-empty">' +
343
+ '<p>Failed to load local reviews.</p>' +
344
+ '</div>';
345
+ container.classList.remove('recent-reviews-loading');
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Load more local review sessions (pagination)
351
+ */
352
+ async function loadMoreLocalReviews() {
353
+ const btn = document.getElementById('btn-local-show-more');
354
+ const tbody = document.getElementById('local-reviews-tbody');
355
+ if (!btn || !tbody) return;
356
+
357
+ btn.classList.add('loading');
358
+ btn.disabled = true;
359
+
360
+ try {
361
+ const params = new URLSearchParams({ limit: localReviewsPagination.pageSize });
362
+ if (localReviewsPagination.lastTimestamp) params.set('before', localReviewsPagination.lastTimestamp);
363
+ const response = await fetch('/api/local/sessions?' + params);
364
+ if (!response.ok) throw new Error('Failed to fetch more sessions');
365
+
366
+ const data = await response.json();
367
+ if (!document.contains(btn)) return;
368
+
369
+ if (!data.success || !data.sessions || data.sessions.length === 0) {
370
+ const showMoreContainer = document.getElementById('local-show-more-container');
371
+ if (showMoreContainer) showMoreContainer.remove();
372
+ localReviewsPagination.hasMore = false;
373
+ return;
374
+ }
375
+
376
+ tbody.insertAdjacentHTML('beforeend', data.sessions.map(renderLocalReviewRow).join(''));
377
+
378
+ localReviewsPagination.lastTimestamp = data.sessions[data.sessions.length - 1].updated_at;
379
+ localReviewsPagination.hasMore = !!data.hasMore;
380
+
381
+ if (!data.hasMore) {
382
+ const container = document.getElementById('local-show-more-container');
383
+ if (container) container.remove();
384
+ } else {
385
+ btn.classList.remove('loading');
386
+ btn.disabled = false;
387
+ }
388
+
389
+ } catch (error) {
390
+ console.error('Error loading more local reviews:', error);
391
+ btn.classList.remove('loading');
392
+ btn.disabled = false;
393
+ const textEl = btn.querySelector('.btn-show-more-text');
394
+ if (textEl) {
395
+ textEl.textContent = 'Failed to load \u2014 click to retry';
396
+ setTimeout(function () { textEl.textContent = 'Show more'; }, 4000);
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Show inline delete confirmation for a local session row
403
+ * @param {HTMLElement} button - The delete button element
404
+ */
405
+ function showDeleteSessionConfirm(button) {
406
+ const sessionId = button.dataset.sessionId;
407
+ const sessionPath = button.dataset.sessionPath || '';
408
+ const row = button.closest('tr');
409
+ if (!row) return;
410
+
411
+ // If already showing confirmation, do nothing
412
+ if (row.classList.contains('delete-confirm-row')) return;
413
+
414
+ // Remember original HTML so we can restore on cancel.
415
+ // Note: restoring innerHTML is safe because all click handlers use event delegation.
416
+ const originalHTML = row.innerHTML;
417
+ const colCount = row.children.length;
418
+
419
+ row.classList.add('delete-confirm-row');
420
+ row.innerHTML =
421
+ '<td colspan="' + colCount + '">' +
422
+ '<div class="delete-confirm-inner">' +
423
+ '<span>Delete session for ' + escapeHtml(sessionPath) + '?</span>' +
424
+ '<button class="btn-confirm-yes" data-session-id="' + sessionId + '">Delete</button>' +
425
+ '<button class="btn-confirm-no">Cancel</button>' +
426
+ '</div>' +
427
+ '</td>';
428
+
429
+ // Wire up buttons
430
+ row.querySelector('.btn-confirm-yes').addEventListener('click', async function () {
431
+ try {
432
+ const response = await fetch('/api/local/sessions/' + sessionId, {
433
+ method: 'DELETE'
434
+ });
435
+ if (!response.ok) {
436
+ const respData = await response.json().catch(function () { return {}; });
437
+ throw new Error(respData.error || 'Failed to delete session');
438
+ }
439
+ // Remove the row from DOM
440
+ row.remove();
441
+ // If no more rows, reload to show empty state
442
+ const tbody = document.getElementById('local-reviews-tbody');
443
+ if (tbody && tbody.children.length === 0) {
444
+ await loadLocalReviews();
445
+ }
446
+ } catch (error) {
447
+ console.error('Error deleting local session:', error);
448
+ // Restore row on failure
449
+ row.classList.remove('delete-confirm-row');
450
+ row.innerHTML = originalHTML;
451
+ }
452
+ });
453
+
454
+ row.querySelector('.btn-confirm-no').addEventListener('click', function () {
455
+ row.classList.remove('delete-confirm-row');
456
+ row.innerHTML = originalHTML;
457
+ });
458
+ }
459
+
460
+ // ─── Local Review Start ─────────────────────────────────────────────────────
461
+
462
+ /**
463
+ * Handle start local review form submission
464
+ * @param {Event} event - Form submit event
465
+ */
466
+ async function handleStartLocal(event) {
467
+ event.preventDefault();
468
+
469
+ const input = document.getElementById('local-path-input');
470
+ const pathValue = input.value.trim();
471
+
472
+ // Clear previous errors/info messages
473
+ const errorEl = document.getElementById('start-review-error-local');
474
+ if (errorEl) errorEl.classList.remove('visible', 'info');
475
+
476
+ if (!pathValue) {
477
+ showError('local', 'Please enter a directory path');
478
+ input.focus();
479
+ return;
480
+ }
481
+
482
+ setFormLoading('local', true, 'Starting local review...');
483
+
484
+ try {
485
+ const response = await fetch('/api/local/start', {
486
+ method: 'POST',
487
+ headers: { 'Content-Type': 'application/json' },
488
+ body: JSON.stringify({ path: pathValue })
489
+ });
490
+
491
+ const data = await response.json();
492
+
493
+ if (!response.ok) {
494
+ throw new Error(data.error || 'Failed to start local review');
495
+ }
496
+
497
+ setFormLoading('local', true, 'Redirecting to review...');
498
+ window.location.href = data.reviewUrl;
499
+
500
+ } catch (error) {
501
+ console.error('Error starting local review:', error);
502
+ setFormLoading('local', false);
503
+ showError('local', error.message || 'An unexpected error occurred. Please try again.');
504
+ }
505
+ }
506
+
507
+ // ─── Browse Directory ──────────────────────────────────────────────────────
508
+
509
+ /**
510
+ * Handle Browse button click — open native OS directory picker via backend API
511
+ */
512
+ async function handleBrowseLocal() {
513
+ const browseBtn = document.getElementById('browse-local-btn');
514
+ const input = document.getElementById('local-path-input');
515
+ if (!browseBtn || !input) return;
516
+
517
+ // Disable button while dialog is open
518
+ browseBtn.disabled = true;
519
+ browseBtn.textContent = 'Browsing...';
520
+
521
+ try {
522
+ const response = await fetch('/api/local/browse', {
523
+ method: 'POST',
524
+ headers: { 'Content-Type': 'application/json' }
525
+ });
526
+
527
+ const data = await response.json();
528
+
529
+ if (!response.ok) {
530
+ showError('local', data.error || 'Failed to open directory picker');
531
+ return;
532
+ }
533
+
534
+ if (!data.cancelled && data.path) {
535
+ input.value = data.path;
536
+ input.focus();
537
+ }
538
+
539
+ } catch (error) {
540
+ console.error('Error browsing for directory:', error);
541
+ showError('local', 'Failed to open directory picker');
542
+ } finally {
543
+ browseBtn.disabled = false;
544
+ browseBtn.textContent = 'Browse';
545
+ }
546
+ }
547
+
548
+ // ─── PR Reviews ─────────────────────────────────────────────────────────────
549
+
550
+ /**
551
+ * Render a single recent review table row
552
+ * @param {Object} worktree - Worktree data
553
+ * @returns {string} HTML string for the table row
554
+ */
555
+ function renderRecentReviewRow(worktree) {
556
+ const parts = worktree.repository.split('/');
557
+ const owner = parts[0];
558
+ const repo = parts[1];
559
+ const link = '/pr/' + owner + '/' + repo + '/' + worktree.pr_number;
560
+ const settingsLink = '/repo-settings.html?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo);
561
+ const relativeTime = formatRelativeTime(worktree.last_accessed_at);
562
+
563
+ const authorDisplay = worktree.author
564
+ ? '<a href="https://github.com/' + encodeURIComponent(worktree.author) + '" target="_blank" rel="noopener">' + escapeHtml(worktree.author) + '</a>'
565
+ : '';
566
+
567
+ return '' +
568
+ '<tr>' +
569
+ '<td class="col-repo">' + escapeHtml(worktree.repository) + '</td>' +
570
+ '<td class="col-pr"><a href="' + link + '">#' + worktree.pr_number + '</a></td>' +
571
+ '<td class="col-title" title="' + escapeHtml(worktree.pr_title) + '">' + escapeHtml(worktree.pr_title) + '</td>' +
572
+ '<td class="col-author">' + authorDisplay + '</td>' +
573
+ '<td class="col-time">' + relativeTime + '</td>' +
574
+ '<td class="col-actions">' +
575
+ '<a href="' + settingsLink + '" class="btn-repo-settings" title="Repository settings">' +
576
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
577
+ '<path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>' +
578
+ '</svg>' +
579
+ '</a>' +
580
+ '<button' +
581
+ ' class="btn-delete-worktree"' +
582
+ ' data-worktree-id="' + worktree.id + '"' +
583
+ ' data-repository="' + escapeHtml(worktree.repository) + '"' +
584
+ ' data-pr-number="' + worktree.pr_number + '"' +
585
+ ' title="Delete worktree"' +
586
+ '>' +
587
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
588
+ '<path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19a1.75 1.75 0 001.741-1.575l.66-6.6a.75.75 0 00-1.492-.15l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>' +
589
+ '</svg>' +
590
+ '</button>' +
591
+ '</td>' +
592
+ '</tr>';
593
+ }
594
+
595
+ /**
596
+ * Show inline delete confirmation for a PR worktree row
597
+ * @param {HTMLElement} button - The delete button element
598
+ */
599
+ function showDeleteWorktreeConfirm(button) {
600
+ const worktreeId = button.dataset.worktreeId;
601
+ const repository = button.dataset.repository;
602
+ const prNumber = button.dataset.prNumber;
603
+ const row = button.closest('tr');
604
+ if (!row) return;
605
+
606
+ // If already showing confirmation, do nothing
607
+ if (row.classList.contains('delete-confirm-row')) return;
608
+
609
+ // Remember original HTML so we can restore on cancel.
610
+ // Note: restoring innerHTML is safe because all click handlers use event delegation.
611
+ const originalHTML = row.innerHTML;
612
+ const colCount = row.children.length;
613
+
614
+ row.classList.add('delete-confirm-row');
615
+ row.innerHTML =
616
+ '<td colspan="' + colCount + '">' +
617
+ '<div class="delete-confirm-inner">' +
618
+ '<span>Delete worktree for ' + escapeHtml(repository) + ' #' + escapeHtml(String(prNumber)) + '?</span>' +
619
+ '<button class="btn-confirm-yes" data-worktree-id="' + worktreeId + '">Delete</button>' +
620
+ '<button class="btn-confirm-no">Cancel</button>' +
621
+ '</div>' +
622
+ '</td>';
623
+
624
+ // Wire up buttons
625
+ row.querySelector('.btn-confirm-yes').addEventListener('click', async function () {
626
+ try {
627
+ const response = await fetch('/api/worktrees/' + worktreeId, {
628
+ method: 'DELETE'
629
+ });
630
+
631
+ if (!response.ok) {
632
+ const data = await response.json().catch(function () { return {}; });
633
+ throw new Error(data.error || 'Failed to delete worktree');
634
+ }
635
+
636
+ // Reload the recent reviews list
637
+ await loadRecentReviews();
638
+
639
+ } catch (error) {
640
+ console.error('Error deleting worktree:', error);
641
+ // Restore row on failure
642
+ row.classList.remove('delete-confirm-row');
643
+ row.innerHTML = originalHTML;
644
+ }
645
+ });
646
+
647
+ row.querySelector('.btn-confirm-no').addEventListener('click', function () {
648
+ row.classList.remove('delete-confirm-row');
649
+ row.innerHTML = originalHTML;
650
+ });
651
+ }
652
+
653
+ /** Pagination state for the recent reviews list */
654
+ const recentReviewsPagination = {
655
+ /** ISO timestamp of the last loaded item (cursor for next fetch) */
656
+ lastTimestamp: null,
657
+ /** Number of worktrees to fetch per page */
658
+ pageSize: 10,
659
+ /** Whether the server has indicated more results exist */
660
+ hasMore: false
661
+ };
662
+
663
+ /**
664
+ * Fetch and display recent reviews (initial load).
665
+ * Resets pagination state and renders the full table from scratch.
666
+ */
667
+ async function loadRecentReviews() {
668
+ const container = document.getElementById('recent-reviews-container');
669
+ const section = document.getElementById('recent-reviews-section');
670
+ const usageInfo = document.getElementById('usage-info');
671
+
672
+ // Reset pagination state
673
+ recentReviewsPagination.lastTimestamp = null;
674
+ recentReviewsPagination.hasMore = false;
675
+
676
+ try {
677
+ const response = await fetch('/api/worktrees/recent?limit=' + recentReviewsPagination.pageSize);
678
+
679
+ if (!response.ok) {
680
+ throw new Error('Failed to fetch recent reviews');
681
+ }
682
+
683
+ const data = await response.json();
684
+
685
+ if (!data.success || !data.worktrees || data.worktrees.length === 0) {
686
+ // Show friendly empty state with usage info
687
+ container.innerHTML =
688
+ '<div class="recent-reviews-empty">' +
689
+ '<p>No PR reviews yet. Paste a PR URL above to get started.</p>' +
690
+ '</div>';
691
+ container.classList.remove('recent-reviews-loading');
692
+ // Show usage info when no reviews exist
693
+ if (usageInfo) usageInfo.classList.remove('loading-hidden');
694
+ return;
695
+ }
696
+
697
+ // Update pagination state - track the cursor for the next page
698
+ recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
699
+ recentReviewsPagination.hasMore = !!data.hasMore;
700
+
701
+ // Render the table of recent reviews
702
+ const html =
703
+ '<table class="recent-reviews-table">' +
704
+ '<thead>' +
705
+ '<tr>' +
706
+ '<th>Repository</th>' +
707
+ '<th>PR</th>' +
708
+ '<th>Title</th>' +
709
+ '<th>Author</th>' +
710
+ '<th>Last Opened</th>' +
711
+ '<th>Actions</th>' +
712
+ '</tr>' +
713
+ '</thead>' +
714
+ '<tbody id="recent-reviews-tbody">' +
715
+ data.worktrees.map(renderRecentReviewRow).join('') +
716
+ '</tbody>' +
717
+ '</table>' +
718
+ renderShowMoreButton(data.hasMore);
719
+ container.innerHTML = html;
720
+ container.classList.remove('recent-reviews-loading');
721
+
722
+ } catch (error) {
723
+ console.error('Error loading recent reviews:', error);
724
+ // Hide the section on error, show usage info as fallback
725
+ section.style.display = 'none';
726
+ if (usageInfo) usageInfo.classList.remove('loading-hidden');
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Render the "Show more" button HTML.
732
+ * @param {boolean} hasMore - Whether more results are available
733
+ * @returns {string} HTML string for the show-more container
734
+ */
735
+ function renderShowMoreButton(hasMore) {
736
+ if (!hasMore) return '';
737
+ return '' +
738
+ '<div class="show-more-container" id="show-more-container">' +
739
+ '<button class="btn-show-more" id="btn-show-more" type="button">' +
740
+ '<span class="btn-show-more-text">Show more</span>' +
741
+ '<span class="spinner"></span>' +
742
+ '</button>' +
743
+ '</div>';
744
+ }
745
+
746
+ /**
747
+ * Load the next page of worktrees and append them to the existing table.
748
+ * Called when the "Show more" button is clicked.
749
+ */
750
+ async function loadMoreReviews() {
751
+ const btn = document.getElementById('btn-show-more');
752
+ const tbody = document.getElementById('recent-reviews-tbody');
753
+ if (!btn || !tbody) return;
754
+
755
+ // Show loading state on the button
756
+ btn.classList.add('loading');
757
+ btn.disabled = true;
758
+
759
+ try {
760
+ const params = new URLSearchParams({ limit: recentReviewsPagination.pageSize });
761
+ if (recentReviewsPagination.lastTimestamp) params.set('before', recentReviewsPagination.lastTimestamp);
762
+ const response = await fetch('/api/worktrees/recent?' + params);
763
+
764
+ if (!response.ok) {
765
+ throw new Error('Failed to fetch more reviews');
766
+ }
767
+
768
+ const data = await response.json();
769
+
770
+ // Guard against stale response if the table was refreshed (e.g. by a delete) while loading
771
+ if (!document.contains(btn)) return;
772
+
773
+ if (!data.success || !data.worktrees || data.worktrees.length === 0) {
774
+ // No more results - remove the button
775
+ const showMoreContainer = document.getElementById('show-more-container');
776
+ if (showMoreContainer) showMoreContainer.remove();
777
+ recentReviewsPagination.hasMore = false;
778
+ return;
779
+ }
780
+
781
+ // Append new rows to the existing table body
782
+ tbody.insertAdjacentHTML('beforeend', data.worktrees.map(renderRecentReviewRow).join(''));
783
+
784
+ // Update pagination state - advance the cursor
785
+ recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
786
+ recentReviewsPagination.hasMore = !!data.hasMore;
787
+
788
+ // Update or remove the "Show more" button
789
+ if (!data.hasMore) {
790
+ const container = document.getElementById('show-more-container');
791
+ if (container) container.remove();
792
+ } else {
793
+ // Reset button state
794
+ btn.classList.remove('loading');
795
+ btn.disabled = false;
796
+ }
797
+
798
+ } catch (error) {
799
+ console.error('Error loading more reviews:', error);
800
+ // Reset button state and show error so the user knows what happened
801
+ btn.classList.remove('loading');
802
+ btn.disabled = false;
803
+ const textEl = btn.querySelector('.btn-show-more-text');
804
+ if (textEl) {
805
+ textEl.textContent = 'Failed to load \u2014 click to retry';
806
+ // Restore original text after a brief delay so the user sees the error
807
+ setTimeout(function () { textEl.textContent = 'Show more'; }, 4000);
808
+ }
809
+ }
810
+ }
811
+
812
+ // ─── PR Start Flow ──────────────────────────────────────────────────────────
813
+
814
+ /**
815
+ * Parse a PR URL using the backend API
816
+ * Supports GitHub and Graphite URLs (with or without protocol)
817
+ * @param {string} url - The PR URL to parse
818
+ * @returns {Promise<Object|null>} { owner, repo, prNumber } or null if invalid
819
+ */
820
+ async function parsePRUrl(url) {
821
+ if (!url || typeof url !== 'string') {
822
+ return null;
823
+ }
824
+
825
+ try {
826
+ const response = await fetch('/api/parse-pr-url', {
827
+ method: 'POST',
828
+ headers: {
829
+ 'Content-Type': 'application/json'
830
+ },
831
+ body: JSON.stringify({ url: url.trim() })
832
+ });
833
+
834
+ const data = await response.json();
835
+
836
+ if (data.valid) {
837
+ return {
838
+ owner: data.owner,
839
+ repo: data.repo,
840
+ prNumber: data.prNumber
841
+ };
842
+ }
843
+
844
+ return null;
845
+ } catch (e) {
846
+ console.error('Error parsing PR URL:', e);
847
+ return null;
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Handle start review form submission
853
+ * @param {Event} event - Form submit event
854
+ */
855
+ async function handleStartReview(event) {
856
+ event.preventDefault();
857
+
858
+ const input = document.getElementById('pr-url-input');
859
+ const url = input.value.trim();
860
+
861
+ // Clear previous errors
862
+ const errorEl = document.getElementById('start-review-error-pr');
863
+ if (errorEl) errorEl.classList.remove('visible');
864
+
865
+ // Validate input
866
+ if (!url) {
867
+ showError('pr', 'Please enter a GitHub PR URL');
868
+ input.focus();
869
+ return;
870
+ }
871
+
872
+ // Show loading state while parsing
873
+ setFormLoading('pr', true, 'Validating PR URL...');
874
+
875
+ // Parse the URL using the backend API
876
+ const parsed = await parsePRUrl(url);
877
+ if (!parsed) {
878
+ setFormLoading('pr', false);
879
+ showError('pr', 'Invalid PR URL. Please enter a GitHub or Graphite PR URL (e.g., https://github.com/owner/repo/pull/123)');
880
+ input.focus();
881
+ return;
882
+ }
883
+
884
+ // Update loading state
885
+ setFormLoading('pr', true, 'Fetching PR data from GitHub...');
886
+
887
+ try {
888
+ // Call the API to create the worktree
889
+ const response = await fetch('/api/worktrees/create', {
890
+ method: 'POST',
891
+ headers: {
892
+ 'Content-Type': 'application/json'
893
+ },
894
+ body: JSON.stringify({
895
+ owner: parsed.owner,
896
+ repo: parsed.repo,
897
+ prNumber: parsed.prNumber
898
+ })
899
+ });
900
+
901
+ const data = await response.json();
902
+
903
+ if (!response.ok) {
904
+ throw new Error(data.error || 'Failed to create worktree');
905
+ }
906
+
907
+ if (!data.success) {
908
+ throw new Error(data.error || 'Failed to create worktree');
909
+ }
910
+
911
+ // Update loading text before redirect
912
+ setFormLoading('pr', true, 'Redirecting to review...');
913
+
914
+ // Redirect to the review page
915
+ window.location.href = data.reviewUrl;
916
+
917
+ } catch (error) {
918
+ console.error('Error starting review:', error);
919
+ setFormLoading('pr', false);
920
+ showError('pr', error.message || 'An unexpected error occurred. Please try again.');
921
+ }
922
+ }
923
+
924
+ // ─── Config & Command Examples ──────────────────────────────────────────────
925
+
926
+ /**
927
+ * Update command examples based on whether running via npx or installed
928
+ * @param {boolean} isNpx - True if running via npx
929
+ */
930
+ function updateCommandExamples(isNpx) {
931
+ const baseCmd = isNpx ? 'npx @in-the-loop-labs/pair-review' : 'pair-review';
932
+ const cmdExamples = document.querySelectorAll('.cmd-example');
933
+ cmdExamples.forEach(function (el) {
934
+ const args = el.dataset.args || '';
935
+ el.textContent = args ? baseCmd + ' ' + args : baseCmd;
936
+ });
937
+ }
938
+
939
+ /**
940
+ * Fetch config from server and update UI accordingly
941
+ */
942
+ async function loadConfigAndUpdateUI() {
943
+ try {
944
+ const response = await fetch('/api/config');
945
+ if (response.ok) {
946
+ const config = await response.json();
947
+ updateCommandExamples(config.is_running_via_npx);
948
+ } else {
949
+ // Fallback: assume installed (shorter command)
950
+ updateCommandExamples(false);
951
+ }
952
+ } catch (error) {
953
+ console.error('Error loading config:', error);
954
+ // Fallback: assume installed (shorter command)
955
+ updateCommandExamples(false);
956
+ }
957
+ }
958
+
959
+ // ─── Event Delegation ───────────────────────────────────────────────────────
960
+
961
+ // Event delegation for buttons, show-more, tab switching
962
+ document.addEventListener('click', function (event) {
963
+ // Delete worktree (PR mode)
964
+ const deleteBtn = event.target.closest('.btn-delete-worktree');
965
+ if (deleteBtn) {
966
+ event.preventDefault();
967
+ event.stopPropagation();
968
+ showDeleteWorktreeConfirm(deleteBtn);
969
+ return;
970
+ }
971
+
972
+ // Delete local session
973
+ const deleteSessionBtn = event.target.closest('.btn-delete-session');
974
+ if (deleteSessionBtn) {
975
+ event.preventDefault();
976
+ event.stopPropagation();
977
+ showDeleteSessionConfirm(deleteSessionBtn);
978
+ return;
979
+ }
980
+
981
+ // Show more (PR reviews)
982
+ const showMoreBtn = event.target.closest('#btn-show-more');
983
+ if (showMoreBtn) {
984
+ event.preventDefault();
985
+ loadMoreReviews();
986
+ return;
987
+ }
988
+
989
+ // Show more (local reviews)
990
+ const localShowMoreBtn = event.target.closest('#btn-local-show-more');
991
+ if (localShowMoreBtn) {
992
+ event.preventDefault();
993
+ loadMoreLocalReviews();
994
+ return;
995
+ }
996
+
997
+ // Unified tab switching
998
+ const unifiedTabBtn = event.target.closest('#unified-tab-bar .tab-btn');
999
+ if (unifiedTabBtn) {
1000
+ const tabBar = document.getElementById('unified-tab-bar');
1001
+ switchTab(tabBar, unifiedTabBtn, function (tabId) {
1002
+ // Persist tab choice
1003
+ localStorage.setItem(TAB_STORAGE_KEY, tabId);
1004
+ // Lazy-load local reviews on first switch
1005
+ if (tabId === 'local-tab' && !localReviewsPagination.loaded) {
1006
+ loadLocalReviews();
1007
+ }
1008
+ });
1009
+ return;
1010
+ }
1011
+ });
1012
+
1013
+ // ─── DOMContentLoaded Initialization ────────────────────────────────────────
1014
+
1015
+ document.addEventListener('DOMContentLoaded', function () {
1016
+ // Load config and update command examples based on npx detection
1017
+ loadConfigAndUpdateUI().then(function () {
1018
+ // Sync help content to usage-info section AFTER command examples are updated
1019
+ const helpContent = document.querySelector('.help-modal-content');
1020
+ const usageInfo = document.getElementById('usage-info');
1021
+ if (helpContent && usageInfo) {
1022
+ usageInfo.innerHTML = '';
1023
+ Array.from(helpContent.childNodes).forEach(function (node) {
1024
+ usageInfo.appendChild(node.cloneNode(true));
1025
+ });
1026
+ }
1027
+ });
1028
+
1029
+ // Restore saved tab from localStorage (default: 'pr-tab')
1030
+ const savedTab = localStorage.getItem(TAB_STORAGE_KEY) || 'pr-tab';
1031
+ const tabBar = document.getElementById('unified-tab-bar');
1032
+ if (tabBar) {
1033
+ const targetBtn = tabBar.querySelector('[data-tab="' + savedTab + '"]');
1034
+ if (targetBtn && savedTab !== 'pr-tab') {
1035
+ // Switch to saved tab (pr-tab is already active by default in HTML)
1036
+ switchTab(tabBar, targetBtn);
1037
+ }
1038
+ }
1039
+
1040
+ // Always load PR reviews (they show on initial load)
1041
+ loadRecentReviews();
1042
+
1043
+ // If local tab is active, load local reviews immediately
1044
+ if (savedTab === 'local-tab') {
1045
+ loadLocalReviews();
1046
+ }
1047
+
1048
+ // Set up start review form handler
1049
+ const form = document.getElementById('start-review-form');
1050
+ if (form) {
1051
+ form.addEventListener('submit', handleStartReview);
1052
+ }
1053
+
1054
+ // Set up local review form handler
1055
+ const localForm = document.getElementById('start-local-form');
1056
+ if (localForm) {
1057
+ localForm.addEventListener('submit', handleStartLocal);
1058
+ }
1059
+
1060
+ // Set up browse button handler
1061
+ const browseBtn = document.getElementById('browse-local-btn');
1062
+ if (browseBtn) {
1063
+ browseBtn.addEventListener('click', handleBrowseLocal);
1064
+ }
1065
+
1066
+ // Note: No explicit Enter keypress handlers are needed here.
1067
+ // Both inputs are inside <form> elements, so pressing Enter
1068
+ // natively triggers form submission.
1069
+ });
1070
+
1071
+ })();