@in-the-loop-labs/pair-review 1.0.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.
Files changed (91) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +371 -0
  3. package/bin/git-diff-lines +146 -0
  4. package/bin/pair-review.js +49 -0
  5. package/package.json +71 -0
  6. package/public/css/ai-summary-modal.css +183 -0
  7. package/public/css/pr.css +8698 -0
  8. package/public/css/repo-settings.css +891 -0
  9. package/public/css/styles.css +479 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +1104 -0
  12. package/public/js/components/AIPanel.js +1639 -0
  13. package/public/js/components/AISummaryModal.js +278 -0
  14. package/public/js/components/AnalysisConfigModal.js +684 -0
  15. package/public/js/components/ConfirmDialog.js +227 -0
  16. package/public/js/components/PreviewModal.js +344 -0
  17. package/public/js/components/ProgressModal.js +678 -0
  18. package/public/js/components/ReviewModal.js +531 -0
  19. package/public/js/components/SplitButton.js +382 -0
  20. package/public/js/components/StatusIndicator.js +265 -0
  21. package/public/js/components/SuggestionNavigator.js +489 -0
  22. package/public/js/components/Toast.js +166 -0
  23. package/public/js/local.js +1580 -0
  24. package/public/js/modules/analysis-history.js +940 -0
  25. package/public/js/modules/comment-manager.js +643 -0
  26. package/public/js/modules/diff-renderer.js +585 -0
  27. package/public/js/modules/file-comment-manager.js +1242 -0
  28. package/public/js/modules/gap-coordinates.js +190 -0
  29. package/public/js/modules/hunk-parser.js +358 -0
  30. package/public/js/modules/line-tracker.js +386 -0
  31. package/public/js/modules/panel-resizer.js +228 -0
  32. package/public/js/modules/storage-cleanup.js +36 -0
  33. package/public/js/modules/suggestion-manager.js +692 -0
  34. package/public/js/pr.js +3503 -0
  35. package/public/js/repo-settings.js +691 -0
  36. package/public/js/utils/file-order.js +87 -0
  37. package/public/js/utils/markdown.js +97 -0
  38. package/public/js/utils/suggestion-ui.js +55 -0
  39. package/public/js/utils/tier-icons.js +25 -0
  40. package/public/local.html +460 -0
  41. package/public/pr.html +329 -0
  42. package/public/repo-settings.html +243 -0
  43. package/src/ai/analyzer.js +2592 -0
  44. package/src/ai/claude-cli.js +153 -0
  45. package/src/ai/claude-provider.js +261 -0
  46. package/src/ai/codex-provider.js +361 -0
  47. package/src/ai/copilot-provider.js +345 -0
  48. package/src/ai/gemini-provider.js +375 -0
  49. package/src/ai/index.js +47 -0
  50. package/src/ai/prompts/baseline/_meta.json +14 -0
  51. package/src/ai/prompts/baseline/level1/balanced.js +239 -0
  52. package/src/ai/prompts/baseline/level1/fast.js +194 -0
  53. package/src/ai/prompts/baseline/level1/thorough.js +319 -0
  54. package/src/ai/prompts/baseline/level2/balanced.js +248 -0
  55. package/src/ai/prompts/baseline/level2/fast.js +201 -0
  56. package/src/ai/prompts/baseline/level2/thorough.js +367 -0
  57. package/src/ai/prompts/baseline/level3/balanced.js +280 -0
  58. package/src/ai/prompts/baseline/level3/fast.js +220 -0
  59. package/src/ai/prompts/baseline/level3/thorough.js +459 -0
  60. package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
  61. package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
  62. package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
  63. package/src/ai/prompts/config.js +52 -0
  64. package/src/ai/prompts/index.js +267 -0
  65. package/src/ai/prompts/shared/diff-instructions.js +50 -0
  66. package/src/ai/prompts/shared/output-schema.js +179 -0
  67. package/src/ai/prompts/shared/valid-files.js +37 -0
  68. package/src/ai/provider.js +260 -0
  69. package/src/config.js +139 -0
  70. package/src/database.js +2284 -0
  71. package/src/git/gitattributes.js +207 -0
  72. package/src/git/worktree.js +688 -0
  73. package/src/github/client.js +893 -0
  74. package/src/github/parser.js +247 -0
  75. package/src/local-review.js +691 -0
  76. package/src/main.js +987 -0
  77. package/src/routes/analysis.js +897 -0
  78. package/src/routes/comments.js +534 -0
  79. package/src/routes/config.js +250 -0
  80. package/src/routes/local.js +1728 -0
  81. package/src/routes/pr.js +1164 -0
  82. package/src/routes/shared.js +218 -0
  83. package/src/routes/worktrees.js +500 -0
  84. package/src/server.js +295 -0
  85. package/src/utils/diff-annotator.js +414 -0
  86. package/src/utils/instructions.js +33 -0
  87. package/src/utils/json-extractor.js +107 -0
  88. package/src/utils/line-validation.js +183 -0
  89. package/src/utils/logger.js +142 -0
  90. package/src/utils/paths.js +161 -0
  91. package/src/utils/stats-calculator.js +86 -0
@@ -0,0 +1,691 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Repository Settings Page JavaScript
4
+ * Handles loading, saving, and managing repository AI settings
5
+ */
6
+
7
+ class RepoSettingsPage {
8
+ constructor() {
9
+ this.owner = null;
10
+ this.repo = null;
11
+ this.originalSettings = {};
12
+ this.currentSettings = {};
13
+ this.hasUnsavedChanges = false;
14
+ this.providers = {};
15
+ this.selectedProvider = null;
16
+
17
+ this.init();
18
+ }
19
+
20
+ async init() {
21
+ // Parse URL to get owner/repo
22
+ this.parseUrl();
23
+
24
+ // Initialize theme
25
+ this.initTheme();
26
+
27
+ // Check for and display back to PR link
28
+ this.initBackLink();
29
+
30
+ // Setup event listeners
31
+ this.setupEventListeners();
32
+
33
+ // Load providers first (needed to render model cards)
34
+ await this.loadProviders();
35
+
36
+ // Load settings
37
+ await this.loadSettings();
38
+ }
39
+
40
+ /**
41
+ * Initialize back link if user navigated from a PR page or local review
42
+ */
43
+ initBackLink() {
44
+ const backLink = document.getElementById('back-to-pr');
45
+ const backLinkText = document.getElementById('back-to-pr-text');
46
+ if (!backLink || !backLinkText) return;
47
+
48
+ // Use scoped key to prevent collision between multiple tabs
49
+ const referrerKey = `settingsReferrer:${this.owner}/${this.repo}`;
50
+
51
+ try {
52
+ const referrerData = localStorage.getItem(referrerKey);
53
+ if (!referrerData) return;
54
+
55
+ const data = JSON.parse(referrerData);
56
+
57
+ // Check if this is a local review referrer
58
+ if (data.type === 'local' && data.localReviewId) {
59
+ backLink.href = `/local/${data.localReviewId}`;
60
+ backLinkText.textContent = 'Return to Local Review';
61
+ backLink.style.display = 'inline-flex';
62
+
63
+ // Clear the referrer when clicking the back link
64
+ backLink.addEventListener('click', () => {
65
+ localStorage.removeItem(referrerKey);
66
+ });
67
+ } else if (data.prNumber) {
68
+ // PR referrer - validate stored data matches current page context as sanity check
69
+ // (Key is already scoped by repo, but this provides extra safety)
70
+ if (data.owner && data.repo && (data.owner !== this.owner || data.repo !== this.repo)) {
71
+ console.warn('PR referrer owner/repo mismatch - clearing stale data');
72
+ localStorage.removeItem(referrerKey);
73
+ return;
74
+ }
75
+ backLink.href = `/pr/${this.owner}/${this.repo}/${data.prNumber}`;
76
+ backLinkText.textContent = `Return to PR #${data.prNumber}`;
77
+ backLink.style.display = 'inline-flex';
78
+
79
+ // Clear the referrer when clicking the back link
80
+ backLink.addEventListener('click', () => {
81
+ localStorage.removeItem(referrerKey);
82
+ });
83
+ }
84
+ // No else clause needed - if format is unknown, just don't show the link
85
+ } catch (error) {
86
+ console.warn('Error parsing settings referrer:', error);
87
+ localStorage.removeItem(referrerKey);
88
+ }
89
+ }
90
+
91
+ parseUrl() {
92
+ // URL format: /settings/:owner/:repo
93
+ const pathParts = window.location.pathname.split('/').filter(Boolean);
94
+ if (pathParts.length >= 3 && pathParts[0] === 'settings') {
95
+ this.owner = pathParts[1];
96
+ this.repo = pathParts[2];
97
+ } else {
98
+ // Try query params as fallback
99
+ const params = new URLSearchParams(window.location.search);
100
+ this.owner = params.get('owner');
101
+ this.repo = params.get('repo');
102
+ }
103
+
104
+ if (!this.owner || !this.repo) {
105
+ this.showToast('error', 'Invalid repository URL');
106
+ return;
107
+ }
108
+
109
+ // Update UI with repo name
110
+ const repoFullName = `${this.owner}/${this.repo}`;
111
+ document.getElementById('repo-name-breadcrumb').textContent = repoFullName;
112
+ document.getElementById('repo-name-header').textContent = repoFullName;
113
+ document.title = `Settings - ${repoFullName} - Pair Review`;
114
+ }
115
+
116
+ initTheme() {
117
+ // Check for saved theme preference
118
+ const savedTheme = localStorage.getItem('theme') || 'light';
119
+ document.documentElement.setAttribute('data-theme', savedTheme);
120
+
121
+ // Theme toggle button
122
+ const themeToggle = document.getElementById('theme-toggle');
123
+ if (themeToggle) {
124
+ themeToggle.addEventListener('click', () => {
125
+ const currentTheme = document.documentElement.getAttribute('data-theme');
126
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
127
+ document.documentElement.setAttribute('data-theme', newTheme);
128
+ localStorage.setItem('theme', newTheme);
129
+ });
130
+ }
131
+ }
132
+
133
+ setupEventListeners() {
134
+ // Model card and provider button listeners are attached dynamically
135
+ // when renderProviderButtons() and renderModelCards() are called
136
+
137
+ // Instructions textarea
138
+ const textarea = document.getElementById('default-instructions');
139
+ if (textarea) {
140
+ textarea.addEventListener('input', () => {
141
+ this.currentSettings.default_instructions = textarea.value;
142
+ this.updateCharCount(textarea.value.length);
143
+ this.checkForChanges();
144
+ });
145
+ }
146
+
147
+ // Form submission
148
+ const form = document.getElementById('settings-form');
149
+ if (form) {
150
+ form.addEventListener('submit', (e) => {
151
+ e.preventDefault();
152
+ this.saveSettings();
153
+ });
154
+ }
155
+
156
+ // Cancel button
157
+ const cancelBtn = document.getElementById('cancel-btn');
158
+ if (cancelBtn) {
159
+ cancelBtn.addEventListener('click', () => this.handleCancel());
160
+ }
161
+
162
+ // Reset settings button
163
+ const resetBtn = document.getElementById('reset-settings');
164
+ if (resetBtn) {
165
+ resetBtn.addEventListener('click', () => this.handleReset());
166
+ }
167
+
168
+ // Clear local path button
169
+ const clearLocalPathBtn = document.getElementById('clear-local-path');
170
+ if (clearLocalPathBtn) {
171
+ clearLocalPathBtn.addEventListener('click', () => this.handleClearLocalPath());
172
+ }
173
+
174
+ // Warn before leaving with unsaved changes
175
+ window.addEventListener('beforeunload', (e) => {
176
+ if (this.hasUnsavedChanges) {
177
+ e.preventDefault();
178
+ e.returnValue = '';
179
+ }
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Load provider definitions from the backend API
185
+ */
186
+ async loadProviders() {
187
+ try {
188
+ const response = await fetch('/api/providers');
189
+ if (!response.ok) {
190
+ throw new Error('Failed to fetch providers');
191
+ }
192
+
193
+ const data = await response.json();
194
+
195
+ // Convert array to object keyed by provider id
196
+ this.providers = {};
197
+ for (const provider of data.providers) {
198
+ this.providers[provider.id] = provider;
199
+ }
200
+
201
+ // Render provider buttons now that we have data
202
+ this.renderProviderButtons();
203
+
204
+ } catch (error) {
205
+ console.error('Error loading providers:', error);
206
+ // Last-resort degraded mode: hardcoded Claude fallback when the /api/providers
207
+ // endpoint is unavailable. This allows basic functionality even if the backend
208
+ // is partially broken. The canonical provider definitions live in
209
+ // src/ai/claude-provider.js - this fallback should mirror those values.
210
+ this.providers = {
211
+ claude: {
212
+ id: 'claude',
213
+ name: 'Claude',
214
+ models: [
215
+ { id: 'haiku', name: 'Haiku', tier: 'fast', badge: 'Fastest', badgeClass: 'badge-speed', tagline: 'Lightning Fast', description: 'Quick analysis for simple changes' },
216
+ { id: 'sonnet', name: 'Sonnet', tier: 'balanced', default: true, badge: 'Recommended', badgeClass: 'badge-recommended', tagline: 'Best Balance', description: 'Recommended for most reviews' },
217
+ { id: 'opus', name: 'Opus', tier: 'thorough', badge: 'Most Thorough', badgeClass: 'badge-power', tagline: 'Most Capable', description: 'Deep analysis for complex code' }
218
+ ],
219
+ defaultModel: 'sonnet'
220
+ }
221
+ };
222
+ this.renderProviderButtons();
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Render provider toggle buttons
228
+ */
229
+ renderProviderButtons() {
230
+ const container = document.getElementById('provider-toggle');
231
+ if (!container) return;
232
+
233
+ const providerIds = Object.keys(this.providers);
234
+ container.innerHTML = providerIds.map(providerId => {
235
+ const provider = this.providers[providerId];
236
+ return `
237
+ <button type="button" class="provider-btn ${providerId === this.selectedProvider ? 'selected' : ''}" data-provider="${providerId}">
238
+ ${provider.name}
239
+ </button>
240
+ `;
241
+ }).join('');
242
+
243
+ // Attach event listeners
244
+ container.querySelectorAll('.provider-btn').forEach(btn => {
245
+ btn.addEventListener('click', () => this.selectProvider(btn.dataset.provider));
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Select a provider and update model cards
251
+ */
252
+ selectProvider(providerId, markAsChanged = true) {
253
+ if (!this.providers[providerId]) return;
254
+
255
+ const previousProvider = this.selectedProvider;
256
+ this.selectedProvider = providerId;
257
+
258
+ if (markAsChanged) {
259
+ this.currentSettings.default_provider = providerId;
260
+ }
261
+
262
+ // Update provider buttons UI
263
+ document.querySelectorAll('.provider-btn').forEach(btn => {
264
+ btn.classList.toggle('selected', btn.dataset.provider === providerId);
265
+ });
266
+
267
+ // Re-render model cards for the new provider
268
+ this.renderModelCards();
269
+
270
+ // If provider changed and we're tracking changes, try to map the model to the new provider
271
+ if (markAsChanged && previousProvider && previousProvider !== providerId) {
272
+ const oldProvider = this.providers[previousProvider];
273
+ const newProvider = this.providers[providerId];
274
+
275
+ // If old provider no longer exists (e.g., was removed from available providers),
276
+ // fall back to the new provider's default model
277
+ if (!oldProvider) {
278
+ const defaultModel = newProvider.models.find(m => m.default) || newProvider.models[0];
279
+ this.selectModel(defaultModel.id, markAsChanged);
280
+ this.checkForChanges();
281
+ return;
282
+ }
283
+
284
+ // Find the model with same tier as currently selected
285
+ const currentModel = oldProvider.models.find(m => m.id === this.currentSettings.default_model);
286
+
287
+ if (currentModel) {
288
+ const matchingModel = newProvider.models.find(m => m.tier === currentModel.tier);
289
+ const defaultModel = newProvider.models.find(m => m.default);
290
+ const fallbackModel = matchingModel || defaultModel || newProvider.models[0];
291
+
292
+ if (!matchingModel && !defaultModel) {
293
+ console.warn(`No matching tier or default model found for provider "${providerId}", using first available model`);
294
+ }
295
+
296
+ this.selectModel(fallbackModel.id, markAsChanged);
297
+ } else {
298
+ // No current model selected, use default for new provider
299
+ const defaultModel = newProvider.models.find(m => m.default) || newProvider.models[0];
300
+ this.selectModel(defaultModel.id, markAsChanged);
301
+ }
302
+
303
+ this.checkForChanges();
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Render model cards for the currently selected provider
309
+ */
310
+ renderModelCards() {
311
+ const container = document.getElementById('model-cards');
312
+ if (!container) return;
313
+
314
+ const provider = this.providers[this.selectedProvider];
315
+ if (!provider) {
316
+ container.innerHTML = '';
317
+ return;
318
+ }
319
+
320
+ container.innerHTML = provider.models.map(model => `
321
+ <button type="button" class="model-card ${model.id === this.currentSettings.default_model ? 'selected' : ''}" data-model="${model.id}" data-tier="${model.tier}">
322
+ <div class="model-badge ${model.badgeClass || ''}">${model.badge || ''}</div>
323
+ <div class="model-icon">${this.getModelIcon(model.tier)}</div>
324
+ <div class="model-info">
325
+ <span class="model-name">${model.name}</span>
326
+ <span class="model-tagline">${model.tagline || ''}</span>
327
+ </div>
328
+ <p class="model-description">${model.description || ''}</p>
329
+ <div class="model-selected-indicator">
330
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
331
+ <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
332
+ </svg>
333
+ </div>
334
+ </button>
335
+ `).join('');
336
+
337
+ // Attach event listeners
338
+ container.querySelectorAll('.model-card').forEach(card => {
339
+ card.addEventListener('click', () => this.selectModel(card.dataset.model));
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Get model icon based on tier
345
+ * Delegates to shared utility in utils/tier-icons.js
346
+ */
347
+ getModelIcon(tier) {
348
+ return window.getTierIcon(tier);
349
+ }
350
+
351
+ async loadSettings() {
352
+ if (!this.owner || !this.repo) return;
353
+
354
+ try {
355
+ const response = await fetch(`/api/repos/${this.owner}/${this.repo}/settings`);
356
+
357
+ if (!response.ok) {
358
+ throw new Error('Failed to load settings');
359
+ }
360
+
361
+ const settings = await response.json();
362
+
363
+ // Store original settings for comparison
364
+ this.originalSettings = {
365
+ default_provider: settings.default_provider || null,
366
+ default_model: settings.default_model || null,
367
+ default_instructions: settings.default_instructions || '',
368
+ local_path: settings.local_path || null
369
+ };
370
+
371
+ // Set current settings
372
+ this.currentSettings = { ...this.originalSettings };
373
+
374
+ // Update UI
375
+ this.updateUI();
376
+
377
+ } catch (error) {
378
+ console.error('Error loading settings:', error);
379
+ // Use defaults if no settings exist
380
+ this.originalSettings = {
381
+ default_provider: null,
382
+ default_model: null,
383
+ default_instructions: '',
384
+ local_path: null
385
+ };
386
+ this.currentSettings = { ...this.originalSettings };
387
+ this.updateUI();
388
+ }
389
+ }
390
+
391
+ updateUI() {
392
+ // Update provider selection - validate provider exists before selecting
393
+ let providerId = this.currentSettings.default_provider;
394
+ const availableProviders = Object.keys(this.providers);
395
+
396
+ if (!providerId || !this.providers[providerId]) {
397
+ // Provider doesn't exist, fall back to first available
398
+ // Update currentSettings directly so state is consistent for saves,
399
+ // but don't mark as changed (no unsaved changes indicator)
400
+ providerId = availableProviders[0] || 'claude';
401
+ this.currentSettings.default_provider = providerId;
402
+ }
403
+
404
+ this.selectProvider(providerId, false);
405
+
406
+ // Update model selection
407
+ if (this.currentSettings.default_model) {
408
+ this.selectModel(this.currentSettings.default_model, false);
409
+ }
410
+
411
+ // Update instructions textarea
412
+ const textarea = document.getElementById('default-instructions');
413
+ if (textarea) {
414
+ textarea.value = this.currentSettings.default_instructions || '';
415
+ this.updateCharCount(textarea.value.length);
416
+ }
417
+
418
+ // Update local path display
419
+ this.updateLocalPathDisplay();
420
+ }
421
+
422
+ /**
423
+ * Update the local path display section
424
+ */
425
+ updateLocalPathDisplay() {
426
+ const localPathValue = document.getElementById('local-path-value');
427
+ const clearLocalPathBtn = document.getElementById('clear-local-path');
428
+ const localPathHint = document.getElementById('local-path-hint');
429
+
430
+ if (!localPathValue) return;
431
+
432
+ const localPath = this.currentSettings.local_path;
433
+
434
+ if (localPath) {
435
+ localPathValue.textContent = localPath;
436
+ localPathValue.classList.add('has-value');
437
+ if (clearLocalPathBtn) clearLocalPathBtn.style.display = 'inline-flex';
438
+ if (localPathHint) localPathHint.style.display = 'none';
439
+ } else {
440
+ localPathValue.textContent = 'Not set';
441
+ localPathValue.classList.remove('has-value');
442
+ if (clearLocalPathBtn) clearLocalPathBtn.style.display = 'none';
443
+ if (localPathHint) localPathHint.style.display = 'flex';
444
+ }
445
+ }
446
+
447
+ selectModel(modelId, markAsChanged = true) {
448
+ // Update current settings
449
+ if (markAsChanged) {
450
+ this.currentSettings.default_model = modelId;
451
+ this.checkForChanges();
452
+ }
453
+
454
+ // Update UI
455
+ document.querySelectorAll('.model-card').forEach(card => {
456
+ card.classList.toggle('selected', card.dataset.model === modelId);
457
+ });
458
+ }
459
+
460
+ updateCharCount(count) {
461
+ const charCountEl = document.getElementById('char-count');
462
+ if (charCountEl) {
463
+ charCountEl.textContent = count;
464
+ }
465
+ }
466
+
467
+ checkForChanges() {
468
+ // Use nullish coalescing to normalize null/undefined for consistent comparison
469
+ const providerChanged = (this.currentSettings.default_provider ?? null) !== (this.originalSettings.default_provider ?? null);
470
+ const modelChanged = (this.currentSettings.default_model ?? null) !== (this.originalSettings.default_model ?? null);
471
+ const instructionsChanged = (this.currentSettings.default_instructions ?? '') !== (this.originalSettings.default_instructions ?? '');
472
+
473
+ this.hasUnsavedChanges = providerChanged || modelChanged || instructionsChanged;
474
+
475
+ // Show/hide action bar
476
+ const actionBar = document.getElementById('action-bar');
477
+ if (actionBar) {
478
+ actionBar.classList.toggle('visible', this.hasUnsavedChanges);
479
+ }
480
+ }
481
+
482
+ async saveSettings() {
483
+ if (!this.owner || !this.repo) return;
484
+
485
+ const saveBtn = document.getElementById('save-btn');
486
+ if (saveBtn) {
487
+ saveBtn.disabled = true;
488
+ saveBtn.innerHTML = `
489
+ <svg class="spinner" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
490
+ <path d="M8 0a8 8 0 018 8h-2a6 6 0 00-6-6V0z"/>
491
+ </svg>
492
+ Saving...
493
+ `;
494
+ }
495
+
496
+ try {
497
+ const response = await fetch(`/api/repos/${this.owner}/${this.repo}/settings`, {
498
+ method: 'POST',
499
+ headers: {
500
+ 'Content-Type': 'application/json'
501
+ },
502
+ body: JSON.stringify({
503
+ default_provider: this.currentSettings.default_provider,
504
+ default_model: this.currentSettings.default_model,
505
+ default_instructions: this.currentSettings.default_instructions
506
+ })
507
+ });
508
+
509
+ if (!response.ok) {
510
+ throw new Error('Failed to save settings');
511
+ }
512
+
513
+ // Update original settings
514
+ this.originalSettings = { ...this.currentSettings };
515
+ this.hasUnsavedChanges = false;
516
+
517
+ // Hide action bar
518
+ const actionBar = document.getElementById('action-bar');
519
+ if (actionBar) {
520
+ actionBar.classList.remove('visible');
521
+ }
522
+
523
+ this.showToast('success', 'Settings saved successfully');
524
+
525
+ } catch (error) {
526
+ console.error('Error saving settings:', error);
527
+ this.showToast('error', 'Failed to save settings. Please try again.');
528
+ } finally {
529
+ if (saveBtn) {
530
+ saveBtn.disabled = false;
531
+ saveBtn.innerHTML = `
532
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
533
+ <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
534
+ </svg>
535
+ Save Settings
536
+ `;
537
+ }
538
+ }
539
+ }
540
+
541
+ handleCancel() {
542
+ if (this.hasUnsavedChanges) {
543
+ const confirmed = confirm('You have unsaved changes. Are you sure you want to discard them?');
544
+ if (!confirmed) return;
545
+ }
546
+
547
+ // Reset to original settings
548
+ this.currentSettings = { ...this.originalSettings };
549
+ this.hasUnsavedChanges = false;
550
+ this.updateUI();
551
+
552
+ // Hide action bar
553
+ const actionBar = document.getElementById('action-bar');
554
+ if (actionBar) {
555
+ actionBar.classList.remove('visible');
556
+ }
557
+ }
558
+
559
+ async handleReset() {
560
+ const confirmed = confirm(
561
+ 'This will remove all custom settings for this repository. The default provider and model will not be pre-selected and no default instructions will be used. Continue?'
562
+ );
563
+
564
+ if (!confirmed) return;
565
+
566
+ try {
567
+ // For now, just clear the settings by saving empty values
568
+ const response = await fetch(`/api/repos/${this.owner}/${this.repo}/settings`, {
569
+ method: 'POST',
570
+ headers: {
571
+ 'Content-Type': 'application/json'
572
+ },
573
+ body: JSON.stringify({
574
+ default_provider: null,
575
+ default_model: null,
576
+ default_instructions: '',
577
+ local_path: null
578
+ })
579
+ });
580
+
581
+ if (!response.ok) {
582
+ throw new Error('Failed to reset settings');
583
+ }
584
+
585
+ // Clear all settings
586
+ this.originalSettings = {
587
+ default_provider: null,
588
+ default_model: null,
589
+ default_instructions: '',
590
+ local_path: null
591
+ };
592
+ this.currentSettings = { ...this.originalSettings };
593
+ this.hasUnsavedChanges = false;
594
+
595
+ // Update UI using updateUI() which handles all the display logic
596
+ this.updateUI();
597
+
598
+ // Hide action bar
599
+ const actionBar = document.getElementById('action-bar');
600
+ if (actionBar) {
601
+ actionBar.classList.remove('visible');
602
+ }
603
+
604
+ this.showToast('success', 'Settings reset to defaults');
605
+
606
+ } catch (error) {
607
+ console.error('Error resetting settings:', error);
608
+ this.showToast('error', 'Failed to reset settings. Please try again.');
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Handle clearing the local repository path
614
+ */
615
+ async handleClearLocalPath() {
616
+ const confirmed = confirm(
617
+ 'This will clear the registered repository location. The next web UI review will need to clone the repository unless you run pair-review from the CLI again. Continue?'
618
+ );
619
+
620
+ if (!confirmed) return;
621
+
622
+ try {
623
+ const response = await fetch(`/api/repos/${this.owner}/${this.repo}/settings`, {
624
+ method: 'POST',
625
+ headers: {
626
+ 'Content-Type': 'application/json'
627
+ },
628
+ body: JSON.stringify({
629
+ local_path: null
630
+ })
631
+ });
632
+
633
+ if (!response.ok) {
634
+ throw new Error('Failed to clear local path');
635
+ }
636
+
637
+ // Update settings
638
+ this.originalSettings.local_path = null;
639
+ this.currentSettings.local_path = null;
640
+
641
+ // Update local path display
642
+ this.updateLocalPathDisplay();
643
+
644
+ this.showToast('success', 'Repository location cleared');
645
+
646
+ } catch (error) {
647
+ console.error('Error clearing local path:', error);
648
+ this.showToast('error', 'Failed to clear repository location. Please try again.');
649
+ }
650
+ }
651
+
652
+ showToast(type, message) {
653
+ const container = document.getElementById('toast-container');
654
+ if (!container) return;
655
+
656
+ const toast = document.createElement('div');
657
+ toast.className = `toast ${type}`;
658
+ toast.innerHTML = `
659
+ <div class="toast-icon">
660
+ ${type === 'success'
661
+ ? '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg>'
662
+ : '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9-3a1 1 0 11-2 0 1 1 0 012 0zM6.92 6.085c.081-.16.19-.299.34-.398.145-.097.371-.187.74-.187.302 0 .558.066.743.205a.677.677 0 01.26.514c0 .217-.062.376-.15.495-.083.112-.216.22-.436.345-.205.119-.36.234-.479.364a.788.788 0 00-.19.478v.413a.75.75 0 101.5 0v-.124c0-.05.024-.1.067-.14.052-.047.154-.113.333-.19.188-.083.37-.196.523-.35a1.724 1.724 0 00.437-1.18c0-.515-.177-.914-.504-1.199-.331-.289-.78-.447-1.3-.447-.531 0-.978.164-1.307.465-.323.295-.496.682-.558 1.093a.75.75 0 001.474.28.327.327 0 01.029-.073zM9 11a1 1 0 11-2 0 1 1 0 012 0z"/></svg>'
663
+ }
664
+ </div>
665
+ <span class="toast-message">${message}</span>
666
+ <button class="toast-close" onclick="this.parentElement.remove()">
667
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
668
+ <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
669
+ </svg>
670
+ </button>
671
+ `;
672
+
673
+ container.appendChild(toast);
674
+
675
+ // Trigger animation
676
+ requestAnimationFrame(() => {
677
+ toast.classList.add('show');
678
+ });
679
+
680
+ // Auto-remove after 5 seconds
681
+ setTimeout(() => {
682
+ toast.classList.remove('show');
683
+ setTimeout(() => toast.remove(), 300);
684
+ }, 5000);
685
+ }
686
+ }
687
+
688
+ // Initialize when DOM is ready
689
+ document.addEventListener('DOMContentLoaded', () => {
690
+ window.repoSettings = new RepoSettingsPage();
691
+ });