@in-the-loop-labs/pair-review 3.6.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. package/src/single-port.js +6 -3
@@ -0,0 +1,328 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Repo Links UI Renderer
4
+ *
5
+ * Fetches `/api/repos/:owner/:repo/links` and renders the resulting
6
+ * configuration into the review header:
7
+ *
8
+ * - `links.external`: insert a new anchor button into the header icon
9
+ * group with the configured label, icon, and substituted URL.
10
+ * - `links.github === false`: hide `#github-link`.
11
+ * - `links.graphite === false`: hide `#graphite-link`.
12
+ *
13
+ * Shared between PR mode (public/js/pr.js) and Local mode
14
+ * (public/js/local.js). Local mode only calls this when the review is
15
+ * associated with a `repos` entry whose `owner/repo` is known.
16
+ *
17
+ * URL templates are substituted client-side because only the frontend
18
+ * has the live PR/branch context (head_sha may change at any refresh).
19
+ */
20
+ (function () {
21
+ // Whitelist of allowed `{placeholder}` names in `url_template`. Must
22
+ // match `ALLOWED_PLACEHOLDERS` in src/links/repo-links.js — kept in
23
+ // sync because mismatch produces silent template drift.
24
+ const ALLOWED_PLACEHOLDERS = [
25
+ 'owner', 'repo', 'number', 'branch', 'base_branch', 'head_sha'
26
+ ];
27
+
28
+ // The most recently resolved links + substitution context for the
29
+ // current review. Stored so other code (ReviewModal, pr.js) can read
30
+ // the configured host name / external URL / icon instead of hardcoding
31
+ // "GitHub". `fetchAndApplyRepoLinks` refreshes these on every load.
32
+ let _currentLinks = null;
33
+ let _currentContext = null;
34
+
35
+ /**
36
+ * Substitute whitelisted placeholders in a URL template. Returns the
37
+ * substituted URL, or null if any required placeholder is missing or
38
+ * the result does not start with `https://`. Mirrors the server-side
39
+ * implementation as defence-in-depth.
40
+ *
41
+ * @param {string} template
42
+ * @param {Object} context
43
+ * @returns {string|null}
44
+ */
45
+ function substituteUrlTemplate(template, context) {
46
+ if (typeof template !== 'string' || !template) return null;
47
+ const ctx = context || {};
48
+
49
+ const substituted = template.replace(/\{([a-zA-Z_]+)\}/g, (match, name) => {
50
+ if (!ALLOWED_PLACEHOLDERS.includes(name)) return match;
51
+ const value = ctx[name];
52
+ if (value === undefined || value === null || value === '') return match;
53
+ return encodeURIComponent(String(value));
54
+ });
55
+
56
+ if (!substituted.startsWith('https://')) return null;
57
+ for (const placeholder of ALLOWED_PLACEHOLDERS) {
58
+ if (substituted.includes('{' + placeholder + '}')) return null;
59
+ }
60
+ return substituted;
61
+ }
62
+
63
+ /**
64
+ * Parse a sanitised SVG string and return its root `<svg>` element,
65
+ * or null if parsing fails. The server sanitises the SVG before
66
+ * sending it down (strips scripts, on* handlers, javascript: URLs).
67
+ * We DOMParse here as a second filter, and also so the DOM we insert
68
+ * doesn't go through `innerHTML` on the live document.
69
+ *
70
+ * @param {string} svgString
71
+ * @returns {SVGElement|null}
72
+ */
73
+ function parseSvgIcon(svgString) {
74
+ if (typeof svgString !== 'string' || !svgString) return null;
75
+ try {
76
+ const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml');
77
+ // image/svg+xml mode returns a <parsererror> root on bad input.
78
+ const root = doc.documentElement;
79
+ if (!root || root.nodeName !== 'svg') return null;
80
+ // Belt-and-braces: strip dangerous attributes/elements even though
81
+ // the server already removed them.
82
+ stripDangerousAttributes(root);
83
+ return root;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Recursively strip on* event handlers and `javascript:` URLs from an
91
+ * SVG element tree, and remove any `<script>` descendants. Defensive
92
+ * second pass after server sanitisation.
93
+ */
94
+ function stripDangerousAttributes(element) {
95
+ if (!element || !element.attributes) return;
96
+ const attrs = Array.from(element.attributes);
97
+ for (const attr of attrs) {
98
+ const name = attr.name.toLowerCase();
99
+ const value = String(attr.value || '').toLowerCase();
100
+ if (name.startsWith('on') || value.includes('javascript:')) {
101
+ element.removeAttribute(attr.name);
102
+ }
103
+ }
104
+ const children = Array.from(element.children || []);
105
+ for (const child of children) {
106
+ if (child.nodeName && child.nodeName.toLowerCase() === 'script') {
107
+ child.remove();
108
+ continue;
109
+ }
110
+ stripDangerousAttributes(child);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Build the external-link anchor element. Returns null if the URL
116
+ * substitution fails (invalid template or missing required values).
117
+ *
118
+ * @param {Object} external { label, url_template, icon }
119
+ * @param {Object} context substitution context
120
+ * @returns {HTMLAnchorElement|null}
121
+ */
122
+ function buildExternalLink(external, context) {
123
+ if (!external || typeof external !== 'object') return null;
124
+ const url = substituteUrlTemplate(external.url_template, context);
125
+ if (!url) {
126
+ console.warn(
127
+ '[repo-links] Dropping external link: url_template substitution failed',
128
+ { template: external.url_template, context }
129
+ );
130
+ return null;
131
+ }
132
+
133
+ const anchor = document.createElement('a');
134
+ anchor.className = 'btn btn-icon';
135
+ anchor.id = 'external-link';
136
+ anchor.href = url;
137
+ anchor.target = '_blank';
138
+ anchor.rel = 'noopener noreferrer';
139
+ anchor.title = external.label;
140
+ anchor.setAttribute('aria-label', external.label);
141
+
142
+ if (external.icon) {
143
+ const svg = parseSvgIcon(external.icon);
144
+ if (svg) {
145
+ // Ensure the icon scales like the existing header icons.
146
+ if (!svg.getAttribute('width')) svg.setAttribute('width', '16');
147
+ if (!svg.getAttribute('height')) svg.setAttribute('height', '16');
148
+ anchor.appendChild(svg);
149
+ } else {
150
+ appendFallbackIcon(anchor);
151
+ }
152
+ } else {
153
+ appendFallbackIcon(anchor);
154
+ }
155
+
156
+ return anchor;
157
+ }
158
+
159
+ /**
160
+ * Append a generic "external link" SVG icon (an arrow leaving a box)
161
+ * when no icon is configured or sanitisation rejected the supplied
162
+ * SVG. Keeps the button visually consistent with the others.
163
+ */
164
+ function appendFallbackIcon(anchor) {
165
+ const svgNs = 'http://www.w3.org/2000/svg';
166
+ const svg = document.createElementNS(svgNs, 'svg');
167
+ svg.setAttribute('viewBox', '0 0 16 16');
168
+ svg.setAttribute('width', '16');
169
+ svg.setAttribute('height', '16');
170
+ svg.setAttribute('fill', 'currentColor');
171
+ const path = document.createElementNS(svgNs, 'path');
172
+ // "open in new tab" icon: box with arrow leaving top-right corner.
173
+ path.setAttribute('d', 'M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.216 2.784 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3a.75.75 0 0 0-1.5 0v3a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3a.75.75 0 0 0 0-1.5h-3Zm6.97-.53a.75.75 0 0 0 0 1.06l1.72 1.72-4.97 4.97a.75.75 0 1 0 1.06 1.06l4.97-4.97 1.72 1.72a.75.75 0 0 0 1.28-.53V2.75A.75.75 0 0 0 14.75 2h-3.5a.75.75 0 0 0-.53 1.28Z');
174
+ svg.appendChild(path);
175
+ anchor.appendChild(svg);
176
+ }
177
+
178
+ /**
179
+ * Apply the resolved links config to the header DOM.
180
+ *
181
+ * - Hide `#github-link` if links.github === false.
182
+ * - Hide `#graphite-link` if links.graphite === false.
183
+ * - Insert an `#external-link` anchor before `#github-link` when
184
+ * `links.external` is set and substitution succeeds.
185
+ *
186
+ * Idempotent: a pre-existing `#external-link` is replaced rather than
187
+ * duplicated. Safe to call multiple times (e.g. after a refresh).
188
+ *
189
+ * @param {Object} links resolved links config from the server
190
+ * @param {Object} context substitution context for url_template
191
+ */
192
+ function applyRepoLinks(links, context) {
193
+ if (!links || typeof links !== 'object') return;
194
+
195
+ const githubLink = document.getElementById('github-link');
196
+ if (githubLink) {
197
+ if (links.github === false) {
198
+ githubLink.style.display = 'none';
199
+ }
200
+ }
201
+
202
+ const graphiteLink = document.getElementById('graphite-link');
203
+ if (graphiteLink) {
204
+ if (links.graphite === false) {
205
+ // Permanently hide — overrides the enable_graphite toggle for
206
+ // this repo. Stored on a data attribute so pr.js can detect the
207
+ // suppression and skip the show-on-load codepath.
208
+ graphiteLink.style.display = 'none';
209
+ graphiteLink.dataset.suppressed = 'true';
210
+ }
211
+ }
212
+
213
+ // Remove any previously inserted external link before re-inserting.
214
+ const existing = document.getElementById('external-link');
215
+ if (existing) existing.remove();
216
+
217
+ if (links.external) {
218
+ const anchor = buildExternalLink(links.external, context);
219
+ if (anchor) {
220
+ // Insert just before #github-link if present; otherwise append to
221
+ // the icon group. Falls back to header-right > div if needed.
222
+ if (githubLink && githubLink.parentNode) {
223
+ githubLink.parentNode.insertBefore(anchor, githubLink);
224
+ } else {
225
+ const iconGroup = document.querySelector('.header .header-icon-group')
226
+ || document.querySelector('.header-icon-group');
227
+ if (iconGroup) iconGroup.appendChild(anchor);
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Fetch the resolved links config for an owner/repo and apply it.
235
+ *
236
+ * @param {string} owner
237
+ * @param {string} repo
238
+ * @param {Object} context substitution context (owner, repo, number,
239
+ * branch, base_branch, head_sha)
240
+ * @returns {Promise<void>}
241
+ */
242
+ async function fetchAndApplyRepoLinks(owner, repo, context) {
243
+ if (!owner || !repo) return;
244
+ // Reset so a failed fetch (or a repo with no links) falls back to the
245
+ // "GitHub" defaults rather than carrying a previous review's host name.
246
+ _currentLinks = null;
247
+ _currentContext = context || null;
248
+ try {
249
+ const response = await fetch(
250
+ '/api/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/links'
251
+ );
252
+ if (!response.ok) {
253
+ console.warn('[repo-links] Failed to fetch repo links:', response.status);
254
+ return;
255
+ }
256
+ const data = await response.json();
257
+ _currentLinks = (data && data.links) || null;
258
+ applyRepoLinks(_currentLinks, context);
259
+ } catch (err) {
260
+ console.warn('[repo-links] Error fetching repo links:', err);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Display name of the remote code host for the current review, for use
266
+ * in user-facing text in place of the literal "GitHub". Returns the
267
+ * configured `links.external.name`, or "GitHub" when unset. Server-side
268
+ * counterpart: `resolveHostName` in src/links/repo-links.js.
269
+ *
270
+ * @returns {string}
271
+ */
272
+ function hostName() {
273
+ if (_currentLinks && _currentLinks.external && _currentLinks.external.name) {
274
+ return _currentLinks.external.name;
275
+ }
276
+ return 'GitHub';
277
+ }
278
+
279
+ /**
280
+ * The substituted external URL for the current review (built from
281
+ * `links.external.url_template` and the stored context), or null when no
282
+ * external link is configured or substitution fails (e.g. Local mode,
283
+ * which has no `{number}`).
284
+ *
285
+ * @returns {string|null}
286
+ */
287
+ function externalUrl() {
288
+ if (!_currentLinks || !_currentLinks.external || !_currentLinks.external.url_template) {
289
+ return null;
290
+ }
291
+ return substituteUrlTemplate(_currentLinks.external.url_template, _currentContext || {});
292
+ }
293
+
294
+ /**
295
+ * The configured (server-sanitised) external host icon SVG string for the
296
+ * current review, or null when none is configured.
297
+ *
298
+ * @returns {string|null}
299
+ */
300
+ function externalIcon() {
301
+ if (_currentLinks && _currentLinks.external && _currentLinks.external.icon) {
302
+ return _currentLinks.external.icon;
303
+ }
304
+ return null;
305
+ }
306
+
307
+ const api = {
308
+ substituteUrlTemplate,
309
+ parseSvgIcon,
310
+ buildExternalLink,
311
+ applyRepoLinks,
312
+ fetchAndApplyRepoLinks,
313
+ hostName,
314
+ externalUrl,
315
+ externalIcon,
316
+ };
317
+
318
+ // Expose on window for use by pr.js and local.js.
319
+ if (typeof window !== 'undefined') {
320
+ window.RepoLinks = api;
321
+ }
322
+
323
+ // Also export for Node.js/test environments. Tests that only need the
324
+ // pure functions (substituteUrlTemplate) don't need a DOM.
325
+ if (typeof module !== 'undefined' && module.exports) {
326
+ module.exports = api;
327
+ }
328
+ })();
@@ -0,0 +1,88 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared provider/model pair resolver.
4
+ *
5
+ * Provider and model defaults are sourced from several scopes (repository
6
+ * settings, then app config) and historically each half was resolved
7
+ * independently with `repo || app || hardcoded`. When a scope overrides only
8
+ * ONE half, the halves can come from different scopes and produce an invalid
9
+ * pair such as `gemini` + `opus` (an Anthropic model). The modal then shows no
10
+ * selected model, and the non-modal auto-analyze path posts the broken pair
11
+ * straight to the backend.
12
+ *
13
+ * This resolver treats each scope as a unit and never mixes a provider from one
14
+ * scope with a model from another: it picks the first scope that names a known
15
+ * provider, keeps that scope's model only if it belongs to the provider, and
16
+ * otherwise derives the model from the provider's own default.
17
+ */
18
+
19
+ /**
20
+ * @param {Object} providerInfo - One entry from /api/providers
21
+ * @param {string} modelId
22
+ * @returns {boolean} Whether modelId is one of the provider's models
23
+ */
24
+ function _modelBelongsToProvider(providerInfo, modelId) {
25
+ return !!(
26
+ providerInfo &&
27
+ Array.isArray(providerInfo.models) &&
28
+ providerInfo.models.some(m => m && m.id === modelId)
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Resolve a coherent { provider, model } pair from ordered candidate scopes.
34
+ *
35
+ * @param {Array<{provider?: string, model?: string}>} scopes - Ordered by
36
+ * precedence (e.g. [repoSettings, appConfig]). Entries, and either half, may
37
+ * be null/undefined.
38
+ * @param {Array<Object>} providersInfo - The `providers` array from
39
+ * /api/providers (each `{ id, models: [{id, ...}], defaultModel }`). May be
40
+ * empty if the fetch failed.
41
+ * @returns {{provider: string, model: (string|null)}} A matched pair. `model`
42
+ * is null only when no provider metadata is available to derive one.
43
+ */
44
+ function resolveProviderModelPair(scopes, providersInfo) {
45
+ const providers = Array.isArray(providersInfo) ? providersInfo : [];
46
+ const findProvider = (id) => providers.find(p => p && p.id === id);
47
+ const providerDefaultModel = (info) =>
48
+ (info && (info.defaultModel || (Array.isArray(info.models) && info.models[0] && info.models[0].id))) || null;
49
+
50
+ for (const scope of (Array.isArray(scopes) ? scopes : [])) {
51
+ if (!scope) continue;
52
+ const provId = scope.provider || null;
53
+ const modelId = scope.model || null;
54
+
55
+ if (provId) {
56
+ const info = findProvider(provId);
57
+ if (info) {
58
+ if (modelId && _modelBelongsToProvider(info, modelId)) {
59
+ return { provider: provId, model: modelId };
60
+ }
61
+ return { provider: provId, model: providerDefaultModel(info) };
62
+ }
63
+ // Provider not in metadata (custom/unavailable): can't validate, pass
64
+ // through this scope's own halves rather than mixing in another scope's.
65
+ return { provider: provId, model: modelId };
66
+ }
67
+
68
+ if (modelId) {
69
+ // Scope names a model but no provider — attribute it to whichever
70
+ // provider owns it. If none owns it, fall through to the next scope.
71
+ const owner = providers.find(p => _modelBelongsToProvider(p, modelId));
72
+ if (owner) return { provider: owner.id, model: modelId };
73
+ }
74
+ }
75
+
76
+ // Nothing resolved: fall back to claude, deriving its default model from
77
+ // metadata when available (else null → let the backend/provider decide).
78
+ const claude = findProvider('claude');
79
+ return { provider: 'claude', model: providerDefaultModel(claude) };
80
+ }
81
+
82
+ if (typeof window !== 'undefined') {
83
+ window.resolveProviderModelPair = resolveProviderModelPair;
84
+ }
85
+
86
+ if (typeof module !== 'undefined' && module.exports) {
87
+ module.exports = { resolveProviderModelPair };
88
+ }
@@ -0,0 +1,164 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Stable scroll-into-view for the lazily rendered diff panel.
4
+ *
5
+ * Since the large-PR perf fix, expanded file bodies start as empty
6
+ * placeholders (`minHeight` ≈ patch lines × APPROX_DIFF_LINE_PX) and only
7
+ * render real rows when an IntersectionObserver sees them near the viewport.
8
+ * A plain `scrollIntoView()` therefore lands wrong on the first attempt:
9
+ * the browser computes the destination from placeholder heights, then the
10
+ * bodies passed during the scroll render and change height, shifting the
11
+ * target away from where the animation ends. A second attempt "works"
12
+ * because everything along the path has rendered by then.
13
+ *
14
+ * `scrollIntoViewStable()` fixes this by:
15
+ * 1. Rendering the target's own file body first (rows inside a lazy body
16
+ * don't exist until rendered, and its placeholder height is wrong).
17
+ * 2. Issuing the caller's scroll (smooth behavior preserved).
18
+ * 3. Waiting for the viewport-relative position of the target to stop
19
+ * moving (scroll animation done AND observer-triggered renders settled),
20
+ * then re-issuing an instant scroll. If that correction moved the
21
+ * target, newly revealed placeholders rendered and shifted layout
22
+ * again — so settle and correct again, up to MAX_CORRECTIONS times.
23
+ *
24
+ * The settle loop aborts if the user starts scrolling themselves (wheel /
25
+ * touch / scroll-intent keys) so corrections never fight real input, and
26
+ * whenever the target leaves the DOM (file list re-render, tour unmount).
27
+ */
28
+
29
+ /** Corrective re-scroll attempts after the initial scroll. */
30
+ const MAX_CORRECTIONS = 4;
31
+ /** Position delta (px) treated as "didn't move". */
32
+ const STABLE_PX = 2;
33
+ /** Consecutive same-position frames before the target counts as settled. */
34
+ const SETTLE_FRAMES = 3;
35
+ /** Hard cap on one settle wait — covers the longest smooth animation. */
36
+ const SETTLE_TIMEOUT_MS = 2000;
37
+
38
+ /** Keys that express scroll intent and should cancel pending corrections. */
39
+ const SCROLL_KEYS = new Set([
40
+ 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '
41
+ ]);
42
+
43
+ /**
44
+ * Latest-scroll-wins token. Every call captures `++activeGeneration`; any
45
+ * call whose captured value no longer matches has been superseded by a newer
46
+ * scroll and must bow out so stale settle loops don't fight or snap the
47
+ * viewport back to an old target.
48
+ */
49
+ let activeGeneration = 0;
50
+
51
+ /**
52
+ * Wait until `target`'s viewport-relative top is unchanged for
53
+ * SETTLE_FRAMES consecutive animation frames (or SETTLE_TIMEOUT_MS passes).
54
+ * Resolves early when the target is disconnected or `isCancelled()` trips.
55
+ * @param {Element} target
56
+ * @param {() => boolean} isCancelled
57
+ * @returns {Promise<void>}
58
+ */
59
+ function waitForStablePosition(target, isCancelled) {
60
+ return new Promise((resolve) => {
61
+ const start = Date.now();
62
+ let lastTop = null;
63
+ let stableFrames = 0;
64
+ const tick = () => {
65
+ if (isCancelled() || !target.isConnected || Date.now() - start > SETTLE_TIMEOUT_MS) {
66
+ resolve();
67
+ return;
68
+ }
69
+ const top = target.getBoundingClientRect().top;
70
+ if (lastTop !== null && Math.abs(top - lastTop) <= STABLE_PX) {
71
+ stableFrames += 1;
72
+ if (stableFrames >= SETTLE_FRAMES) {
73
+ resolve();
74
+ return;
75
+ }
76
+ } else {
77
+ stableFrames = 0;
78
+ }
79
+ lastTop = top;
80
+ requestAnimationFrame(tick);
81
+ };
82
+ requestAnimationFrame(tick);
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Scroll `target` into view and keep it there while lazy file bodies render.
88
+ * Safe to call on any element; outside a lazy diff it degrades to one
89
+ * `scrollIntoView` plus a settle wait. Fire-and-forget friendly.
90
+ * @param {Element} target - Element to bring into view
91
+ * @param {ScrollIntoViewOptions} [options] - Passed to the initial scroll;
92
+ * corrective re-scrolls force `behavior: 'auto'`.
93
+ * @returns {Promise<void>} resolves once the position is stable (or aborted)
94
+ */
95
+ async function scrollIntoViewStable(target, options = {}) {
96
+ if (!target || !target.isConnected || typeof target.scrollIntoView !== 'function') return;
97
+
98
+ // Claim the active-scroll slot. A later call bumps activeGeneration past
99
+ // ours, at which point isCancelled() trips and this call bows out.
100
+ const myGen = ++activeGeneration;
101
+
102
+ // Render the target's own lazy body first: until then its rows don't
103
+ // exist and the wrapper's height is the placeholder estimate. Skip
104
+ // collapsed wrappers — their body is display:none (zero height), so
105
+ // rendering would pay full renderPatch cost without changing the scroll.
106
+ // Skip file-level comment/suggestion cards too: they live in
107
+ // `.file-comments-zone`, which sits above the lazy body, so rendering the
108
+ // body can't move them — only burn the renderPatch cost we want to avoid.
109
+ const prManager = (typeof window !== 'undefined') ? window.prManager : null;
110
+ const wrapper = target.closest?.('.d2h-file-wrapper');
111
+ if (wrapper && !target.closest?.('.file-comments-zone')
112
+ && !wrapper.classList.contains('collapsed')
113
+ && typeof prManager?.ensureFileBodyRendered === 'function') {
114
+ try {
115
+ await prManager.ensureFileBodyRendered(wrapper);
116
+ } catch (err) {
117
+ console.warn('[ScrollUtils] ensureFileBodyRendered failed; scrolling anyway', err);
118
+ }
119
+ if (!target.isConnected || myGen !== activeGeneration) return;
120
+ }
121
+
122
+ // Cancel corrections when the user scrolls on their own OR when a newer
123
+ // scrollIntoViewStable call supersedes this one (latest-scroll-wins).
124
+ let cancelled = false;
125
+ const isCancelled = () => cancelled || myGen !== activeGeneration;
126
+ const cancel = () => { cancelled = true; };
127
+ const onKeyDown = (e) => {
128
+ // Scroll-intent keys are also everyday caret/typing keys inside form
129
+ // fields — there they mean "move the cursor", not "scroll the page", so
130
+ // they must not abort the correction loop.
131
+ if (e.target?.closest?.('input, textarea, select') || e.target?.isContentEditable) return;
132
+ if (SCROLL_KEYS.has(e.key)) cancelled = true;
133
+ };
134
+ window.addEventListener('wheel', cancel, { capture: true, passive: true });
135
+ window.addEventListener('touchstart', cancel, { capture: true, passive: true });
136
+ window.addEventListener('keydown', onKeyDown, { capture: true });
137
+
138
+ try {
139
+ target.scrollIntoView(options);
140
+ for (let i = 0; i < MAX_CORRECTIONS; i++) {
141
+ await waitForStablePosition(target, isCancelled);
142
+ if (isCancelled() || !target.isConnected) return;
143
+ // Re-issue instantly: a no-op when the smooth scroll landed true, a
144
+ // snap to the real position when lazy renders shifted the layout.
145
+ const before = target.getBoundingClientRect().top;
146
+ target.scrollIntoView({ ...options, behavior: 'auto' });
147
+ if (Math.abs(target.getBoundingClientRect().top - before) <= STABLE_PX) return;
148
+ // The correction moved us — newly revealed bodies may render and
149
+ // shift layout once more; loop to settle and verify again.
150
+ }
151
+ } finally {
152
+ window.removeEventListener('wheel', cancel, { capture: true });
153
+ window.removeEventListener('touchstart', cancel, { capture: true });
154
+ window.removeEventListener('keydown', onKeyDown, { capture: true });
155
+ }
156
+ }
157
+
158
+ if (typeof window !== 'undefined') {
159
+ window.ScrollUtils = { scrollIntoViewStable, waitForStablePosition };
160
+ }
161
+
162
+ if (typeof module !== 'undefined' && module.exports) {
163
+ module.exports = { scrollIntoViewStable, waitForStablePosition, MAX_CORRECTIONS, STABLE_PX, SETTLE_FRAMES, SETTLE_TIMEOUT_MS };
164
+ }
@@ -0,0 +1,50 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared localStorage-key helpers.
4
+ *
5
+ * Single source of truth for repository-scoped storage keys. These keys are
6
+ * shared ACROSS pages — the index/bulk page writes per-repo keys (e.g.
7
+ * `pair-review-tab`, `pair-review-instructions`) that the PR page later reads
8
+ * via `PRManager.getRepoStorageKey`. Any divergence in the base64 byte
9
+ * assembly would silently break remembered-tab / instructions reuse with no
10
+ * error, so the encoding lives here once rather than being copied per page.
11
+ */
12
+
13
+ /**
14
+ * Base64-encode a UTF-8 string in a byte-accurate way (btoa alone mangles
15
+ * multibyte characters).
16
+ * @param {string} value
17
+ * @returns {string} Base64 (with '=' padding)
18
+ */
19
+ function encodeBase64Utf8(value) {
20
+ const bytes = new TextEncoder().encode(value);
21
+ let binary = '';
22
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
23
+ return btoa(binary);
24
+ }
25
+
26
+ /**
27
+ * Generate a safe localStorage key for repository-specific settings.
28
+ * Uses base64 encoding to handle special characters in owner/repo names.
29
+ * @param {string} prefix - Key prefix (e.g. 'pair-review-tab')
30
+ * @param {string} owner - Repository owner
31
+ * @param {string} repo - Repository name
32
+ * @returns {string} Safe localStorage key
33
+ */
34
+ function getRepoStorageKey(prefix, owner, repo) {
35
+ try {
36
+ const repoId = encodeBase64Utf8(owner + '/' + repo).replace(/=/g, '');
37
+ return prefix + ':' + repoId;
38
+ } catch (_error) {
39
+ return prefix + ':' + encodeURIComponent(owner + '/' + repo);
40
+ }
41
+ }
42
+
43
+ if (typeof window !== 'undefined') {
44
+ window.encodeBase64Utf8 = encodeBase64Utf8;
45
+ window.getRepoStorageKey = getRepoStorageKey;
46
+ }
47
+
48
+ if (typeof module !== 'undefined' && module.exports) {
49
+ module.exports = { encodeBase64Utf8, getRepoStorageKey };
50
+ }
package/public/local.html CHANGED
@@ -595,6 +595,10 @@
595
595
  <!-- Tier icons utility -->
596
596
  <script src="/js/utils/tier-icons.js"></script>
597
597
 
598
+ <!-- Shared storage-key and provider/model helpers -->
599
+ <script src="/js/utils/storage-keys.js"></script>
600
+ <script src="/js/utils/provider-model.js"></script>
601
+
598
602
  <!-- Category emoji mapping -->
599
603
  <script src="/js/utils/category-emoji.js"></script>
600
604
 
@@ -610,6 +614,9 @@
610
614
  <!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
611
615
  <script src="/js/utils/modal-detection.js"></script>
612
616
 
617
+ <!-- Stable scroll-into-view for lazily rendered diff bodies -->
618
+ <script src="/js/utils/scroll-into-view.js"></script>
619
+
613
620
  <!-- WebSocket client -->
614
621
  <script src="/js/ws-client.js"></script>
615
622
 
@@ -674,6 +681,9 @@
674
681
  pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
675
682
  <script src="/runtime-config.js"></script>
676
683
 
684
+ <!-- Repo Links UI (must load before pr.js / local.js) -->
685
+ <script src="/js/repo-links.js"></script>
686
+
677
687
  <!-- PR JavaScript (shared with local mode) -->
678
688
  <script src="/js/pr.js"></script>
679
689