codex-overleaf-link 2.3.2 → 2.3.4
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 +23 -23
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/src/content/contentRuntime.js +42 -5
- package/extension/src/content/generated/content.bundle.js +227 -11
- package/extension/src/content/generated/content.bundle.meta.json +3 -3
- package/extension/src/content/markdownDomRenderer.js +70 -0
- package/extension/src/content/modelPicker.js +1 -1
- package/extension/src/content/panelMaintenance.js +2 -0
- package/extension/src/content/panelRenderer.js +80 -1
- package/extension/src/content/pendingInputView.js +16 -3
- package/extension/src/content/providerSettingsDialog.js +1 -0
- package/extension/src/content/runController.js +35 -0
- package/extension/src/content/runTimelineView.js +3 -0
- package/extension/src/popup.js +10 -10
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/styles/panel.css +243 -31
- package/native-host/src/codexRuntimeIdentity.js +79 -4
- package/native-host/src/nativeEnvironment.js +4 -1
- package/native-host/src/textPatch.js +707 -259
- package/package.json +1 -1
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
const ROOT_CLASS = 'codex-overleaf-panel-mounted';
|
|
5
5
|
const RESIZE_CLASS = 'codex-overleaf-panel-resizing';
|
|
6
6
|
const COMPACT_CLASS = 'codex-overleaf-panel-compact';
|
|
7
|
+
const LAUNCHER_VISIBILITY_STORAGE_KEY = 'codex-overleaf:launcher-visible';
|
|
7
8
|
|
|
8
9
|
function codexIcon(name) {
|
|
9
10
|
const icons = {
|
|
10
11
|
refresh: '<path d="M4.2 5.3A5 5 0 1 1 3.6 10"/><path d="M4.2 2.2v3.1H1.1"/>',
|
|
11
12
|
plus: '<path d="M8 3.2v9.6"/><path d="M3.2 8h9.6"/>',
|
|
13
|
+
panel: '<rect x="2.3" y="2.3" width="11.4" height="11.4" rx="2"/><path d="M9.2 2.5v11"/><path d="M5.1 6.1h1.5M5.1 8h1.5M5.1 9.9h1.5"/>',
|
|
14
|
+
collapse: '<path d="m6.1 3.3 4.7 4.7-4.7 4.7"/>',
|
|
12
15
|
settings: '<path d="M3.4 5.2h5.9"/><path d="M11.7 5.2h.9"/><path d="M10.1 3.6v3.2"/><path d="M3.4 10.8h1"/><path d="M6.8 10.8h5.8"/><path d="M5.9 9.2v3.2"/>'
|
|
13
16
|
};
|
|
14
17
|
const safeName = Object.prototype.hasOwnProperty.call(icons, name) ? name : 'settings';
|
|
@@ -19,9 +22,11 @@
|
|
|
19
22
|
const doc = options.document || document;
|
|
20
23
|
const container = options.container || doc.documentElement;
|
|
21
24
|
const panelEl = doc.createElement('aside');
|
|
25
|
+
const launcherEl = doc.createElement('button');
|
|
22
26
|
const callbacks = options.callbacks || {};
|
|
23
27
|
const instance = {
|
|
24
28
|
panelEl,
|
|
29
|
+
launcherEl,
|
|
25
30
|
callbacks,
|
|
26
31
|
defaultWidth: options.defaultWidth || 380,
|
|
27
32
|
minWidth: options.minWidth || 340,
|
|
@@ -30,9 +35,18 @@
|
|
|
30
35
|
lastDesktopWidth: options.initialWidth || options.defaultWidth || 380,
|
|
31
36
|
listeners: [],
|
|
32
37
|
document: doc,
|
|
33
|
-
window: options.window || window
|
|
38
|
+
window: options.window || window,
|
|
39
|
+
chromeApi: options.chromeApi || (typeof chrome !== 'undefined' ? chrome : null),
|
|
40
|
+
storageChangeListener: null
|
|
34
41
|
};
|
|
35
42
|
|
|
43
|
+
launcherEl.id = 'codex-overleaf-launcher';
|
|
44
|
+
launcherEl.type = 'button';
|
|
45
|
+
launcherEl.title = options.launcherLabel || 'Show or hide Codex panel';
|
|
46
|
+
launcherEl.setAttribute('aria-label', launcherEl.title);
|
|
47
|
+
launcherEl.setAttribute('aria-expanded', 'false');
|
|
48
|
+
launcherEl.innerHTML = codexIcon('panel');
|
|
49
|
+
|
|
36
50
|
panelEl.id = options.panelId || 'codex-overleaf-panel';
|
|
37
51
|
panelEl.dataset.view = 'session';
|
|
38
52
|
panelEl.innerHTML = `
|
|
@@ -44,6 +58,7 @@
|
|
|
44
58
|
<div data-diagnostics-slot></div>
|
|
45
59
|
<button type="button" data-new-session title="New Session" aria-label="New Session">${codexIcon('plus')}</button>
|
|
46
60
|
<button type="button" data-custom-instructions-settings title="Project Settings" aria-label="Project Settings" aria-expanded="false">${codexIcon('settings')}</button>
|
|
61
|
+
<button type="button" data-close-panel title="Close Codex panel" aria-label="Close Codex panel">${codexIcon('collapse')}</button>
|
|
47
62
|
</div>
|
|
48
63
|
</div>
|
|
49
64
|
<div data-settings-slot></div>
|
|
@@ -59,6 +74,7 @@
|
|
|
59
74
|
`;
|
|
60
75
|
|
|
61
76
|
container.append(panelEl);
|
|
77
|
+
container.append(launcherEl);
|
|
62
78
|
doc.documentElement.classList.add(ROOT_CLASS);
|
|
63
79
|
|
|
64
80
|
instance.headerEl = panelEl.querySelector('[data-panel-header]');
|
|
@@ -73,6 +89,8 @@
|
|
|
73
89
|
bind(instance, panelEl.querySelector('[data-refresh]'), 'click', () => callbacks.onRefresh?.());
|
|
74
90
|
bind(instance, panelEl.querySelector('[data-new-session]'), 'click', () => callbacks.onNewSession?.());
|
|
75
91
|
bind(instance, panelEl.querySelector('[data-custom-instructions-settings]'), 'click', () => callbacks.onSettingsClick?.());
|
|
92
|
+
bind(instance, panelEl.querySelector('[data-close-panel]'), 'click', () => callbacks.onClosePanel?.());
|
|
93
|
+
bind(instance, launcherEl, 'click', () => callbacks.onLauncherToggle?.());
|
|
76
94
|
bind(instance, panelEl.querySelector('[data-panel-resize-handle]'), 'pointerdown', event => startResize(instance, event));
|
|
77
95
|
bind(instance, panelEl.querySelector('[data-panel-resize-handle]'), 'dblclick', event => {
|
|
78
96
|
event?.preventDefault?.();
|
|
@@ -87,9 +105,11 @@
|
|
|
87
105
|
});
|
|
88
106
|
|
|
89
107
|
setWidth(instance, options.initialWidth || instance.defaultWidth, { notify: false });
|
|
108
|
+
initializeLauncherVisibility(instance);
|
|
90
109
|
|
|
91
110
|
return {
|
|
92
111
|
panelEl,
|
|
112
|
+
launcherEl,
|
|
93
113
|
headerEl: instance.headerEl,
|
|
94
114
|
bodyEl: instance.bodyEl,
|
|
95
115
|
diagnosticsSlot: instance.diagnosticsSlot,
|
|
@@ -198,11 +218,64 @@
|
|
|
198
218
|
function setVisible(panelEl, visible) {
|
|
199
219
|
panelEl?.classList?.toggle('is-open', Boolean(visible));
|
|
200
220
|
document.documentElement.classList.toggle(ROOT_CLASS, Boolean(visible));
|
|
221
|
+
const launcherEl = panelEl?.ownerDocument?.getElementById?.('codex-overleaf-launcher');
|
|
222
|
+
launcherEl?.classList?.toggle('is-panel-open', Boolean(visible));
|
|
223
|
+
launcherEl?.setAttribute?.('aria-expanded', visible ? 'true' : 'false');
|
|
224
|
+
if (launcherEl) {
|
|
225
|
+
launcherEl.hidden = Boolean(visible) || launcherEl.dataset.enabled === 'false';
|
|
226
|
+
launcherEl.setAttribute('aria-hidden', launcherEl.hidden ? 'true' : 'false');
|
|
227
|
+
}
|
|
201
228
|
if (!visible) {
|
|
202
229
|
document.documentElement.classList.remove(RESIZE_CLASS, COMPACT_CLASS);
|
|
203
230
|
}
|
|
204
231
|
}
|
|
205
232
|
|
|
233
|
+
function initializeLauncherVisibility(instance) {
|
|
234
|
+
const storage = instance.chromeApi?.storage;
|
|
235
|
+
if (!storage?.local?.get) {
|
|
236
|
+
setLauncherVisible(instance, true, { persist: false });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
Promise.resolve(storage.local.get({ [LAUNCHER_VISIBILITY_STORAGE_KEY]: true }))
|
|
240
|
+
.then(values => applyLauncherVisibility(instance, values?.[LAUNCHER_VISIBILITY_STORAGE_KEY]))
|
|
241
|
+
.catch(() => applyLauncherVisibility(instance, true));
|
|
242
|
+
if (storage.onChanged?.addListener) {
|
|
243
|
+
instance.storageChangeListener = (changes, areaName) => {
|
|
244
|
+
if (areaName === 'local' && changes?.[LAUNCHER_VISIBILITY_STORAGE_KEY]) {
|
|
245
|
+
applyLauncherVisibility(instance, changes[LAUNCHER_VISIBILITY_STORAGE_KEY].newValue);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
storage.onChanged.addListener(instance.storageChangeListener);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function applyLauncherVisibility(instance, value) {
|
|
253
|
+
const visible = value !== false;
|
|
254
|
+
setLauncherVisible(instance, visible, { persist: false });
|
|
255
|
+
instance.callbacks.onLauncherVisibilityChange?.(visible);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function setLauncherVisible(target, visible, options = {}) {
|
|
259
|
+
const instance = target?._instance || target;
|
|
260
|
+
const launcherEl = instance?.launcherEl;
|
|
261
|
+
if (!launcherEl) return false;
|
|
262
|
+
const nextVisible = Boolean(visible);
|
|
263
|
+
launcherEl.dataset.enabled = nextVisible ? 'true' : 'false';
|
|
264
|
+
launcherEl.hidden = !nextVisible || instance.panelEl?.classList?.contains('is-open') === true;
|
|
265
|
+
launcherEl.setAttribute('aria-hidden', launcherEl.hidden ? 'true' : 'false');
|
|
266
|
+
if (options.persist === true) {
|
|
267
|
+
Promise.resolve(instance.chromeApi?.storage?.local?.set?.({
|
|
268
|
+
[LAUNCHER_VISIBILITY_STORAGE_KEY]: nextVisible
|
|
269
|
+
})).catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
return nextVisible;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isLauncherVisible(target) {
|
|
275
|
+
const instance = target?._instance || target;
|
|
276
|
+
return Boolean(instance?.launcherEl && instance.launcherEl.dataset.enabled !== 'false');
|
|
277
|
+
}
|
|
278
|
+
|
|
206
279
|
function setBadge(headerEl, badge = {}) {
|
|
207
280
|
const actions = headerEl?.querySelector?.('.codex-vscode-head-actions');
|
|
208
281
|
if (!actions) {
|
|
@@ -340,6 +413,10 @@
|
|
|
340
413
|
target.removeEventListener?.(type, listener, options);
|
|
341
414
|
}
|
|
342
415
|
instance.panelEl?.remove?.();
|
|
416
|
+
instance.launcherEl?.remove?.();
|
|
417
|
+
if (instance.storageChangeListener) {
|
|
418
|
+
instance.chromeApi?.storage?.onChanged?.removeListener?.(instance.storageChangeListener);
|
|
419
|
+
}
|
|
343
420
|
instance.document.documentElement.classList.remove(ROOT_CLASS, RESIZE_CLASS, COMPACT_CLASS);
|
|
344
421
|
}
|
|
345
422
|
|
|
@@ -347,6 +424,8 @@
|
|
|
347
424
|
create,
|
|
348
425
|
createConfirmController,
|
|
349
426
|
setVisible,
|
|
427
|
+
setLauncherVisible,
|
|
428
|
+
isLauncherVisible,
|
|
350
429
|
setBadge,
|
|
351
430
|
setWidth
|
|
352
431
|
};
|
|
@@ -16,10 +16,17 @@
|
|
|
16
16
|
}
|
|
17
17
|
container.replaceChildren();
|
|
18
18
|
container.hidden = !items.length;
|
|
19
|
+
if (!items.length) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
19
22
|
for (const item of items) {
|
|
20
23
|
const row = document.createElement('article');
|
|
21
24
|
row.className = 'codex-pending-input';
|
|
22
25
|
row.dataset.status = item.status || 'queued';
|
|
26
|
+
const marker = document.createElement('span');
|
|
27
|
+
marker.className = 'codex-pending-input-marker';
|
|
28
|
+
marker.setAttribute('aria-hidden', 'true');
|
|
29
|
+
marker.append(createIcon('queue'));
|
|
23
30
|
const copy = document.createElement('div');
|
|
24
31
|
copy.className = 'codex-pending-input-copy';
|
|
25
32
|
const status = document.createElement('span');
|
|
@@ -34,7 +41,10 @@
|
|
|
34
41
|
const text = document.createElement('span');
|
|
35
42
|
text.className = 'codex-pending-input-text';
|
|
36
43
|
text.textContent = item.text;
|
|
37
|
-
copy.append(
|
|
44
|
+
copy.append(text);
|
|
45
|
+
if (item.status && item.status !== 'queued') {
|
|
46
|
+
copy.append(status);
|
|
47
|
+
}
|
|
38
48
|
const actions = document.createElement('div');
|
|
39
49
|
actions.className = 'codex-pending-input-actions';
|
|
40
50
|
if (item.status === 'paused') {
|
|
@@ -60,7 +70,8 @@
|
|
|
60
70
|
remove.disabled = item.status === 'steering' || item.status === 'executing';
|
|
61
71
|
text.title = item.text;
|
|
62
72
|
actions.append(remove);
|
|
63
|
-
row.
|
|
73
|
+
row.setAttribute('aria-label', `${status.textContent || options.tr?.('queuedInputQueued') || 'Queued'}: ${item.text}`);
|
|
74
|
+
row.append(marker, copy, actions);
|
|
64
75
|
container.append(row);
|
|
65
76
|
}
|
|
66
77
|
}
|
|
@@ -93,7 +104,9 @@
|
|
|
93
104
|
svg.setAttribute('focusable', 'false');
|
|
94
105
|
const paths = icon === 'guide'
|
|
95
106
|
? ['M4 4.5v4a4 4 0 0 0 4 4h7', 'm12 9 3.5 3.5L12 16']
|
|
96
|
-
:
|
|
107
|
+
: icon === 'queue'
|
|
108
|
+
? ['M4 5.5h12', 'M4 10h12', 'M4 14.5h8']
|
|
109
|
+
: ['M6 6h8', 'M8 6V4.5h4V6', 'm7.2 8 0.6 7.5h4.4l.6-7.5'];
|
|
97
110
|
for (const value of paths) {
|
|
98
111
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
99
112
|
path.setAttribute('d', value);
|
|
@@ -985,6 +985,7 @@
|
|
|
985
985
|
return;
|
|
986
986
|
}
|
|
987
987
|
const style = instance.document.defaultView.getComputedStyle(panel);
|
|
988
|
+
instance.root.dataset.theme = panel.dataset.theme === 'light' ? 'light' : 'dark';
|
|
988
989
|
instance.root.style.setProperty('--provider-panel-bg', style.backgroundColor || '#151716');
|
|
989
990
|
instance.root.style.setProperty('--provider-panel-color', style.color || '#eceeea');
|
|
990
991
|
}
|
|
@@ -231,11 +231,46 @@
|
|
|
231
231
|
return String(content || '').trim().length > 0;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
function isRetryReplacementEligible(run = {}, projection = {}) {
|
|
235
|
+
if (!run.id || !['failed', 'interrupted'].includes(run.status)) return false;
|
|
236
|
+
if (run.trackedChangeStatus || run.undoStatus) return false;
|
|
237
|
+
if (['undoOperations', 'undoTrackedChanges', 'undoExpectedFiles', 'undoBaseFiles', 'appliedOperations']
|
|
238
|
+
.some(field => Array.isArray(run[field]) && run[field].length)) return false;
|
|
239
|
+
if (projection.canAccept || projection.canUndo || projection.changedDocument === true) return false;
|
|
240
|
+
if (projection.failureTerminalState === 'needs_review' || projection.terminalState === 'needs_review') return false;
|
|
241
|
+
return projection.changedDocument === false || run.mode === 'ask' || hasZeroWriteReport(run.events);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function hasZeroWriteReport(events = []) {
|
|
245
|
+
return (Array.isArray(events) ? events : []).some(event => {
|
|
246
|
+
const rows = Array.isArray(event?.detailStructured?.meta) ? event.detailStructured.meta : [];
|
|
247
|
+
return rows.some(row => row?.key === 'writeResult'
|
|
248
|
+
&& /(?:wrote|写入)\s*0\b[\s\S]*(?:skipped|跳过)\s*0\b/i.test(String(row.value || '')));
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function resolveRetryReplacement({ candidate, activeSessionId, runs, projectRunSettlement } = {}) {
|
|
253
|
+
if (!candidate?.runId || candidate.sessionId !== activeSessionId) return '';
|
|
254
|
+
const source = (Array.isArray(runs) ? runs : []).find(run => run?.id === candidate.runId);
|
|
255
|
+
if (!source) return '';
|
|
256
|
+
const projection = typeof projectRunSettlement === 'function' ? projectRunSettlement(source) : {};
|
|
257
|
+
return isRetryReplacementEligible(source, projection) ? source.id : '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function replaceRunForRetry(runs, sourceRunId, nextRun, maxRuns = 20) {
|
|
261
|
+
const retained = (Array.isArray(runs) ? runs : [])
|
|
262
|
+
.filter(run => !sourceRunId || run?.id !== sourceRunId);
|
|
263
|
+
return [...retained, nextRun].slice(-Math.max(1, maxRuns));
|
|
264
|
+
}
|
|
265
|
+
|
|
234
266
|
return {
|
|
235
267
|
buildCodexRunParams,
|
|
236
268
|
canUseFocusedPartialSnapshot,
|
|
237
269
|
shouldRestrictWritebackToFocus,
|
|
238
270
|
buildSessionHistoryResult,
|
|
271
|
+
isRetryReplacementEligible,
|
|
272
|
+
replaceRunForRetry,
|
|
273
|
+
resolveRetryReplacement,
|
|
239
274
|
truncateSessionHistoryText,
|
|
240
275
|
normalizeComposerAttachments,
|
|
241
276
|
normalizeSkillInvocation
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
cssEscape,
|
|
31
31
|
renderMarkdownInlineText,
|
|
32
32
|
renderMarkdownBlockText,
|
|
33
|
+
decorateCompletionReport,
|
|
33
34
|
sanitizeAssistantVisibleText,
|
|
34
35
|
sanitizeAssistantVisibleValue,
|
|
35
36
|
stripEmptyHtmlCommentPlaceholders = value => value,
|
|
@@ -709,6 +710,7 @@
|
|
|
709
710
|
}
|
|
710
711
|
if (mainSections.length) {
|
|
711
712
|
renderMarkdownBlockText(main, mainSections.join('\n\n'));
|
|
713
|
+
decorateCompletionReport(main, { tx });
|
|
712
714
|
report.append(main);
|
|
713
715
|
}
|
|
714
716
|
|
|
@@ -729,6 +731,7 @@
|
|
|
729
731
|
const body = document.createElement('div');
|
|
730
732
|
body.className = 'run-final-answer';
|
|
731
733
|
renderMarkdownBlockText(body, split.body || flatText);
|
|
734
|
+
decorateCompletionReport(body, { tx });
|
|
732
735
|
report.append(body);
|
|
733
736
|
appendCompileFix(report);
|
|
734
737
|
appendRejectedRedo(report);
|
package/extension/src/popup.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
let currentInstallCommand = DEFAULT_INSTALL_COMMAND;
|
|
19
19
|
|
|
20
20
|
installCommand.textContent = currentInstallCommand;
|
|
21
|
-
button.textContent = '
|
|
21
|
+
button.textContent = 'Show Codex button in Overleaf';
|
|
22
22
|
setVersionStatus({
|
|
23
23
|
status: 'native_missing',
|
|
24
24
|
classification: 'incompatible',
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
await chrome.tabs.sendMessage(tab.id, { type: 'codex-overleaf/toggle-
|
|
35
|
+
await chrome.tabs.sendMessage(tab.id, { type: 'codex-overleaf/toggle-launcher' });
|
|
36
36
|
window.close();
|
|
37
37
|
});
|
|
38
38
|
|
|
@@ -56,20 +56,20 @@
|
|
|
56
56
|
const tab = await getActiveOverleafProjectTab();
|
|
57
57
|
activeOverleafTab = tab;
|
|
58
58
|
if (!tab?.id) {
|
|
59
|
-
button.textContent = '
|
|
59
|
+
button.textContent = 'Show Codex button in Overleaf';
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
try {
|
|
64
64
|
const response = await chrome.tabs.sendMessage(tab.id, { type: 'codex-overleaf/get-panel-state' });
|
|
65
|
-
const
|
|
66
|
-
button.textContent =
|
|
67
|
-
status.textContent =
|
|
68
|
-
? '
|
|
69
|
-
: '
|
|
65
|
+
const visible = response?.launcherVisible !== false;
|
|
66
|
+
button.textContent = visible ? 'Hide Codex edge button' : 'Show Codex edge button';
|
|
67
|
+
status.textContent = visible
|
|
68
|
+
? 'The edge button appears whenever the Codex panel is closed.'
|
|
69
|
+
: 'The Codex edge button is hidden in Overleaf.';
|
|
70
70
|
} catch (_error) {
|
|
71
|
-
button.textContent = '
|
|
72
|
-
status.textContent = 'Refresh the Overleaf tab, then
|
|
71
|
+
button.textContent = 'Show Codex button in Overleaf';
|
|
72
|
+
status.textContent = 'Refresh the Overleaf tab, then show the Codex button.';
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
const MIN_NATIVE_VERSION = '1.0.0';
|
|
13
13
|
const MIN_COMPATIBLE_NATIVE_VERSION = '1.0.0';
|
|
14
14
|
const MIN_COMPATIBLE_EXTENSION_VERSION = '1.0.0';
|
|
15
|
-
const BUILD_TARGET_VERSION = '2.3.
|
|
15
|
+
const BUILD_TARGET_VERSION = '2.3.4';
|
|
16
16
|
const DEFAULT_CHROME_EXTENSION_ID = 'illdpneeeopfffmiepaejglgmhpmdhdc';
|
|
17
17
|
const REQUIRED_CAPABILITIES = Object.freeze([
|
|
18
18
|
'bridgePing',
|