@in-the-loop-labs/pair-review 1.3.3 → 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.
package/public/index.html CHANGED
@@ -474,6 +474,17 @@
474
474
  cursor: not-allowed;
475
475
  }
476
476
 
477
+ .btn-browse {
478
+ background-color: var(--ai-subtle);
479
+ color: var(--ai-primary);
480
+ border: 1px solid var(--ai-border);
481
+ }
482
+
483
+ .btn-browse:hover:not(:disabled) {
484
+ background-color: var(--ai-glow);
485
+ border-color: var(--ai-primary);
486
+ }
487
+
477
488
  .start-review-error {
478
489
  margin-top: 12px;
479
490
  padding: 10px 14px;
@@ -494,6 +505,17 @@
494
505
  display: block;
495
506
  }
496
507
 
508
+ .start-review-error.info {
509
+ color: var(--color-fg-muted);
510
+ background-color: rgba(9, 105, 218, 0.08);
511
+ border-color: rgba(9, 105, 218, 0.3);
512
+ }
513
+
514
+ [data-theme="dark"] .start-review-error.info {
515
+ background-color: rgba(56, 139, 253, 0.1);
516
+ border-color: rgba(56, 139, 253, 0.3);
517
+ }
518
+
497
519
  .start-review-loading {
498
520
  display: none;
499
521
  align-items: center;
@@ -691,6 +713,14 @@
691
713
  white-space: nowrap;
692
714
  }
693
715
 
716
+ .local-table .col-actions {
717
+ text-align: right;
718
+ }
719
+
720
+ .local-table .btn-repo-settings {
721
+ margin-right: 4px;
722
+ }
723
+
694
724
  .btn-delete-worktree,
695
725
  .btn-repo-settings {
696
726
  display: inline-flex;
@@ -797,6 +827,181 @@
797
827
  display: none;
798
828
  }
799
829
 
830
+ /* Tab Container Styles */
831
+ .tab-container {
832
+ width: 100%;
833
+ }
834
+
835
+ .tab-bar {
836
+ display: flex;
837
+ gap: 0;
838
+ border-bottom: 1px solid var(--color-border-secondary);
839
+ margin-bottom: 16px;
840
+ }
841
+
842
+ .tab-btn {
843
+ padding: 8px 16px;
844
+ font-family: var(--font-sans);
845
+ font-size: 13px;
846
+ font-weight: 500;
847
+ color: var(--color-text-secondary);
848
+ background: transparent;
849
+ border: none;
850
+ border-bottom: 2px solid transparent;
851
+ cursor: pointer;
852
+ transition: all var(--transition-fast);
853
+ white-space: nowrap;
854
+ }
855
+
856
+ .tab-btn:hover {
857
+ color: var(--color-text-primary);
858
+ }
859
+
860
+ .tab-btn.active {
861
+ color: var(--ai-primary);
862
+ border-bottom-color: var(--ai-primary);
863
+ }
864
+
865
+ .tab-pane {
866
+ display: none;
867
+ }
868
+
869
+ .tab-pane.active {
870
+ display: block;
871
+ }
872
+
873
+ /* Local Reviews Table - tighter spacing */
874
+ .recent-reviews-table.local-table th,
875
+ .recent-reviews-table.local-table td {
876
+ padding: 8px 10px;
877
+ }
878
+
879
+ .col-local-path {
880
+ max-width: 260px;
881
+ overflow: hidden;
882
+ text-overflow: ellipsis;
883
+ white-space: nowrap;
884
+ /* RTL direction + LTR alignment makes ellipsis truncate from the left, showing the end of long paths */
885
+ direction: rtl;
886
+ text-align: left;
887
+ color: var(--color-text-tertiary);
888
+ font-family: var(--font-mono);
889
+ font-size: 12px;
890
+ }
891
+
892
+ .col-local-name {
893
+ max-width: 180px;
894
+ overflow: hidden;
895
+ text-overflow: ellipsis;
896
+ white-space: nowrap;
897
+ color: var(--ai-primary);
898
+ }
899
+
900
+ .col-local-sha {
901
+ font-family: var(--font-mono);
902
+ font-size: 12px;
903
+ color: var(--color-text-tertiary);
904
+ white-space: nowrap;
905
+ }
906
+
907
+ .col-local-name a {
908
+ color: var(--ai-primary);
909
+ text-decoration: none;
910
+ font-weight: 500;
911
+ font-family: var(--font-mono);
912
+ }
913
+
914
+ .col-local-name a:hover {
915
+ color: var(--ai-secondary);
916
+ text-decoration: underline;
917
+ }
918
+
919
+ .col-local-name em {
920
+ font-style: italic;
921
+ }
922
+
923
+ /* Delete session button */
924
+ .btn-delete-session {
925
+ display: inline-flex;
926
+ align-items: center;
927
+ justify-content: center;
928
+ width: 28px;
929
+ height: 28px;
930
+ padding: 0;
931
+ background: transparent;
932
+ border: 1px solid transparent;
933
+ border-radius: var(--radius-md);
934
+ color: var(--color-text-tertiary);
935
+ cursor: pointer;
936
+ transition: all var(--transition-fast);
937
+ }
938
+
939
+ .btn-delete-session:hover {
940
+ background-color: rgba(208, 36, 47, 0.08);
941
+ border-color: rgba(208, 36, 47, 0.3);
942
+ color: var(--color-danger);
943
+ }
944
+
945
+ [data-theme="dark"] .btn-delete-session:hover {
946
+ background-color: rgba(248, 81, 73, 0.1);
947
+ border-color: rgba(248, 81, 73, 0.3);
948
+ }
949
+
950
+ /* Inline delete confirmation — use higher specificity to override tbody tr:hover */
951
+ .recent-reviews-table tbody tr.delete-confirm-row td {
952
+ background-color: rgba(208, 36, 47, 0.04);
953
+ }
954
+
955
+ [data-theme="dark"] .recent-reviews-table tbody tr.delete-confirm-row td {
956
+ background-color: rgba(248, 81, 73, 0.06);
957
+ }
958
+
959
+ .delete-confirm-inner {
960
+ display: flex;
961
+ align-items: center;
962
+ gap: 8px;
963
+ font-size: 13px;
964
+ color: var(--color-text-secondary);
965
+ }
966
+
967
+ .delete-confirm-inner .btn-confirm-yes {
968
+ padding: 2px 10px;
969
+ font-size: 12px;
970
+ font-weight: 500;
971
+ background: var(--color-danger);
972
+ color: #ffffff;
973
+ border: none;
974
+ border-radius: var(--radius-sm);
975
+ cursor: pointer;
976
+ }
977
+
978
+ .delete-confirm-inner .btn-confirm-yes:hover {
979
+ background: var(--color-danger-hover);
980
+ }
981
+
982
+ .delete-confirm-inner .btn-confirm-no {
983
+ padding: 2px 10px;
984
+ font-size: 12px;
985
+ font-weight: 500;
986
+ background: transparent;
987
+ color: var(--color-text-secondary);
988
+ border: 1px solid var(--color-border-primary);
989
+ border-radius: var(--radius-sm);
990
+ cursor: pointer;
991
+ }
992
+
993
+ .delete-confirm-inner .btn-confirm-no:hover {
994
+ background: var(--color-bg-secondary);
995
+ color: var(--color-text-primary);
996
+ }
997
+
998
+ /* Start review section spacing inside unified tabs */
999
+ .tab-pane .start-review-section {
1000
+ max-width: 100%;
1001
+ margin-bottom: 24px;
1002
+ padding-top: 0;
1003
+ }
1004
+
800
1005
  /* Initial loading state - hide content until JS determines what to show */
801
1006
  .loading-hidden {
802
1007
  display: none !important;
@@ -863,41 +1068,76 @@
863
1068
  <!-- Main Content -->
864
1069
  <main class="main-content">
865
1070
  <div class="welcome-section" id="welcome-section">
866
- <!-- Start Review Section -->
867
- <div class="start-review-section" id="start-review-section">
868
- <form class="start-review-form" id="start-review-form">
869
- <input
870
- type="text"
871
- class="start-review-input"
872
- id="pr-url-input"
873
- placeholder="Enter GitHub or Graphite PR URL"
874
- autocomplete="off"
875
- spellcheck="false"
876
- >
877
- <button type="submit" class="start-review-btn" id="start-review-btn">
878
- Start Review
879
- </button>
880
- </form>
881
- <div class="start-review-error" id="start-review-error"></div>
882
- <div class="start-review-loading" id="start-review-loading">
883
- <div class="spinner"></div>
884
- <span id="start-review-loading-text">Creating worktree and fetching PR data...</span>
1071
+ <!-- Unified Tab Section -->
1072
+ <div class="recent-reviews-section" id="recent-reviews-section">
1073
+ <div class="section-header" id="recent-reviews-header">
1074
+ <div class="tab-bar" id="unified-tab-bar">
1075
+ <button class="tab-btn active" data-tab="pr-tab" type="button">Pull Requests</button>
1076
+ <button class="tab-btn" data-tab="local-tab" type="button">Local Reviews</button>
1077
+ </div>
1078
+ </div>
1079
+
1080
+ <!-- Pull Requests Tab: Input + Listing -->
1081
+ <div class="tab-pane active" id="pr-tab">
1082
+ <div class="start-review-section">
1083
+ <form class="start-review-form" id="start-review-form">
1084
+ <input
1085
+ type="text"
1086
+ class="start-review-input"
1087
+ id="pr-url-input"
1088
+ placeholder="Enter GitHub or Graphite PR URL"
1089
+ autocomplete="off"
1090
+ spellcheck="false"
1091
+ >
1092
+ <button type="submit" class="start-review-btn" id="start-review-btn">
1093
+ Start Review
1094
+ </button>
1095
+ </form>
1096
+ <div class="start-review-error" id="start-review-error-pr"></div>
1097
+ <div class="start-review-loading" id="start-review-loading-pr">
1098
+ <div class="spinner"></div>
1099
+ <span id="start-review-loading-text-pr">Creating worktree and fetching PR data...</span>
1100
+ </div>
1101
+ </div>
1102
+ <div id="recent-reviews-container" class="recent-reviews-loading">
1103
+ Loading recent reviews...
1104
+ </div>
1105
+ </div>
1106
+
1107
+ <!-- Local Reviews Tab: Input + Listing -->
1108
+ <div class="tab-pane" id="local-tab">
1109
+ <div class="start-review-section">
1110
+ <form class="start-review-form" id="start-local-form">
1111
+ <input
1112
+ type="text"
1113
+ class="start-review-input"
1114
+ id="local-path-input"
1115
+ placeholder="Enter directory path (e.g. /Users/me/project)"
1116
+ autocomplete="off"
1117
+ spellcheck="false"
1118
+ >
1119
+ <button type="button" class="start-review-btn btn-browse" id="browse-local-btn" title="Browse for directory">
1120
+ Browse
1121
+ </button>
1122
+ <button type="submit" class="start-review-btn" id="start-local-btn">
1123
+ Review Local
1124
+ </button>
1125
+ </form>
1126
+ <div class="start-review-error" id="start-review-error-local"></div>
1127
+ <div class="start-review-loading" id="start-review-loading-local">
1128
+ <div class="spinner"></div>
1129
+ <span id="start-review-loading-text-local">Starting local review...</span>
1130
+ </div>
1131
+ </div>
1132
+ <div id="local-reviews-container" class="recent-reviews-loading">
1133
+ Loading local reviews...
1134
+ </div>
885
1135
  </div>
886
1136
  </div>
887
1137
 
888
1138
  <!-- Usage Info (shown when no reviews, hidden initially during loading) -->
889
1139
  <!-- Content is populated from help modal via JS to avoid duplication -->
890
1140
  <div class="usage-info loading-hidden" id="usage-info"></div>
891
-
892
- <!-- Recent Reviews Section -->
893
- <div class="recent-reviews-section" id="recent-reviews-section">
894
- <div class="section-header loading-hidden" id="recent-reviews-header">
895
- <h3 class="section-title">Recent Reviews</h3>
896
- </div>
897
- <div id="recent-reviews-container" class="recent-reviews-loading">
898
- Loading recent reviews...
899
- </div>
900
- </div>
901
1141
  </div>
902
1142
  </main>
903
1143
  </div>
@@ -941,599 +1181,6 @@
941
1181
  </div>
942
1182
  </div>
943
1183
 
944
- <script>
945
- /**
946
- * Theme Management
947
- */
948
- function initTheme() {
949
- const savedTheme = localStorage.getItem('theme');
950
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
951
- const theme = savedTheme || (prefersDark ? 'dark' : 'light');
952
- document.documentElement.setAttribute('data-theme', theme);
953
- }
954
-
955
- function toggleTheme() {
956
- const currentTheme = document.documentElement.getAttribute('data-theme');
957
- const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
958
- document.documentElement.setAttribute('data-theme', newTheme);
959
- localStorage.setItem('theme', newTheme);
960
- }
961
-
962
- // Initialize theme on page load
963
- initTheme();
964
-
965
- // Set up theme toggle button
966
- document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
967
-
968
- /**
969
- * Help Modal Management
970
- */
971
- function openHelpModal() {
972
- const overlay = document.getElementById('help-modal-overlay');
973
- overlay.classList.add('visible');
974
- document.body.style.overflow = 'hidden';
975
- }
976
-
977
- function closeHelpModal() {
978
- const overlay = document.getElementById('help-modal-overlay');
979
- overlay.classList.remove('visible');
980
- document.body.style.overflow = '';
981
- }
982
-
983
- // Set up help button
984
- document.getElementById('help-btn').addEventListener('click', openHelpModal);
985
-
986
- // Set up close button
987
- document.getElementById('help-modal-close').addEventListener('click', closeHelpModal);
988
-
989
- // Close on overlay click (but not modal click)
990
- document.getElementById('help-modal-overlay').addEventListener('click', function(e) {
991
- if (e.target === this) {
992
- closeHelpModal();
993
- }
994
- });
995
-
996
- // Close on Escape key
997
- document.addEventListener('keydown', function(e) {
998
- if (e.key === 'Escape') {
999
- const overlay = document.getElementById('help-modal-overlay');
1000
- if (overlay.classList.contains('visible')) {
1001
- closeHelpModal();
1002
- }
1003
- }
1004
- });
1005
-
1006
- // Listen for system theme changes
1007
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
1008
- if (!localStorage.getItem('theme')) {
1009
- document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
1010
- }
1011
- });
1012
-
1013
- /**
1014
- * Format a relative time string from a date
1015
- * @param {string} dateString - ISO date string
1016
- * @returns {string} Human-readable relative time
1017
- */
1018
- function formatRelativeTime(dateString) {
1019
- const date = new Date(dateString);
1020
- const now = new Date();
1021
- const diffMs = now - date;
1022
- const diffSecs = Math.floor(diffMs / 1000);
1023
- const diffMins = Math.floor(diffSecs / 60);
1024
- const diffHours = Math.floor(diffMins / 60);
1025
- const diffDays = Math.floor(diffHours / 24);
1026
-
1027
- if (diffSecs < 60) {
1028
- return 'Just now';
1029
- } else if (diffMins < 60) {
1030
- return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
1031
- } else if (diffHours < 24) {
1032
- return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
1033
- } else if (diffDays < 7) {
1034
- return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
1035
- } else if (diffDays < 30) {
1036
- const weeks = Math.floor(diffDays / 7);
1037
- return `${weeks} week${weeks !== 1 ? 's' : ''} ago`;
1038
- } else {
1039
- const months = Math.floor(diffDays / 30);
1040
- return `${months} month${months !== 1 ? 's' : ''} ago`;
1041
- }
1042
- }
1043
-
1044
- /**
1045
- * Render a single recent review table row
1046
- * @param {Object} worktree - Worktree data
1047
- * @returns {string} HTML string for the table row
1048
- */
1049
- function renderRecentReviewRow(worktree) {
1050
- const [owner, repo] = worktree.repository.split('/');
1051
- const link = `/pr/${owner}/${repo}/${worktree.pr_number}`;
1052
- const settingsLink = `/repo-settings.html?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
1053
- const relativeTime = formatRelativeTime(worktree.last_accessed_at);
1054
-
1055
- const authorDisplay = worktree.author
1056
- ? `<a href="https://github.com/${encodeURIComponent(worktree.author)}" target="_blank" rel="noopener">${escapeHtml(worktree.author)}</a>`
1057
- : '';
1058
-
1059
- return `
1060
- <tr>
1061
- <td class="col-repo">${worktree.repository}</td>
1062
- <td class="col-pr"><a href="${link}">#${worktree.pr_number}</a></td>
1063
- <td class="col-title" title="${escapeHtml(worktree.pr_title)}">${escapeHtml(worktree.pr_title)}</td>
1064
- <td class="col-author">${authorDisplay}</td>
1065
- <td class="col-time">${relativeTime}</td>
1066
- <td class="col-actions">
1067
- <a
1068
- href="${settingsLink}"
1069
- class="btn-repo-settings"
1070
- title="Repository settings"
1071
- >
1072
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1073
- <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"/>
1074
- </svg>
1075
- </a>
1076
- <button
1077
- class="btn-delete-worktree"
1078
- data-worktree-id="${worktree.id}"
1079
- data-repository="${escapeHtml(worktree.repository)}"
1080
- data-pr-number="${worktree.pr_number}"
1081
- title="Delete worktree"
1082
- >
1083
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1084
- <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>
1085
- </svg>
1086
- </button>
1087
- </td>
1088
- </tr>
1089
- `;
1090
- }
1091
-
1092
- /**
1093
- * Escape HTML special characters
1094
- * @param {string} text - Text to escape
1095
- * @returns {string} Escaped text
1096
- */
1097
- function escapeHtml(text) {
1098
- const div = document.createElement('div');
1099
- div.textContent = text || '';
1100
- return div.innerHTML;
1101
- }
1102
-
1103
- /**
1104
- * Delete a worktree
1105
- * @param {HTMLElement} button - The delete button element
1106
- */
1107
- async function deleteWorktree(button) {
1108
- const worktreeId = button.dataset.worktreeId;
1109
- const repository = button.dataset.repository;
1110
- const prNumber = button.dataset.prNumber;
1111
-
1112
- if (!confirm(`Delete worktree for ${repository} #${prNumber}?`)) {
1113
- return;
1114
- }
1115
-
1116
- try {
1117
- const response = await fetch(`/api/worktrees/${worktreeId}`, {
1118
- method: 'DELETE'
1119
- });
1120
-
1121
- if (!response.ok) {
1122
- const data = await response.json().catch(() => ({}));
1123
- throw new Error(data.error || 'Failed to delete worktree');
1124
- }
1125
-
1126
- // Reload the recent reviews list
1127
- await loadRecentReviews();
1128
-
1129
- } catch (error) {
1130
- console.error('Error deleting worktree:', error);
1131
- alert('Failed to delete worktree: ' + error.message);
1132
- }
1133
- }
1134
-
1135
- // Event delegation for delete buttons and show-more button
1136
- document.addEventListener('click', function(event) {
1137
- const deleteBtn = event.target.closest('.btn-delete-worktree');
1138
- if (deleteBtn) {
1139
- event.preventDefault();
1140
- event.stopPropagation();
1141
- deleteWorktree(deleteBtn);
1142
- return;
1143
- }
1144
-
1145
- const showMoreBtn = event.target.closest('#btn-show-more');
1146
- if (showMoreBtn) {
1147
- event.preventDefault();
1148
- loadMoreReviews();
1149
- }
1150
- });
1151
-
1152
- /** Pagination state for the recent reviews list */
1153
- const recentReviewsPagination = {
1154
- /** ISO timestamp of the last loaded item (cursor for next fetch) */
1155
- lastTimestamp: null,
1156
- /** Number of worktrees to fetch per page */
1157
- pageSize: 10,
1158
- /** Whether the server has indicated more results exist */
1159
- hasMore: false
1160
- };
1161
-
1162
- /**
1163
- * Fetch and display recent reviews (initial load).
1164
- * Resets pagination state and renders the full table from scratch.
1165
- */
1166
- async function loadRecentReviews() {
1167
- const container = document.getElementById('recent-reviews-container');
1168
- const section = document.getElementById('recent-reviews-section');
1169
- const sectionHeader = document.getElementById('recent-reviews-header');
1170
- const usageInfo = document.getElementById('usage-info');
1171
-
1172
- // Reset pagination state
1173
- recentReviewsPagination.lastTimestamp = null;
1174
- recentReviewsPagination.hasMore = false;
1175
-
1176
- try {
1177
- const response = await fetch(`/api/worktrees/recent?limit=${recentReviewsPagination.pageSize}`);
1178
-
1179
- if (!response.ok) {
1180
- throw new Error('Failed to fetch recent reviews');
1181
- }
1182
-
1183
- const data = await response.json();
1184
-
1185
- if (!data.success || !data.worktrees || data.worktrees.length === 0) {
1186
- // Show friendly empty state with usage info
1187
- container.innerHTML = `
1188
- <div class="recent-reviews-empty">
1189
- <p>No reviews yet. Paste a PR URL above to get started.</p>
1190
- </div>
1191
- `;
1192
- container.classList.remove('recent-reviews-loading');
1193
- // Show usage info when no reviews exist
1194
- if (usageInfo) usageInfo.classList.remove('loading-hidden');
1195
- // Keep header hidden for empty state
1196
- return;
1197
- }
1198
-
1199
- // Update pagination state — track the cursor for the next page
1200
- recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
1201
- recentReviewsPagination.hasMore = !!data.hasMore;
1202
-
1203
- // Hide usage info when there are reviews (keep loading-hidden class)
1204
- // Show the section header
1205
- if (sectionHeader) sectionHeader.classList.remove('loading-hidden');
1206
-
1207
- // Render the table of recent reviews
1208
- const html = `
1209
- <table class="recent-reviews-table">
1210
- <thead>
1211
- <tr>
1212
- <th>Repository</th>
1213
- <th>PR</th>
1214
- <th>Title</th>
1215
- <th>Author</th>
1216
- <th>Last Opened</th>
1217
- <th>Actions</th>
1218
- </tr>
1219
- </thead>
1220
- <tbody id="recent-reviews-tbody">
1221
- ${data.worktrees.map(renderRecentReviewRow).join('')}
1222
- </tbody>
1223
- </table>
1224
- ${renderShowMoreButton(data.hasMore)}
1225
- `;
1226
- container.innerHTML = html;
1227
- container.classList.remove('recent-reviews-loading');
1228
-
1229
- } catch (error) {
1230
- console.error('Error loading recent reviews:', error);
1231
- // Hide the section on error, show usage info as fallback
1232
- section.style.display = 'none';
1233
- if (usageInfo) usageInfo.classList.remove('loading-hidden');
1234
- }
1235
- }
1236
-
1237
- /**
1238
- * Render the "Show more" button HTML.
1239
- * @param {boolean} hasMore - Whether more results are available
1240
- * @returns {string} HTML string for the show-more container
1241
- */
1242
- function renderShowMoreButton(hasMore) {
1243
- if (!hasMore) return '';
1244
- return `
1245
- <div class="show-more-container" id="show-more-container">
1246
- <button class="btn-show-more" id="btn-show-more" type="button">
1247
- <span class="btn-show-more-text">Show more</span>
1248
- <span class="spinner"></span>
1249
- </button>
1250
- </div>
1251
- `;
1252
- }
1253
-
1254
- /**
1255
- * Load the next page of worktrees and append them to the existing table.
1256
- * Called when the "Show more" button is clicked.
1257
- */
1258
- async function loadMoreReviews() {
1259
- const btn = document.getElementById('btn-show-more');
1260
- const tbody = document.getElementById('recent-reviews-tbody');
1261
- if (!btn || !tbody) return;
1262
-
1263
- // Show loading state on the button
1264
- btn.classList.add('loading');
1265
- btn.disabled = true;
1266
-
1267
- try {
1268
- const { lastTimestamp, pageSize } = recentReviewsPagination;
1269
- const params = new URLSearchParams({ limit: pageSize });
1270
- if (lastTimestamp) params.set('before', lastTimestamp);
1271
- const response = await fetch(`/api/worktrees/recent?${params}`);
1272
-
1273
- if (!response.ok) {
1274
- throw new Error('Failed to fetch more reviews');
1275
- }
1276
-
1277
- const data = await response.json();
1278
-
1279
- // Guard against stale response if the table was refreshed (e.g. by a delete) while loading
1280
- if (!document.contains(btn)) return;
1281
-
1282
- if (!data.success || !data.worktrees || data.worktrees.length === 0) {
1283
- // No more results - remove the button
1284
- const showMoreContainer = document.getElementById('show-more-container');
1285
- if (showMoreContainer) showMoreContainer.remove();
1286
- recentReviewsPagination.hasMore = false;
1287
- return;
1288
- }
1289
-
1290
- // Append new rows to the existing table body
1291
- tbody.insertAdjacentHTML('beforeend', data.worktrees.map(renderRecentReviewRow).join(''));
1292
-
1293
- // Update pagination state — advance the cursor
1294
- recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
1295
- recentReviewsPagination.hasMore = !!data.hasMore;
1296
-
1297
- // Update or remove the "Show more" button
1298
- if (!data.hasMore) {
1299
- const showMoreContainer = document.getElementById('show-more-container');
1300
- if (showMoreContainer) showMoreContainer.remove();
1301
- } else {
1302
- // Reset button state
1303
- btn.classList.remove('loading');
1304
- btn.disabled = false;
1305
- }
1306
-
1307
- } catch (error) {
1308
- console.error('Error loading more reviews:', error);
1309
- // Reset button state and show error so the user knows what happened
1310
- btn.classList.remove('loading');
1311
- btn.disabled = false;
1312
- const textEl = btn.querySelector('.btn-show-more-text');
1313
- if (textEl) {
1314
- textEl.textContent = 'Failed to load — click to retry';
1315
- // Restore original text after a brief delay so the user sees the error
1316
- setTimeout(() => { textEl.textContent = 'Show more'; }, 4000);
1317
- }
1318
- }
1319
- }
1320
-
1321
- /**
1322
- * Parse a PR URL using the backend API
1323
- * Supports GitHub and Graphite URLs (with or without protocol)
1324
- * @param {string} url - The PR URL to parse
1325
- * @returns {Promise<Object|null>} { owner, repo, prNumber } or null if invalid
1326
- */
1327
- async function parsePRUrl(url) {
1328
- if (!url || typeof url !== 'string') {
1329
- return null;
1330
- }
1331
-
1332
- try {
1333
- const response = await fetch('/api/parse-pr-url', {
1334
- method: 'POST',
1335
- headers: {
1336
- 'Content-Type': 'application/json'
1337
- },
1338
- body: JSON.stringify({ url: url.trim() })
1339
- });
1340
-
1341
- const data = await response.json();
1342
-
1343
- if (data.valid) {
1344
- return {
1345
- owner: data.owner,
1346
- repo: data.repo,
1347
- prNumber: data.prNumber
1348
- };
1349
- }
1350
-
1351
- return null;
1352
- } catch (e) {
1353
- console.error('Error parsing PR URL:', e);
1354
- return null;
1355
- }
1356
- }
1357
-
1358
- /**
1359
- * Set loading state for the start review form
1360
- * @param {boolean} loading - Whether to show loading state
1361
- * @param {string} text - Optional loading text
1362
- */
1363
- function setStartReviewLoading(loading, text = 'Creating worktree and fetching PR data...') {
1364
- const form = document.getElementById('start-review-form');
1365
- const input = document.getElementById('pr-url-input');
1366
- const btn = document.getElementById('start-review-btn');
1367
- const loadingEl = document.getElementById('start-review-loading');
1368
- const loadingText = document.getElementById('start-review-loading-text');
1369
- const errorEl = document.getElementById('start-review-error');
1370
-
1371
- if (loading) {
1372
- input.disabled = true;
1373
- btn.disabled = true;
1374
- btn.textContent = 'Starting...';
1375
- loadingEl.classList.add('visible');
1376
- loadingText.textContent = text;
1377
- errorEl.classList.remove('visible');
1378
- } else {
1379
- input.disabled = false;
1380
- btn.disabled = false;
1381
- btn.textContent = 'Start Review';
1382
- loadingEl.classList.remove('visible');
1383
- }
1384
- }
1385
-
1386
- /**
1387
- * Show error message for the start review form
1388
- * @param {string} message - Error message to display
1389
- */
1390
- function showStartReviewError(message) {
1391
- const errorEl = document.getElementById('start-review-error');
1392
- errorEl.textContent = message;
1393
- errorEl.classList.add('visible');
1394
- }
1395
-
1396
- /**
1397
- * Handle start review form submission
1398
- * @param {Event} event - Form submit event
1399
- */
1400
- async function handleStartReview(event) {
1401
- event.preventDefault();
1402
-
1403
- const input = document.getElementById('pr-url-input');
1404
- const url = input.value.trim();
1405
-
1406
- // Clear previous errors
1407
- const errorEl = document.getElementById('start-review-error');
1408
- errorEl.classList.remove('visible');
1409
-
1410
- // Validate input
1411
- if (!url) {
1412
- showStartReviewError('Please enter a GitHub PR URL');
1413
- input.focus();
1414
- return;
1415
- }
1416
-
1417
- // Show loading state while parsing
1418
- setStartReviewLoading(true, 'Validating PR URL...');
1419
-
1420
- // Parse the URL using the backend API
1421
- const parsed = await parsePRUrl(url);
1422
- if (!parsed) {
1423
- setStartReviewLoading(false);
1424
- showStartReviewError('Invalid PR URL. Please enter a GitHub or Graphite PR URL (e.g., https://github.com/owner/repo/pull/123)');
1425
- input.focus();
1426
- return;
1427
- }
1428
-
1429
- // Update loading state
1430
- setStartReviewLoading(true, 'Fetching PR data from GitHub...');
1431
-
1432
- try {
1433
- // Call the API to create the worktree
1434
- const response = await fetch('/api/worktrees/create', {
1435
- method: 'POST',
1436
- headers: {
1437
- 'Content-Type': 'application/json'
1438
- },
1439
- body: JSON.stringify({
1440
- owner: parsed.owner,
1441
- repo: parsed.repo,
1442
- prNumber: parsed.prNumber
1443
- })
1444
- });
1445
-
1446
- const data = await response.json();
1447
-
1448
- if (!response.ok) {
1449
- throw new Error(data.error || 'Failed to create worktree');
1450
- }
1451
-
1452
- if (!data.success) {
1453
- throw new Error(data.error || 'Failed to create worktree');
1454
- }
1455
-
1456
- // Update loading text before redirect
1457
- setStartReviewLoading(true, 'Redirecting to review...');
1458
-
1459
- // Redirect to the review page
1460
- window.location.href = data.reviewUrl;
1461
-
1462
- } catch (error) {
1463
- console.error('Error starting review:', error);
1464
- setStartReviewLoading(false);
1465
- showStartReviewError(error.message || 'An unexpected error occurred. Please try again.');
1466
- }
1467
- }
1468
-
1469
- /**
1470
- * Update command examples based on whether running via npx or installed
1471
- * @param {boolean} isNpx - True if running via npx
1472
- */
1473
- function updateCommandExamples(isNpx) {
1474
- const baseCmd = isNpx ? 'npx @in-the-loop-labs/pair-review' : 'pair-review';
1475
- const cmdExamples = document.querySelectorAll('.cmd-example');
1476
- cmdExamples.forEach(el => {
1477
- const args = el.dataset.args || '';
1478
- el.textContent = args ? `${baseCmd} ${args}` : baseCmd;
1479
- });
1480
- }
1481
-
1482
- /**
1483
- * Fetch config from server and update UI accordingly
1484
- */
1485
- async function loadConfigAndUpdateUI() {
1486
- try {
1487
- const response = await fetch('/api/config');
1488
- if (response.ok) {
1489
- const config = await response.json();
1490
- updateCommandExamples(config.is_running_via_npx);
1491
- } else {
1492
- // Fallback: assume installed (shorter command)
1493
- updateCommandExamples(false);
1494
- }
1495
- } catch (error) {
1496
- console.error('Error loading config:', error);
1497
- // Fallback: assume installed (shorter command)
1498
- updateCommandExamples(false);
1499
- }
1500
- }
1501
-
1502
- // Load recent reviews when the page loads
1503
- document.addEventListener('DOMContentLoaded', function() {
1504
- // Load config and update command examples based on npx detection
1505
- loadConfigAndUpdateUI().then(() => {
1506
- // Sync help content to usage-info section AFTER command examples are updated
1507
- const helpContent = document.querySelector('.help-modal-content');
1508
- const usageInfo = document.getElementById('usage-info');
1509
- if (helpContent && usageInfo) {
1510
- // Clear any existing content
1511
- usageInfo.innerHTML = '';
1512
- // Clone the content nodes safely
1513
- Array.from(helpContent.childNodes).forEach(node => {
1514
- usageInfo.appendChild(node.cloneNode(true));
1515
- });
1516
- }
1517
- });
1518
-
1519
- loadRecentReviews();
1520
-
1521
- // Set up start review form handler
1522
- const form = document.getElementById('start-review-form');
1523
- if (form) {
1524
- form.addEventListener('submit', handleStartReview);
1525
- }
1526
-
1527
- // Allow Enter key to submit
1528
- const input = document.getElementById('pr-url-input');
1529
- if (input) {
1530
- input.addEventListener('keypress', function(e) {
1531
- if (e.key === 'Enter') {
1532
- form.dispatchEvent(new Event('submit'));
1533
- }
1534
- });
1535
- }
1536
- });
1537
- </script>
1184
+ <script src="/js/index.js"></script>
1538
1185
  </body>
1539
1186
  </html>