@agent-native/core 0.45.1 → 0.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/agent/production-agent.d.ts +28 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +14 -7
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/skills.d.ts +2 -2
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +33 -0
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/components/LiveCursorOverlay.d.ts +46 -0
- package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -0
- package/dist/client/components/LiveCursorOverlay.js +137 -0
- package/dist/client/components/LiveCursorOverlay.js.map +1 -0
- package/dist/client/components/PresenceBar.d.ts +11 -1
- package/dist/client/components/PresenceBar.d.ts.map +1 -1
- package/dist/client/components/PresenceBar.js +39 -7
- package/dist/client/components/PresenceBar.js.map +1 -1
- package/dist/client/components/RemoteSelectionRings.d.ts +43 -0
- package/dist/client/components/RemoteSelectionRings.d.ts.map +1 -0
- package/dist/client/components/RemoteSelectionRings.js +116 -0
- package/dist/client/components/RemoteSelectionRings.js.map +1 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -1
- package/dist/coding-tools/run-code.d.ts +40 -0
- package/dist/coding-tools/run-code.d.ts.map +1 -0
- package/dist/coding-tools/run-code.js +511 -0
- package/dist/coding-tools/run-code.js.map +1 -0
- package/dist/collab/awareness.d.ts +25 -0
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/awareness.js +42 -5
- package/dist/collab/awareness.js.map +1 -1
- package/dist/collab/client.d.ts +19 -1
- package/dist/collab/client.d.ts.map +1 -1
- package/dist/collab/client.js +362 -57
- package/dist/collab/client.js.map +1 -1
- package/dist/collab/follow-mode.d.ts +56 -0
- package/dist/collab/follow-mode.d.ts.map +1 -0
- package/dist/collab/follow-mode.js +54 -0
- package/dist/collab/follow-mode.js.map +1 -0
- package/dist/collab/index.d.ts +3 -1
- package/dist/collab/index.d.ts.map +1 -1
- package/dist/collab/index.js +5 -1
- package/dist/collab/index.js.map +1 -1
- package/dist/collab/presence.d.ts +56 -0
- package/dist/collab/presence.d.ts.map +1 -0
- package/dist/collab/presence.js +98 -0
- package/dist/collab/presence.js.map +1 -0
- package/dist/collab/routes.d.ts.map +1 -1
- package/dist/collab/routes.js +33 -6
- package/dist/collab/routes.js.map +1 -1
- package/dist/collab/struct-routes.d.ts.map +1 -1
- package/dist/collab/struct-routes.js +24 -4
- package/dist/collab/struct-routes.js.map +1 -1
- package/dist/collab/ydoc-manager.d.ts +13 -0
- package/dist/collab/ydoc-manager.d.ts.map +1 -1
- package/dist/collab/ydoc-manager.js +51 -15
- package/dist/collab/ydoc-manager.js.map +1 -1
- package/dist/extensions/fetch-tool.d.ts.map +1 -1
- package/dist/extensions/fetch-tool.js +62 -7
- package/dist/extensions/fetch-tool.js.map +1 -1
- package/dist/extensions/web-search-tool.d.ts +41 -0
- package/dist/extensions/web-search-tool.d.ts.map +1 -0
- package/dist/extensions/web-search-tool.js +200 -0
- package/dist/extensions/web-search-tool.js.map +1 -0
- package/dist/provider-api/custom-registry.d.ts +92 -0
- package/dist/provider-api/custom-registry.d.ts.map +1 -0
- package/dist/provider-api/custom-registry.js +289 -0
- package/dist/provider-api/custom-registry.js.map +1 -0
- package/dist/provider-api/index.d.ts +80 -44
- package/dist/provider-api/index.d.ts.map +1 -1
- package/dist/provider-api/index.js +569 -18
- package/dist/provider-api/index.js.map +1 -1
- package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
- package/dist/secrets/register-framework-secrets.js +36 -3
- package/dist/secrets/register-framework-secrets.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts +36 -0
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +119 -0
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/collab-plugin.d.ts +6 -0
- package/dist/server/collab-plugin.d.ts.map +1 -1
- package/dist/server/collab-plugin.js +105 -5
- package/dist/server/collab-plugin.js.map +1 -1
- package/dist/server/poll-events.d.ts +5 -0
- package/dist/server/poll-events.d.ts.map +1 -1
- package/dist/server/poll-events.js +27 -4
- package/dist/server/poll-events.js.map +1 -1
- package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
- package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
- package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
- package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
- package/dist/workspace-files/index.d.ts +4 -0
- package/dist/workspace-files/index.d.ts.map +1 -0
- package/dist/workspace-files/index.js +4 -0
- package/dist/workspace-files/index.js.map +1 -0
- package/dist/workspace-files/schema.d.ts +195 -0
- package/dist/workspace-files/schema.d.ts.map +1 -0
- package/dist/workspace-files/schema.js +48 -0
- package/dist/workspace-files/schema.js.map +1 -0
- package/dist/workspace-files/store.d.ts +89 -0
- package/dist/workspace-files/store.d.ts.map +1 -0
- package/dist/workspace-files/store.js +298 -0
- package/dist/workspace-files/store.js.map +1 -0
- package/dist/workspace-files/tool.d.ts +15 -0
- package/dist/workspace-files/tool.d.ts.map +1 -0
- package/dist/workspace-files/tool.js +226 -0
- package/dist/workspace-files/tool.js.map +1 -0
- package/docs/content/real-time-collaboration.md +481 -97
- package/package.json +2 -1
- package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
- package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
- package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
- package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
package/dist/collab/client.js
CHANGED
|
@@ -7,6 +7,22 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Also manages Yjs Awareness for cursor positions and user presence,
|
|
9
9
|
* synced via polling to the server's awareness endpoint.
|
|
10
|
+
*
|
|
11
|
+
* Transport improvements (vs previous version):
|
|
12
|
+
* - Local update POSTs are debounced and coalesced with Y.mergeUpdates (~80ms)
|
|
13
|
+
* to avoid per-keystroke requests. The batch is flushed immediately on
|
|
14
|
+
* visibilitychange/pagehide and before each poll/awareness cycle.
|
|
15
|
+
* - GET state?stateVector= is NOT fetched on every poll cycle. It is fetched:
|
|
16
|
+
* (a) on (re)connect / initial load, (b) when a poll response indicates a
|
|
17
|
+
* gap (version jump > ring-buffer size), (c) after applying an update fails,
|
|
18
|
+
* and (d) as a low-frequency safety net every STATE_VECTOR_FETCH_INTERVAL
|
|
19
|
+
* poll cycles (~15×).
|
|
20
|
+
* - Network errors use exponential backoff with jitter (cap ~15s), reset on
|
|
21
|
+
* success.
|
|
22
|
+
* - SSE fast-path: collab events are received push-style from
|
|
23
|
+
* /_agent-native/poll-events (the existing SSE stream). While SSE is
|
|
24
|
+
* healthy the poll loop relaxes to a slow cadence (10–15s). If SSE is
|
|
25
|
+
* unavailable the 2s poll resumes automatically.
|
|
10
26
|
*/
|
|
11
27
|
import { useEffect, useRef, useState, useMemo } from "react";
|
|
12
28
|
import * as Y from "yjs";
|
|
@@ -151,8 +167,48 @@ function base64ToUint8Array(b64) {
|
|
|
151
167
|
}
|
|
152
168
|
return arr;
|
|
153
169
|
}
|
|
170
|
+
/** Debounce delay for coalescing local Yjs update POSTs (ms). */
|
|
171
|
+
const UPDATE_DEBOUNCE_MS = 80;
|
|
172
|
+
/** Fetch state-vector every N poll cycles as a low-frequency safety net. */
|
|
173
|
+
const STATE_VECTOR_FETCH_INTERVAL = 15;
|
|
174
|
+
/** Poll ring-buffer size on the server (MAX_BUFFER in poll.ts). */
|
|
175
|
+
const POLL_RING_BUFFER_SIZE = 200;
|
|
176
|
+
/** Exponential backoff: base delay (ms), multiplier, cap (ms). */
|
|
177
|
+
const BACKOFF_BASE_MS = 500;
|
|
178
|
+
const BACKOFF_MAX_MS = 15_000;
|
|
179
|
+
function calcBackoff(consecutiveErrors) {
|
|
180
|
+
const exp = Math.min(consecutiveErrors, 10);
|
|
181
|
+
const delay = BACKOFF_BASE_MS * Math.pow(2, exp);
|
|
182
|
+
// Add jitter: ±25%
|
|
183
|
+
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
|
|
184
|
+
return Math.min(delay + jitter, BACKOFF_MAX_MS);
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Fast awareness helper — throttled per (docId, ydocId) pair so multiple
|
|
188
|
+
// setLocalStateField calls within a 150ms window are coalesced into one POST.
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
const _awarenessThrottleTimers = new Map();
|
|
191
|
+
function scheduleAwarenessPush(baseUrl, docId, clientId, getState) {
|
|
192
|
+
if (typeof window === "undefined")
|
|
193
|
+
return;
|
|
194
|
+
const key = `${docId}::${clientId}`;
|
|
195
|
+
if (_awarenessThrottleTimers.has(key))
|
|
196
|
+
return; // already scheduled
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
_awarenessThrottleTimers.delete(key);
|
|
199
|
+
const state = getState();
|
|
200
|
+
if (!state)
|
|
201
|
+
return;
|
|
202
|
+
fetch(`${baseUrl}/${docId}/awareness`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
body: JSON.stringify({ clientId, state: JSON.stringify(state) }),
|
|
206
|
+
}).catch(() => { }); // best-effort; poll cycle is the baseline fallback
|
|
207
|
+
}, 150);
|
|
208
|
+
_awarenessThrottleTimers.set(key, timer);
|
|
209
|
+
}
|
|
154
210
|
export function useCollaborativeDoc(options) {
|
|
155
|
-
const { docId, pollInterval = 2000, pauseWhenHidden = true, baseUrl = agentNativePath("/_agent-native/collab"), requestSource, user, } = options;
|
|
211
|
+
const { docId, pollInterval = 2000, pollIntervalWithSse = 12000, pauseWhenHidden = true, baseUrl = agentNativePath("/_agent-native/collab"), requestSource, user, } = options;
|
|
156
212
|
// Stable Y.Doc per docId
|
|
157
213
|
const ydoc = useMemo(() => {
|
|
158
214
|
if (!docId)
|
|
@@ -190,6 +246,23 @@ export function useCollaborativeDoc(options) {
|
|
|
190
246
|
});
|
|
191
247
|
awareness.setLocalStateField("visible", !isDocumentHidden());
|
|
192
248
|
}, [awareness, user?.name, user?.email, user?.color]);
|
|
249
|
+
// Fast awareness push: whenever local state changes (e.g. cursor moves,
|
|
250
|
+
// setPresence() calls), schedule a throttled POST so peers receive updates
|
|
251
|
+
// at ~150ms instead of waiting for the next 2s poll cycle. The poll cycle
|
|
252
|
+
// remains the authoritative baseline (cursors degrade gracefully without SSE).
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!awareness || !ydoc || !docId || !user)
|
|
255
|
+
return;
|
|
256
|
+
const clientId = ydoc.clientID;
|
|
257
|
+
const onLocalStateChange = () => {
|
|
258
|
+
scheduleAwarenessPush(baseUrl, docId, clientId, () => awareness.getLocalState());
|
|
259
|
+
};
|
|
260
|
+
// awareness emits "change" for local state changes too (when origin is "local").
|
|
261
|
+
awareness.on("change", onLocalStateChange);
|
|
262
|
+
return () => {
|
|
263
|
+
awareness.off("change", onLocalStateChange);
|
|
264
|
+
};
|
|
265
|
+
}, [awareness, ydoc, docId, baseUrl, user]);
|
|
193
266
|
// Track active users from awareness changes
|
|
194
267
|
useEffect(() => {
|
|
195
268
|
if (!awareness)
|
|
@@ -262,56 +335,206 @@ export function useCollaborativeDoc(options) {
|
|
|
262
335
|
cancelled = true;
|
|
263
336
|
};
|
|
264
337
|
}, [ydoc, docId, baseUrl]);
|
|
265
|
-
// Send local updates to server
|
|
338
|
+
// Send local updates to server — debounced and coalesced with Y.mergeUpdates.
|
|
339
|
+
//
|
|
340
|
+
// Instead of firing one POST per Yjs update (one per keystroke), we accumulate
|
|
341
|
+
// updates in a buffer for UPDATE_DEBOUNCE_MS then merge them into a single
|
|
342
|
+
// request. The buffer is also flushed immediately on visibilitychange/pagehide
|
|
343
|
+
// and before each poll/awareness cycle so we don't hold stale local state.
|
|
266
344
|
useEffect(() => {
|
|
267
345
|
if (!ydoc || !docId || docMissing)
|
|
268
346
|
return;
|
|
269
|
-
|
|
270
|
-
|
|
347
|
+
let pendingUpdates = [];
|
|
348
|
+
let flushTimer = null;
|
|
349
|
+
const flushPendingUpdates = (keepalive = false) => {
|
|
350
|
+
if (flushTimer) {
|
|
351
|
+
clearTimeout(flushTimer);
|
|
352
|
+
flushTimer = null;
|
|
353
|
+
}
|
|
354
|
+
if (pendingUpdates.length === 0)
|
|
271
355
|
return;
|
|
356
|
+
const toSend = pendingUpdates;
|
|
357
|
+
pendingUpdates = [];
|
|
358
|
+
const merged = toSend.length === 1 ? toSend[0] : Y.mergeUpdates(toSend);
|
|
272
359
|
fetch(`${baseUrl}/${docId}/update`, {
|
|
273
360
|
method: "POST",
|
|
274
361
|
headers: { "Content-Type": "application/json" },
|
|
275
362
|
body: JSON.stringify({
|
|
276
|
-
update: uint8ArrayToBase64(
|
|
363
|
+
update: uint8ArrayToBase64(merged),
|
|
277
364
|
requestSource,
|
|
278
365
|
}),
|
|
279
|
-
|
|
366
|
+
...(keepalive ? { keepalive: true } : {}),
|
|
367
|
+
}).catch(() => { });
|
|
368
|
+
};
|
|
369
|
+
// Expose flush to the poll loop via a ref so it can flush before each cycle.
|
|
370
|
+
// We store the flusher in a closure-captured variable; the poll effect
|
|
371
|
+
// below reads it through the shared `pendingFlushRef`.
|
|
372
|
+
ydoc.__collabFlush = flushPendingUpdates;
|
|
373
|
+
const handler = (update, origin) => {
|
|
374
|
+
if (origin === "remote")
|
|
375
|
+
return;
|
|
376
|
+
pendingUpdates.push(update);
|
|
377
|
+
if (flushTimer)
|
|
378
|
+
clearTimeout(flushTimer);
|
|
379
|
+
flushTimer = setTimeout(flushPendingUpdates, UPDATE_DEBOUNCE_MS);
|
|
380
|
+
};
|
|
381
|
+
const handlePageHide = () => {
|
|
382
|
+
flushPendingUpdates(true /* keepalive */);
|
|
280
383
|
};
|
|
281
384
|
ydoc.on("update", handler);
|
|
385
|
+
if (typeof window !== "undefined") {
|
|
386
|
+
window.addEventListener("pagehide", handlePageHide);
|
|
387
|
+
}
|
|
282
388
|
return () => {
|
|
283
389
|
ydoc.off("update", handler);
|
|
390
|
+
delete ydoc.__collabFlush;
|
|
391
|
+
if (typeof window !== "undefined") {
|
|
392
|
+
window.removeEventListener("pagehide", handlePageHide);
|
|
393
|
+
}
|
|
394
|
+
// Flush any remaining updates on teardown
|
|
395
|
+
flushPendingUpdates(true);
|
|
284
396
|
};
|
|
285
397
|
}, [ydoc, docId, baseUrl, requestSource, docMissing]);
|
|
286
|
-
// Poll for remote doc updates + awareness sync
|
|
398
|
+
// Poll for remote doc updates + awareness sync, with SSE fast-path.
|
|
287
399
|
useEffect(() => {
|
|
288
400
|
if (!ydoc || !docId || docMissing)
|
|
289
401
|
return;
|
|
290
402
|
let stopped = false;
|
|
291
403
|
let timer = null;
|
|
404
|
+
let consecutiveErrors = 0;
|
|
405
|
+
let pollCycleCount = 0;
|
|
406
|
+
// Track the last version we successfully polled. Used to detect ring-buffer
|
|
407
|
+
// overflow (version gap larger than the ring buffer).
|
|
408
|
+
let lastPolledVersion = pollVersionRef.current;
|
|
409
|
+
// SSE connection state. When SSE is healthy, poll interval is relaxed.
|
|
410
|
+
let sseActive = false;
|
|
411
|
+
let sseEventSource = null;
|
|
412
|
+
// ── SSE fast-path ────────────────────────────────────────────────
|
|
413
|
+
// Wire into the existing /_agent-native/poll-events SSE stream.
|
|
414
|
+
// Collab update events arrive push-style; we apply them immediately,
|
|
415
|
+
// avoiding ~2s polling latency for peer edits.
|
|
416
|
+
//
|
|
417
|
+
// NOTE: SSE events are subject to the same server-side access scoping as
|
|
418
|
+
// polling — the server only pushes events that canSeeChangeForUser allows.
|
|
419
|
+
// The server tags collab events with owner/orgId (security commit).
|
|
420
|
+
function initSSE() {
|
|
421
|
+
if (typeof EventSource === "undefined")
|
|
422
|
+
return;
|
|
423
|
+
try {
|
|
424
|
+
const es = new EventSource(agentNativePath("/_agent-native/poll-events"));
|
|
425
|
+
sseEventSource = es;
|
|
426
|
+
es.onopen = () => {
|
|
427
|
+
sseActive = true;
|
|
428
|
+
consecutiveErrors = 0;
|
|
429
|
+
};
|
|
430
|
+
es.onmessage = (ev) => {
|
|
431
|
+
try {
|
|
432
|
+
const change = JSON.parse(ev.data);
|
|
433
|
+
if (change.source === "collab" &&
|
|
434
|
+
change.docId === docId &&
|
|
435
|
+
change.update) {
|
|
436
|
+
if (requestSource && change.requestSource === requestSource)
|
|
437
|
+
return;
|
|
438
|
+
try {
|
|
439
|
+
Y.applyUpdate(ydoc, base64ToUint8Array(change.update), "remote");
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// Malformed update — trigger state-vector fetch on next poll
|
|
443
|
+
}
|
|
444
|
+
if (change.requestSource === "agent") {
|
|
445
|
+
setAgentActive(true);
|
|
446
|
+
if (agentTimerRef.current)
|
|
447
|
+
clearTimeout(agentTimerRef.current);
|
|
448
|
+
agentTimerRef.current = setTimeout(() => setAgentActive(false), 3000);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Keep pollVersionRef updated from SSE events so the poll loop
|
|
452
|
+
// starts from the right version when SSE drops.
|
|
453
|
+
if (typeof change.version === "number") {
|
|
454
|
+
pollVersionRef.current = Math.max(pollVersionRef.current, change.version);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// Ignore malformed events
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
es.onerror = () => {
|
|
462
|
+
sseActive = false;
|
|
463
|
+
es.close();
|
|
464
|
+
sseEventSource = null;
|
|
465
|
+
// Retry SSE after a short delay
|
|
466
|
+
if (!stopped) {
|
|
467
|
+
setTimeout(initSSE, 5_000 + Math.random() * 5_000);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
// SSE not available (edge runtime, etc.) — fall back to polling only
|
|
473
|
+
sseActive = false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Only set up SSE in browser environments that support it.
|
|
477
|
+
if (typeof EventSource !== "undefined") {
|
|
478
|
+
initSSE();
|
|
479
|
+
}
|
|
480
|
+
// ── Poll loop ───────────────────────────────────────────────────
|
|
481
|
+
function getActivePollInterval() {
|
|
482
|
+
return sseActive ? pollIntervalWithSse : pollInterval;
|
|
483
|
+
}
|
|
292
484
|
function schedulePoll() {
|
|
293
485
|
if (stopped)
|
|
294
486
|
return;
|
|
295
487
|
if (pauseWhenHidden && isDocumentHidden())
|
|
296
488
|
return;
|
|
297
|
-
timer = setTimeout(poll,
|
|
489
|
+
timer = setTimeout(poll, getActivePollInterval());
|
|
490
|
+
}
|
|
491
|
+
async function fetchStateVector() {
|
|
492
|
+
try {
|
|
493
|
+
const stateVector = uint8ArrayToBase64(Y.encodeStateVector(ydoc));
|
|
494
|
+
const stateRes = await fetch(`${baseUrl}/${docId}/state?stateVector=${encodeURIComponent(stateVector)}`);
|
|
495
|
+
if (stateRes.ok) {
|
|
496
|
+
const stateData = (await stateRes.json().catch(() => null));
|
|
497
|
+
if (stateData?.state) {
|
|
498
|
+
const binary = base64ToUint8Array(stateData.state);
|
|
499
|
+
if (binary.length > 2) {
|
|
500
|
+
Y.applyUpdate(ydoc, binary, "remote");
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Non-fatal; the next poll cycle will retry
|
|
507
|
+
}
|
|
298
508
|
}
|
|
299
509
|
async function poll() {
|
|
300
510
|
if (stopped)
|
|
301
511
|
return;
|
|
512
|
+
// Flush any pending local updates before polling so the server has the
|
|
513
|
+
// latest state before we read remote changes.
|
|
514
|
+
const flush = ydoc.__collabFlush;
|
|
515
|
+
flush?.();
|
|
302
516
|
try {
|
|
303
|
-
// Poll for document updates
|
|
304
517
|
const res = await fetch(agentNativePath(`/_agent-native/poll?since=${pollVersionRef.current}`));
|
|
305
518
|
if (!res.ok)
|
|
306
519
|
throw new Error("HTTP " + res.status);
|
|
307
520
|
const data = await res.json();
|
|
308
521
|
const { version, events } = data;
|
|
522
|
+
// Detect ring-buffer overflow: if the version jumped by more than the
|
|
523
|
+
// ring buffer size, some events were evicted and we need a state-vector
|
|
524
|
+
// fetch to reconcile the gap.
|
|
525
|
+
const versionGap = version - lastPolledVersion;
|
|
526
|
+
const hadGap = versionGap > POLL_RING_BUFFER_SIZE;
|
|
309
527
|
for (const evt of events) {
|
|
310
528
|
if (evt.source === "collab" && evt.docId === docId && evt.update) {
|
|
311
529
|
if (requestSource && evt.requestSource === requestSource)
|
|
312
530
|
continue;
|
|
313
|
-
|
|
314
|
-
|
|
531
|
+
try {
|
|
532
|
+
Y.applyUpdate(ydoc, base64ToUint8Array(evt.update), "remote");
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
// Failed to apply — fetch full state-vector below
|
|
536
|
+
await fetchStateVector();
|
|
537
|
+
}
|
|
315
538
|
if (evt.requestSource === "agent") {
|
|
316
539
|
setAgentActive(true);
|
|
317
540
|
if (agentTimerRef.current)
|
|
@@ -321,64 +544,67 @@ export function useCollaborativeDoc(options) {
|
|
|
321
544
|
}
|
|
322
545
|
}
|
|
323
546
|
pollVersionRef.current = version;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (binary.length > 2) {
|
|
335
|
-
Y.applyUpdate(ydoc, binary, "remote");
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
catch {
|
|
341
|
-
// The next poll retries; awareness should still sync below.
|
|
547
|
+
lastPolledVersion = version;
|
|
548
|
+
pollCycleCount++;
|
|
549
|
+
consecutiveErrors = 0;
|
|
550
|
+
// Fetch state-vector only when needed:
|
|
551
|
+
// 1. Ring-buffer overflow detected (missed events).
|
|
552
|
+
// 2. Low-frequency safety net every STATE_VECTOR_FETCH_INTERVAL cycles.
|
|
553
|
+
// 3. NOT on every cycle (the previous behavior causing 3 requests/cycle).
|
|
554
|
+
const shouldFetchStateVector = hadGap || pollCycleCount % STATE_VECTOR_FETCH_INTERVAL === 0;
|
|
555
|
+
if (shouldFetchStateVector) {
|
|
556
|
+
await fetchStateVector();
|
|
342
557
|
}
|
|
343
558
|
// Sync awareness (cursor positions)
|
|
344
559
|
if (awareness) {
|
|
345
560
|
const localState = awareness.getLocalState();
|
|
346
561
|
if (localState) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
562
|
+
try {
|
|
563
|
+
const awarenessRes = await fetch(`${baseUrl}/${docId}/awareness`, {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers: { "Content-Type": "application/json" },
|
|
566
|
+
body: JSON.stringify({
|
|
567
|
+
clientId: ydoc.clientID,
|
|
568
|
+
state: JSON.stringify(localState),
|
|
569
|
+
}),
|
|
570
|
+
});
|
|
571
|
+
if (awarenessRes.ok) {
|
|
572
|
+
const awarenessData = await awarenessRes.json();
|
|
573
|
+
const remoteStates = [];
|
|
574
|
+
for (const remote of awarenessData.states || []) {
|
|
575
|
+
try {
|
|
576
|
+
const remoteState = JSON.parse(remote.state);
|
|
577
|
+
remoteStates.push({
|
|
578
|
+
clientId: Number(remote.clientId),
|
|
579
|
+
state: remoteState,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// Invalid state — skip
|
|
584
|
+
}
|
|
365
585
|
}
|
|
366
|
-
|
|
367
|
-
|
|
586
|
+
const changes = reconcileRemoteAwarenessStates(awareness.getStates(), ydoc.clientID, remoteStates);
|
|
587
|
+
if (changes.added.length ||
|
|
588
|
+
changes.updated.length ||
|
|
589
|
+
changes.removed.length) {
|
|
590
|
+
awareness.emit("change", [changes, "remote"]);
|
|
368
591
|
}
|
|
369
592
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
changes.removed.length) {
|
|
374
|
-
awareness.emit("change", [changes, "remote"]);
|
|
375
|
-
}
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// Awareness sync failure is non-fatal
|
|
376
596
|
}
|
|
377
597
|
}
|
|
378
598
|
}
|
|
379
599
|
}
|
|
380
600
|
catch {
|
|
381
|
-
// Network error —
|
|
601
|
+
// Network error — exponential backoff
|
|
602
|
+
consecutiveErrors++;
|
|
603
|
+
const backoff = calcBackoff(consecutiveErrors);
|
|
604
|
+
if (!stopped) {
|
|
605
|
+
timer = setTimeout(poll, backoff);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
382
608
|
}
|
|
383
609
|
schedulePoll();
|
|
384
610
|
}
|
|
@@ -394,7 +620,7 @@ export function useCollaborativeDoc(options) {
|
|
|
394
620
|
// Publish this tab's visibility to peers. A hidden tab pauses its poll, so
|
|
395
621
|
// we push the state immediately (keepalive) instead of waiting for the next
|
|
396
622
|
// cycle — otherwise peers keep treating a backgrounded tab as the visible
|
|
397
|
-
// lead and an agent edit never lands on the tab the user is
|
|
623
|
+
// lead and an agent edit never lands on the tab the user is actually viewing.
|
|
398
624
|
function publishVisibility(visible) {
|
|
399
625
|
if (!awareness)
|
|
400
626
|
return;
|
|
@@ -416,6 +642,9 @@ export function useCollaborativeDoc(options) {
|
|
|
416
642
|
const visible = document.visibilityState === "visible";
|
|
417
643
|
publishVisibility(visible);
|
|
418
644
|
if (visible) {
|
|
645
|
+
// Also flush any pending updates when coming back into view
|
|
646
|
+
const flush = ydoc.__collabFlush;
|
|
647
|
+
flush?.();
|
|
419
648
|
pollNow();
|
|
420
649
|
}
|
|
421
650
|
else if (pauseWhenHidden && timer) {
|
|
@@ -432,6 +661,10 @@ export function useCollaborativeDoc(options) {
|
|
|
432
661
|
stopped = true;
|
|
433
662
|
if (timer)
|
|
434
663
|
clearTimeout(timer);
|
|
664
|
+
if (sseEventSource) {
|
|
665
|
+
sseEventSource.close();
|
|
666
|
+
sseEventSource = null;
|
|
667
|
+
}
|
|
435
668
|
window.removeEventListener("focus", pollNow);
|
|
436
669
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
437
670
|
};
|
|
@@ -440,11 +673,83 @@ export function useCollaborativeDoc(options) {
|
|
|
440
673
|
awareness,
|
|
441
674
|
docId,
|
|
442
675
|
pollInterval,
|
|
676
|
+
pollIntervalWithSse,
|
|
443
677
|
pauseWhenHidden,
|
|
444
678
|
requestSource,
|
|
445
679
|
baseUrl,
|
|
446
680
|
docMissing,
|
|
447
681
|
]);
|
|
682
|
+
// SSE fast-path for awareness: subscribe to the poll-events stream and
|
|
683
|
+
// apply any awareness-change events immediately so peers receive cursor
|
|
684
|
+
// moves push-style without waiting for the next poll cycle.
|
|
685
|
+
// Polling fallback keeps working when SSE is unavailable.
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
if (!ydoc || !docId || !awareness || typeof EventSource === "undefined") {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const sseUrl = agentNativePath("/_agent-native/poll-events");
|
|
691
|
+
let source = null;
|
|
692
|
+
let stopped = false;
|
|
693
|
+
function connect() {
|
|
694
|
+
if (stopped || source)
|
|
695
|
+
return;
|
|
696
|
+
source = new EventSource(sseUrl);
|
|
697
|
+
source.onmessage = (msg) => {
|
|
698
|
+
if (stopped)
|
|
699
|
+
return;
|
|
700
|
+
try {
|
|
701
|
+
const data = JSON.parse(msg.data);
|
|
702
|
+
if (data.source !== "awareness" ||
|
|
703
|
+
data.type !== "awareness-change" ||
|
|
704
|
+
data.docId !== docId) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const remoteStates = [];
|
|
708
|
+
for (const remote of data.states ?? []) {
|
|
709
|
+
try {
|
|
710
|
+
remoteStates.push({
|
|
711
|
+
clientId: Number(remote.clientId),
|
|
712
|
+
state: JSON.parse(remote.state),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
// Invalid state entry — skip
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
const changes = reconcileRemoteAwarenessStates(awareness.getStates(), ydoc.clientID, remoteStates);
|
|
720
|
+
if (changes.added.length ||
|
|
721
|
+
changes.updated.length ||
|
|
722
|
+
changes.removed.length) {
|
|
723
|
+
awareness.emit("change", [changes, "remote"]);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
// Ignore malformed SSE frames; poll cycle is the safety net.
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
source.onerror = () => {
|
|
731
|
+
// On permanent close let go of the ref so re-focus can reconnect.
|
|
732
|
+
if (source && source.readyState === EventSource.CLOSED) {
|
|
733
|
+
source = null;
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
connect();
|
|
738
|
+
function onFocus() {
|
|
739
|
+
if (!source || source.readyState === EventSource.CLOSED) {
|
|
740
|
+
source?.close();
|
|
741
|
+
source = null;
|
|
742
|
+
connect();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
window.addEventListener("focus", onFocus);
|
|
746
|
+
return () => {
|
|
747
|
+
stopped = true;
|
|
748
|
+
source?.close();
|
|
749
|
+
source = null;
|
|
750
|
+
window.removeEventListener("focus", onFocus);
|
|
751
|
+
};
|
|
752
|
+
}, [ydoc, docId, awareness]);
|
|
448
753
|
return {
|
|
449
754
|
ydoc,
|
|
450
755
|
awareness,
|