@in-the-loop-labs/pair-review 2.2.0 → 2.3.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.
@@ -14,7 +14,8 @@ class CouncilProgressModal {
14
14
  this.modal = null;
15
15
  this.isVisible = false;
16
16
  this.currentAnalysisId = null;
17
- this.eventSource = null;
17
+ this._wsUnsub = null;
18
+ this._onReconnect = null;
18
19
  this.statusCheckInterval = null;
19
20
  this.isRunningInBackground = false;
20
21
  this.councilConfig = null;
@@ -146,59 +147,49 @@ class CouncilProgressModal {
146
147
 
147
148
  /**
148
149
  * Configure for local mode.
149
- * Retained as a public method since callers invoke it, but the SSE endpoint
150
- * is now unified so no per-mode state is needed.
150
+ * Retained as a public method since callers invoke it, but the WebSocket
151
+ * topic subscription is now unified so no per-mode state is needed.
151
152
  */
152
153
  setLocalMode(_reviewId) {
153
- // no-op: SSE endpoint is unified
154
+ // no-op: WebSocket topic subscription is unified
154
155
  }
155
156
 
156
157
  /**
157
158
  * Configure for PR mode (default).
158
- * Retained as a public method since callers invoke it, but the SSE endpoint
159
- * is now unified so no per-mode state is needed.
159
+ * Retained as a public method since callers invoke it, but the WebSocket
160
+ * topic subscription is now unified so no per-mode state is needed.
160
161
  */
161
162
  setPRMode() {
162
- // no-op: SSE endpoint is unified
163
+ // no-op: WebSocket topic subscription is unified
163
164
  }
164
165
 
165
166
  // ---------------------------------------------------------------------------
166
- // SSE / Polling
167
+ // WebSocket / Polling
167
168
  // ---------------------------------------------------------------------------
168
169
 
169
170
  startProgressMonitoring() {
170
- if (this.eventSource) {
171
- this.eventSource.close();
172
- }
171
+ this.stopProgressMonitoring();
173
172
  if (!this.currentAnalysisId) return;
174
173
 
175
- const sseUrl = `/api/analyses/${this.currentAnalysisId}/progress`;
174
+ window.wsClient.connect();
176
175
 
177
- this.eventSource = new EventSource(sseUrl);
178
-
179
- this.eventSource.onopen = () => {
180
- console.log('Council progress: connected to SSE stream');
181
- };
182
-
183
- this.eventSource.onmessage = (event) => {
184
- try {
185
- const data = JSON.parse(event.data);
186
- if (data.type === 'connected') return;
187
- if (data.type === 'progress') {
188
- this.updateProgress(data);
189
- if (['completed', 'failed', 'cancelled'].includes(data.status)) {
190
- this.stopProgressMonitoring();
191
- }
176
+ // Subscribe to analysis progress via WebSocket
177
+ this._wsUnsub = window.wsClient.subscribe('analysis:' + this.currentAnalysisId, (msg) => {
178
+ if (msg.type === 'progress') {
179
+ this.updateProgress(msg);
180
+ if (['completed', 'failed', 'cancelled'].includes(msg.status)) {
181
+ this.stopProgressMonitoring();
192
182
  }
193
- } catch (error) {
194
- console.error('Error parsing council SSE data:', error);
195
183
  }
196
- };
184
+ });
197
185
 
198
- this.eventSource.onerror = () => {
199
- console.error('Council SSE connection error, falling back to polling');
200
- this._fallbackToPolling();
201
- };
186
+ // Fetch initial state via HTTP (covers startup race)
187
+ this._fetchAndApplyStatus();
188
+
189
+ // Listen for WebSocket reconnects — any events broadcast during the
190
+ // reconnect gap are lost, so we re-fetch via HTTP to catch up.
191
+ this._onReconnect = () => { this._fetchAndApplyStatus(); };
192
+ window.addEventListener('wsReconnected', this._onReconnect);
202
193
  }
203
194
 
204
195
  stopProgressMonitoring() {
@@ -206,35 +197,36 @@ class CouncilProgressModal {
206
197
  clearInterval(this.statusCheckInterval);
207
198
  this.statusCheckInterval = null;
208
199
  }
209
- if (this.eventSource) {
210
- this.eventSource.close();
211
- this.eventSource = null;
212
- }
213
- }
214
-
215
- _fallbackToPolling() {
216
- if (this.eventSource) {
217
- this.eventSource.close();
218
- this.eventSource = null;
200
+ if (this._wsUnsub) {
201
+ this._wsUnsub();
202
+ this._wsUnsub = null;
219
203
  }
220
- if (this.statusCheckInterval) {
221
- clearInterval(this.statusCheckInterval);
204
+ if (this._onReconnect) {
205
+ window.removeEventListener('wsReconnected', this._onReconnect);
206
+ this._onReconnect = null;
222
207
  }
208
+ }
223
209
 
224
- this.statusCheckInterval = setInterval(async () => {
225
- if (!this.currentAnalysisId) return;
226
- try {
227
- const response = await fetch(`/api/analyses/${this.currentAnalysisId}/status`);
228
- if (!response.ok) throw new Error('Failed to fetch status');
229
- const status = await response.json();
230
- this.updateProgress(status);
231
- if (['completed', 'failed', 'cancelled'].includes(status.status)) {
232
- this.stopProgressMonitoring();
210
+ /**
211
+ * Fetch the current analysis status via HTTP and apply it to the UI.
212
+ * Used both on initial monitoring start and after WebSocket reconnects
213
+ * to catch up on any events missed during connection gaps.
214
+ */
215
+ _fetchAndApplyStatus() {
216
+ if (!this.currentAnalysisId) return;
217
+ fetch('/api/analyses/' + this.currentAnalysisId + '/status')
218
+ .then(r => r.ok ? r.json() : null)
219
+ .then(status => {
220
+ if (status) {
221
+ this.updateProgress(status);
222
+ if (['completed', 'failed', 'cancelled'].includes(status.status)) {
223
+ this.stopProgressMonitoring();
224
+ }
233
225
  }
234
- } catch (error) {
235
- console.error('Error polling council analysis status:', error);
236
- }
237
- }, 1000);
226
+ })
227
+ .catch(err => {
228
+ console.warn('Failed to fetch analysis status:', err);
229
+ });
238
230
  }
239
231
 
240
232
  // ---------------------------------------------------------------------------
@@ -264,7 +256,7 @@ class CouncilProgressModal {
264
256
  this._updateConsolidation(level4);
265
257
  }
266
258
  } else if (this._renderMode === 'council') {
267
- // Voice-centric council: transpose level-first SSE data to voice-first DOM
259
+ // Voice-centric council: transpose level-first WebSocket data to voice-first DOM
268
260
  this._updateVoiceCentric(status);
269
261
  } else {
270
262
  // Advanced (level-centric): update voices within levels
@@ -503,10 +495,10 @@ class CouncilProgressModal {
503
495
  // ---------------------------------------------------------------------------
504
496
 
505
497
  /**
506
- * Update voice-centric DOM from level-first SSE data.
507
- * Transposes levels -> voices: for each level in SSE data, update the
498
+ * Update voice-centric DOM from level-first WebSocket data.
499
+ * Transposes levels -> voices: for each level in WebSocket data, update the
508
500
  * corresponding level-child under each voice parent.
509
- * @param {Object} status - SSE status object with levels map
501
+ * @param {Object} status - WebSocket status object with levels map
510
502
  */
511
503
  _updateVoiceCentric(status) {
512
504
  for (let level = 1; level <= 3; level++) {
@@ -1045,7 +1037,7 @@ class CouncilProgressModal {
1045
1037
  // approach as the backend (runReviewerCentricCouncil in analyzer.js).
1046
1038
  // The backend deduplicates by provider|model|tier|customInstructions, then
1047
1039
  // generates keys from the index into the global deduplicated array.
1048
- // We must mirror this exactly so voice keys match SSE progress events.
1040
+ // We must mirror this exactly so voice keys match WebSocket progress events.
1049
1041
  //
1050
1042
  // Voice-centric format: levels are booleans (e.g. { '1': true }), voices
1051
1043
  // are a top-level array (config.voices). Advanced format: levels are objects
@@ -70,25 +70,6 @@ class FileCommentManager {
70
70
  return window.CategoryEmoji?.getEmoji(category) || '\u{1F4AC}';
71
71
  }
72
72
 
73
- /**
74
- * Format adopted comment text with emoji and category prefix
75
- * @param {string} text - Comment text
76
- * @param {string} category - Category name
77
- * @returns {string} Formatted text
78
- */
79
- formatAdoptedComment(text, category) {
80
- if (!category) {
81
- return text;
82
- }
83
- const emoji = this.getCategoryEmoji(category);
84
- // Properly capitalize hyphenated categories (e.g., "code-style" -> "Code Style")
85
- const capitalizedCategory = category
86
- .split('-')
87
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
88
- .join(' ');
89
- return `${emoji} **${capitalizedCategory}**: ${text}`;
90
- }
91
-
92
73
  /**
93
74
  * Get the appropriate API endpoint and request body for file-level comments
94
75
  * @private
@@ -482,9 +463,10 @@ class FileCommentManager {
482
463
  // Get category label for display (same as line-level)
483
464
  const categoryLabel = suggestion.type || suggestion.category || '';
484
465
 
466
+ const displayBody = suggestion.formattedBody || suggestion.body || '';
485
467
  const renderedBody = window.renderMarkdown
486
- ? window.renderMarkdown(suggestion.body)
487
- : this.escapeHtml(suggestion.body);
468
+ ? window.renderMarkdown(displayBody)
469
+ : this.escapeHtml(displayBody);
488
470
 
489
471
  // Use exact same HTML structure as line-level suggestions (suggestion-manager.js)
490
472
  card.innerHTML = `
@@ -605,8 +587,8 @@ class FileCommentManager {
605
587
  }
606
588
  }
607
589
 
608
- // Format the comment body with category prefix for display (matches server-side formatting)
609
- const formattedBody = this.formatAdoptedComment(suggestion.body, suggestion.type);
590
+ // Use the server-formatted body server is the single source of truth
591
+ const formattedBody = adoptResult.formattedBody;
610
592
 
611
593
  // Display as user comment with formatted body
612
594
  const commentData = {
@@ -760,7 +742,7 @@ class FileCommentManager {
760
742
  class="file-comment-textarea"
761
743
  placeholder="Edit the suggestion..."
762
744
  data-file="${window.escapeHtmlAttribute(suggestion.file)}"
763
- >${this.escapeHtml(suggestion.body)}</textarea>
745
+ >${this.escapeHtml(suggestion.formattedBody || suggestion.body)}</textarea>
764
746
  <div class="file-comment-form-footer">
765
747
  <button class="file-comment-form-btn submit submit-btn">Adopt</button>
766
748
  <button class="file-comment-form-btn cancel cancel-btn">Cancel</button>
@@ -821,11 +803,7 @@ class FileCommentManager {
821
803
  */
822
804
  async adoptWithEdit(zone, suggestion, editedBody) {
823
805
  try {
824
- // Format the edited body with category prefix (matches line-level behavior)
825
- const formattedBody = this.formatAdoptedComment(editedBody, suggestion.type);
826
-
827
- // Use the /edit endpoint which atomically creates a user comment with the edited
828
- // body and sets the suggestion status to 'adopted' with parent_id linkage
806
+ // Send raw edited text plus metadata to the server for formatting
829
807
  const reviewId = this.prManager?.currentPR?.id;
830
808
  const editEndpoint = `/api/reviews/${reviewId}/suggestions/${suggestion.id}/edit`;
831
809
 
@@ -834,7 +812,7 @@ class FileCommentManager {
834
812
  headers: { 'Content-Type': 'application/json' },
835
813
  body: JSON.stringify({
836
814
  action: 'adopt_edited',
837
- editedText: formattedBody
815
+ editedText: editedBody
838
816
  })
839
817
  });
840
818
 
@@ -853,6 +831,9 @@ class FileCommentManager {
853
831
  }
854
832
  }
855
833
 
834
+ // Use the server-formatted body — server is the single source of truth
835
+ const formattedBody = editResult.formattedBody;
836
+
856
837
  // Display as user comment with formatted body
857
838
  const commentData = {
858
839
  id: editResult.userCommentId,
@@ -159,25 +159,6 @@ class SuggestionManager {
159
159
  return window.CategoryEmoji?.getEmoji(category) || '\u{1F4AC}';
160
160
  }
161
161
 
162
- /**
163
- * Format adopted comment text with emoji and category prefix
164
- * @param {string} text - Comment text
165
- * @param {string} category - Category name
166
- * @returns {string} Formatted text
167
- */
168
- formatAdoptedComment(text, category) {
169
- if (!category) {
170
- return text;
171
- }
172
- const emoji = this.getCategoryEmoji(category);
173
- // Properly capitalize hyphenated categories (e.g., "code-style" -> "Code Style")
174
- const capitalizedCategory = category
175
- .split('-')
176
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
177
- .join(' ');
178
- return `${emoji} **${capitalizedCategory}**: ${text}`;
179
- }
180
-
181
162
  /**
182
163
  * Find suggestions that target lines currently hidden in gaps
183
164
  * @param {Array} suggestions - Array of suggestions
@@ -536,10 +517,8 @@ class SuggestionManager {
536
517
  </div>
537
518
  <div class="ai-suggestion-body">
538
519
  ${(() => {
539
- const body = suggestion.body || '';
540
- // Debug: Log what we're rendering
541
- console.log('Rendering AI suggestion body:', body.substring(0, 200));
542
- return window.renderMarkdown ? window.renderMarkdown(body) : escapeHtml(body);
520
+ const displayBody = suggestion.formattedBody || suggestion.body || '';
521
+ return window.renderMarkdown ? window.renderMarkdown(displayBody) : escapeHtml(displayBody);
543
522
  })()}
544
523
  </div>
545
524
  <div class="ai-suggestion-actions">
package/public/js/pr.js CHANGED
@@ -136,9 +136,9 @@ class PRManager {
136
136
  this.hideWhitespace = false;
137
137
  // Diff options dropdown (gear icon popover)
138
138
  this.diffOptionsDropdown = null;
139
- // Unique client ID for self-echo suppression on SSE review events.
139
+ // Unique client ID for self-echo suppression on WebSocket review events.
140
140
  // Sent as X-Client-Id header on mutation requests; the server echoes
141
- // it back in the SSE broadcast so this tab can skip its own events.
141
+ // it back in the WebSocket broadcast so this tab can skip its own events.
142
142
  this._clientId = Math.random().toString(36).slice(2) + Date.now().toString(36);
143
143
  this._installFetchInterceptor();
144
144
 
@@ -203,7 +203,7 @@ class PRManager {
203
203
  * fetch call site should manually set this header.
204
204
  * This ensures that even direct fetch() calls (e.g. from page.evaluate
205
205
  * in tests, or any code that bypasses PRManager methods) carry the
206
- * client ID so the server can tag the SSE broadcast for self-echo
206
+ * client ID so the server can tag the WebSocket broadcast for self-echo
207
207
  * suppression.
208
208
  */
209
209
  _installFetchInterceptor() {
@@ -462,7 +462,7 @@ class PRManager {
462
462
  // Check if AI analysis is currently running
463
463
  await this.checkRunningAnalysis();
464
464
 
465
- // Listen for review mutation events via multiplexed SSE
465
+ // Listen for review mutation events via WebSocket pub/sub
466
466
  this._initReviewEventListeners();
467
467
 
468
468
  } catch (error) {
@@ -475,14 +475,14 @@ class PRManager {
475
475
 
476
476
  /**
477
477
  * Listen for review-scoped CustomEvents dispatched by ChatPanel's
478
- * multiplexed SSE connection. Replaces the old per-review EventSource.
478
+ * WebSocket pub/sub connection.
479
479
  */
480
480
  _initReviewEventListeners() {
481
481
  if (this._reviewEventsBound) return;
482
482
  this._reviewEventsBound = true;
483
483
 
484
- // Eagerly connect chat SSE so review events flow even before chat opens
485
- window.chatPanel?._ensureGlobalSSE();
484
+ // Eagerly connect WebSocket subscriptions so review events flow even before chat opens
485
+ window.chatPanel?._ensureSubscriptions();
486
486
 
487
487
  // Late-bind reviewId to ChatPanel if it was auto-opened by PanelGroup
488
488
  // before prManager was ready (DOMContentLoaded race condition)
@@ -2595,10 +2595,6 @@ class PRManager {
2595
2595
  return this.suggestionManager.getCategoryEmoji(category);
2596
2596
  }
2597
2597
 
2598
- formatAdoptedComment(text, category) {
2599
- return this.suggestionManager.formatAdoptedComment(text, category);
2600
- }
2601
-
2602
2598
  getTypeDescription(type) {
2603
2599
  return this.suggestionManager.getTypeDescription(type);
2604
2600
  }
@@ -2656,8 +2652,8 @@ class PRManager {
2656
2652
  // Collapse the suggestion in the UI
2657
2653
  this.collapseSuggestionForAdoption(suggestionRow, suggestionId);
2658
2654
 
2659
- // Build comment data from the adopt response and suggestion metadata
2660
- const formattedText = this.formatAdoptedComment(suggestionText, suggestionType);
2655
+ // Use the server-formatted body — server is the single source of truth
2656
+ const formattedText = adoptResult.formattedBody;
2661
2657
  const newComment = {
2662
2658
  id: adoptResult.userCommentId,
2663
2659
  file: fileName,
@@ -4169,7 +4165,7 @@ class PRManager {
4169
4165
 
4170
4166
  /**
4171
4167
  * Load context files for the current review and render them in the diff panel.
4172
- * Called after renderDiff() and on SSE context_files_changed events.
4168
+ * Called after renderDiff() and on WebSocket context_files_changed events.
4173
4169
  */
4174
4170
  async loadContextFiles() {
4175
4171
  const reviewId = this.currentPR?.id;
@@ -4593,7 +4589,7 @@ class PRManager {
4593
4589
  console.error('Failed to remove context file:', resp.status);
4594
4590
  return;
4595
4591
  }
4596
- // Refresh immediately — SSE self-echo is suppressed by the client ID filter
4592
+ // Refresh immediately — WebSocket self-echo is suppressed by the client ID filter
4597
4593
  await this.loadContextFiles();
4598
4594
  } catch (error) {
4599
4595
  console.error('Error removing context file:', error);
@@ -0,0 +1,155 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ /**
4
+ * Browser-side WebSocket client singleton.
5
+ * Provides topic-based pub/sub over a single WebSocket connection
6
+ * with automatic reconnection and subscription restoration.
7
+ */
8
+ (function () {
9
+ 'use strict';
10
+
11
+ class WSClient {
12
+ constructor() {
13
+ /** @type {WebSocket|null} */
14
+ this._ws = null;
15
+ /** @type {Map<string, Set<Function>>} topic -> callbacks */
16
+ this._subscriptions = new Map();
17
+ /** @type {boolean} */
18
+ this.connected = false;
19
+ /** @type {number} current backoff delay in ms */
20
+ this._backoff = 1000;
21
+ /** @type {number} */
22
+ this._backoffMax = 10000;
23
+ /** @type {boolean} whether close() was called intentionally */
24
+ this._closed = false;
25
+ /** @type {number|null} reconnect timer id */
26
+ this._reconnectTimer = null;
27
+ /** @type {boolean} whether at least one connection has been established */
28
+ this._hasConnected = false;
29
+ }
30
+
31
+ /**
32
+ * Open the WebSocket connection. No-op if already connected or connecting.
33
+ */
34
+ connect() {
35
+ if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) {
36
+ return;
37
+ }
38
+ this._closed = false;
39
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
40
+ const url = `${protocol}//${location.host}/ws`;
41
+ this._ws = new WebSocket(url);
42
+
43
+ this._ws.onopen = () => {
44
+ this.connected = true;
45
+ this._backoff = 1000;
46
+ // Re-subscribe to all active topics (_subscriptions is authoritative)
47
+ for (const topic of this._subscriptions.keys()) {
48
+ this._ws.send(JSON.stringify({ action: 'subscribe', topic }));
49
+ }
50
+ // Emit reconnected event on subsequent opens (not the initial connect)
51
+ if (this._hasConnected) {
52
+ window.dispatchEvent(new CustomEvent('wsReconnected'));
53
+ }
54
+ this._hasConnected = true;
55
+ };
56
+
57
+ this._ws.onmessage = (event) => {
58
+ let msg;
59
+ try {
60
+ msg = JSON.parse(event.data);
61
+ } catch {
62
+ return;
63
+ }
64
+ const callbacks = this._subscriptions.get(msg.topic);
65
+ if (callbacks) {
66
+ for (const cb of [...callbacks]) {
67
+ try {
68
+ cb(msg);
69
+ } catch (e) {
70
+ console.error('[WSClient] Subscriber error:', e);
71
+ }
72
+ }
73
+ }
74
+ };
75
+
76
+ this._ws.onclose = () => {
77
+ this.connected = false;
78
+ this._ws = null;
79
+ if (!this._closed) {
80
+ this._scheduleReconnect();
81
+ }
82
+ };
83
+
84
+ this._ws.onerror = () => {
85
+ // onclose will fire after onerror, which handles reconnection
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Subscribe to a topic. Returns an unsubscribe function.
91
+ * Safe to call before connect() — the subscribe message will be
92
+ * sent once the connection is established.
93
+ *
94
+ * @param {string} topic
95
+ * @param {Function} callback - receives the full parsed message object
96
+ * @returns {Function} unsubscribe
97
+ */
98
+ subscribe(topic, callback) {
99
+ let callbacks = this._subscriptions.get(topic);
100
+ if (!callbacks) {
101
+ callbacks = new Set();
102
+ this._subscriptions.set(topic, callbacks);
103
+ }
104
+ callbacks.add(callback);
105
+
106
+ // Send subscribe message if connected (otherwise it will be sent on connect via _subscriptions)
107
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
108
+ this._ws.send(JSON.stringify({ action: 'subscribe', topic }));
109
+ }
110
+
111
+ // Return unsubscribe function
112
+ return () => {
113
+ callbacks.delete(callback);
114
+ if (callbacks.size === 0) {
115
+ this._subscriptions.delete(topic);
116
+ const unsub = { action: 'unsubscribe', topic };
117
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
118
+ this._ws.send(JSON.stringify(unsub));
119
+ }
120
+ }
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Close the WebSocket and stop reconnection.
126
+ */
127
+ close() {
128
+ this._closed = true;
129
+ if (this._reconnectTimer !== null) {
130
+ clearTimeout(this._reconnectTimer);
131
+ this._reconnectTimer = null;
132
+ }
133
+ if (this._ws) {
134
+ this._ws.close();
135
+ this._ws = null;
136
+ }
137
+ this.connected = false;
138
+ }
139
+
140
+ /** @private */
141
+ _scheduleReconnect() {
142
+ this._reconnectTimer = setTimeout(() => {
143
+ this._reconnectTimer = null;
144
+ this.connect();
145
+ }, this._backoff);
146
+ this._backoff = Math.min(this._backoff * 2, this._backoffMax);
147
+ }
148
+ }
149
+
150
+ // Export as singleton on window
151
+ if (typeof window !== 'undefined') {
152
+ window.WSClient = WSClient;
153
+ window.wsClient = new WSClient();
154
+ }
155
+ })();
package/public/local.html CHANGED
@@ -516,6 +516,9 @@
516
516
  <!-- Timestamp parsing utility -->
517
517
  <script src="/js/utils/time.js"></script>
518
518
 
519
+ <!-- WebSocket client -->
520
+ <script src="/js/ws-client.js"></script>
521
+
519
522
  <!-- Components -->
520
523
  <script src="/js/components/Toast.js"></script>
521
524
  <script src="/js/components/ConfirmDialog.js"></script>
package/public/pr.html CHANGED
@@ -339,6 +339,9 @@
339
339
  <!-- Timestamp parsing utility -->
340
340
  <script src="/js/utils/time.js"></script>
341
341
 
342
+ <!-- WebSocket client -->
343
+ <script src="/js/ws-client.js"></script>
344
+
342
345
  <!-- Components -->
343
346
  <script src="/js/components/Toast.js"></script>
344
347
  <script src="/js/components/ConfirmDialog.js"></script>