@in-the-loop-labs/pair-review 2.6.1 → 2.6.3

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.
@@ -68,7 +68,7 @@ function executeGitDiff(args, cwd = null) {
68
68
  if (cwd) {
69
69
  spawnOptions.cwd = cwd;
70
70
  }
71
- const gitProcess = spawn('git', ['diff', ...args], spawnOptions);
71
+ const gitProcess = spawn('git', ['diff', '--no-ext-diff', ...args], spawnOptions);
72
72
 
73
73
  let stdout = '';
74
74
  let stderr = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.6.1",
3
+ "version": "2.6.3",
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.1",
3
+ "version": "2.6.3",
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.1",
3
+ "version": "2.6.3",
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",
@@ -84,7 +84,7 @@ GIT_CMD=(git)
84
84
  if [[ -n "$GIT_CWD" ]]; then
85
85
  GIT_CMD+=(-C "$GIT_CWD")
86
86
  fi
87
- GIT_CMD+=(diff)
87
+ GIT_CMD+=(diff --no-ext-diff)
88
88
  # Guard: expanding an empty array with set -u fails in Bash < 4.4
89
89
  if [[ ${#GIT_ARGS[@]} -gt 0 ]]; then
90
90
  GIT_CMD+=("${GIT_ARGS[@]}")
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 {
@@ -1542,6 +1587,13 @@
1542
1587
  color: #f85149;
1543
1588
  }
1544
1589
 
1590
+ /* When collapsed, the wrapper is only as tall as the header, so the header's
1591
+ sticky positioning can't push it below the toolbar. Adding scroll-margin
1592
+ ensures scrollIntoView() lands the header below the sticky toolbar. */
1593
+ .d2h-file-wrapper.collapsed {
1594
+ scroll-margin-top: var(--toolbar-height, 0px);
1595
+ }
1596
+
1545
1597
  /* Hide diff content when collapsed */
1546
1598
  .d2h-file-wrapper.collapsed .d2h-file-body,
1547
1599
  .d2h-file-wrapper.collapsed .d2h-diff-table {
@@ -6078,15 +6130,25 @@ body::before {
6078
6130
  justify-content: center;
6079
6131
  }
6080
6132
 
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;
6133
+ /* Icon buttons within the icon group have special styling (no border, transparent bg).
6134
+ Uses high-specificity selector to override the generic .btn-icon rule (council selector)
6135
+ and the [data-theme="dark"] .btn-icon dark-mode override.
6136
+ NOTE: ID-based selectors (#theme-toggle, #refresh-pr) beat this rule regardless of
6137
+ how many classes are stacked, so those rules must also set border to transparent. */
6138
+ [data-theme] .header-icon-group .btn.btn-icon {
6139
+ border: 1px solid transparent;
6140
+ border-color: transparent;
6085
6141
  background: transparent;
6142
+ color: var(--color-text-tertiary);
6143
+ width: 36px;
6144
+ height: 36px;
6145
+ box-sizing: border-box;
6086
6146
  }
6087
6147
 
6088
- .header-icon-group .btn-icon:hover {
6148
+ [data-theme] .header-icon-group .btn.btn-icon:hover {
6089
6149
  background: var(--color-bg-tertiary);
6150
+ border-color: var(--color-border-primary);
6151
+ color: var(--color-text-primary);
6090
6152
  }
6091
6153
 
6092
6154
  .header .btn-secondary {
@@ -6111,27 +6173,33 @@ body::before {
6111
6173
  box-shadow: 0 0 12px var(--ai-glow);
6112
6174
  }
6113
6175
 
6114
- /* Theme toggle in header */
6176
+ /* Theme toggle in header — border is transparent at rest, visible on hover.
6177
+ Must set border-color explicitly here because this ID selector beats the
6178
+ class-based .header-icon-group .btn.btn-icon:hover rule in specificity. */
6115
6179
  .header #theme-toggle {
6116
6180
  background: transparent;
6117
- border: 1px solid var(--color-border-primary);
6181
+ border: 1px solid transparent;
6118
6182
  color: var(--color-text-secondary);
6119
6183
  }
6120
6184
 
6121
6185
  .header #theme-toggle:hover {
6122
6186
  background: var(--color-bg-secondary);
6187
+ border-color: var(--color-border-primary);
6123
6188
  color: var(--color-text-primary);
6124
6189
  }
6125
6190
 
6126
- /* Refresh button in header */
6191
+ /* Refresh button in header — border is transparent at rest, visible on hover.
6192
+ Must set border-color explicitly here because this ID selector beats the
6193
+ class-based .header-icon-group .btn.btn-icon:hover rule in specificity. */
6127
6194
  .header #refresh-pr {
6128
6195
  background: transparent;
6129
- border: 1px solid var(--color-border-primary);
6196
+ border: 1px solid transparent;
6130
6197
  color: var(--color-text-secondary);
6131
6198
  }
6132
6199
 
6133
6200
  .header #refresh-pr:hover {
6134
6201
  background: var(--color-bg-secondary);
6202
+ border-color: var(--color-border-primary);
6135
6203
  color: var(--color-text-primary);
6136
6204
  }
6137
6205
 
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">
@@ -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"/>' +
@@ -1103,6 +1118,7 @@
1103
1118
  window.__pairReview.chatProvider = config.chat_provider || 'pi';
1104
1119
  const chatProviders = config.chat_providers || [];
1105
1120
  window.__pairReview.chatProviders = chatProviders;
1121
+ window.__pairReview.enableGraphite = config.enable_graphite === true;
1106
1122
 
1107
1123
  // Set chat feature state based on config and provider availability
1108
1124
  let chatState = 'disabled';
@@ -1232,9 +1248,11 @@
1232
1248
 
1233
1249
  // ─── DOMContentLoaded Initialization ────────────────────────────────────────
1234
1250
 
1235
- document.addEventListener('DOMContentLoaded', function () {
1236
- // Load config and update command examples based on npx detection
1237
- 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();
1238
1256
 
1239
1257
  // Restore saved tab from localStorage (default: 'pr-tab')
1240
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>
@@ -659,7 +659,7 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
659
659
  async getChangedFilesList(worktreePath, prMetadata) {
660
660
  try {
661
661
  const { stdout } = await execPromise(
662
- `git diff ${prMetadata.base_sha}...${prMetadata.head_sha} --name-only`,
662
+ `git diff --no-ext-diff ${prMetadata.base_sha}...${prMetadata.head_sha} --name-only`,
663
663
  { cwd: worktreePath }
664
664
  );
665
665
  return stdout.trim().split('\n').filter(f => f.length > 0);
@@ -685,7 +685,7 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
685
685
  try {
686
686
  // Get modified tracked files (unstaged only - staged files are excluded by design)
687
687
  const { stdout: unstaged } = await execPromise(
688
- 'git diff --name-only',
688
+ 'git diff --no-ext-diff --name-only',
689
689
  { cwd: localPath }
690
690
  );
691
691
 
@@ -777,7 +777,7 @@ The following files are marked as generated in .gitattributes and should be SKIP
777
777
  ${generatedPatterns.map(p => `- ${p}`).join('\n')}
778
778
 
779
779
  These are auto-generated files (like package-lock.json, build outputs, etc.) that should not be reviewed.
780
- When running git diff, you can exclude these with: git diff ${'{base}'}...${'{head}'} -- ':!pattern' for each pattern.
780
+ When running git diff, you can exclude these with: git diff --no-ext-diff ${'{base}'}...${'{head}'} -- ':!pattern' for each pattern.
781
781
  Or simply ignore any changes to files matching these patterns in your analysis.
782
782
  `;
783
783
  }
@@ -983,10 +983,10 @@ ${prMetadata.description || '(No description provided)'}
983
983
  const isLocal = prMetadata.reviewType === 'local';
984
984
  if (isLocal) {
985
985
  // For local mode, diff against HEAD to see working directory changes
986
- return suffix ? `git diff HEAD ${suffix}` : 'git diff HEAD';
986
+ return suffix ? `git diff --no-ext-diff HEAD ${suffix}` : 'git diff --no-ext-diff HEAD';
987
987
  }
988
988
  // For PR mode, diff between base and head commits
989
- const baseCmd = `git diff ${prMetadata.base_sha}...${prMetadata.head_sha}`;
989
+ const baseCmd = `git diff --no-ext-diff ${prMetadata.base_sha}...${prMetadata.head_sha}`;
990
990
  return suffix ? `${baseCmd} ${suffix}` : baseCmd;
991
991
  }
992
992
 
@@ -30,9 +30,9 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
30
30
  *
31
31
  * Tier structure:
32
32
  * - free (auto): Cursor's default auto-routing model
33
- * - fast (composer-1, gpt-5.3-codex-fast, gemini-3-flash): Quick analysis
33
+ * - fast (composer-2-fast, gpt-5.3-codex-fast, gemini-3-flash): Quick analysis
34
34
  * - balanced (composer-1.5, sonnet-4.6-thinking, sonnet-4.5-thinking, gemini-3.1-pro): Recommended for most reviews
35
- * - thorough (gpt-5.3-codex-high, gpt-5.3-codex-xhigh, opus-4.5-thinking, opus-4.6-thinking): Deep analysis for complex code
35
+ * - thorough (composer-2, gpt-5.3-codex-high, gpt-5.3-codex-xhigh, opus-4.5-thinking, opus-4.6-thinking): Deep analysis for complex code
36
36
  */
37
37
  const CURSOR_AGENT_MODELS = [
38
38
  {
@@ -45,23 +45,32 @@ const CURSOR_AGENT_MODELS = [
45
45
  badgeClass: 'badge-speed'
46
46
  },
47
47
  {
48
- id: 'composer-1.5',
49
- name: 'Composer 1.5',
50
- tier: 'balanced',
48
+ id: 'composer-2',
49
+ name: 'Composer 2',
50
+ tier: 'thorough',
51
51
  tagline: 'Latest Composer',
52
- description: 'Cursor Composer model—positioned between Sonnet and Opus for multi-file edits',
53
- badge: 'Balanced',
54
- badgeClass: 'badge-balanced'
52
+ description: 'Frontier-level coding model trained with compaction-in-the-loop RL—strong on long-horizon tasks requiring hundreds of actions',
53
+ badge: 'Latest',
54
+ badgeClass: 'badge-power'
55
55
  },
56
56
  {
57
- id: 'composer-1',
58
- name: 'Composer 1',
57
+ id: 'composer-2-fast',
58
+ name: 'Composer 2 Fast',
59
59
  tier: 'fast',
60
- tagline: 'Original Composer',
61
- description: 'Cursor Composer modelgood for quick multi-file editing workflows',
60
+ tagline: 'Fast Composer',
61
+ description: 'Same intelligence as Composer 2 with lower latencydefault for interactive Cursor sessions',
62
62
  badge: 'Fast',
63
63
  badgeClass: 'badge-speed'
64
64
  },
65
+ {
66
+ id: 'composer-1.5',
67
+ name: 'Composer 1.5',
68
+ tier: 'balanced',
69
+ tagline: 'Previous Composer',
70
+ description: 'Previous generation Cursor Composer model—positioned between Sonnet and Opus for multi-file edits',
71
+ badge: 'Previous Gen',
72
+ badgeClass: 'badge-balanced'
73
+ },
65
74
  {
66
75
  id: 'gpt-5.3-codex-fast',
67
76
  name: 'GPT-5.3 Codex Fast',
@@ -124,8 +124,8 @@ function buildReviewContext(review, prData) {
124
124
  lines.push(`This is a local code review for: ${name}`);
125
125
  lines.push('');
126
126
  lines.push('## Viewing Code Changes');
127
- lines.push('The changes under review are **unstaged and untracked local changes**. Staged changes (`git diff --cached`) are treated as already reviewed.');
128
- lines.push('To see the diff under review: `git diff`');
127
+ lines.push('The changes under review are **unstaged and untracked local changes**. Staged changes (`git diff --no-ext-diff --cached`) are treated as already reviewed.');
128
+ lines.push('To see the diff under review: `git diff --no-ext-diff`');
129
129
  lines.push('Do NOT use `git diff HEAD~1` or `git log` — those show committed history, not the changes under review.');
130
130
  } else {
131
131
  const parts = [];
@@ -146,7 +146,7 @@ function buildReviewContext(review, prData) {
146
146
  lines.push('');
147
147
  lines.push('## Viewing Code Changes');
148
148
  lines.push(`The changes under review are the diff between base commit \`${prData.base_sha.substring(0, 8)}\` and head commit \`${prData.head_sha.substring(0, 8)}\`.`);
149
- lines.push(`To see the full diff: \`git diff ${prData.base_sha}...${prData.head_sha}\``);
149
+ lines.push(`To see the full diff: \`git diff --no-ext-diff ${prData.base_sha}...${prData.head_sha}\``);
150
150
  lines.push('Do NOT use `git diff HEAD~1` or `git diff` without arguments — those do not show the PR changes.');
151
151
  }
152
152
  }
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
  /**
@@ -536,7 +536,8 @@ class GitWorktreeManager {
536
536
  // This ensures we compare the exact commits from the PR, even if the base branch has moved
537
537
  const diff = await git.diff([
538
538
  `${prData.base_sha}...${prData.head_sha}`,
539
- '--unified=3'
539
+ '--unified=3',
540
+ '--no-ext-diff'
540
541
  ]);
541
542
 
542
543
  return diff;
@@ -559,7 +560,7 @@ class GitWorktreeManager {
559
560
 
560
561
  // Get file changes with stats using base SHA and head SHA
561
562
  // This ensures we get the exact files changed in the PR, even if the base branch has moved
562
- const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`]);
563
+ const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`, '--no-ext-diff']);
563
564
 
564
565
  // Parse .gitattributes to identify generated files
565
566
  const gitattributes = await getGeneratedFilePatterns(worktreePath);
@@ -626,6 +627,29 @@ class GitWorktreeManager {
626
627
  }
627
628
  }
628
629
 
630
+ /**
631
+ * Resolve the owning git repository for a worktree path.
632
+ * Uses `git rev-parse --git-common-dir` to find the main repo,
633
+ * which works even when worktrees are outside the parent repo directory.
634
+ * @param {string} worktreePath - Path to a worktree
635
+ * @returns {Promise<import('simple-git').SimpleGit|null>} simpleGit instance for the owning repo, or null
636
+ */
637
+ async resolveOwningRepo(worktreePath) {
638
+ try {
639
+ const git = simpleGit(worktreePath);
640
+ const commonDir = (await git.raw(['rev-parse', '--git-common-dir'])).trim();
641
+ // commonDir is either a .git subdirectory (regular repos) or the bare repo root itself.
642
+ // Only strip the last component when it's actually a .git directory.
643
+ const resolvedCommonDir = path.resolve(worktreePath, commonDir);
644
+ const repoRoot = path.basename(resolvedCommonDir) === '.git'
645
+ ? path.dirname(resolvedCommonDir)
646
+ : resolvedCommonDir;
647
+ return simpleGit(repoRoot);
648
+ } catch {
649
+ return null;
650
+ }
651
+ }
652
+
629
653
  /**
630
654
  * Cleanup a specific worktree
631
655
  * @param {string} worktreePath - Path to worktree to cleanup
@@ -634,27 +658,30 @@ class GitWorktreeManager {
634
658
  async cleanupWorktree(worktreePath) {
635
659
  try {
636
660
  // First try to prune any stale worktree registrations
637
- await this.pruneWorktrees();
638
-
661
+ await this.pruneWorktrees(worktreePath);
662
+
639
663
  // Check if worktree exists
640
664
  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
665
 
652
- // If directory exists, remove it manually
653
666
  if (exists) {
667
+ // Try to remove via git worktree remove first (handles both directory and registration)
668
+ try {
669
+ const owningRepo = await this.resolveOwningRepo(worktreePath);
670
+ if (!owningRepo) {
671
+ throw new Error('Could not resolve owning repository');
672
+ }
673
+ await owningRepo.raw(['worktree', 'remove', '--force', worktreePath]);
674
+ console.log(`Removed worktree via git: ${worktreePath}`);
675
+ return;
676
+ } catch (gitError) {
677
+ console.log('Git worktree remove failed, trying manual cleanup...');
678
+ }
679
+
680
+ // git remove failed — remove directory manually
654
681
  await this.removeDirectory(worktreePath);
655
682
  console.log(`Removed worktree directory: ${worktreePath}`);
656
683
  }
657
-
684
+
658
685
  } catch (error) {
659
686
  console.warn(`Warning: Could not cleanup worktree at ${worktreePath}: ${error.message}`);
660
687
  // Don't throw - this is cleanup, continue with creation
@@ -777,14 +804,22 @@ class GitWorktreeManager {
777
804
  }
778
805
 
779
806
  /**
780
- * Prune stale worktree registrations
807
+ * Prune stale worktree registrations from the owning repository.
808
+ * @param {string|import('simple-git').SimpleGit} [worktreePathOrGit] - A worktree path to
809
+ * resolve the owning repo from, or a SimpleGit instance directly. Falls back to process.cwd().
781
810
  * @returns {Promise<void>}
782
811
  */
783
- async pruneWorktrees() {
812
+ async pruneWorktrees(worktreePathOrGit) {
784
813
  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());
814
+ let git;
815
+ if (worktreePathOrGit && typeof worktreePathOrGit === 'object') {
816
+ git = worktreePathOrGit;
817
+ } else if (typeof worktreePathOrGit === 'string') {
818
+ git = await this.resolveOwningRepo(worktreePathOrGit);
819
+ }
820
+ if (!git) {
821
+ git = simpleGit(process.cwd());
822
+ }
788
823
  await git.raw(['worktree', 'prune']);
789
824
  console.log('Pruned stale worktree registrations');
790
825
  } catch (error) {
@@ -892,12 +927,31 @@ class GitWorktreeManager {
892
927
 
893
928
  console.log(`[pair-review] Found ${staleWorktrees.length} stale worktrees older than ${retentionDays} days`);
894
929
 
930
+ // Resolve owning repos BEFORE cleanup removes directories
931
+ const owningRepos = new Map();
932
+ for (const worktree of staleWorktrees) {
933
+ try {
934
+ const repo = await this.resolveOwningRepo(worktree.path);
935
+ if (repo) {
936
+ const repoPath = (await repo.raw(['rev-parse', '--git-dir'])).trim();
937
+ if (!owningRepos.has(repoPath)) {
938
+ owningRepos.set(repoPath, repo);
939
+ }
940
+ }
941
+ } catch {
942
+ // ignore - will fall back to manual removal
943
+ }
944
+ }
945
+
895
946
  for (const worktree of staleWorktrees) {
896
947
  try {
897
948
  // Try to remove via git worktree remove first
898
949
  try {
899
- const git = simpleGit(path.dirname(worktree.path));
900
- await git.raw(['worktree', 'remove', '--force', worktree.path]);
950
+ const owningRepo = await this.resolveOwningRepo(worktree.path);
951
+ if (!owningRepo) {
952
+ throw new Error('Could not resolve owning repository');
953
+ }
954
+ await owningRepo.raw(['worktree', 'remove', '--force', worktree.path]);
901
955
  console.log(`[pair-review] Removed worktree via git: ${worktree.path}`);
902
956
  } catch (gitError) {
903
957
  // If git worktree remove fails, try manual directory removal
@@ -923,8 +977,10 @@ class GitWorktreeManager {
923
977
  }
924
978
  }
925
979
 
926
- // Run git worktree prune to clean up orphaned registrations
927
- await this.pruneWorktrees();
980
+ // Prune each unique owning repo
981
+ for (const repo of owningRepos.values()) {
982
+ await this.pruneWorktrees(repo);
983
+ }
928
984
 
929
985
  if (result.cleaned > 0) {
930
986
  console.log(`[pair-review] Cleaned up ${result.cleaned} stale worktrees (older than ${retentionDays} days)`);
@@ -658,7 +658,7 @@ async function computeLocalDiffDigest(localPath) {
658
658
  // Get unstaged diff (the actual content being reviewed)
659
659
  let unstagedDiff = '';
660
660
  try {
661
- const result = await execAsync('git diff', {
661
+ const result = await execAsync('git diff --no-ext-diff', {
662
662
  cwd: localPath,
663
663
  encoding: 'utf8',
664
664
  maxBuffer: 50 * 1024 * 1024
package/src/main.js CHANGED
@@ -662,10 +662,10 @@ async function performHeadlessReview(args, config, db, flags, options) {
662
662
  }
663
663
  }
664
664
 
665
- diff = await git.diff([`${prData.base_sha}...${prData.head_sha}`, '--unified=3']);
665
+ diff = await git.diff([`${prData.base_sha}...${prData.head_sha}`, '--unified=3', '--no-ext-diff']);
666
666
 
667
667
  // Get changed files
668
- const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`]);
668
+ const diffSummary = await git.diffSummary([`${prData.base_sha}...${prData.head_sha}`, '--no-ext-diff']);
669
669
  const gitattributes = await getGeneratedFilePatterns(worktreePath);
670
670
 
671
671
  changedFiles = diffSummary.files.map(file => {
@@ -55,6 +55,7 @@ router.get('/api/config', (req, res) => {
55
55
  chat_enable_shortcuts: config.chat?.enable_shortcuts !== false,
56
56
  pi_available: getCachedAvailability('pi')?.available || false,
57
57
  assisted_by_url: config.assisted_by_url || 'https://github.com/in-the-loop-labs/pair-review',
58
+ enable_graphite: config.enable_graphite === true,
58
59
  // Share configuration for external review viewers.
59
60
  // - url: The base URL of the external share site
60
61
  // - method: Plumbed through for future use (e.g., POST-based share flows).
@@ -45,7 +45,7 @@ router.post('/api/github/review-requests/refresh', async (req, res) => {
45
45
  const db = req.app.get('db');
46
46
  const client = new GitHubClient(githubToken);
47
47
  const user = await client.getAuthenticatedUser();
48
- const prs = await client.searchPullRequests(`is:pr is:open user-review-requested:${user.login}`);
48
+ const prs = await client.searchPullRequests(`is:pr is:open archived:false user-review-requested:${user.login}`);
49
49
 
50
50
  await withTransaction(db, async () => {
51
51
  await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['review-requests']);
@@ -99,7 +99,7 @@ router.post('/api/github/my-prs/refresh', async (req, res) => {
99
99
  const db = req.app.get('db');
100
100
  const client = new GitHubClient(githubToken);
101
101
  const user = await client.getAuthenticatedUser();
102
- const prs = await client.searchPullRequests(`is:pr is:open author:${user.login}`);
102
+ const prs = await client.searchPullRequests(`is:pr is:open archived:false author:${user.login}`);
103
103
 
104
104
  await withTransaction(db, async () => {
105
105
  await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['my-prs']);
package/src/routes/pr.js CHANGED
@@ -682,10 +682,10 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
682
682
 
683
683
  if (baseSha && headSha) {
684
684
  // Regenerate diff with -w flag to ignore whitespace changes
685
- diffContent = await git.diff([`${baseSha}...${headSha}`, '--unified=3', '-w']);
685
+ diffContent = await git.diff([`${baseSha}...${headSha}`, '--unified=3', '-w', '--no-ext-diff']);
686
686
 
687
687
  // Regenerate changed files stats with -w flag
688
- const diffSummary = await git.diffSummary([`${baseSha}...${headSha}`, '-w']);
688
+ const diffSummary = await git.diffSummary([`${baseSha}...${headSha}`, '-w', '--no-ext-diff']);
689
689
  const gitattributes = await getGeneratedFilePatterns(worktreePath);
690
690
  changedFiles = diffSummary.files.map(file => {
691
691
  const resolvedFile = resolveRenamedFile(file.file);
@@ -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({
@@ -37,7 +37,7 @@ async function getDiffFileList(db, review) {
37
37
  try {
38
38
  const opts = { cwd: review.local_path };
39
39
  const [{ stdout: unstaged }, { stdout: untracked }] = await Promise.all([
40
- execPromise('git diff --name-only', opts),
40
+ execPromise('git diff --no-ext-diff --name-only', opts),
41
41
  execPromise('git ls-files --others --exclude-standard', opts),
42
42
  ]);
43
43
  const combined = `${unstaged}\n${untracked}`