git-reviewer 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -10,25 +10,308 @@ let currentFiles = [];
10
10
  let currentFile = null;
11
11
  let currentDiffLines = [];
12
12
  let comments = [];
13
+ // The `lastUpdated` stamp `comments` was loaded (or last saved) at, and the
14
+ // persisted snapshot that state is based on. Together they are what let a
15
+ // save notice that another tab -- or a writer outside the page -- got there
16
+ // first, and merge instead of overwriting (#107).
17
+ let commentsLastUpdated = null;
18
+ let commentsBase = [];
19
+
20
+ /**
21
+ * A comment's identity for merging: its key. Every comment has one -- minted
22
+ * here when it is written, or given by the server to one saved before keys
23
+ * existed -- so where it sits and what it says is only a fallback.
24
+ */
25
+ function commentKey(comment) {
26
+ return comment.key ?? JSON.stringify([comment.file, comment.line, comment.text]);
27
+ }
28
+
29
+ /**
30
+ * A new key for a comment or a follow-up: eight characters, the same shape
31
+ * the server mints. Minted here rather than on save so that a comment has its
32
+ * identity from the moment it exists -- an agent may answer it before this
33
+ * tab has saved anything else.
34
+ */
35
+ function mintKey() {
36
+ const bytes = new Uint8Array(6);
37
+ crypto.getRandomValues(bytes);
38
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
39
+ }
40
+
41
+ /** The identity a follow-up is merged by. Mirrors `followUpIdentity` in lib/threads.js. */
42
+ function followUpIdentity(followUp) {
43
+ return followUp.key ?? JSON.stringify([followUp.timestamp ?? null, followUp.text, followUp.by ?? null]);
44
+ }
45
+
46
+ /**
47
+ * Every follow-up in either list, once. Mirrors `unionFollowUps` in
48
+ * lib/threads.js: follow-ups are only ever added, so one present on either
49
+ * side is one somebody wrote, and a union is the merge that cannot lose an
50
+ * answer. `ours` keeps its order; the rest are placed by timestamp.
51
+ */
52
+ function unionFollowUps(ours = [], theirs = []) {
53
+ const merged = [...ours];
54
+ const seen = new Set(ours.map(followUpIdentity));
55
+ for (const followUp of theirs) {
56
+ const identity = followUpIdentity(followUp);
57
+ if (seen.has(identity)) continue;
58
+ seen.add(identity);
59
+ const at = followUp.timestamp ?? '';
60
+ let index = merged.length;
61
+ if (at) {
62
+ while (index > 0 && (merged[index - 1].timestamp ?? '') > at) index--;
63
+ }
64
+ merged.splice(index, 0, followUp);
65
+ }
66
+ return merged;
67
+ }
68
+
69
+ /**
70
+ * Add the reviewer's reply to a thread.
71
+ *
72
+ * A new array, never a push. The thread's array can be the very one
73
+ * `commentsBase` holds -- loading and merging both copy comments shallowly --
74
+ * and pushing onto it changed the base along with the comment, so the merge
75
+ * after a refused save saw no change here and took the server's copy of the
76
+ * thread: the reply was gone from the page and from disk.
77
+ */
78
+ function appendFollowUp(comment, text) {
79
+ comment.followUps = [...(comment.followUps ?? []), {
80
+ key: mintKey(),
81
+ text,
82
+ timestamp: new Date().toISOString()
83
+ }];
84
+ }
85
+
86
+ /**
87
+ * A copy of comments as persisted, sharing nothing with what it was taken
88
+ * from: what `commentsBase` holds must not change when the live comments do.
89
+ */
90
+ function snapshotComments(list) {
91
+ return JSON.parse(JSON.stringify(list.map(toPersistedComment)));
92
+ }
93
+
94
+ /** Reduce a live comment to the shape that gets persisted, for `commentsBase`. */
95
+ function toPersistedComment(comment) {
96
+ const persisted = {
97
+ ...(comment.key ? { key: comment.key } : {}),
98
+ file: comment.file,
99
+ line: comment.line,
100
+ lineContent: comment.lineContent,
101
+ text: comment.text
102
+ };
103
+ if (comment.selectedText) persisted.selectedText = comment.selectedText;
104
+ if (Array.isArray(comment.followUps) && comment.followUps.length > 0) {
105
+ persisted.followUps = comment.followUps;
106
+ }
107
+ return persisted;
108
+ }
109
+
110
+ /**
111
+ * Merge this tab's comments with comments that arrived from elsewhere.
112
+ *
113
+ * `baseComments` is what both sides started from, so it is what tells an
114
+ * arrival from a local edit apart: a comment in the remote list but not in
115
+ * the base arrived; a base comment missing on either side was deleted on
116
+ * that side and stays deleted, unless the other side changed it (a new
117
+ * follow-up is a change, and losing it would be the bug again); a local
118
+ * comment missing from the base is new and is kept.
119
+ *
120
+ * Where both sides hold a comment, one this tab has not touched since the
121
+ * base is taken as it now stands -- that is how an agent's answer, or an edit
122
+ * made in another tab, arrives. One changed here as well keeps this tab's
123
+ * text, and the follow-ups of both sides, so neither a follow-up added here
124
+ * nor an answer added there is undone.
125
+ */
126
+ function mergeCommentsForRetry(localComments, baseComments, remoteComments) {
127
+ const localByKey = new Map(localComments.map(comment => [commentKey(comment), comment]));
128
+ const baseByKey = new Map(baseComments.map(comment => [commentKey(comment), comment]));
129
+ const remoteKeys = new Set(remoteComments.map(commentKey));
130
+ const removedLocally = new Set(
131
+ baseComments.filter(comment => !localByKey.has(commentKey(comment))).map(commentKey)
132
+ );
133
+ const changedLocally = key =>
134
+ !baseByKey.has(key) ||
135
+ JSON.stringify(toPersistedComment(localByKey.get(key))) !==
136
+ JSON.stringify(toPersistedComment(baseByKey.get(key)));
137
+
138
+ const merged = [];
139
+ const seen = new Set();
140
+ for (const remote of remoteComments) {
141
+ const key = commentKey(remote);
142
+ if (removedLocally.has(key) || seen.has(key)) continue;
143
+ const local = localByKey.get(key);
144
+ if (!local) {
145
+ merged.push({ ...remote, diffLineIndex: null, matchType: remote.matchType || 'exact' });
146
+ } else if (changedLocally(key)) {
147
+ merged.push({ ...local, followUps: unionFollowUps(local.followUps, remote.followUps) });
148
+ } else {
149
+ // Where this tab drew it stays; what it says comes from the remote.
150
+ merged.push({
151
+ ...remote,
152
+ diffLineIndex: local.diffLineIndex,
153
+ matchType: local.matchType,
154
+ newLineContent: local.newLineContent
155
+ });
156
+ }
157
+ seen.add(key);
158
+ }
159
+ for (const local of localComments) {
160
+ const key = commentKey(local);
161
+ if (seen.has(key)) continue;
162
+ // In the base but not in the remote: deleted elsewhere. Keep it only
163
+ // when this tab changed it in the meantime.
164
+ if (baseByKey.has(key) && !remoteKeys.has(key) && !changedLocally(key)) continue;
165
+ merged.push(local);
166
+ seen.add(key);
167
+ }
168
+ return merged;
169
+ }
13
170
 
14
171
  // Auto-save comments to backend
15
172
  async function saveCommentsToBackend() {
16
173
  if (!currentRepoId) return;
17
174
 
175
+ // On a 409 the file has moved on since this tab loaded: take the current
176
+ // comments from the refusal, merge them with this tab's, and retry with
177
+ // the fresh stamp. A bounded number of retries, so two tabs racing in a
178
+ // loop cannot spin forever; if they are exhausted the next edit saves
179
+ // again, now based on the merged state.
180
+ for (let attempt = 0; attempt < 3; attempt++) {
181
+ try {
182
+ const sent = snapshotComments(comments);
183
+ const response = await fetch(`${API_BASE}/save-comments`, {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({
187
+ repoId: currentRepoId,
188
+ comments: sent, // Send empty array if no comments
189
+ lastUpdated: commentsLastUpdated
190
+ })
191
+ });
192
+
193
+ if (response.status === 409) {
194
+ const data = await response.json().catch(() => ({}));
195
+ adoptRemoteComments(Array.isArray(data.comments) ? data.comments : [], data.lastUpdated ?? null);
196
+ continue;
197
+ }
198
+
199
+ if (!response.ok) {
200
+ console.error('Failed to save comments:', response.status);
201
+ return;
202
+ }
203
+
204
+ const data = await response.json().catch(() => ({}));
205
+ if (Array.isArray(data.comments)) {
206
+ // What was written, which can hold more than what was sent: the
207
+ // server keeps every follow-up already on disk, and an agent may have
208
+ // answered in a thread this tab had not heard about yet.
209
+ adoptRemoteComments(data.comments, data.lastUpdated ?? commentsLastUpdated, sent);
210
+ } else {
211
+ if (data.lastUpdated) commentsLastUpdated = data.lastUpdated;
212
+ commentsBase = snapshotComments(sent);
213
+ }
214
+ return;
215
+ } catch (error) {
216
+ console.error('Failed to save comments:', error);
217
+ return;
218
+ }
219
+ }
220
+
221
+ console.error('Failed to save comments: still changing elsewhere after retries');
222
+ }
223
+
224
+ /**
225
+ * Pick up comments written somewhere else while this tab was out of sight.
226
+ *
227
+ * A tab only learned about another tab's comments when it saved something
228
+ * itself, so a tab you were only reading kept showing a stale review (#107).
229
+ * Coming back into view is the moment someone looks again, so that is when
230
+ * it asks. Every edit is saved as it is made, so there is nothing unsaved to
231
+ * protect -- except text being typed into an open box, which redrawing the
232
+ * code pane would throw away. While one is open only the sidebar is brought
233
+ * up to date, and the pane catches up the next time a file is drawn.
234
+ */
235
+ // Set when comments from elsewhere were merged in but the code pane could not
236
+ // be redrawn because someone was typing; cleared whenever the pane is drawn.
237
+ let commentsPaneStale = false;
238
+
239
+ async function refreshCommentsFromElsewhere() {
240
+ if (!currentRepoId || document.visibilityState !== 'visible') return;
241
+ const repoId = currentRepoId;
242
+
243
+ let data;
18
244
  try {
19
- await fetch(`${API_BASE}/save-comments`, {
20
- method: 'POST',
21
- headers: { 'Content-Type': 'application/json' },
22
- body: JSON.stringify({
23
- repoId: currentRepoId,
24
- comments: comments // Send empty array if no comments
25
- })
26
- });
27
- } catch (error) {
28
- console.error('Failed to save comments:', error);
245
+ const response = await fetch(`${API_BASE}/load-comments/${repoId}`);
246
+ if (!response.ok) return;
247
+ data = await response.json();
248
+ } catch {
249
+ return;
250
+ }
251
+ if (currentRepoId !== repoId) return;
252
+
253
+ const lastUpdated = data.lastUpdated ?? null;
254
+ if (lastUpdated !== commentsLastUpdated) {
255
+ adoptRemoteComments(Array.isArray(data.comments) ? data.comments : [], lastUpdated);
256
+ } else {
257
+ redrawCommentsPane();
29
258
  }
30
259
  }
31
260
 
261
+ /**
262
+ * Bring this tab up to date with comments as they stand on the server.
263
+ *
264
+ * Shared by a refused save, a save's reply, the live feed and a tab coming
265
+ * back into view: each is a list from the server and the base it should be
266
+ * merged against. The sidebar is redrawn at once; the code pane as soon as
267
+ * nobody is typing in it.
268
+ *
269
+ * @param {object[]} remoteComments
270
+ * @param {string|null} lastUpdated
271
+ * @param {object[]} [base] what this tab last knew the server to hold
272
+ */
273
+ function adoptRemoteComments(remoteComments, lastUpdated, base = commentsBase) {
274
+ const before = JSON.stringify(comments.map(toPersistedComment));
275
+ comments = mergeCommentsForRetry(comments, base, remoteComments);
276
+ commentsBase = snapshotComments(remoteComments);
277
+ commentsLastUpdated = lastUpdated;
278
+ for (const comment of comments) {
279
+ // An answer has landed; the words that were streaming in are now in the thread.
280
+ if (agentState.partials[comment.key] && lastAuthorOf(comment)) delete agentState.partials[comment.key];
281
+ }
282
+ if (JSON.stringify(comments.map(toPersistedComment)) !== before) {
283
+ updateCommentsSidebar();
284
+ commentsPaneStale = true;
285
+ }
286
+ redrawCommentsPane();
287
+ }
288
+
289
+ /**
290
+ * Redraw the open file's comments from `comments`, if they have changed and
291
+ * nobody is typing into a box the redraw would throw away. Uses the diff
292
+ * already on screen rather than fetching it again, and keeps the scroll
293
+ * position, so an answer arriving does not move what is being read.
294
+ */
295
+ function redrawCommentsPane() {
296
+ const typing = document.querySelector('.comment-input-box, .followup-input-box');
297
+ if (!commentsPaneStale || !currentFile || typing) return;
298
+ // Nor while code is selected in the pane: that is the first half of
299
+ // quoting it in a comment, and a redraw would drop the selection.
300
+ const selection = window.getSelection?.();
301
+ if (selection && !selection.isCollapsed && document.getElementById('codeViewer').contains(selection.anchorNode)) return;
302
+ if (isFullContextMode && storedFullFileLines) {
303
+ displayFullContext(currentFile, storedFullFileLines);
304
+ commentsPaneStale = false;
305
+ return;
306
+ }
307
+ if (!currentDiffLines.length) return;
308
+ const viewer = document.getElementById('codeViewer');
309
+ const scrollTop = viewer.scrollTop;
310
+ matchCommentsToFile(currentFile, currentDiffLines);
311
+ displayCode(currentFile, currentDiffLines, false);
312
+ viewer.scrollTop = scrollTop;
313
+ }
314
+
32
315
  // Load comments from backend
33
316
  async function loadCommentsFromBackend() {
34
317
  if (!currentRepoId) return [];
@@ -36,6 +319,8 @@ async function loadCommentsFromBackend() {
36
319
  try {
37
320
  const response = await fetch(`${API_BASE}/load-comments/${currentRepoId}`);
38
321
  const data = await response.json();
322
+ commentsLastUpdated = data.lastUpdated ?? null;
323
+ commentsBase = snapshotComments(data.comments || []);
39
324
  return data.comments || [];
40
325
  } catch (error) {
41
326
  console.error('Failed to load comments:', error);
@@ -881,6 +1166,10 @@ async function loadRepo(scope = {}) {
881
1166
  showStatus(`Picked up ${plural(savedComments.length, 'saved comment')} from last time.`, 'success');
882
1167
  }
883
1168
 
1169
+ // From here on, comments written anywhere else -- another tab, an agent
1170
+ // answering in a thread -- arrive as they are written.
1171
+ openReviewEvents(currentRepoId);
1172
+
884
1173
  // Open something. A loaded repository used to sit behind an empty pane
885
1174
  // until a file was clicked, which reads as a failure rather than as a
886
1175
  // result (#47). Done here rather than in `displayFiles` because the saved
@@ -1065,45 +1354,7 @@ async function loadFile(filePath, index) {
1065
1354
  //
1066
1355
  // `comments` is what exists. A comment that is not in it does not exist.
1067
1356
  if (comments.length > 0) {
1068
- const matchedComments = matchCommentsToDiff(comments, filePath, data.diffLines);
1069
-
1070
- // Update existing comments in the array with matched diff indices
1071
- matchedComments.forEach(mc => {
1072
- const existing = comments.find(c =>
1073
- c.file === mc.file &&
1074
- c.line === mc.line &&
1075
- c.text === mc.text
1076
- );
1077
-
1078
- if (existing) {
1079
- // Update existing comment with new diff line index and match type
1080
- existing.diffLineIndex = mc.diffLineIndex;
1081
- existing.matchType = mc.matchType;
1082
- existing.newLineContent = mc.newLineContent;
1083
- // Preserve followUps from saved data
1084
- if (mc.followUps) {
1085
- existing.followUps = mc.followUps;
1086
- }
1087
- } else {
1088
- // Unreachable now that the matcher is fed the live list: everything
1089
- // it returns came from there. Kept as a guard rather than removed,
1090
- // because silently dropping a comment is the worse failure of the
1091
- // two, and this is the line that would do it.
1092
- // Add new comment if it doesn't exist
1093
- comments.push({
1094
- file: mc.file,
1095
- diffLineIndex: mc.diffLineIndex,
1096
- line: mc.line,
1097
- lineContent: mc.lineContent,
1098
- selectedText: mc.selectedText,
1099
- text: mc.text,
1100
- matchType: mc.matchType,
1101
- newLineContent: mc.newLineContent,
1102
- followUps: mc.followUps || []
1103
- });
1104
- }
1105
- });
1106
-
1357
+ matchCommentsToFile(filePath, data.diffLines);
1107
1358
  // Update comments sidebar after matching comments
1108
1359
  updateCommentsSidebar();
1109
1360
  }
@@ -1119,6 +1370,35 @@ async function loadFile(filePath, index) {
1119
1370
  }
1120
1371
  }
1121
1372
 
1373
+ /**
1374
+ * Give this file's comments their position in the diff on screen.
1375
+ *
1376
+ * Comments are found by key, not by file, line and text: two identical
1377
+ * comments on one line are two comments, and an edited one is still itself.
1378
+ *
1379
+ * @param {string} filePath
1380
+ * @param {object[]} diffLines
1381
+ */
1382
+ function matchCommentsToFile(filePath, diffLines) {
1383
+ const matchedComments = matchCommentsToDiff(comments, filePath, diffLines);
1384
+
1385
+ matchedComments.forEach(mc => {
1386
+ const existing = comments.find(c => commentKey(c) === commentKey(mc));
1387
+
1388
+ if (existing) {
1389
+ existing.diffLineIndex = mc.diffLineIndex;
1390
+ existing.matchType = mc.matchType;
1391
+ existing.newLineContent = mc.newLineContent;
1392
+ } else {
1393
+ // Unreachable now that the matcher is fed the live list: everything
1394
+ // it returns came from there. Kept as a guard rather than removed,
1395
+ // because silently dropping a comment is the worse failure of the
1396
+ // two, and this is the line that would do it.
1397
+ comments.push({ ...mc, followUps: mc.followUps || [] });
1398
+ }
1399
+ });
1400
+ }
1401
+
1122
1402
  // Display code with diff highlighting
1123
1403
  function displayCode(filePath, diffLines, binary) {
1124
1404
  const codeSection = document.getElementById('codeSection');
@@ -1127,6 +1407,7 @@ function displayCode(filePath, diffLines, binary) {
1127
1407
 
1128
1408
  currentFileEl.textContent = filePath;
1129
1409
  currentDiffLines = diffLines; // Store for reference when adding comments
1410
+ commentsPaneStale = false; // drawn from `comments` as they stand now
1130
1411
 
1131
1412
  // A binary file has no lines to show and none to comment on. Saying so is
1132
1413
  // the whole feature: it used to be decoded as UTF-8 and rendered as
@@ -1183,24 +1464,10 @@ function displayCode(filePath, diffLines, binary) {
1183
1464
  </div>
1184
1465
  ` : '';
1185
1466
 
1186
- // Generate follow-ups HTML
1187
- let followUpsHtml = '';
1188
- if (hasComment.followUps && hasComment.followUps.length > 0) {
1189
- followUpsHtml = '<div class="comment-followups">';
1190
- hasComment.followUps.forEach((followUp, idx) => {
1191
- const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
1192
- followUpsHtml += `
1193
- <div class="comment-followup-item">
1194
- <span class="followup-text">${escapeHtml(followUp.text)}</span>
1195
- ${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
1196
- </div>
1197
- `;
1198
- });
1199
- followUpsHtml += '</div>';
1200
- }
1467
+ const followUpsHtml = threadHtml(hasComment);
1201
1468
 
1202
1469
  html += `
1203
- <div class="comment-box ${matchTypeClass}">
1470
+ <div class="comment-box ${matchTypeClass}" data-key="${escapeHtml(commentKey(hasComment))}">
1204
1471
  ${fuzzyWarning}
1205
1472
  <span class="comment-text">${escapeHtml(hasComment.text)}</span>
1206
1473
  ${followUpsHtml}
@@ -1220,21 +1487,7 @@ function displayCode(filePath, diffLines, binary) {
1220
1487
  if (unmatchedComments.length > 0) {
1221
1488
  html += '<div class="unmatched-comments-section">';
1222
1489
  unmatchedComments.forEach((comment, unmatchedIndex) => {
1223
- // Generate follow-ups for unmatched comments
1224
- let unmatchedFollowUpsHtml = '';
1225
- if (comment.followUps && comment.followUps.length > 0) {
1226
- unmatchedFollowUpsHtml = '<div class="comment-followups">';
1227
- comment.followUps.forEach((followUp, idx) => {
1228
- const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
1229
- unmatchedFollowUpsHtml += `
1230
- <div class="comment-followup-item">
1231
- <span class="followup-text">${escapeHtml(followUp.text)}</span>
1232
- ${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
1233
- </div>
1234
- `;
1235
- });
1236
- unmatchedFollowUpsHtml += '</div>';
1237
- }
1490
+ const unmatchedFollowUpsHtml = threadHtml(comment);
1238
1491
 
1239
1492
  // Every control in here used to carry the comment's file name and text,
1240
1493
  // quoted into an attribute that is evaluated as JavaScript -- with `'`
@@ -1244,7 +1497,7 @@ function displayCode(filePath, diffLines, binary) {
1244
1497
  // rendered from. The row's random id went with it: the position is the
1245
1498
  // identifier now.
1246
1499
  html += `
1247
- <div class="unmatched-comment-item collapsed" tabindex="0">
1500
+ <div class="unmatched-comment-item collapsed" tabindex="0" data-key="${escapeHtml(commentKey(comment))}">
1248
1501
  <div class="unmatched-comment-header" onclick="toggleUnmatchedComment(${Number(unmatchedIndex)})">
1249
1502
  <span class="unmatched-comment-icon">⚠️</span>
1250
1503
  <span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
@@ -1402,15 +1655,7 @@ function saveFollowUp(diffLineIndex) {
1402
1655
  const comment = comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex);
1403
1656
  if (!comment) return;
1404
1657
 
1405
- // Initialize followUps if it doesn't exist
1406
- if (!comment.followUps) {
1407
- comment.followUps = [];
1408
- }
1409
-
1410
- comment.followUps.push({
1411
- text: text,
1412
- timestamp: new Date().toISOString()
1413
- });
1658
+ appendFollowUp(comment, text);
1414
1659
 
1415
1660
  // Auto-save to backend
1416
1661
  saveCommentsToBackend();
@@ -1504,15 +1749,7 @@ function saveFollowUpToUnmatched(index) {
1504
1749
  const comment = unmatchedShown[Number(index)];
1505
1750
  if (!comment) return;
1506
1751
 
1507
- // Initialize followUps if it doesn't exist
1508
- if (!comment.followUps) {
1509
- comment.followUps = [];
1510
- }
1511
-
1512
- comment.followUps.push({
1513
- text: followUpText,
1514
- timestamp: new Date().toISOString()
1515
- });
1752
+ appendFollowUp(comment, followUpText);
1516
1753
 
1517
1754
  // Auto-save to backend
1518
1755
  saveCommentsToBackend();
@@ -1692,6 +1929,17 @@ function toggleCommentInputWithSelection(diffLineIndex, selectedText = null) {
1692
1929
  });
1693
1930
  }
1694
1931
 
1932
+ /**
1933
+ * The comment being edited, while its box is open.
1934
+ *
1935
+ * Editing takes a comment out of the list and saving puts a new one back.
1936
+ * The new one used to start with an empty thread, so editing a comment that
1937
+ * had replies deleted every one of them -- and now that replies include an
1938
+ * agent's answers, that is the answers gone. Holding on to the original is
1939
+ * how the edited comment keeps its key and its thread.
1940
+ */
1941
+ let commentBeingEdited = null;
1942
+
1695
1943
  // Save comment
1696
1944
  function saveComment(diffLineIndex, selectedText = null) {
1697
1945
  const input = document.getElementById('commentInput');
@@ -1709,7 +1957,11 @@ function saveComment(diffLineIndex, selectedText = null) {
1709
1957
  const lineContent = diffLine.content || '';
1710
1958
  const lineNum = diffLine.newLine || diffLine.oldLine;
1711
1959
 
1960
+ const edited = commentBeingEdited?.file === currentFile ? commentBeingEdited : null;
1961
+ commentBeingEdited = null;
1962
+
1712
1963
  comments.push({
1964
+ key: edited?.key ?? mintKey(),
1713
1965
  file: currentFile,
1714
1966
  diffLineIndex: diffLineIndex,
1715
1967
  line: lineNum,
@@ -1717,7 +1969,7 @@ function saveComment(diffLineIndex, selectedText = null) {
1717
1969
  selectedText: selectedText,
1718
1970
  text: text,
1719
1971
  matchType: 'exact', // New comments are exact matches
1720
- followUps: [] // Initialize empty follow-ups array
1972
+ followUps: edited?.followUps ?? []
1721
1973
  });
1722
1974
 
1723
1975
  // Auto-save to backend
@@ -1745,6 +1997,7 @@ function editComment(diffLineIndex) {
1745
1997
 
1746
1998
  // Remove the comment from the list temporarily
1747
1999
  comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
2000
+ commentBeingEdited = comment;
1748
2001
 
1749
2002
  // Reload file and show edit input
1750
2003
  const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
@@ -1768,7 +2021,7 @@ function editComment(diffLineIndex) {
1768
2021
  <textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput">${escapeHtml(comment.text)}</textarea>
1769
2022
  <div class="actions">
1770
2023
  <button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
1771
- <button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
2024
+ <button class="cancel-btn" onclick="cancelEdit()">Cancel</button>
1772
2025
  </div>
1773
2026
  `;
1774
2027
 
@@ -1794,16 +2047,29 @@ function editComment(diffLineIndex) {
1794
2047
  }
1795
2048
  if (e.key === 'Escape') {
1796
2049
  e.preventDefault();
1797
- inputBox.remove();
1798
- // Restore the comment if canceled
1799
- comments.push(comment);
1800
- const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
1801
- loadFile(currentFile, fileIndex);
2050
+ cancelEdit();
1802
2051
  }
1803
2052
  });
1804
2053
  });
1805
2054
  }
1806
2055
 
2056
+ /**
2057
+ * Put back the comment whose edit was abandoned.
2058
+ *
2059
+ * Escape always did this; the Cancel button only closed the box, which left
2060
+ * the comment out of the list -- and the next save, from any edit at all,
2061
+ * deleted it from the review.
2062
+ */
2063
+ function cancelEdit() {
2064
+ document.querySelector('.comment-input-box')?.remove();
2065
+ if (commentBeingEdited) {
2066
+ comments.push(commentBeingEdited);
2067
+ commentBeingEdited = null;
2068
+ }
2069
+ const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
2070
+ loadFile(currentFile, fileIndex);
2071
+ }
2072
+
1807
2073
  // Delete comment
1808
2074
  function deleteComment(diffLineIndex) {
1809
2075
  comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
@@ -1952,11 +2218,14 @@ async function cleanupRepo() {
1952
2218
 
1953
2219
  // Reset application
1954
2220
  function resetApp() {
2221
+ closeReviewEvents();
1955
2222
  currentRepoId = null;
1956
2223
  currentFiles = [];
1957
2224
  currentFile = null;
1958
2225
  currentFileIndex = -1;
1959
2226
  comments = [];
2227
+ commentsLastUpdated = null;
2228
+ commentsBase = [];
1960
2229
 
1961
2230
  document.getElementById('repoPath').value = '';
1962
2231
  setRepoButton(null);
@@ -2311,6 +2580,14 @@ function updateCommentsSidebar() {
2311
2580
  const elsewhereHtml = elsewhere
2312
2581
  ? '<div class="comment-item-elsewhere-note">Not in this view</div>'
2313
2582
  : '';
2583
+ const replies = (comment.followUps ?? []).length;
2584
+ const answering = threadsBeingAnswered().has(commentKey(comment)) || agentState.partials[commentKey(comment)];
2585
+ const answeredBy = lastAuthorOf(comment);
2586
+ const threadNote = answering
2587
+ ? '<div class="comment-item-thread comment-item-answering">Claude is answering…</div>'
2588
+ : replies > 0
2589
+ ? `<div class="comment-item-thread">${replies} ${replies === 1 ? 'reply' : 'replies'}${answeredBy ? ` · answered by ${escapeHtml(authorName(answeredBy))}` : ''}</div>`
2590
+ : '';
2314
2591
  html += `
2315
2592
  <div class="comment-item${elsewhere ? ' comment-item-elsewhere' : ''}">
2316
2593
  <button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
@@ -2319,6 +2596,7 @@ function updateCommentsSidebar() {
2319
2596
  <div class="comment-item-line">Line ${comment.line}</div>
2320
2597
  <div class="comment-item-text">${escapeHtml(comment.text)}</div>
2321
2598
  ${selectedHtml}
2599
+ ${threadNote}
2322
2600
  ${elsewhereHtml}
2323
2601
  </div>
2324
2602
  </div>
@@ -2484,6 +2762,343 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
2484
2762
  return map[key] || null;
2485
2763
  }
2486
2764
 
2765
+ /*
2766
+ * Agents answering in the review's threads.
2767
+ *
2768
+ * A Claude Code session can be connected to the review -- in the background,
2769
+ * from the dialog below, or live from a terminal with `reviewer connect` --
2770
+ * and answers every comment and reply the reviewer writes, in the thread.
2771
+ * The answers arrive through the same comments file everything else does;
2772
+ * what the page adds is showing who is connected, which threads are being
2773
+ * answered, and an answer's words as they are written.
2774
+ */
2775
+
2776
+ /**
2777
+ * @type {{agents: object[], answerer: object|null, partials: Object<string, string>, events: EventSource|null, sessions: object[]|null}}
2778
+ * `agents` are the presences the server reports (live, background or
2779
+ * watching); `answerer` is a background session this server runs;
2780
+ * `partials` holds answers still being written, by comment key.
2781
+ */
2782
+ const agentState = { agents: [], answerer: null, partials: {}, events: null, sessions: null };
2783
+
2784
+ /** The display name for whoever wrote a follow-up. */
2785
+ function authorName(by) {
2786
+ return by === 'claude' ? 'Claude' : by;
2787
+ }
2788
+
2789
+ /** Who wrote the last word in a thread: null for the reviewer. Mirrors lib/threads.js. */
2790
+ function lastAuthorOf(comment) {
2791
+ const followUps = comment.followUps ?? [];
2792
+ return followUps.length === 0 ? null : (followUps[followUps.length - 1].by ?? null);
2793
+ }
2794
+
2795
+ /** Keys of the threads some agent is answering right now. */
2796
+ function threadsBeingAnswered() {
2797
+ const keys = new Set();
2798
+ for (const agent of agentState.agents) (agent.working ?? []).forEach(key => keys.add(key));
2799
+ if (agentState.answerer?.working) keys.add(agentState.answerer.working);
2800
+ return keys;
2801
+ }
2802
+
2803
+ /** Whether anything is connected to answer at all. */
2804
+ function agentConnected() {
2805
+ return agentState.agents.length > 0 || Boolean(agentState.answerer);
2806
+ }
2807
+
2808
+ /**
2809
+ * A little Markdown, for answers: fenced code, inline code and bold. The text
2810
+ * is escaped first and only these three patterns are turned back into
2811
+ * markup, so nothing in an answer can become anything else on the page.
2812
+ */
2813
+ function renderMarkdown(text) {
2814
+ const blocks = String(text ?? '').split(/^```[^\n]*\n?/m);
2815
+ return blocks.map((block, index) => {
2816
+ if (index % 2 === 1) return `<pre class="followup-code"><code>${escapeHtml(block.replace(/\n$/, ''))}</code></pre>`;
2817
+ return escapeHtml(block)
2818
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
2819
+ .replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
2820
+ }).join('');
2821
+ }
2822
+
2823
+ /** One follow-up in a thread. */
2824
+ function followUpHtml(followUp) {
2825
+ const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
2826
+ if (followUp.by) {
2827
+ return `
2828
+ <div class="comment-followup-item followup-agent">
2829
+ <span class="followup-author">${escapeHtml(authorName(followUp.by))}</span>
2830
+ <div class="followup-text followup-markdown">${renderMarkdown(followUp.text)}</div>
2831
+ ${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
2832
+ </div>
2833
+ `;
2834
+ }
2835
+ return `
2836
+ <div class="comment-followup-item">
2837
+ <span class="followup-text">${escapeHtml(followUp.text)}</span>
2838
+ ${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
2839
+ </div>
2840
+ `;
2841
+ }
2842
+
2843
+ /** The answer being written in a thread, or a note that one is coming. */
2844
+ function pendingAnswerHtml(comment) {
2845
+ const key = commentKey(comment);
2846
+ const partial = agentState.partials[key];
2847
+ if (threadsBeingAnswered().has(key) || partial) {
2848
+ return `
2849
+ <div class="comment-followup-item followup-agent followup-pending" data-pending-key="${escapeHtml(key)}">
2850
+ <span class="followup-author">Claude</span>
2851
+ <div class="followup-text followup-markdown">${partial ? renderMarkdown(partial) : '<span class="agent-typing">answering…</span>'}</div>
2852
+ </div>
2853
+ `;
2854
+ }
2855
+ if (agentConnected() && lastAuthorOf(comment) === null) {
2856
+ return '<div class="followup-queued">Waiting for Claude to answer</div>';
2857
+ }
2858
+ return '';
2859
+ }
2860
+
2861
+ /** A comment's follow-ups, and whatever answer is on its way. */
2862
+ function threadHtml(comment) {
2863
+ const items = (comment.followUps ?? []).map(followUpHtml).join('');
2864
+ const pending = pendingAnswerHtml(comment);
2865
+ if (!items && !pending) return '';
2866
+ return `<div class="comment-followups">${items}${pending}</div>`;
2867
+ }
2868
+
2869
+ /**
2870
+ * Put an answer's latest words on screen without redrawing the pane: the
2871
+ * pane may hold a box someone is typing in, and a partial arrives many times
2872
+ * a second.
2873
+ */
2874
+ function showPartialAnswer(key) {
2875
+ const box = document.querySelector(`.comment-box[data-key="${CSS.escape(key)}"], .unmatched-comment-item[data-key="${CSS.escape(key)}"]`);
2876
+ if (!box) return;
2877
+ const comment = comments.find(c => commentKey(c) === key);
2878
+ if (!comment) return;
2879
+ const pending = box.querySelector('.followup-pending .followup-text');
2880
+ if (pending) {
2881
+ pending.innerHTML = renderMarkdown(agentState.partials[key]);
2882
+ return;
2883
+ }
2884
+ const thread = box.querySelector('.comment-followups');
2885
+ const html = threadHtml(comment);
2886
+ if (thread) thread.outerHTML = html;
2887
+ else box.querySelector('.comment-text')?.insertAdjacentHTML('afterend', html);
2888
+ }
2889
+
2890
+ /** Redraw every thread's pending state after who-is-answering-what changed. */
2891
+ function refreshPendingAnswers() {
2892
+ renderAgentButton();
2893
+ updateCommentsSidebar();
2894
+ commentsPaneStale = true;
2895
+ redrawCommentsPane();
2896
+ }
2897
+
2898
+ /** The header button: whether anything is answering, and what it is doing. */
2899
+ function renderAgentButton() {
2900
+ const button = document.getElementById('agentButton');
2901
+ const label = document.getElementById('agentButtonLabel');
2902
+ if (!button || !label) return;
2903
+ button.classList.toggle('hidden', !currentRepoId);
2904
+
2905
+ const working = threadsBeingAnswered().size;
2906
+ const live = agentState.agents.find(agent => agent.kind === 'channel');
2907
+ const connected = agentConnected();
2908
+ button.classList.toggle('connected', connected);
2909
+ button.classList.toggle('working', working > 0);
2910
+ if (!connected) {
2911
+ label.textContent = 'Ask Claude Code';
2912
+ button.title = 'Have the Claude Code session that wrote this code answer your comments';
2913
+ } else {
2914
+ label.textContent = working > 0 ? 'Claude is answering…' : 'Claude Code connected';
2915
+ const title = live?.title ?? agentState.answerer?.title ?? agentState.agents[0]?.title;
2916
+ const how = live ? 'Live, from your terminal'
2917
+ : agentState.answerer ? 'In the background'
2918
+ : 'Following the review from an agent session';
2919
+ button.title = `${how}${title ? `: ${title}` : ''}`;
2920
+ }
2921
+ }
2922
+
2923
+ /** Follow the open review's live feed. One per tab, replaced with the repository. */
2924
+ function openReviewEvents(repoId) {
2925
+ closeReviewEvents();
2926
+ if (typeof EventSource === 'undefined') return;
2927
+ const events = new EventSource(`${API_BASE}/events/${repoId}`);
2928
+ agentState.events = events;
2929
+ // The feed counts as this tab watching once it is open; the separate
2930
+ // signal is closed only then, so there is no moment with neither.
2931
+ events.addEventListener('open', stopWatchingFromThisTab, { once: true });
2932
+
2933
+ const parse = event => {
2934
+ try {
2935
+ return JSON.parse(event.data);
2936
+ } catch {
2937
+ return null;
2938
+ }
2939
+ };
2940
+
2941
+ events.addEventListener('comments', event => {
2942
+ const data = parse(event);
2943
+ if (!data || currentRepoId !== repoId) return;
2944
+ if ((data.lastUpdated ?? null) === commentsLastUpdated) return;
2945
+ adoptRemoteComments(Array.isArray(data.comments) ? data.comments : [], data.lastUpdated ?? null);
2946
+ });
2947
+ events.addEventListener('agents', event => {
2948
+ const data = parse(event);
2949
+ if (!data || currentRepoId !== repoId) return;
2950
+ agentState.agents = Array.isArray(data.agents) ? data.agents : [];
2951
+ refreshPendingAnswers();
2952
+ });
2953
+ events.addEventListener('answerer', event => {
2954
+ if (currentRepoId !== repoId) return;
2955
+ agentState.answerer = parse(event);
2956
+ refreshPendingAnswers();
2957
+ });
2958
+ events.addEventListener('partial', event => {
2959
+ const data = parse(event);
2960
+ if (!data || currentRepoId !== repoId || !data.key) return;
2961
+ agentState.partials[data.key] = data.text;
2962
+ showPartialAnswer(data.key);
2963
+ });
2964
+ events.addEventListener('agent-error', event => {
2965
+ const data = parse(event);
2966
+ if (!data || currentRepoId !== repoId) return;
2967
+ if (data.key) delete agentState.partials[data.key];
2968
+ showStatus(`Claude Code: ${data.message}`, 'error');
2969
+ refreshPendingAnswers();
2970
+ });
2971
+ }
2972
+
2973
+ function closeReviewEvents() {
2974
+ if (agentState.events) watchFromThisTab();
2975
+ agentState.events?.close();
2976
+ agentState.events = null;
2977
+ agentState.agents = [];
2978
+ agentState.answerer = null;
2979
+ agentState.partials = {};
2980
+ renderAgentButton();
2981
+ }
2982
+
2983
+ /** How long ago, briefly. */
2984
+ function sinceText(iso) {
2985
+ return timeAgo(iso) || new Date(iso).toLocaleString();
2986
+ }
2987
+
2988
+ async function openAgentDialog() {
2989
+ if (!currentRepoId) return;
2990
+ document.getElementById('agentModal').classList.remove('hidden');
2991
+ const list = document.getElementById('agentSessions');
2992
+ list.innerHTML = '<p class="agent-empty">Looking for Claude Code sessions…</p>';
2993
+ renderAgentConnection();
2994
+
2995
+ try {
2996
+ const response = await fetch(`${API_BASE}/agent/sessions/${currentRepoId}`);
2997
+ const data = await response.json();
2998
+ if (!response.ok) throw new Error(data.error || 'Could not list sessions');
2999
+ agentState.sessions = data.sessions;
3000
+ renderAgentSessions();
3001
+ } catch (error) {
3002
+ list.innerHTML = `<p class="agent-empty">${escapeHtml(error.message)}</p>`;
3003
+ }
3004
+ }
3005
+
3006
+ function closeAgentDialog() {
3007
+ document.getElementById('agentModal').classList.add('hidden');
3008
+ }
3009
+
3010
+ /** What is connected now, with a way to stop the background session. */
3011
+ function renderAgentConnection() {
3012
+ const box = document.getElementById('agentConnected');
3013
+ const parts = [];
3014
+ for (const agent of agentState.agents.filter(a => a.kind !== 'background')) {
3015
+ parts.push(`<div class="agent-connection"><span class="agent-dot"></span>
3016
+ <div><strong>${agent.kind === 'channel' ? 'Live in your terminal' : 'Watching from an agent'}</strong>
3017
+ <div class="agent-connection-title">${escapeHtml(agent.title ?? (agent.session ? `session ${agent.session.slice(0, 8)}` : 'Claude Code'))}</div></div></div>`);
3018
+ }
3019
+ if (agentState.answerer) {
3020
+ const a = agentState.answerer;
3021
+ parts.push(`<div class="agent-connection"><span class="agent-dot"></span>
3022
+ <div><strong>Answering in the background</strong>
3023
+ <div class="agent-connection-title">${escapeHtml(a.title ?? a.session)}</div>
3024
+ <div class="agent-connection-meta">${a.forked ? `In a copy of ${escapeHtml(a.session.slice(0, 8))}${a.answeringIn && a.answeringIn !== a.session ? ` (${escapeHtml(a.answeringIn.slice(0, 8))})` : ''}; the original is untouched.` : `In session ${escapeHtml(a.session.slice(0, 8))} itself.`}</div></div>
3025
+ <button class="agent-disconnect" onclick="disconnectAgent()">Stop</button></div>`);
3026
+ }
3027
+ box.innerHTML = parts.join('');
3028
+ box.classList.toggle('hidden', parts.length === 0);
3029
+ }
3030
+
3031
+ function renderAgentSessions() {
3032
+ const list = document.getElementById('agentSessions');
3033
+ const sessions = agentState.sessions ?? [];
3034
+ if (sessions.length === 0) {
3035
+ list.innerHTML = '<p class="agent-empty">No Claude Code session has worked in this repository yet. ' +
3036
+ 'Sessions are found under <code>~/.claude/projects</code> by the directory they ran in.</p>';
3037
+ return;
3038
+ }
3039
+ list.innerHTML = sessions.map((session, index) => {
3040
+ const label = session.title ?? session.firstPrompt ?? '(no prompt)';
3041
+ const copy = session.title?.startsWith('Review answers:');
3042
+ return `
3043
+ <div class="agent-session${copy ? ' agent-session-copy' : ''}">
3044
+ <div class="agent-session-text">
3045
+ <div class="agent-session-title">${escapeHtml(label)}</div>
3046
+ <div class="agent-session-meta">${escapeHtml(session.id.slice(0, 8))}${session.branch ? ` · ${escapeHtml(session.branch)}` : ''} · ${escapeHtml(sinceText(session.updatedAt))}</div>
3047
+ </div>
3048
+ <div class="agent-session-actions">
3049
+ <button class="agent-connect" onclick="connectAgent(${Number(index)})">Answer my comments</button>
3050
+ <button class="agent-copy" onclick="copyConnectCommand(${Number(index)})" title="Copy a command that opens this session live in a terminal">Copy live command</button>
3051
+ </div>
3052
+ </div>
3053
+ `;
3054
+ }).join('');
3055
+ }
3056
+
3057
+ async function connectAgent(index) {
3058
+ const session = agentState.sessions?.[Number(index)];
3059
+ if (!session || !currentRepoId) return;
3060
+ const fork = document.getElementById('agentFork').checked;
3061
+ try {
3062
+ const response = await fetch(`${API_BASE}/agent/connect`, {
3063
+ method: 'POST',
3064
+ headers: { 'Content-Type': 'application/json' },
3065
+ body: JSON.stringify({ repoId: currentRepoId, sessionId: session.id, fork })
3066
+ });
3067
+ const data = await response.json();
3068
+ if (!response.ok) throw new Error(data.error || 'Could not connect');
3069
+ agentState.answerer = data.answerer;
3070
+ closeAgentDialog();
3071
+ refreshPendingAnswers();
3072
+ showStatus('Claude Code will answer your comments in their threads.', 'success');
3073
+ } catch (error) {
3074
+ showStatus(`Could not connect Claude Code: ${error.message}`, 'error');
3075
+ }
3076
+ }
3077
+
3078
+ async function disconnectAgent() {
3079
+ if (!currentRepoId) return;
3080
+ await fetch(`${API_BASE}/agent/disconnect`, {
3081
+ method: 'POST',
3082
+ headers: { 'Content-Type': 'application/json' },
3083
+ body: JSON.stringify({ repoId: currentRepoId })
3084
+ }).catch(() => {});
3085
+ agentState.answerer = null;
3086
+ renderAgentConnection();
3087
+ refreshPendingAnswers();
3088
+ }
3089
+
3090
+ async function copyConnectCommand(index) {
3091
+ const session = agentState.sessions?.[Number(index)];
3092
+ if (!session) return;
3093
+ const command = `reviewer connect --session ${session.id}`;
3094
+ try {
3095
+ await navigator.clipboard.writeText(command);
3096
+ showStatus(`Copied: ${command}`, 'success');
3097
+ } catch {
3098
+ showStatus(`Run this in a terminal: ${command}`, 'success');
3099
+ }
3100
+ }
3101
+
2487
3102
  // Allow Enter key to load repo
2488
3103
  /**
2489
3104
  * Tell the server this tab exists, by holding a connection open for as long
@@ -2498,18 +3113,35 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
2498
3113
  * server's grace period work -- a flaky connection comes back inside it and
2499
3114
  * nothing is stopped.
2500
3115
  */
3116
+ let aliveStream = null;
3117
+
2501
3118
  function watchFromThisTab() {
3119
+ if (aliveStream) return;
2502
3120
  try {
2503
- new EventSource(`${API_BASE}/alive`);
3121
+ aliveStream = new EventSource(`${API_BASE}/alive`);
2504
3122
  } catch {
2505
3123
  // Older browsers, or a page opened from a file:// URL. Losing this costs
2506
3124
  // an automatic exit, not a review, so there is nothing to report.
2507
3125
  }
2508
3126
  }
2509
3127
 
3128
+ /**
3129
+ * Hand the "this tab is open" signal over to the review's live feed, which
3130
+ * the server counts the same way. One long-lived connection per tab rather
3131
+ * than two: browsers allow six to a host, and every save needs one.
3132
+ */
3133
+ function stopWatchingFromThisTab() {
3134
+ aliveStream?.close();
3135
+ aliveStream = null;
3136
+ }
3137
+
2510
3138
  document.addEventListener('DOMContentLoaded', () => {
2511
3139
  watchFromThisTab();
2512
3140
 
3141
+ // A tab you were only reading catches up on other tabs' comments when it
3142
+ // comes back into view. See refreshCommentsFromElsewhere.
3143
+ document.addEventListener('visibilitychange', refreshCommentsFromElsewhere);
3144
+
2513
3145
  document.getElementById('repoPath').addEventListener('keypress', (e) => {
2514
3146
  if (e.key === 'Enter') {
2515
3147
  loadRepo();