@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.
- package/README.md +4 -0
- package/package.json +20 -15
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +17 -1737
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +89 -44
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +11 -1
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/SuggestionNavigator.js +55 -10
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +58 -8
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +45 -5
- package/public/js/pr.js +703 -171
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/scroll-into-view.js +164 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +10 -0
- package/public/pr.html +10 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/claude-provider.js +31 -3
- package/src/config.js +664 -10
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +13 -4
- package/src/main.js +136 -32
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +102 -2
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +101 -11
- package/src/routes/mcp.js +47 -4
- package/src/routes/pr.js +298 -68
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- 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
|
|