@agent-native/core 0.120.3 → 0.121.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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +22 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/use-db-sync.ts +23 -21
- package/corpus/core/src/deploy/build.ts +1 -0
- package/corpus/core/src/server/core-routes-plugin.ts +3 -0
- package/corpus/core/src/server/gateway-access-check.ts +67 -0
- package/corpus/core/src/server/poll.ts +295 -9
- package/corpus/core/src/server/short-lived-token.ts +122 -0
- package/corpus/templates/content/actions/update-document.ts +145 -91
- package/corpus/templates/content/app/components/editor/CommentComposer.tsx +4 -1
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +366 -164
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +51 -28
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +19 -8
- package/corpus/templates/content/app/i18n-data.ts +15 -0
- package/corpus/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-comment-drafts-now-stay-open-when-saving-fails-and-long-live.md +6 -0
- package/corpus/templates/design/actions/update-file.ts +85 -18
- package/corpus/templates/design/app/lib/design-save-outbox.ts +39 -0
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
- package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
- package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
- package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
- package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/useCollabReconcile.ts +83 -15
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +27 -22
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +1 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +3 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/gateway-access-check.d.ts +12 -0
- package/dist/server/gateway-access-check.d.ts.map +1 -0
- package/dist/server/gateway-access-check.js +49 -0
- package/dist/server/gateway-access-check.js.map +1 -0
- package/dist/server/poll.d.ts +55 -1
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +254 -7
- package/dist/server/poll.js.map +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/short-lived-token.d.ts +28 -0
- package/dist/server/short-lived-token.d.ts.map +1 -1
- package/dist/server/short-lived-token.js +78 -0
- package/dist/server/short-lived-token.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +2 -2
- package/src/client/use-db-sync.ts +23 -21
- package/src/deploy/build.ts +1 -0
- package/src/server/core-routes-plugin.ts +3 -0
- package/src/server/gateway-access-check.ts +67 -0
- package/src/server/poll.ts +295 -9
- package/src/server/short-lived-token.ts +122 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for editing styled bullet lists — list-item rows built from a marker
|
|
3
|
+
* glyph plus text (e.g. `<div><span>●</span><span>Point</span></div>`) rather
|
|
4
|
+
* than real <ul>/<li> markup, which is how generated decks represent bullets.
|
|
5
|
+
*
|
|
6
|
+
* Kept in a standalone module (no React exports) so SlideEditor stays
|
|
7
|
+
* Fast-Refresh friendly and this logic is unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Zero-width space: keeps the caret inside an otherwise-empty text span so
|
|
11
|
+
* typed characters inherit that span's font instead of the container's. */
|
|
12
|
+
export const ZERO_WIDTH_SPACE = "\u200B";
|
|
13
|
+
|
|
14
|
+
/** Single glyphs commonly used as bullet markers in styled (non-<ul>) lists. */
|
|
15
|
+
const BULLET_GLYPHS = new Set([
|
|
16
|
+
"\u2022", // •
|
|
17
|
+
"\u25CF", // ●
|
|
18
|
+
"\u25E6", // ◦
|
|
19
|
+
"\u25AA", // ▪
|
|
20
|
+
"\u2023", // ‣
|
|
21
|
+
"\u00B7", // ·
|
|
22
|
+
"\u2043", // ⁃
|
|
23
|
+
"-",
|
|
24
|
+
"\u2013", // –
|
|
25
|
+
"\u2014", // —
|
|
26
|
+
"*",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** True if an element is a bullet marker — either a text glyph (a leading ●
|
|
30
|
+
* span) or an empty CSS shape (a small square/dot/box span used as a marker). */
|
|
31
|
+
export function isBulletMarker(el: Element): boolean {
|
|
32
|
+
return isGlyphMarker(el) || isShapeMarker(el);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A leading span whose text is only bullet glyph characters (e.g. "●"). */
|
|
36
|
+
function isGlyphMarker(el: Element): boolean {
|
|
37
|
+
const text = (el.textContent ?? "").trim();
|
|
38
|
+
return text.length > 0 && [...text].every((c) => BULLET_GLYPHS.has(c));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An empty, small, roughly-square span drawn as a marker via border/background
|
|
42
|
+
* (e.g. `<span style="width:21px;height:21px;border:2px solid ...">`), which is
|
|
43
|
+
* how generated decks often render checkbox/dot bullets with no text glyph. */
|
|
44
|
+
function isShapeMarker(el: Element): boolean {
|
|
45
|
+
if ((el.textContent ?? "").trim().length > 0) return false;
|
|
46
|
+
if (el.childElementCount > 0) return false;
|
|
47
|
+
const w = parseCssPx(styleValue(el, "width"));
|
|
48
|
+
const h = parseCssPx(styleValue(el, "height"));
|
|
49
|
+
if (!(w > 0 && h > 0) || w > 48 || h > 48) return false;
|
|
50
|
+
const ratio = w / h;
|
|
51
|
+
if (ratio < 0.5 || ratio > 2) return false;
|
|
52
|
+
const hasBorder =
|
|
53
|
+
parseCssPx(styleValue(el, "border-top-width")) > 0 ||
|
|
54
|
+
parseCssPx(styleValue(el, "border-left-width")) > 0 ||
|
|
55
|
+
parseCssPx(styleValue(el, "border-width")) > 0;
|
|
56
|
+
const bg = styleValue(el, "background-color");
|
|
57
|
+
const hasBg = !!bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)";
|
|
58
|
+
const hasRadius = parseCssPx(styleValue(el, "border-radius")) > 0;
|
|
59
|
+
return hasBorder || hasBg || hasRadius;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read a style property, preferring inline styles and falling back to computed
|
|
63
|
+
* styles when available (jsdom-safe). */
|
|
64
|
+
function styleValue(el: Element, prop: string): string {
|
|
65
|
+
const inline = (el as HTMLElement).style?.getPropertyValue(prop);
|
|
66
|
+
if (inline) return inline;
|
|
67
|
+
if (typeof window !== "undefined" && window.getComputedStyle) {
|
|
68
|
+
try {
|
|
69
|
+
return window.getComputedStyle(el).getPropertyValue(prop);
|
|
70
|
+
} catch {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseCssPx(value: string): number {
|
|
78
|
+
const parsed = Number.parseFloat(value);
|
|
79
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The bullet-marker element enclosing a node (or the node itself), bounded by
|
|
83
|
+
* `root`, or null when the node isn't inside a marker glyph. */
|
|
84
|
+
function enclosingMarker(node: Node, root: HTMLElement): HTMLElement | null {
|
|
85
|
+
let el: HTMLElement | null =
|
|
86
|
+
node.nodeType === Node.ELEMENT_NODE
|
|
87
|
+
? (node as HTMLElement)
|
|
88
|
+
: node.parentElement;
|
|
89
|
+
while (el && el !== root && root.contains(el)) {
|
|
90
|
+
if (isBulletMarker(el)) return el;
|
|
91
|
+
el = el.parentElement;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A "bullet row" is a styled list item whose first element child is a marker
|
|
98
|
+
* glyph. The text after it may be a <span> or a bare text node (contentEditable
|
|
99
|
+
* often unwraps spans while editing), and may be empty for a freshly-added
|
|
100
|
+
* bullet — so only the leading marker is required.
|
|
101
|
+
*/
|
|
102
|
+
export function isBulletRow(el: HTMLElement): boolean {
|
|
103
|
+
if (el.tagName !== "DIV" && el.tagName !== "LI" && el.tagName !== "P") {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const first = el.firstElementChild;
|
|
107
|
+
return !!first && isBulletMarker(first);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Count the styled bullet rows directly inside a container. */
|
|
111
|
+
export function bulletRowCount(el: HTMLElement): number {
|
|
112
|
+
return Array.from(el.children).filter((k) => isBulletRow(k as HTMLElement))
|
|
113
|
+
.length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A container whose element children are (essentially) all styled bullet rows. */
|
|
117
|
+
export function isBulletList(el: HTMLElement): boolean {
|
|
118
|
+
const kids = Array.from(el.children);
|
|
119
|
+
if (kids.length === 0) return false;
|
|
120
|
+
const rows = bulletRowCount(el);
|
|
121
|
+
// Tolerate one stray non-bullet child (e.g. a stray <br>/<div> left behind by
|
|
122
|
+
// contentEditable) as long as the container is clearly a bullet list.
|
|
123
|
+
return rows >= 1 && rows >= kids.length - 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Regex for a markdown-style bullet prefix: a dash or asterisk plus a space,
|
|
127
|
+
* at the very start of a block's content (e.g. "- " or "* "). */
|
|
128
|
+
const MARKDOWN_BULLET_PREFIX = /^[-*] $/;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* If `el`'s content starts with a markdown-style "- "/"* " prefix and the
|
|
132
|
+
* caret sits right after it, convert `el`'s content into a styled bullet row
|
|
133
|
+
* — a small marker span plus a text span holding the rest of `el`'s content —
|
|
134
|
+
* nested inside `el`, which becomes the list container. `el` itself must stay
|
|
135
|
+
* the contentEditable root (nesting the row rather than turning `el` itself
|
|
136
|
+
* into the row) so a later Enter's cloned sibling row lands inside the same
|
|
137
|
+
* contentEditable boundary and is actually typeable. Returns false when `el`
|
|
138
|
+
* is already a bullet row/list, there's no such prefix, or the selection
|
|
139
|
+
* isn't a collapsed caret.
|
|
140
|
+
*/
|
|
141
|
+
export function convertMarkdownPrefixToBullet(el: HTMLElement): boolean {
|
|
142
|
+
if (isBulletRow(el) || isBulletList(el)) return false;
|
|
143
|
+
const sel = window.getSelection();
|
|
144
|
+
if (!sel || sel.rangeCount === 0) return false;
|
|
145
|
+
const caretRange = sel.getRangeAt(0);
|
|
146
|
+
if (!caretRange.collapsed) return false;
|
|
147
|
+
|
|
148
|
+
const beforeCaretRange = document.createRange();
|
|
149
|
+
beforeCaretRange.selectNodeContents(el);
|
|
150
|
+
beforeCaretRange.setEnd(caretRange.endContainer, caretRange.endOffset);
|
|
151
|
+
// A freshly-placed text box seeds its content with a zero-width-space
|
|
152
|
+
// placeholder (see placeTextBoxAt) so it has a font to inherit before any
|
|
153
|
+
// real text exists. Strip it before testing so "- " typed as the very
|
|
154
|
+
// first characters is still recognized as a bullet prefix.
|
|
155
|
+
const beforeCaretText = beforeCaretRange
|
|
156
|
+
.toString()
|
|
157
|
+
.replace(new RegExp(ZERO_WIDTH_SPACE, "g"), "");
|
|
158
|
+
if (!MARKDOWN_BULLET_PREFIX.test(beforeCaretText)) return false;
|
|
159
|
+
beforeCaretRange.deleteContents();
|
|
160
|
+
|
|
161
|
+
const marker = document.createElement("span");
|
|
162
|
+
marker.style.fontSize = "0.3em";
|
|
163
|
+
marker.style.position = "relative";
|
|
164
|
+
marker.style.top = "-0.15em";
|
|
165
|
+
marker.textContent = "\u25CF";
|
|
166
|
+
|
|
167
|
+
const textSpan = document.createElement("span");
|
|
168
|
+
while (el.firstChild) textSpan.appendChild(el.firstChild);
|
|
169
|
+
const restFirstChild = textSpan.firstChild;
|
|
170
|
+
let placeholderZws: Text | null = null;
|
|
171
|
+
if (!restFirstChild) {
|
|
172
|
+
placeholderZws = document.createTextNode(ZERO_WIDTH_SPACE);
|
|
173
|
+
textSpan.appendChild(placeholderZws);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const row = document.createElement("div");
|
|
177
|
+
row.style.display = "flex";
|
|
178
|
+
row.style.alignItems = "baseline";
|
|
179
|
+
row.style.gap = "0.7em";
|
|
180
|
+
row.append(marker, textSpan);
|
|
181
|
+
el.append(row);
|
|
182
|
+
|
|
183
|
+
if (!el.style.display) el.style.display = "flex";
|
|
184
|
+
if (!el.style.flexDirection) el.style.flexDirection = "column";
|
|
185
|
+
if (!el.style.gap) el.style.gap = "0.6em";
|
|
186
|
+
|
|
187
|
+
const range = document.createRange();
|
|
188
|
+
if (restFirstChild) {
|
|
189
|
+
range.setStartBefore(restFirstChild);
|
|
190
|
+
} else {
|
|
191
|
+
// See primeNewRow: anchor the caret inside the placeholder text node
|
|
192
|
+
// (not an element-based position) so it keeps the text span's font
|
|
193
|
+
// instead of falling back to the marker's.
|
|
194
|
+
range.setStart(placeholderZws as Text, ZERO_WIDTH_SPACE.length);
|
|
195
|
+
}
|
|
196
|
+
range.collapse(true);
|
|
197
|
+
sel.removeAllRanges();
|
|
198
|
+
sel.addRange(range);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Walk up from a text leaf to the nearest enclosing styled bullet-row
|
|
204
|
+
* container, so Enter can add a new item to the whole list instead of being
|
|
205
|
+
* trapped inside one item.
|
|
206
|
+
*/
|
|
207
|
+
export function findEnclosingList(
|
|
208
|
+
el: HTMLElement,
|
|
209
|
+
root: HTMLElement,
|
|
210
|
+
): HTMLElement | null {
|
|
211
|
+
let node: HTMLElement | null = el;
|
|
212
|
+
while (node && root.contains(node)) {
|
|
213
|
+
const parentEl: HTMLElement | null = node.parentElement;
|
|
214
|
+
if (!parentEl) break;
|
|
215
|
+
if (isBulletRow(node) && isBulletList(parentEl)) return parentEl;
|
|
216
|
+
// Even from a bullet row whose siblings aren't all bullets, treat the
|
|
217
|
+
// parent as a list once it holds two or more bullet rows.
|
|
218
|
+
if (isBulletRow(node) && bulletRowCount(parentEl) >= 2) return parentEl;
|
|
219
|
+
node = parentEl;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The non-marker text container of a row: a dedicated text <span> if present,
|
|
225
|
+
* otherwise the row itself (rows whose text is a bare node). */
|
|
226
|
+
function rowTextContainer(
|
|
227
|
+
row: HTMLElement,
|
|
228
|
+
marker: HTMLElement | null,
|
|
229
|
+
): HTMLElement {
|
|
230
|
+
const textSpan = Array.from(row.children).find(
|
|
231
|
+
(c) => c.tagName === "SPAN" && c !== marker && !isBulletMarker(c),
|
|
232
|
+
) as HTMLElement | undefined;
|
|
233
|
+
return textSpan ?? row;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Seed a freshly-inserted row with the caret's trailing content and place the
|
|
238
|
+
* caret at the start of its editable text. `tail` is a DOM fragment (not a
|
|
239
|
+
* string) so inline formatting such as <strong>/<em> carried over from the
|
|
240
|
+
* split point is preserved. When there is no tail, a zero-width space text node
|
|
241
|
+
* keeps the caret inside the font-carrying text span rather than dropping it to
|
|
242
|
+
* the container.
|
|
243
|
+
*/
|
|
244
|
+
function primeNewRow(row: HTMLElement, tail: DocumentFragment | null): void {
|
|
245
|
+
const marker =
|
|
246
|
+
row.firstElementChild && isBulletMarker(row.firstElementChild)
|
|
247
|
+
? (row.firstElementChild as HTMLElement)
|
|
248
|
+
: null;
|
|
249
|
+
const container = rowTextContainer(row, marker);
|
|
250
|
+
|
|
251
|
+
// Clear existing text content, preserving the marker glyph.
|
|
252
|
+
if (container !== row) {
|
|
253
|
+
container.replaceChildren();
|
|
254
|
+
} else {
|
|
255
|
+
while (marker?.nextSibling) marker.nextSibling.remove();
|
|
256
|
+
if (!marker) row.replaceChildren();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const firstTailNode = tail?.firstChild ?? null;
|
|
260
|
+
if (tail && firstTailNode) container.appendChild(tail);
|
|
261
|
+
|
|
262
|
+
const sel = window.getSelection();
|
|
263
|
+
if (!sel) return;
|
|
264
|
+
const range = document.createRange();
|
|
265
|
+
if (firstTailNode) {
|
|
266
|
+
// Caret at the very start of the moved tail (before the marker is not
|
|
267
|
+
// possible: setStartBefore anchors relative to the tail's first node).
|
|
268
|
+
range.setStartBefore(firstTailNode);
|
|
269
|
+
} else {
|
|
270
|
+
const zws = document.createTextNode(ZERO_WIDTH_SPACE);
|
|
271
|
+
container.appendChild(zws);
|
|
272
|
+
range.setStart(zws, ZERO_WIDTH_SPACE.length);
|
|
273
|
+
}
|
|
274
|
+
range.collapse(true);
|
|
275
|
+
sel.removeAllRanges();
|
|
276
|
+
sel.addRange(range);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Insert a new list item after the caret's current row. Content after the caret
|
|
281
|
+
* moves into the new row (with inline formatting preserved); the marker glyph is
|
|
282
|
+
* preserved on both rows. Returns false when the caret isn't inside a direct row
|
|
283
|
+
* of the list so the caller can fall back.
|
|
284
|
+
*/
|
|
285
|
+
export function insertBulletAfterCaret(list: HTMLElement): boolean {
|
|
286
|
+
const sel = window.getSelection();
|
|
287
|
+
if (!sel || sel.rangeCount === 0) return false;
|
|
288
|
+
const range = sel.getRangeAt(0);
|
|
289
|
+
if (!range.collapsed) {
|
|
290
|
+
// A selection that spans a row's marker glyph would delete it here, blanking
|
|
291
|
+
// the bullet on the surviving row (and its clone). Clamp both boundaries out
|
|
292
|
+
// of any enclosing marker so deletion never touches the glyphs.
|
|
293
|
+
const startMarker = enclosingMarker(range.startContainer, list);
|
|
294
|
+
if (startMarker) range.setStartAfter(startMarker);
|
|
295
|
+
const endMarker = enclosingMarker(range.endContainer, list);
|
|
296
|
+
if (endMarker) range.setEndBefore(endMarker);
|
|
297
|
+
if (!range.collapsed) range.deleteContents();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let row: HTMLElement | null = null;
|
|
301
|
+
let node: Node | null = range.endContainer;
|
|
302
|
+
while (node && node !== list) {
|
|
303
|
+
if (node.parentNode === list) {
|
|
304
|
+
row = node as HTMLElement;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
node = node.parentNode;
|
|
308
|
+
}
|
|
309
|
+
if (!row) return false;
|
|
310
|
+
|
|
311
|
+
const marker =
|
|
312
|
+
row.firstElementChild && isBulletMarker(row.firstElementChild)
|
|
313
|
+
? (row.firstElementChild as HTMLElement)
|
|
314
|
+
: null;
|
|
315
|
+
|
|
316
|
+
// Never split inside the marker glyph itself: a caret at offset 0 of the "●"
|
|
317
|
+
// text node (e.g. clicking the marker's leading edge) would otherwise blank
|
|
318
|
+
// the marker and un-bullet the row. In that case add an empty bullet instead.
|
|
319
|
+
const caretInMarker = !!marker && marker.contains(range.endContainer);
|
|
320
|
+
|
|
321
|
+
const container = rowTextContainer(row, marker);
|
|
322
|
+
let tail: DocumentFragment | null = null;
|
|
323
|
+
if (!caretInMarker && container.contains(range.endContainer)) {
|
|
324
|
+
const tailRange = document.createRange();
|
|
325
|
+
tailRange.setStart(range.endContainer, range.endOffset);
|
|
326
|
+
const lastChild = container.lastChild;
|
|
327
|
+
if (lastChild) tailRange.setEndAfter(lastChild);
|
|
328
|
+
else tailRange.setEnd(container, container.childNodes.length);
|
|
329
|
+
// extractContents() moves the trailing DOM subtree (preserving <strong>/
|
|
330
|
+
// <em>) out of the original row so it can be reparented into the new one.
|
|
331
|
+
tail = tailRange.extractContents();
|
|
332
|
+
// A caret at the very end of the text (the common case) makes tailRange
|
|
333
|
+
// collapsed, but extractContents() on a collapsed range still clones the
|
|
334
|
+
// boundary text node with empty data instead of returning an empty
|
|
335
|
+
// fragment. Treat that as "no tail" so primeNewRow falls through to the
|
|
336
|
+
// zero-width-space placeholder — otherwise it moves in an empty text node
|
|
337
|
+
// with no character to anchor the caret's font to, and typing falls back
|
|
338
|
+
// to the marker span's formatting instead of the text span's.
|
|
339
|
+
if (tail.textContent === "") tail = null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const newRow = row.cloneNode(true) as HTMLElement;
|
|
343
|
+
for (const el of [newRow, ...Array.from(newRow.querySelectorAll("*"))]) {
|
|
344
|
+
el.removeAttribute("data-builder-id");
|
|
345
|
+
el.removeAttribute("data-fusion-element-id");
|
|
346
|
+
}
|
|
347
|
+
row.after(newRow);
|
|
348
|
+
primeNewRow(newRow, tail);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
@@ -945,9 +945,7 @@ export const defaultSlideContent: Record<SlideLayout, string> = {
|
|
|
945
945
|
"full-image": `<div class="fmd-slide" style="padding: 0; align-items: center; justify-content: center;">
|
|
946
946
|
<div class="fmd-img-placeholder" style="width: 100%; height: 100%;">Full-bleed image or screenshot</div>
|
|
947
947
|
</div>`,
|
|
948
|
-
blank: `<div class="fmd-slide" style="padding: 80px 110px;
|
|
949
|
-
<div style="font-size: 28px; font-weight: 600; color: rgba(255,255,255,0.4); line-height: 1.3; font-family: 'Poppins', sans-serif;">Double-click to edit</div>
|
|
950
|
-
</div>`,
|
|
948
|
+
blank: `<div class="fmd-slide" style="padding: 80px 110px; position: relative; font-family: 'Poppins', sans-serif;"></div>`,
|
|
951
949
|
};
|
|
952
950
|
|
|
953
951
|
export function DeckProvider({ children }: { children: ReactNode }) {
|
|
@@ -121,6 +121,7 @@ const messages = {
|
|
|
121
121
|
uploadAttachedFailed: "Could not upload the attached file.",
|
|
122
122
|
uploadFailed: "Upload failed",
|
|
123
123
|
doubleClickEdit: "Double-click any text to edit",
|
|
124
|
+
dragToMove: "Drag to move",
|
|
124
125
|
aiEditing: "AI editing",
|
|
125
126
|
startTypingCommands: "Start typing… or press / for commands",
|
|
126
127
|
pinDropHint: "Click anywhere to drop a comment pin",
|
|
@@ -210,6 +211,7 @@ const messages = {
|
|
|
210
211
|
elementAnimations: "Element animations",
|
|
211
212
|
tweaks: "Tweaks",
|
|
212
213
|
drawOnSlide: "Draw on slide",
|
|
214
|
+
addTextBox: "Add text box",
|
|
213
215
|
pinComments: "Pin comments",
|
|
214
216
|
pinCommentsDescription:
|
|
215
217
|
"Click spots on the slide to queue several edits, then send them all at once.",
|
|
@@ -196,6 +196,7 @@ export default function DeckEditor() {
|
|
|
196
196
|
const [tweaksOpen, setTweaksOpen] = useState(false);
|
|
197
197
|
const [drawMode, setDrawMode] = useState(false);
|
|
198
198
|
const [pinMode, setPinMode] = useState(false);
|
|
199
|
+
const [textBoxMode, setTextBoxMode] = useState(false);
|
|
199
200
|
const [pendingComment, setPendingComment] = useState<{
|
|
200
201
|
quotedText: string;
|
|
201
202
|
} | null>(null);
|
|
@@ -816,6 +817,8 @@ export default function DeckEditor() {
|
|
|
816
817
|
onToggleDrawMode={() => setDrawMode((v) => !v)}
|
|
817
818
|
pinMode={pinMode}
|
|
818
819
|
onTogglePinMode={() => setPinMode((v) => !v)}
|
|
820
|
+
textBoxMode={textBoxMode}
|
|
821
|
+
onToggleTextBoxMode={() => setTextBoxMode((v) => !v)}
|
|
819
822
|
onDuplicateDeck={() => {
|
|
820
823
|
const newId = `deck-${nanoid()}`;
|
|
821
824
|
const optimistic = duplicateDeck(id, newId);
|
|
@@ -992,6 +995,8 @@ export default function DeckEditor() {
|
|
|
992
995
|
onExitDrawMode={() => setDrawMode(false)}
|
|
993
996
|
pinMode={pinMode}
|
|
994
997
|
onExitPinMode={() => setPinMode(false)}
|
|
998
|
+
textBoxMode={textBoxMode}
|
|
999
|
+
onExitTextBoxMode={() => setTextBoxMode(false)}
|
|
995
1000
|
slideId={currentSlide.id}
|
|
996
1001
|
slideTitle={(() => {
|
|
997
1002
|
const m = currentSlide.content?.match(
|
|
@@ -33,6 +33,11 @@ export function getEditorMarkdown(editor: Editor): string {
|
|
|
33
33
|
* is identical, so skipping it is safe by construction.
|
|
34
34
|
*/
|
|
35
35
|
const EMITTED_RING_MAX = 16;
|
|
36
|
+
// The hosted awareness transport polls every 2s when its SSE gateway cannot
|
|
37
|
+
// forward presence. Give that first snapshot one poll plus margin before an
|
|
38
|
+
// empty SQL value is allowed to clear a nonempty Y.Doc. This is the same settle
|
|
39
|
+
// window used below when a known peer may deliver an edit through Yjs.
|
|
40
|
+
const PEER_SETTLE_MS = 2500;
|
|
36
41
|
function pushEmittedRing(ring: string[], value: string): void {
|
|
37
42
|
if (!value) return;
|
|
38
43
|
if (ring[ring.length - 1] === value) return;
|
|
@@ -264,6 +269,11 @@ export function useCollabReconcile({
|
|
|
264
269
|
// arrives via Yjs, so external markdown reconcile must defer (avoid applying
|
|
265
270
|
// the same change through both Yjs and setContent).
|
|
266
271
|
const peerCountRef = useRef(0);
|
|
272
|
+
// Briefly gates the first empty-SQL reconcile while Collaboration projects
|
|
273
|
+
// a nonempty fragment. `seededRef` is still released immediately so a real
|
|
274
|
+
// first keystroke can persist; this ref only postpones the ambiguous clear
|
|
275
|
+
// decision until we can distinguish an active/local edit from stale CRDT.
|
|
276
|
+
const emptySnapshotDecisionPendingRef = useRef(false);
|
|
267
277
|
useEffect(() => {
|
|
268
278
|
if (!collab || !awareness || !ydoc) {
|
|
269
279
|
setIsLeadClient(true);
|
|
@@ -299,25 +309,73 @@ export function useCollabReconcile({
|
|
|
299
309
|
if (!collab || !editor || editor.isDestroyed || !ydoc) return;
|
|
300
310
|
if (seededRef.current) return;
|
|
301
311
|
if (!collabSynced) return;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
// app's sentinel-empty filler via a custom `shouldSeed`).
|
|
309
|
-
if (
|
|
310
|
-
!shouldSeed({ value, currentMarkdown, fragmentLength: fragment.length })
|
|
311
|
-
) {
|
|
312
|
+
// An empty SQL value has nothing to seed. Release the first real keystroke
|
|
313
|
+
// immediately, but when a fragment already exists defer the ambiguous
|
|
314
|
+
// reconcile decision for one task: active-peer or just-emitted local content
|
|
315
|
+
// is live and must be adopted; stale persisted CRDT with no active writer is
|
|
316
|
+
// cleared by the canonical SQL snapshot.
|
|
317
|
+
if (!value.trim()) {
|
|
312
318
|
seededRef.current = true;
|
|
313
|
-
|
|
314
|
-
|
|
319
|
+
const fragment = ydoc.getXmlFragment("default");
|
|
320
|
+
const currentMarkdown = getMarkdown(editor);
|
|
321
|
+
if (fragment.length === 0 && !currentMarkdown.trim()) return;
|
|
315
322
|
|
|
323
|
+
emptySnapshotDecisionPendingRef.current = true;
|
|
324
|
+
let cancelled = false;
|
|
325
|
+
const adoptTimer = setTimeout(
|
|
326
|
+
() => {
|
|
327
|
+
if (cancelled || editor.isDestroyed) return;
|
|
328
|
+
const projectedMarkdown = getMarkdown(editor);
|
|
329
|
+
const isOwnFreshEdit =
|
|
330
|
+
projectedMarkdown.trim() &&
|
|
331
|
+
(projectedMarkdown === lastEmittedRef.current ||
|
|
332
|
+
recentEmittedRef.current.includes(projectedMarkdown));
|
|
333
|
+
if (
|
|
334
|
+
projectedMarkdown.trim() &&
|
|
335
|
+
(peerCountRef.current > 0 || isOwnFreshEdit)
|
|
336
|
+
) {
|
|
337
|
+
lastAppliedValueRef.current = value;
|
|
338
|
+
lastAppliedSerializedRef.current = projectedMarkdown;
|
|
339
|
+
if (contentUpdatedAt) {
|
|
340
|
+
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
emptySnapshotDecisionPendingRef.current = false;
|
|
344
|
+
},
|
|
345
|
+
awareness ? PEER_SETTLE_MS : 0,
|
|
346
|
+
);
|
|
347
|
+
return () => {
|
|
348
|
+
cancelled = true;
|
|
349
|
+
clearTimeout(adoptTimer);
|
|
350
|
+
emptySnapshotDecisionPendingRef.current = false;
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (!isLeadClient) return;
|
|
316
354
|
let cancelled = false;
|
|
317
355
|
// Defer via a timer task (NOT a microtask — microtasks can still run
|
|
318
356
|
// inside React's commit and trigger flushSync-from-lifecycle warnings).
|
|
357
|
+
// Read the editor and fragment INSIDE that task. The collaboration
|
|
358
|
+
// extension can finish projecting an already-populated Y.Doc into
|
|
359
|
+
// ProseMirror after this effect is scheduled. Capturing the pre-projection
|
|
360
|
+
// empty editor here would incorrectly seed the SQL snapshot alongside the
|
|
361
|
+
// existing CRDT content, duplicating the whole document after reload.
|
|
319
362
|
const seedTimer = setTimeout(() => {
|
|
320
363
|
if (cancelled || editor.isDestroyed) return;
|
|
364
|
+
const fragment = ydoc.getXmlFragment("default");
|
|
365
|
+
const currentMarkdown = getMarkdown(editor);
|
|
366
|
+
// Seed only when the shared doc is genuinely empty — either the fragment
|
|
367
|
+
// has no nodes yet, or it holds no semantic markdown (an empty paragraph,
|
|
368
|
+
// or an app's sentinel-empty filler via a custom `shouldSeed`).
|
|
369
|
+
if (
|
|
370
|
+
!shouldSeed({
|
|
371
|
+
value,
|
|
372
|
+
currentMarkdown,
|
|
373
|
+
fragmentLength: fragment.length,
|
|
374
|
+
})
|
|
375
|
+
) {
|
|
376
|
+
seededRef.current = true;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
321
379
|
isSettingContentRef.current = true;
|
|
322
380
|
try {
|
|
323
381
|
setContent(editor, value, {});
|
|
@@ -362,16 +420,26 @@ export function useCollabReconcile({
|
|
|
362
420
|
// With peers present, a peer's edit also arrives via Yjs. Defer one poll
|
|
363
421
|
// cycle (+margin) and re-check before applying via setContent so the same
|
|
364
422
|
// change isn't inserted twice (Yjs + setContent → duplicated region).
|
|
365
|
-
const PEER_SETTLE_MS = 2500;
|
|
366
|
-
|
|
367
423
|
const apply = (deferred = false) => {
|
|
368
424
|
if (cancelled || editor.isDestroyed) return;
|
|
369
425
|
// In collab mode, defer all reconcile until the shared doc is seeded so we
|
|
370
426
|
// never setContent over an unseeded fragment.
|
|
371
|
-
if (collab &&
|
|
427
|
+
if (collab && !collabSynced) {
|
|
372
428
|
retry = setTimeout(() => apply(deferred), 300);
|
|
373
429
|
return;
|
|
374
430
|
}
|
|
431
|
+
// The seed decision itself is deferred one task so Collaboration can
|
|
432
|
+
// project persisted Y.Doc state before we inspect it. Poll that short
|
|
433
|
+
// handoff promptly: waiting the full provider cadence here makes a newer
|
|
434
|
+
// canonical SQL snapshot visibly stale after reload.
|
|
435
|
+
if (collab && !seededRef.current) {
|
|
436
|
+
retry = setTimeout(() => apply(deferred), 25);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (collab && emptySnapshotDecisionPendingRef.current) {
|
|
440
|
+
retry = setTimeout(() => apply(deferred), 50);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
375
443
|
const currentMarkdown = getMarkdown(editor);
|
|
376
444
|
// Compare against the canonical form the editor would emit so a serializer
|
|
377
445
|
// that re-normalizes (Content's NFM) still recognizes "already in sync".
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-db-sync.d.ts","sourceRoot":"","sources":["../../src/client/use-db-sync.ts"],"names":[],"mappings":"AAkBA,UAAU,KAAK;IACb,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;CAC9B;AAED,UAAU,WAAW;IACnB,iBAAiB,CACf,IAAI,CAAC,EAAE;QACL,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;KACvC,EACD,OAAO,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GACpC,OAAO,CAAC;IACX,UAAU,CAAC,CAAC,OAAO,CAAC,EAAE;QACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;KACvC,GAAG,MAAM,CAAC;CACZ;AA2BD,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB,CAAC;AA4HF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAUxE;
|
|
1
|
+
{"version":3,"file":"use-db-sync.d.ts","sourceRoot":"","sources":["../../src/client/use-db-sync.ts"],"names":[],"mappings":"AAkBA,UAAU,KAAK;IACb,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;CAC9B;AAED,UAAU,WAAW;IACnB,iBAAiB,CACf,IAAI,CAAC,EAAE;QACL,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;KACvC,EACD,OAAO,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GACpC,OAAO,CAAC;IACX,UAAU,CAAC,CAAC,OAAO,CAAC,EAAE;QACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;KACvC,GAAG,MAAM,CAAC;CACZ;AA2BD,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB,CAAC;AA4HF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAUxE;AA4sBD,gBAAgB;AAChB,wBAAgB,mCAAmC,IAAI,IAAI,CAE1D;AAED,MAAM,WAAW,0BAA0B;IACzC,gEAAgE;IAChE,QAAQ,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IACrE,+EAA+E;IAC/E,gBAAgB,CAAC,EAAE,CACjB,SAAS,EAAE,OAAO,EAClB,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,KAC7B,IAAI,CAAC;IACV,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,MAAM,IAAI,CAuBZ;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,SAAS,CACvB,OAAO,GAAE;IACP,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACxB,sCAAsC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yBAAyB,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACtD,6BAA6B,CAAC,EAAE,MAAM,EAAE,CAAC;CACrC,GACL,IAAI,CAwTN;AAED,wCAAwC;AACxC,eAAO,MAAM,cAAc,kBAAY,CAAC;AAExC;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,GAAE;IACP,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,OAAO,CAAC;CACtB,GACL,MAAM,CAqER"}
|
|
@@ -196,9 +196,12 @@ class SyncTransport {
|
|
|
196
196
|
}
|
|
197
197
|
get activeSseUrl() {
|
|
198
198
|
if (this.mode === "hosted" && this.gateway) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
if (!this.token)
|
|
200
|
+
return this.gateway.sseUrl;
|
|
201
|
+
const base = `${this.gateway.sseUrl}?token=${encodeURIComponent(this.token)}`;
|
|
202
|
+
// Cursor lets the gateway replay the reconnect gap on connect instead of
|
|
203
|
+
// deferring it to the next poll; 0 on first connect means nothing to replay.
|
|
204
|
+
return this.versionRef > 0 ? `${base}&since=${this.versionRef}` : base;
|
|
202
205
|
}
|
|
203
206
|
return this.sseUrl;
|
|
204
207
|
}
|
|
@@ -514,28 +517,30 @@ class SyncTransport {
|
|
|
514
517
|
this.schedulePoll();
|
|
515
518
|
};
|
|
516
519
|
source.onerror = () => {
|
|
520
|
+
// A replaced/closed source can still fire late; ignore it so it can't
|
|
521
|
+
// flip the connected state or tear down the current stream.
|
|
522
|
+
if (this.eventSource !== source)
|
|
523
|
+
return;
|
|
517
524
|
this.setSseConnected(false);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
525
|
+
if (this.mode === "hosted" && this.gateway) {
|
|
526
|
+
// Browser auto-reconnect reuses the URL frozen at construction, so it
|
|
527
|
+
// would replay from a stale `since`. Own the reconnect so the next
|
|
528
|
+
// connect rebuilds activeSseUrl from the current versionRef. CLOSED also
|
|
529
|
+
// refreshes the token (expired/rotated/deploy); CONNECTING keeps it. A
|
|
530
|
+
// successful reconnect resets the count in onopen; a hard-down gateway
|
|
531
|
+
// trips the threshold and health-gates to local.
|
|
532
|
+
if (source.readyState === EventSource.CLOSED)
|
|
533
|
+
this.token = null;
|
|
534
|
+
this.closeEvents();
|
|
535
|
+
this.onGatewayTransientFailure();
|
|
536
|
+
if (this.mode === "hosted")
|
|
537
|
+
this.scheduleGatewayReconnect();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// Local mode: native EventSource reconnect is fine. Drop a CLOSED ref so a
|
|
541
|
+
// later connectEvents() (focus/visibility) can establish a fresh stream.
|
|
523
542
|
if (source.readyState === EventSource.CLOSED) {
|
|
524
543
|
this.eventSource = null;
|
|
525
|
-
if (this.mode === "hosted" && this.gateway) {
|
|
526
|
-
// A closed gateway stream is most likely an expired token or a
|
|
527
|
-
// request-timeout/deploy cycle. Re-mint and reconnect with jitter;
|
|
528
|
-
// this is NOT the poll-401 cooldown path. Each closed stream counts
|
|
529
|
-
// toward the unhealthy threshold so a hard-down gateway (or one
|
|
530
|
-
// rejecting our tokens) health-gates to local instead of looping
|
|
531
|
-
// mint+connect forever; a successful reconnect resets the count in
|
|
532
|
-
// onopen above.
|
|
533
|
-
this.token = null;
|
|
534
|
-
this.onGatewayTransientFailure();
|
|
535
|
-
if (this.mode === "hosted")
|
|
536
|
-
this.scheduleGatewayReconnect();
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
544
|
}
|
|
540
545
|
this.schedulePoll();
|
|
541
546
|
};
|