@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.
- package/README.md +77 -0
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/components/ChatPanel.js +160 -99
- package/public/js/components/CouncilProgressModal.js +56 -64
- package/public/js/modules/file-comment-manager.js +11 -30
- package/public/js/modules/suggestion-manager.js +2 -23
- package/public/js/pr.js +11 -15
- package/public/js/ws-client.js +155 -0
- package/public/local.html +3 -0
- package/public/pr.html +3 -0
- package/public/setup.html +51 -90
- package/src/ai/analyzer.js +5 -8
- package/src/config.js +70 -49
- package/src/database.js +29 -9
- package/src/events/review-events.js +30 -0
- package/src/routes/analyses.js +3 -102
- package/src/routes/chat.js +37 -74
- package/src/routes/config.js +62 -4
- package/src/routes/context-files.js +1 -1
- package/src/routes/local.js +1 -2
- package/src/routes/mcp.js +1 -1
- package/src/routes/pr.js +1 -2
- package/src/routes/reviews.js +36 -29
- package/src/routes/setup.js +17 -114
- package/src/routes/shared.js +5 -49
- package/src/server.js +4 -0
- package/src/utils/comment-formatter.js +137 -0
- package/src/ws/index.js +2 -0
- package/src/ws/server.js +123 -0
- package/src/sse/review-events.js +0 -46
|
@@ -14,7 +14,8 @@ class CouncilProgressModal {
|
|
|
14
14
|
this.modal = null;
|
|
15
15
|
this.isVisible = false;
|
|
16
16
|
this.currentAnalysisId = null;
|
|
17
|
-
this.
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
163
|
+
// no-op: WebSocket topic subscription is unified
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
// ---------------------------------------------------------------------------
|
|
166
|
-
//
|
|
167
|
+
// WebSocket / Polling
|
|
167
168
|
// ---------------------------------------------------------------------------
|
|
168
169
|
|
|
169
170
|
startProgressMonitoring() {
|
|
170
|
-
|
|
171
|
-
this.eventSource.close();
|
|
172
|
-
}
|
|
171
|
+
this.stopProgressMonitoring();
|
|
173
172
|
if (!this.currentAnalysisId) return;
|
|
174
173
|
|
|
175
|
-
|
|
174
|
+
window.wsClient.connect();
|
|
176
175
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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.
|
|
210
|
-
this.
|
|
211
|
-
this.
|
|
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.
|
|
221
|
-
|
|
204
|
+
if (this._onReconnect) {
|
|
205
|
+
window.removeEventListener('wsReconnected', this._onReconnect);
|
|
206
|
+
this._onReconnect = null;
|
|
222
207
|
}
|
|
208
|
+
}
|
|
223
209
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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
|
|
507
|
-
* Transposes levels -> voices: for each level in
|
|
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 -
|
|
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
|
|
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(
|
|
487
|
-
: this.escapeHtml(
|
|
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
|
-
//
|
|
609
|
-
const formattedBody =
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
540
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
485
|
-
window.chatPanel?.
|
|
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
|
-
//
|
|
2660
|
-
const formattedText =
|
|
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
|
|
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 —
|
|
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>
|