@in-the-loop-labs/pair-review 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
package/public/css/pr.css CHANGED
@@ -99,6 +99,50 @@
99
99
  color: var(--color-text-tertiary);
100
100
  }
101
101
 
102
+ /* Stale Badge */
103
+ .stale-badge {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ gap: 5px;
107
+ padding: 3px 10px;
108
+ border-radius: 12px;
109
+ font-size: 11px;
110
+ font-weight: 700;
111
+ letter-spacing: 0.5px;
112
+ color: #fff;
113
+ background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
114
+ box-shadow: 0 2px 6px rgba(217, 119, 6, 0.35);
115
+ transition: opacity 0.15s ease, transform 0.15s ease;
116
+ white-space: nowrap;
117
+ margin-left: 8px;
118
+ }
119
+
120
+
121
+ .stale-badge.pr-closed {
122
+ background: linear-gradient(135deg, #cf222e 0%, #a40e26 100%);
123
+ box-shadow: 0 2px 6px rgba(207, 34, 46, 0.35);
124
+ }
125
+
126
+ .stale-badge.pr-merged {
127
+ background: linear-gradient(135deg, #8250df 0%, #6639ba 100%);
128
+ box-shadow: 0 2px 6px rgba(130, 80, 223, 0.35);
129
+ }
130
+
131
+ [data-theme="dark"] .stale-badge {
132
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
133
+ box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4);
134
+ }
135
+
136
+ [data-theme="dark"] .stale-badge.pr-closed {
137
+ background: linear-gradient(135deg, #f85149 0%, #cf222e 100%);
138
+ box-shadow: 0 2px 8px rgba(248, 81, 73, 0.4);
139
+ }
140
+
141
+ [data-theme="dark"] .stale-badge.pr-merged {
142
+ background: linear-gradient(135deg, #a371f7 0%, #8250df 100%);
143
+ box-shadow: 0 2px 8px rgba(163, 113, 247, 0.4);
144
+ }
145
+
102
146
  .pr-title .github-link {
103
147
  display: inline-flex;
104
148
  align-items: center;
@@ -514,7 +558,7 @@
514
558
  /* Theme toggle button specific styling */
515
559
  #theme-toggle {
516
560
  background-color: transparent;
517
- border: 1px solid var(--color-border-primary);
561
+ border: 1px solid transparent;
518
562
  color: var(--color-text-primary);
519
563
  padding: 6px 10px;
520
564
  display: inline-flex;
@@ -524,6 +568,7 @@
524
568
 
525
569
  #theme-toggle:hover {
526
570
  background-color: var(--color-bg-secondary);
571
+ border-color: var(--color-border-primary);
527
572
  }
528
573
 
529
574
  #theme-toggle svg {
@@ -6078,15 +6123,25 @@ body::before {
6078
6123
  justify-content: center;
6079
6124
  }
6080
6125
 
6081
- /* Icon buttons within the icon group have special styling (no border, transparent bg)
6082
- This selector is more specific than .header .btn-icon to override properly */
6083
- .header-icon-group .btn-icon {
6084
- border: none;
6126
+ /* Icon buttons within the icon group have special styling (no border, transparent bg).
6127
+ Uses high-specificity selector to override the generic .btn-icon rule (council selector)
6128
+ and the [data-theme="dark"] .btn-icon dark-mode override.
6129
+ NOTE: ID-based selectors (#theme-toggle, #refresh-pr) beat this rule regardless of
6130
+ how many classes are stacked, so those rules must also set border to transparent. */
6131
+ [data-theme] .header-icon-group .btn.btn-icon {
6132
+ border: 1px solid transparent;
6133
+ border-color: transparent;
6085
6134
  background: transparent;
6135
+ color: var(--color-text-tertiary);
6136
+ width: 36px;
6137
+ height: 36px;
6138
+ box-sizing: border-box;
6086
6139
  }
6087
6140
 
6088
- .header-icon-group .btn-icon:hover {
6141
+ [data-theme] .header-icon-group .btn.btn-icon:hover {
6089
6142
  background: var(--color-bg-tertiary);
6143
+ border-color: var(--color-border-primary);
6144
+ color: var(--color-text-primary);
6090
6145
  }
6091
6146
 
6092
6147
  .header .btn-secondary {
@@ -6111,27 +6166,33 @@ body::before {
6111
6166
  box-shadow: 0 0 12px var(--ai-glow);
6112
6167
  }
6113
6168
 
6114
- /* Theme toggle in header */
6169
+ /* Theme toggle in header — border is transparent at rest, visible on hover.
6170
+ Must set border-color explicitly here because this ID selector beats the
6171
+ class-based .header-icon-group .btn.btn-icon:hover rule in specificity. */
6115
6172
  .header #theme-toggle {
6116
6173
  background: transparent;
6117
- border: 1px solid var(--color-border-primary);
6174
+ border: 1px solid transparent;
6118
6175
  color: var(--color-text-secondary);
6119
6176
  }
6120
6177
 
6121
6178
  .header #theme-toggle:hover {
6122
6179
  background: var(--color-bg-secondary);
6180
+ border-color: var(--color-border-primary);
6123
6181
  color: var(--color-text-primary);
6124
6182
  }
6125
6183
 
6126
- /* Refresh button in header */
6184
+ /* Refresh button in header — border is transparent at rest, visible on hover.
6185
+ Must set border-color explicitly here because this ID selector beats the
6186
+ class-based .header-icon-group .btn.btn-icon:hover rule in specificity. */
6127
6187
  .header #refresh-pr {
6128
6188
  background: transparent;
6129
- border: 1px solid var(--color-border-primary);
6189
+ border: 1px solid transparent;
6130
6190
  color: var(--color-text-secondary);
6131
6191
  }
6132
6192
 
6133
6193
  .header #refresh-pr:hover {
6134
6194
  background: var(--color-bg-secondary);
6195
+ border-color: var(--color-border-primary);
6135
6196
  color: var(--color-text-primary);
6136
6197
  }
6137
6198
 
package/public/index.html CHANGED
@@ -7,6 +7,13 @@
7
7
  const savedTheme = localStorage.getItem('theme') || 'light';
8
8
  document.documentElement.setAttribute('data-theme', savedTheme);
9
9
  })();
10
+ window.__pairReview = window.__pairReview || {};
11
+ window.__pairReview.toGraphiteUrl = function(githubUrl) {
12
+ return githubUrl.replace(
13
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
14
+ 'https://app.graphite.com/github/pr/$1/$2/$3'
15
+ );
16
+ };
10
17
  </script>
11
18
  <meta charset="UTF-8">
12
19
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -177,6 +184,14 @@
177
184
  color: var(--color-text-primary);
178
185
  }
179
186
 
187
+ .app-version {
188
+ color: var(--color-fg-muted, #656d76);
189
+ font-size: 12px;
190
+ font-weight: 400;
191
+ margin-left: 6px;
192
+ opacity: 0.7;
193
+ }
194
+
180
195
  .header-right {
181
196
  display: flex;
182
197
  align-items: center;
@@ -1089,6 +1104,7 @@
1089
1104
  <path transform="rotate(-50 12 12)" d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.356-8-5.096 0-5.096 8 0 8 5.223 0 7.26-8 12.356-8z"/>
1090
1105
  </svg>
1091
1106
  <span class="logo-text">pair<span class="logo-accent">review</span></span>
1107
+ <span id="app-version" class="app-version"></span>
1092
1108
  </a>
1093
1109
  </div>
1094
1110
  <div class="header-right">
@@ -313,6 +313,16 @@
313
313
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>' +
314
314
  '</a>';
315
315
 
316
+ var graphiteLinkHtml = '';
317
+ if (window.__pairReview?.enableGraphite && pr.html_url) {
318
+ // Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
319
+ var graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
320
+ graphiteLinkHtml =
321
+ '<a href="' + escapeHtml(graphiteUrl) + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on Graphite">' +
322
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.7932,1.3079L3.101,3.101l-1.7932,6.6921,4.899,4.899,6.6921-1.7931,1.7932-6.6921L9.7932,1.3079Zm1.0936,11.6921H5.1133l-2.8867-5L5.1133,3h5.7735l2.8867,5-2.8867,5Z"/><polygon points="11.3504 4.6496 6.7737 3.4232 3.4232 6.7737 4.6496 11.3504 9.2263 12.5768 12.5768 9.2263 11.3504 4.6496"/></svg>' +
323
+ '</a>';
324
+ }
325
+
316
326
  var authorTd = collection === 'my-prs'
317
327
  ? ''
318
328
  : '<td class="col-author">' + authorDisplay + '</td>';
@@ -324,7 +334,7 @@
324
334
  '<td class="col-title" title="' + escapeHtml(pr.title || '') + '">' + escapeHtml(pr.title || '') + '</td>' +
325
335
  authorTd +
326
336
  '<td class="col-time">' + relativeTime + '</td>' +
327
- '<td class="col-actions">' + githubLinkHtml + '</td>' +
337
+ '<td class="col-actions">' + githubLinkHtml + graphiteLinkHtml + '</td>' +
328
338
  '</tr>';
329
339
  }
330
340
 
@@ -750,6 +760,11 @@
750
760
  '<a href="https://github.com/' + escapeHtml(worktree.repository) + '/pull/' + worktree.pr_number + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on GitHub">' +
751
761
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>' +
752
762
  '</a>' +
763
+ (window.__pairReview?.enableGraphite && worktree.html_url
764
+ ? '<a href="' + escapeHtml(window.__pairReview.toGraphiteUrl(worktree.html_url)) + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on Graphite">' +
765
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.7932,1.3079L3.101,3.101l-1.7932,6.6921,4.899,4.899,6.6921-1.7931,1.7932-6.6921L9.7932,1.3079Zm1.0936,11.6921H5.1133l-2.8867-5L5.1133,3h5.7735l2.8867,5-2.8867,5Z"/><polygon points="11.3504 4.6496 6.7737 3.4232 3.4232 6.7737 4.6496 11.3504 9.2263 12.5768 12.5768 9.2263 11.3504 4.6496"/></svg>' +
766
+ '</a>'
767
+ : '') +
753
768
  '<a href="' + settingsLink + '" class="btn-repo-settings" title="Repository settings">' +
754
769
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
755
770
  '<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"/>' +
@@ -1092,11 +1107,18 @@
1092
1107
  const config = await response.json();
1093
1108
  updateCommandExamples(config.is_running_via_npx);
1094
1109
 
1110
+ // Display version in header
1111
+ if (config.version) {
1112
+ const versionEl = document.getElementById('app-version');
1113
+ if (versionEl) versionEl.textContent = 'v' + config.version;
1114
+ }
1115
+
1095
1116
  // Expose chat provider config to components (ChatPanel reads these)
1096
1117
  window.__pairReview = window.__pairReview || {};
1097
1118
  window.__pairReview.chatProvider = config.chat_provider || 'pi';
1098
1119
  const chatProviders = config.chat_providers || [];
1099
1120
  window.__pairReview.chatProviders = chatProviders;
1121
+ window.__pairReview.enableGraphite = config.enable_graphite === true;
1100
1122
 
1101
1123
  // Set chat feature state based on config and provider availability
1102
1124
  let chatState = 'disabled';
@@ -1226,9 +1248,11 @@
1226
1248
 
1227
1249
  // ─── DOMContentLoaded Initialization ────────────────────────────────────────
1228
1250
 
1229
- document.addEventListener('DOMContentLoaded', function () {
1230
- // Load config and update command examples based on npx detection
1231
- loadConfigAndUpdateUI();
1251
+ document.addEventListener('DOMContentLoaded', async function () {
1252
+ // Load config and update command examples based on npx detection.
1253
+ // Await so that window.__pairReview.enableGraphite (and other config
1254
+ // values) are available before any tab content is rendered.
1255
+ await loadConfigAndUpdateUI();
1232
1256
 
1233
1257
  // Restore saved tab from localStorage (default: 'pr-tab')
1234
1258
  const savedTab = localStorage.getItem(TAB_STORAGE_KEY) || 'pr-tab';
@@ -588,6 +588,12 @@ class LocalManager {
588
588
  githubLink.style.display = 'none';
589
589
  }
590
590
 
591
+ // Hide Graphite link (no PR to link to in local mode)
592
+ const graphiteLink = document.getElementById('graphite-link');
593
+ if (graphiteLink) {
594
+ graphiteLink.style.display = 'none';
595
+ }
596
+
591
597
  // Hide refresh button (no remote to refresh from)
592
598
  const refreshBtn = document.getElementById('refresh-pr');
593
599
  if (refreshBtn) {
package/public/js/pr.js CHANGED
@@ -136,6 +136,8 @@ class PRManager {
136
136
  this.hideWhitespace = false;
137
137
  // Diff options dropdown (gear icon popover)
138
138
  this.diffOptionsDropdown = null;
139
+ // Cached staleness check promise — shared between on-load and triggerAIAnalysis
140
+ this._stalenessPromise = null;
139
141
  // Unique client ID for self-echo suppression on WebSocket review events.
140
142
  // Sent as X-Client-Id header on mutation requests; the server echoes
141
143
  // it back in the WebSocket broadcast so this tab can skip its own events.
@@ -511,6 +513,9 @@ class PRManager {
511
513
  // Listen for review mutation events via WebSocket pub/sub
512
514
  this._initReviewEventListeners();
513
515
 
516
+ // Fire-and-forget staleness check — shows badge or auto-refreshes
517
+ this._stalenessPromise = this._checkStalenessOnLoad(owner, repo, number);
518
+
514
519
  } catch (error) {
515
520
  console.error('Error loading PR:', error);
516
521
  this.showError(error.message);
@@ -902,6 +907,15 @@ class PRManager {
902
907
  githubLink.href = pr.html_url;
903
908
  }
904
909
 
910
+ // Update Graphite link (gated on enable_graphite config)
911
+ const graphiteLink = document.getElementById('graphite-link');
912
+ if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite) {
913
+ // Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
914
+ const graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
915
+ graphiteLink.href = graphiteUrl;
916
+ graphiteLink.style.display = '';
917
+ }
918
+
905
919
  // Update settings link
906
920
  const settingsLink = document.getElementById('settings-link');
907
921
  if (settingsLink && pr.owner && pr.repo) {
@@ -4080,19 +4094,13 @@ class PRManager {
4080
4094
  return;
4081
4095
  }
4082
4096
 
4083
- // Run stale check and settings fetch in parallel to minimize dialog delay
4084
- // Use AbortController so the fetch is truly cancelled on timeout,
4085
- // freeing the HTTP connection for subsequent requests.
4097
+ // Run stale check and settings fetch in parallel to minimize dialog delay.
4098
+ // Reuse the on-load staleness promise if still available, otherwise fetch fresh.
4086
4099
  const _tParallel0 = performance.now();
4087
- const staleAbort = new AbortController();
4088
- const staleTimer = setTimeout(() => {
4089
- console.debug(`[Analyze] stale-check timed out after ${STALE_TIMEOUT}ms, aborting`);
4090
- staleAbort.abort();
4091
- }, STALE_TIMEOUT);
4092
- const staleCheckWithTimeout = fetch(`/api/pr/${owner}/${repo}/${number}/check-stale`, { signal: staleAbort.signal })
4093
- .then(r => r.ok ? r.json() : null)
4094
- .then(result => { clearTimeout(staleTimer); return result; })
4095
- .catch(() => { clearTimeout(staleTimer); return null; });
4100
+ const staleCheckWithTimeout = this._stalenessPromise
4101
+ ? this._stalenessPromise
4102
+ : this._fetchStaleness(owner, repo, number);
4103
+ this._stalenessPromise = null; // consume it
4096
4104
 
4097
4105
  const [staleResult, repoSettings, reviewSettings] = await Promise.all([
4098
4106
  staleCheckWithTimeout,
@@ -4330,6 +4338,127 @@ class PRManager {
4330
4338
  this.resetButton();
4331
4339
  }
4332
4340
 
4341
+ // ─── Staleness Badge ────────────────────────────────────────────
4342
+
4343
+ /**
4344
+ * Fire-and-forget staleness check on page load.
4345
+ * If stale and no active session data, silently refreshes.
4346
+ * If stale and session data exists, shows the badge.
4347
+ * Also shows badge variants for closed/merged PRs.
4348
+ * @returns {Promise<Object|null>} The parsed staleness result, or null on failure.
4349
+ */
4350
+ async _checkStalenessOnLoad(owner, repo, number) {
4351
+ try {
4352
+ const result = await this._fetchStaleness(owner, repo, number);
4353
+ if (!result) return null;
4354
+
4355
+ // Show badge for closed/merged PRs regardless of staleness
4356
+ if (result.prState && result.prState !== 'open') {
4357
+ const type = result.merged ? 'merged' : 'closed';
4358
+ this._showStaleBadge(type);
4359
+ return result;
4360
+ }
4361
+
4362
+ if (result.isStale !== true) return result;
4363
+
4364
+ // Stale — decide: silent refresh or show badge
4365
+ const hasData = await this._hasActiveSessionData();
4366
+ if (hasData) {
4367
+ this._showStaleBadge('stale');
4368
+ } else {
4369
+ // No user work to protect — refresh silently
4370
+ await this.refreshPR();
4371
+ }
4372
+ return result;
4373
+ } catch {
4374
+ // Fail silently — staleness badge is best-effort
4375
+ return null;
4376
+ }
4377
+ }
4378
+
4379
+ /**
4380
+ * Fetch staleness data from the server with a timeout.
4381
+ * @returns {Promise<Object|null>} The parsed staleness result, or null on failure/timeout.
4382
+ */
4383
+ async _fetchStaleness(owner, repo, number) {
4384
+ try {
4385
+ const staleAbort = new AbortController();
4386
+ const staleTimer = setTimeout(() => staleAbort.abort(), STALE_TIMEOUT);
4387
+ const response = await fetch(
4388
+ `/api/pr/${owner}/${repo}/${number}/check-stale`,
4389
+ { signal: staleAbort.signal }
4390
+ );
4391
+ clearTimeout(staleTimer);
4392
+ if (!response.ok) return null;
4393
+ return await response.json();
4394
+ } catch {
4395
+ return null;
4396
+ }
4397
+ }
4398
+
4399
+ /**
4400
+ * Check whether the current session has user work worth protecting
4401
+ * (analysis results or active user comments).
4402
+ * Returns true if uncertain (fail-safe: don't auto-refresh).
4403
+ */
4404
+ async _hasActiveSessionData() {
4405
+ const reviewId = this.currentPR?.id;
4406
+ // No review record → no session data possible
4407
+ if (!reviewId) return false;
4408
+
4409
+ try {
4410
+ const [suggestionsRes, commentsRes] = await Promise.all([
4411
+ fetch(`/api/reviews/${reviewId}/suggestions/check`).then(r => r.ok ? r.json() : null),
4412
+ fetch(`/api/reviews/${reviewId}/comments`).then(r => r.ok ? r.json() : null)
4413
+ ]);
4414
+
4415
+ const hasAnalysis = suggestionsRes?.analysisHasRun === true;
4416
+ const hasUserComments = (commentsRes?.comments || []).some(
4417
+ c => c.source === 'user' && c.status !== 'inactive'
4418
+ );
4419
+
4420
+ return hasAnalysis || hasUserComments;
4421
+ } catch {
4422
+ // Uncertain — fail safe
4423
+ return true;
4424
+ }
4425
+ }
4426
+
4427
+ /**
4428
+ * Show the stale badge with an optional variant class.
4429
+ * @param {'stale'|'closed'|'merged'} type
4430
+ */
4431
+ _showStaleBadge(type) {
4432
+ const badge = document.getElementById('stale-badge');
4433
+ if (!badge) return;
4434
+
4435
+ // Reset variant classes
4436
+ badge.classList.remove('pr-closed', 'pr-merged');
4437
+
4438
+ const textEl = badge.querySelector('.stale-badge-text');
4439
+ if (type === 'merged') {
4440
+ badge.classList.add('pr-merged');
4441
+ if (textEl) textEl.textContent = 'MERGED';
4442
+ badge.title = 'This PR has been merged';
4443
+ } else if (type === 'closed') {
4444
+ badge.classList.add('pr-closed');
4445
+ if (textEl) textEl.textContent = 'CLOSED';
4446
+ badge.title = 'This PR has been closed';
4447
+ } else {
4448
+ if (textEl) textEl.textContent = 'STALE';
4449
+ badge.title = 'PR data is outdated';
4450
+ }
4451
+ badge.style.display = '';
4452
+ }
4453
+
4454
+ /**
4455
+ * Hide the stale badge.
4456
+ */
4457
+ _hideStaleBadge() {
4458
+ const badge = document.getElementById('stale-badge');
4459
+ if (badge) badge.style.display = 'none';
4460
+ }
4461
+
4333
4462
  /**
4334
4463
  * Refresh the PR data
4335
4464
  */
@@ -4397,6 +4526,8 @@ class PRManager {
4397
4526
  window.scrollTo(0, scrollPosition);
4398
4527
  }, 100);
4399
4528
 
4529
+ this._hideStaleBadge();
4530
+ this._stalenessPromise = null;
4400
4531
  console.log('PR refreshed successfully');
4401
4532
  }
4402
4533
  } catch (error) {
package/public/pr.html CHANGED
@@ -17,9 +17,16 @@
17
17
  state = anyAvailable ? 'available' : 'unavailable';
18
18
  }
19
19
  window.__pairReview = window.__pairReview || {};
20
+ window.__pairReview.toGraphiteUrl = function(githubUrl) {
21
+ return githubUrl.replace(
22
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
23
+ 'https://app.graphite.com/github/pr/$1/$2/$3'
24
+ );
25
+ };
20
26
  window.__pairReview.chatProvider = config.chat_provider || 'pi';
21
27
  window.__pairReview.chatProviders = chatProviders;
22
28
  window.__pairReview.share = config.share || null;
29
+ window.__pairReview.enableGraphite = config.enable_graphite === true;
23
30
  document.documentElement.setAttribute('data-chat', state);
24
31
  const shortcutsState = config.chat_enable_shortcuts === false ? 'disabled' : 'enabled';
25
32
  document.documentElement.setAttribute('data-chat-shortcuts', shortcutsState);
@@ -91,6 +98,9 @@
91
98
  <span class="breadcrumb-repo">--</span>
92
99
  <span class="breadcrumb-pr">#--</span>
93
100
  </div>
101
+ <span id="stale-badge" class="stale-badge" style="display: none" title="New version available">
102
+ <span class="stale-badge-text">STALE</span>
103
+ </span>
94
104
  </div>
95
105
  <div class="header-center">
96
106
  <div class="pr-title-wrapper">
@@ -131,6 +141,12 @@
131
141
  <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/>
132
142
  </svg>
133
143
  </a>
144
+ <a class="btn btn-icon" id="graphite-link" href="#" target="_blank" title="View on Graphite" aria-label="View on Graphite" style="display: none;">
145
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16">
146
+ <path d="M9.7932,1.3079L3.101,3.101l-1.7932,6.6921,4.899,4.899,6.6921-1.7931,1.7932-6.6921L9.7932,1.3079Zm1.0936,11.6921H5.1133l-2.8867-5L5.1133,3h5.7735l2.8867,5-2.8867,5Z"/>
147
+ <polygon points="11.3504 4.6496 6.7737 3.4232 3.4232 6.7737 4.6496 11.3504 9.2263 12.5768 12.5768 9.2263 11.3504 4.6496"/>
148
+ </svg>
149
+ </a>
134
150
  </div>
135
151
  <div id="split-button-placeholder"></div>
136
152
  </div>
package/public/setup.html CHANGED
@@ -137,6 +137,14 @@
137
137
  color: var(--color-text-primary);
138
138
  }
139
139
 
140
+ .app-version {
141
+ color: var(--color-fg-muted, #656d76);
142
+ font-size: 12px;
143
+ font-weight: 400;
144
+ margin-left: 6px;
145
+ opacity: 0.7;
146
+ }
147
+
140
148
  .setup-target {
141
149
  font-family: var(--font-mono);
142
150
  font-size: 15px;
@@ -463,6 +471,7 @@
463
471
  <path transform="rotate(-50 12 12)" d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.356-8-5.096 0-5.096 8 0 8 5.223 0 7.26-8 12.356-8z"/>
464
472
  </svg>
465
473
  <span class="logo-text">pair<span class="logo-accent">review</span></span>
474
+ <span id="app-version" class="app-version"></span>
466
475
  </a>
467
476
  <div class="setup-target" id="setup-target"></div>
468
477
  <div class="setup-subtitle">Setting up review</div>
@@ -839,6 +848,16 @@
839
848
  });
840
849
  }
841
850
 
851
+ /* ── Display Version ── */
852
+ fetch('/api/config').then(function(r) {
853
+ return r.ok ? r.json() : null;
854
+ }).then(function(config) {
855
+ if (config && config.version) {
856
+ var versionEl = document.getElementById('app-version');
857
+ if (versionEl) versionEl.textContent = 'v' + config.version;
858
+ }
859
+ }).catch(function() { /* non-critical */ });
860
+
842
861
  /* ── Kick Off ── */
843
862
  startSetup();
844
863
  })();
package/src/config.js CHANGED
@@ -34,7 +34,8 @@ const DEFAULT_CONFIG = {
34
34
  providers: {}, // Custom AI analysis provider configurations (overrides built-in defaults)
35
35
  chat_providers: {}, // Custom chat provider configurations (overrides built-in defaults)
36
36
  monorepos: {}, // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
37
- assisted_by_url: "https://github.com/in-the-loop-labs/pair-review" // URL for "Review assisted by" footer link
37
+ assisted_by_url: "https://github.com/in-the-loop-labs/pair-review", // URL for "Review assisted by" footer link
38
+ enable_graphite: false // When true, shows Graphite links alongside GitHub links
38
39
  };
39
40
 
40
41
  /**
@@ -185,22 +186,26 @@ async function loadConfig() {
185
186
 
186
187
  let mergedConfig = { ...DEFAULT_CONFIG };
187
188
  let isFirstRun = false;
189
+ let hasManagedConfig = false;
188
190
 
189
191
  for (const source of sources) {
190
192
  try {
191
193
  const data = await fs.readFile(source.path, 'utf8');
192
194
  const parsed = JSON.parse(data);
195
+ if (source.label === 'managed config' && Object.keys(parsed).length > 0) {
196
+ hasManagedConfig = true;
197
+ }
193
198
  mergedConfig = deepMerge(mergedConfig, parsed);
194
199
  } catch (error) {
195
200
  if (error.code === 'ENOENT') {
196
- if (source.required) {
201
+ if (source.required && !hasManagedConfig) {
197
202
  // Global config doesn't exist — create it with defaults
198
203
  const config = { ...DEFAULT_CONFIG };
199
204
  await saveConfig(config);
200
205
  logger.debug(`Created default config file: ${CONFIG_FILE}`);
201
206
  isFirstRun = true;
202
207
  }
203
- // Optional files: skip silently
208
+ // Optional files or managed-config-present: skip silently
204
209
  } else if (error instanceof SyntaxError) {
205
210
  if (source.required) {
206
211
  console.error(`Invalid configuration file at ~/.pair-review/config.json`);
@@ -626,6 +626,29 @@ class GitWorktreeManager {
626
626
  }
627
627
  }
628
628
 
629
+ /**
630
+ * Resolve the owning git repository for a worktree path.
631
+ * Uses `git rev-parse --git-common-dir` to find the main repo,
632
+ * which works even when worktrees are outside the parent repo directory.
633
+ * @param {string} worktreePath - Path to a worktree
634
+ * @returns {Promise<import('simple-git').SimpleGit|null>} simpleGit instance for the owning repo, or null
635
+ */
636
+ async resolveOwningRepo(worktreePath) {
637
+ try {
638
+ const git = simpleGit(worktreePath);
639
+ const commonDir = (await git.raw(['rev-parse', '--git-common-dir'])).trim();
640
+ // commonDir is either a .git subdirectory (regular repos) or the bare repo root itself.
641
+ // Only strip the last component when it's actually a .git directory.
642
+ const resolvedCommonDir = path.resolve(worktreePath, commonDir);
643
+ const repoRoot = path.basename(resolvedCommonDir) === '.git'
644
+ ? path.dirname(resolvedCommonDir)
645
+ : resolvedCommonDir;
646
+ return simpleGit(repoRoot);
647
+ } catch {
648
+ return null;
649
+ }
650
+ }
651
+
629
652
  /**
630
653
  * Cleanup a specific worktree
631
654
  * @param {string} worktreePath - Path to worktree to cleanup
@@ -634,27 +657,30 @@ class GitWorktreeManager {
634
657
  async cleanupWorktree(worktreePath) {
635
658
  try {
636
659
  // First try to prune any stale worktree registrations
637
- await this.pruneWorktrees();
638
-
660
+ await this.pruneWorktrees(worktreePath);
661
+
639
662
  // Check if worktree exists
640
663
  const exists = await this.pathExists(worktreePath);
641
-
642
- // Try to remove via git worktree remove first (handles both directory and registration)
643
- try {
644
- const parentGit = simpleGit(path.dirname(worktreePath));
645
- await parentGit.raw(['worktree', 'remove', '--force', worktreePath]);
646
- console.log(`Removed worktree via git: ${worktreePath}`);
647
- return;
648
- } catch (gitError) {
649
- console.log('Git worktree remove failed, trying manual cleanup...');
650
- }
651
664
 
652
- // If directory exists, remove it manually
653
665
  if (exists) {
666
+ // Try to remove via git worktree remove first (handles both directory and registration)
667
+ try {
668
+ const owningRepo = await this.resolveOwningRepo(worktreePath);
669
+ if (!owningRepo) {
670
+ throw new Error('Could not resolve owning repository');
671
+ }
672
+ await owningRepo.raw(['worktree', 'remove', '--force', worktreePath]);
673
+ console.log(`Removed worktree via git: ${worktreePath}`);
674
+ return;
675
+ } catch (gitError) {
676
+ console.log('Git worktree remove failed, trying manual cleanup...');
677
+ }
678
+
679
+ // git remove failed — remove directory manually
654
680
  await this.removeDirectory(worktreePath);
655
681
  console.log(`Removed worktree directory: ${worktreePath}`);
656
682
  }
657
-
683
+
658
684
  } catch (error) {
659
685
  console.warn(`Warning: Could not cleanup worktree at ${worktreePath}: ${error.message}`);
660
686
  // Don't throw - this is cleanup, continue with creation
@@ -777,14 +803,22 @@ class GitWorktreeManager {
777
803
  }
778
804
 
779
805
  /**
780
- * Prune stale worktree registrations
806
+ * Prune stale worktree registrations from the owning repository.
807
+ * @param {string|import('simple-git').SimpleGit} [worktreePathOrGit] - A worktree path to
808
+ * resolve the owning repo from, or a SimpleGit instance directly. Falls back to process.cwd().
781
809
  * @returns {Promise<void>}
782
810
  */
783
- async pruneWorktrees() {
811
+ async pruneWorktrees(worktreePathOrGit) {
784
812
  try {
785
- // Find the parent git repository to prune from
786
- // We need to find any git repo to run the prune command
787
- const git = simpleGit(process.cwd());
813
+ let git;
814
+ if (worktreePathOrGit && typeof worktreePathOrGit === 'object') {
815
+ git = worktreePathOrGit;
816
+ } else if (typeof worktreePathOrGit === 'string') {
817
+ git = await this.resolveOwningRepo(worktreePathOrGit);
818
+ }
819
+ if (!git) {
820
+ git = simpleGit(process.cwd());
821
+ }
788
822
  await git.raw(['worktree', 'prune']);
789
823
  console.log('Pruned stale worktree registrations');
790
824
  } catch (error) {
@@ -892,12 +926,31 @@ class GitWorktreeManager {
892
926
 
893
927
  console.log(`[pair-review] Found ${staleWorktrees.length} stale worktrees older than ${retentionDays} days`);
894
928
 
929
+ // Resolve owning repos BEFORE cleanup removes directories
930
+ const owningRepos = new Map();
931
+ for (const worktree of staleWorktrees) {
932
+ try {
933
+ const repo = await this.resolveOwningRepo(worktree.path);
934
+ if (repo) {
935
+ const repoPath = (await repo.raw(['rev-parse', '--git-dir'])).trim();
936
+ if (!owningRepos.has(repoPath)) {
937
+ owningRepos.set(repoPath, repo);
938
+ }
939
+ }
940
+ } catch {
941
+ // ignore - will fall back to manual removal
942
+ }
943
+ }
944
+
895
945
  for (const worktree of staleWorktrees) {
896
946
  try {
897
947
  // Try to remove via git worktree remove first
898
948
  try {
899
- const git = simpleGit(path.dirname(worktree.path));
900
- await git.raw(['worktree', 'remove', '--force', worktree.path]);
949
+ const owningRepo = await this.resolveOwningRepo(worktree.path);
950
+ if (!owningRepo) {
951
+ throw new Error('Could not resolve owning repository');
952
+ }
953
+ await owningRepo.raw(['worktree', 'remove', '--force', worktree.path]);
901
954
  console.log(`[pair-review] Removed worktree via git: ${worktree.path}`);
902
955
  } catch (gitError) {
903
956
  // If git worktree remove fails, try manual directory removal
@@ -923,8 +976,10 @@ class GitWorktreeManager {
923
976
  }
924
977
  }
925
978
 
926
- // Run git worktree prune to clean up orphaned registrations
927
- await this.pruneWorktrees();
979
+ // Prune each unique owning repo
980
+ for (const repo of owningRepos.values()) {
981
+ await this.pruneWorktrees(repo);
982
+ }
928
983
 
929
984
  if (result.cleaned > 0) {
930
985
  console.log(`[pair-review] Cleaned up ${result.cleaned} stale worktrees (older than ${retentionDays} days)`);
@@ -21,6 +21,7 @@ const {
21
21
  } = require('../ai');
22
22
  const { normalizeRepository } = require('../utils/paths');
23
23
  const { isRunningViaNpx, saveConfig } = require('../config');
24
+ const { version } = require('../../package.json');
24
25
  const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
25
26
  const { PRESETS } = require('../utils/comment-formatter');
26
27
  const logger = require('../utils/logger');
@@ -42,6 +43,7 @@ router.get('/api/config', (req, res) => {
42
43
 
43
44
  // Only return safe configuration values (not secrets like github_token)
44
45
  res.json({
46
+ version,
45
47
  theme: config.theme || 'light',
46
48
  comment_button_action: config.comment_button_action || 'submit',
47
49
  comment_format: config.comment_format || 'legacy',
@@ -53,6 +55,7 @@ router.get('/api/config', (req, res) => {
53
55
  chat_enable_shortcuts: config.chat?.enable_shortcuts !== false,
54
56
  pi_available: getCachedAvailability('pi')?.available || false,
55
57
  assisted_by_url: config.assisted_by_url || 'https://github.com/in-the-loop-labs/pair-review',
58
+ enable_graphite: config.enable_graphite === true,
56
59
  // Share configuration for external review viewers.
57
60
  // - url: The base URL of the external share site
58
61
  // - method: Plumbed through for future use (e.g., POST-based share flows).
@@ -156,7 +156,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
156
156
  w.created_at,
157
157
  pm.title as pr_title,
158
158
  pm.author,
159
- pm.head_branch
159
+ pm.head_branch,
160
+ json_extract(pm.pr_data, '$.html_url') as html_url
160
161
  FROM worktrees w
161
162
  LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
162
163
  WHERE w.last_accessed_at < ?
@@ -176,7 +177,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
176
177
  w.created_at,
177
178
  pm.title as pr_title,
178
179
  pm.author,
179
- pm.head_branch
180
+ pm.head_branch,
181
+ json_extract(pm.pr_data, '$.html_url') as html_url
180
182
  FROM worktrees w
181
183
  LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
182
184
  ORDER BY w.last_accessed_at DESC
@@ -235,7 +237,8 @@ router.get('/api/worktrees/recent', async (req, res) => {
235
237
  branch: w.branch,
236
238
  head_branch: w.head_branch || w.branch,
237
239
  last_accessed_at: w.last_accessed_at,
238
- created_at: w.created_at
240
+ created_at: w.created_at,
241
+ html_url: w.html_url || null
239
242
  }));
240
243
 
241
244
  res.json({