@songsid/agend 2.2.0-alpha.6 → 2.2.0-alpha.8
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/dist/ansi-faint.d.ts +25 -0
- package/dist/ansi-faint.js +90 -0
- package/dist/ansi-faint.js.map +1 -0
- package/dist/fleet-manager.d.ts +12 -0
- package/dist/fleet-manager.js +116 -28
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +11 -2
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +2 -1
- package/dist/locale.js +4 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.js +8 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/tz-utils.d.ts +6 -0
- package/dist/tz-utils.js +23 -0
- package/dist/tz-utils.js.map +1 -1
- package/dist/ui/chat-render.js +29 -1
- package/dist/ui/chat-strings.js +2 -0
- package/dist/ui/chat-thread.js +128 -3
- package/dist/ui/panel-chat.js +1 -0
- package/dist/ui/shared/app-i18n.js +2 -0
- package/dist/ui/shared/app-shell.js +9 -1
- package/dist/ui/shared/app-stream.js +186 -43
- package/dist/ui/shared/app.css +16 -0
- package/dist/ui/shared/app.js +6 -0
- package/dist/ui/shared/panel-view.js +21 -8
- package/dist/ui/shared/usage-dialog.js +16 -1
- package/dist/ui/shared/view-strings.js +10 -0
- package/dist/usage/usage-api.d.ts +8 -1
- package/dist/usage/usage-api.js +100 -31
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/web-api.d.ts +1 -1
- package/dist/web-api.js +67 -4
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +7 -0
- package/dist/web-chat-history.js +19 -0
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-host-guard.d.ts +2 -0
- package/dist/web-host-guard.js +7 -1
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-upload.d.ts +17 -0
- package/dist/web-upload.js +38 -7
- package/dist/web-upload.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/chat-thread.js
CHANGED
|
@@ -11,6 +11,16 @@ import "./preview.js";
|
|
|
11
11
|
import { loadHtmlAttachment } from "./html-attachment.js";
|
|
12
12
|
|
|
13
13
|
const R = () => globalThis.AgendChatRender;
|
|
14
|
+
/** #1589: the public link shows attachments as downloads only (its page says so: <body data-public-link="1">). */
|
|
15
|
+
const PUBLIC_LINK = () => globalThis.document?.body?.dataset?.publicLink === "1";
|
|
16
|
+
/** #1589: how much of a text attachment is shown before "Show all". */
|
|
17
|
+
export const TEXT_PREVIEW_LINES = 200;
|
|
18
|
+
/** #1589: the most elements one formatted text card may build (table cells, highlight spans, Markdown elements), Show all
|
|
19
|
+
* included. Lines and the 1 MiB read do not bound it (one 200 KB line can be 100,000 cells): past it the card is plain
|
|
20
|
+
* text, one text node. */
|
|
21
|
+
export const TEXT_PREVIEW_NODES = 4000;
|
|
22
|
+
/** A csv/tsv wider than this is shown as text, as Markdown tables are capped at 20 columns. */
|
|
23
|
+
export const TEXT_PREVIEW_COLUMNS = 50;
|
|
14
24
|
const P = () => globalThis.AgendPreview;
|
|
15
25
|
const COPY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
16
26
|
|
|
@@ -46,7 +56,7 @@ export function createThread(list, scroller, opts) {
|
|
|
46
56
|
const ticks = u && x.delivery ? R().deliveryHtml(x.delivery, TICKS()) : "";
|
|
47
57
|
const tools = u ? "" : `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
|
|
48
58
|
const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
|
|
49
|
-
return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, x.role === "agent" ? { htmlCards: true } : undefined)}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable") })}${buttons}</div>${tools}</div>`;
|
|
59
|
+
return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, x.role === "agent" ? { htmlCards: true } : undefined)}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
|
|
50
60
|
}
|
|
51
61
|
/**
|
|
52
62
|
* #1266: an agent reply's buttons. Labels are text (escaped, never Markdown); the click names the set and the index,
|
|
@@ -69,6 +79,7 @@ export function createThread(list, scroller, opts) {
|
|
|
69
79
|
const node = tpl.content.firstElementChild;
|
|
70
80
|
decorateCode(node);
|
|
71
81
|
if (x) decorateHtmlCards(node, x, stoppedKeys || []);
|
|
82
|
+
if (x) decorateTextCards(node, x);
|
|
72
83
|
return node;
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -117,6 +128,114 @@ export function createThread(list, scroller, opts) {
|
|
|
117
128
|
buildCard(ph, x, `${msgKey(x)}:a${att.id}`, { att: { id: att.id, name: att.name, size: att.size } }, stoppedKeys);
|
|
118
129
|
}
|
|
119
130
|
}
|
|
131
|
+
// #1589: a text attachment (md, csv, json, code, plain) gets a card: Show reads the file once (the same capped,
|
|
132
|
+
// same-origin read as an HTML attachment's) and shows it — Markdown through renderMarkdown (it escapes first), code
|
|
133
|
+
// through the highlighter (escaped), csv as a table and anything else as text, all built with textContent. Nothing in
|
|
134
|
+
// the file is ever run: no frame, no script, no HTML of its own.
|
|
135
|
+
function decorateTextCards(node, x) {
|
|
136
|
+
const atts = Array.isArray(x.attachments) ? x.attachments : [];
|
|
137
|
+
for (const ph of node.querySelectorAll(".text-card[data-att]")) {
|
|
138
|
+
const att = atts.find(a => a && a.id === ph.dataset.att);
|
|
139
|
+
const type = att ? R().attachmentPreviewType(att) : null;
|
|
140
|
+
if (!att || !type || type !== ph.dataset.type) continue;
|
|
141
|
+
buildTextCard(ph, att, type);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function buildTextCard(ph, att, type) {
|
|
145
|
+
const doc = list.ownerDocument;
|
|
146
|
+
const el = (tag, cls, text) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
|
147
|
+
ph.textContent = "";
|
|
148
|
+
const head = el("div", "tc-head");
|
|
149
|
+
const label = el("span", "tc-label", att.name);
|
|
150
|
+
const show = el("button", "btn btn-sm tc-show", tr("chat.tcShow")); show.type = "button";
|
|
151
|
+
const dl = el("a", "btn btn-sm tc-dl", tr("chat.tcDownload")); dl.href = `/ui/file/${att.id}`; dl.setAttribute("download", att.name);
|
|
152
|
+
head.append(label, show, dl);
|
|
153
|
+
const note = el("div", "pv-note");
|
|
154
|
+
const body = el("div", "tc-body"); body.hidden = true;
|
|
155
|
+
ph.append(head, note, body);
|
|
156
|
+
let text = null, reading = false, all = false;
|
|
157
|
+
const draw = () => {
|
|
158
|
+
body.textContent = "";
|
|
159
|
+
const lines = text.split(/\r?\n/);
|
|
160
|
+
const cut = !all && lines.length > TEXT_PREVIEW_LINES;
|
|
161
|
+
const shown = cut ? lines.slice(0, TEXT_PREVIEW_LINES) : lines;
|
|
162
|
+
const src = shown.join("\n");
|
|
163
|
+
// Formatting is built only within TEXT_PREVIEW_NODES: the markup is measured as a string first, never as DOM.
|
|
164
|
+
const plain = () => {
|
|
165
|
+
const pre = el("pre", "tc-pre"), code = el("code", null, src);
|
|
166
|
+
pre.append(code); body.append(el("div", "pv-note", tr("chat.tcPlain")), pre);
|
|
167
|
+
};
|
|
168
|
+
if (type === "md") {
|
|
169
|
+
const html = R().renderMarkdown(src);
|
|
170
|
+
if (tagCount(html) > TEXT_PREVIEW_NODES) plain();
|
|
171
|
+
else { const md = el("div", "md"); md.innerHTML = html; body.append(md); }
|
|
172
|
+
} else if (type === "csv" || type === "tsv") {
|
|
173
|
+
const table = csvTable(src, type === "tsv" ? "\t" : ",", el);
|
|
174
|
+
if (table) body.append(table); else plain();
|
|
175
|
+
} else {
|
|
176
|
+
const lang = type === "json" ? "json" : type.startsWith("code:") ? type.slice(5) : "";
|
|
177
|
+
const lit = lang ? R().highlight(src, lang) : null;
|
|
178
|
+
if (lit != null && tagCount(lit) > TEXT_PREVIEW_NODES) plain();
|
|
179
|
+
else {
|
|
180
|
+
const pre = el("pre", "tc-pre"), code = el("code");
|
|
181
|
+
if (lit != null) code.innerHTML = lit; else code.textContent = src;
|
|
182
|
+
pre.append(code); body.append(pre);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (cut) {
|
|
186
|
+
const more = el("button", "btn btn-sm tc-more", trf("chat.tcShowAll", lines.length)); more.type = "button";
|
|
187
|
+
more.onclick = () => { all = true; draw(); };
|
|
188
|
+
body.append(more);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
show.onclick = async () => {
|
|
192
|
+
if (reading) return;
|
|
193
|
+
if (text !== null) { body.hidden = !body.hidden; show.textContent = tr(body.hidden ? "chat.tcShow" : "chat.tcHide"); return; }
|
|
194
|
+
reading = true; note.textContent = tr("chat.pvLoading");
|
|
195
|
+
const r = await loadHtmlAttachment({ id: att.id, name: att.name, size: att.size });
|
|
196
|
+
reading = false;
|
|
197
|
+
if (!ph.isConnected) return; // the card left the page while it was read: nothing to draw
|
|
198
|
+
if (!r.ok) { note.textContent = r.reason === "over" ? tr("chat.tcOver") : attReason(r.reason); return; }
|
|
199
|
+
note.textContent = "";
|
|
200
|
+
text = r.code;
|
|
201
|
+
if (type === "json") { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch { /* shown as written */ } }
|
|
202
|
+
draw();
|
|
203
|
+
body.hidden = false; show.textContent = tr("chat.tcHide");
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** How many elements an HTML string would build: its opening tags (escaped text has none). */
|
|
207
|
+
function tagCount(html) {
|
|
208
|
+
let n = 0;
|
|
209
|
+
for (let i = html.indexOf("<"); i !== -1; i = html.indexOf("<", i + 1)) { const c = html.charCodeAt(i + 1); if ((c | 32) >= 97 && (c | 32) <= 122) n++; }
|
|
210
|
+
return n;
|
|
211
|
+
}
|
|
212
|
+
/** A csv/tsv as a table: quoted fields ("a, b", "say ""hi""") kept whole; every cell is textContent. */
|
|
213
|
+
function csvTable(src, sep, el) {
|
|
214
|
+
const rows = [];
|
|
215
|
+
let row = [], cell = "", quoted = false;
|
|
216
|
+
for (let i = 0; i < src.length; i++) {
|
|
217
|
+
const c = src[i];
|
|
218
|
+
if (quoted) {
|
|
219
|
+
if (c === '"') { if (src[i + 1] === '"') { cell += '"'; i++; } else quoted = false; }
|
|
220
|
+
else cell += c;
|
|
221
|
+
} else if (c === '"' && cell === "") quoted = true;
|
|
222
|
+
else if (c === sep) { row.push(cell); cell = ""; }
|
|
223
|
+
else if (c === "\n") { row.push(cell.replace(/\r$/, "")); rows.push(row); row = []; cell = ""; }
|
|
224
|
+
else cell += c;
|
|
225
|
+
}
|
|
226
|
+
if (cell !== "" || row.length) { row.push(cell.replace(/\r$/, "")); rows.push(row); }
|
|
227
|
+
// Within the card's budget, or not a table at all (the caller shows it as text).
|
|
228
|
+
let cells = 0;
|
|
229
|
+
for (const r of rows) { cells += r.length; if (r.length > TEXT_PREVIEW_COLUMNS || cells + rows.length > TEXT_PREVIEW_NODES) return null; }
|
|
230
|
+
const table = el("table", "tc-table");
|
|
231
|
+
rows.forEach((r, i) => {
|
|
232
|
+
const tr_ = el("tr");
|
|
233
|
+
for (const v of r) tr_.append(el(i === 0 ? "th" : "td", null, v));
|
|
234
|
+
table.append(tr_);
|
|
235
|
+
});
|
|
236
|
+
const wrap = el("div", "tc-table-wrap"); wrap.append(table);
|
|
237
|
+
return wrap;
|
|
238
|
+
}
|
|
120
239
|
/** Why an attachment's HTML could not be read, for the card's note. */
|
|
121
240
|
const attReason = (reason) => tr(reason === "over" ? "chat.pvAttOver" : reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed");
|
|
122
241
|
/** One card: `source` is { n, code } for a fence or { att } for an attachment (its HTML read on the first click). */
|
|
@@ -201,11 +320,17 @@ export function createThread(list, scroller, opts) {
|
|
|
201
320
|
for (const node of list.querySelectorAll(".html-card[data-card], .html-card[data-att]")) { const f = cardRefresh.get(node); if (f) f(); }
|
|
202
321
|
}
|
|
203
322
|
|
|
323
|
+
// Near the bottom (48 px slack): where the READER counts as at the newest message — for the pill and for pinning on scroll.
|
|
204
324
|
const nearBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight);
|
|
325
|
+
// #1584: at the exact bottom — what a pinned view is kept at. The slack is the reader's, not the layout's: a dock that
|
|
326
|
+
// grew by less than 48 px (the working line, a command card, the composer shrinking after a send) used to leave the
|
|
327
|
+
// newest bubble clipped behind it, and the next growth past 48 px put the pill over it.
|
|
328
|
+
const atBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight, 1);
|
|
205
329
|
function reportJump() { opts.onJump({ show: !nearBottom(), unseen }); }
|
|
206
330
|
function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; lastTop = scroller.scrollTop; }
|
|
207
|
-
// Sizes change without a scroll event (layout after the render):
|
|
208
|
-
|
|
331
|
+
// Sizes change without a scroll event (layout after the render, the dock under the thread): while pinned, the view
|
|
332
|
+
// stays flush with the bottom; a reader who scrolled up is never moved.
|
|
333
|
+
const resized = () => { if (pinned && !atBottom()) toBottom(); reportJump(); };
|
|
209
334
|
const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
|
|
210
335
|
if (ro) { ro.observe(list); ro.observe(scroller); }
|
|
211
336
|
|
package/dist/ui/panel-chat.js
CHANGED
|
@@ -421,6 +421,7 @@ function Composer({ name, lease, busy }) {
|
|
|
421
421
|
<button id="stopBtn" type="button" class="btn btn-stop" hidden=${!busy} title=${t("chat.stopReplyTitle")}
|
|
422
422
|
disabled=${!!s.cancelling[name]} onClick=${() => store.cancelReply(name)}><${Icon} name="stop" size=${14} /><span class="stop-label">${t("chat.stopReply")}</span></button>
|
|
423
423
|
<button id="sendBtn" type="button" class="btn btn-primary btn-send" hidden=${busy && !content} disabled=${!content || !!s.sending[name]}
|
|
424
|
+
onMouseDown=${(e) => e.preventDefault()}
|
|
424
425
|
aria-label=${t("chat.send")} title=${t("chat.send")} onClick=${submit}><${Icon} name="send" size=${16} /><span class="send-label">${t("chat.send")}</span></button>
|
|
425
426
|
</div></div>`;
|
|
426
427
|
}
|
|
@@ -46,6 +46,7 @@ register("app", {
|
|
|
46
46
|
textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage",
|
|
47
47
|
statusRunning: "running", statusStopped: "stopped", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
|
|
48
48
|
connPolling: "Live updates paused — refreshing every 5 s", connDown: "Disconnected — trying again…", connConnecting: "Connecting…",
|
|
49
|
+
connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
|
|
49
50
|
hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
|
|
50
51
|
skip: "Skip to the conversation", loadFailed: "This could not be loaded.", retry: "Try again", loading: "Loading…",
|
|
51
52
|
noInstances: "No instances yet", noInstancesHint: "Create one to start chatting.",
|
|
@@ -75,6 +76,7 @@ register("app", {
|
|
|
75
76
|
textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量",
|
|
76
77
|
statusRunning: "執行中", statusStopped: "已停止", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
|
|
77
78
|
connPolling: "即時更新暫停中——每 5 秒重新整理", connDown: "已斷線——正在重試…", connConnecting: "連線中…",
|
|
79
|
+
connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
|
|
78
80
|
hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
|
|
79
81
|
skip: "跳到對話", loadFailed: "無法載入。", retry: "再試一次", loading: "載入中…",
|
|
80
82
|
noInstances: "還沒有 instance", noInstancesHint: "建立一個就能開始聊天。",
|
|
@@ -227,10 +227,18 @@ function ConnectionLine() {
|
|
|
227
227
|
return html`<div class="conn" role="status">${hydration === "failed" ? t("app.hydrateFailed") : t("app.hydrating")}
|
|
228
228
|
${hydration === "failed" && retryHydration ? html` <button type="button" class="btn btn-sm" onClick=${() => retryHydration()}>${t("app.retry")}</button>` : null}</div>`;
|
|
229
229
|
}
|
|
230
|
-
if (connection === "
|
|
230
|
+
if (connection === "reconnecting") return html`<${Reconnecting} />`;
|
|
231
|
+
if (connection === "live" || connection === "none" || connection === "ended") return null; // ended: agend-auth's banner
|
|
231
232
|
const text = connection === "polling" ? t("app.connPolling") : connection === "down" ? t("app.connDown") : null;
|
|
232
233
|
return text ? html`<div class="conn" role="status">${text}</div>` : null;
|
|
233
234
|
}
|
|
235
|
+
/** #1580: the fleet is not answering (a restart): said, with when the next try is — never a silent stale page. */
|
|
236
|
+
function Reconnecting() {
|
|
237
|
+
const { retryAt } = useStore(appStore);
|
|
238
|
+
useTick(true);
|
|
239
|
+
const s = typeof retryAt === "number" ? Math.max(0, Math.ceil((retryAt - NOW()) / 1000)) : 0;
|
|
240
|
+
return html`<div class="conn reconnecting" role="status">${s > 0 ? t("app.connReconnectingIn", s) : t("app.connReconnecting")}</div>`;
|
|
241
|
+
}
|
|
234
242
|
|
|
235
243
|
const NOW = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
236
244
|
function useTick(on, ms = 1000) {
|
|
@@ -14,21 +14,53 @@
|
|
|
14
14
|
// applied they are replayed in arrival order, so an answer or a new prompt that came meanwhile is never undone by the
|
|
15
15
|
// older snapshot. A failed read is retried a few times with a growing delay, and then waits for the person (Retry);
|
|
16
16
|
// the state is told as "hydration" events. Polls never overlap: a poll asked for while one runs follows it.
|
|
17
|
+
//
|
|
18
|
+
// Reconnecting (#1580): a fleet restart drops every open tab's stream at once, and used to have each tab retry through
|
|
19
|
+
// the browser's own EventSource loop (about every 3 s) AND poll every 5 s, both forever, hidden or not — through a
|
|
20
|
+
// relay (WSL2's wslrelay, a tunnel) that is dozens of fresh connections per tab per minute. Now this file owns it:
|
|
21
|
+
// - a dropped stream is closed (no browser retry) and reopened on a capped exponential backoff with jitter (about
|
|
22
|
+
// 1, 2, 4, 8, 16, then 30 s; each 50–100 % of its step). One EventSource at a time, ever.
|
|
23
|
+
// - a stream that fails before it speaks is followed by ONE /ui/poll probe: answered → the fleet is up and only the
|
|
24
|
+
// stream is broken (a buffering proxy): poll every 5 s as before, and keep retrying the stream on the backoff; not
|
|
25
|
+
// answered → "reconnecting", and no poll loop at all. The public link's poll loop backs off the same way.
|
|
26
|
+
// - a step due while the tab is hidden waits until it is shown again (a live stream stays open: Needs you).
|
|
27
|
+
// - every reconnect catches up once the new stream speaks (catchUp(), with the page's cursor — #1566/#1578), so what
|
|
28
|
+
// was said meanwhile is shown, once.
|
|
29
|
+
// - a 401 is the session ending: nothing is retried (agend-auth.js says so).
|
|
30
|
+
// - suspend()/resume(): the page is leaving (pagehide) / came back from the back-forward cache.
|
|
17
31
|
|
|
18
32
|
const EVENTS = ["status", "message", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons"];
|
|
19
33
|
const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
|
|
20
34
|
const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
|
|
21
35
|
const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed
|
|
22
36
|
const MAX_OVERFLOW_READS = 3; // reads again after dropped live events, within one catch-up
|
|
37
|
+
// #1580: the reconnect backoff — step n waits min(cap, base·2ⁿ), drawn from 50–100 % of that (jitter: tabs that lost
|
|
38
|
+
// the fleet together do not come back together).
|
|
39
|
+
export const RECONNECT = { baseMs: 1000, capMs: 30000 };
|
|
40
|
+
export function reconnectDelay(step, random = Math.random) {
|
|
41
|
+
const full = Math.min(RECONNECT.capMs, RECONNECT.baseMs * 2 ** Math.min(step, 20));
|
|
42
|
+
return Math.round(full * (0.5 + random() * 0.5));
|
|
43
|
+
}
|
|
23
44
|
|
|
24
45
|
export function createStream(opts) {
|
|
25
46
|
const env = opts.env || globalThis;
|
|
26
47
|
const listeners = new Map(); // event name → Set of handlers
|
|
27
48
|
const state = { connection: "connecting", cursor: "", hydration: "none" };
|
|
28
|
-
let sse = null, pollTimer = null, silentTimer = null,
|
|
49
|
+
let sse = null, pollTimer = null, silentTimer = null, closed = false, started = false;
|
|
50
|
+
// #1580: the reconnect step waiting, how many failed in a row, a step waiting for the tab to be shown, a page that left.
|
|
51
|
+
let reconnectTimer = null, attempts = 0, waitingVisible = false, suspended = false, ended = false, retryAt = 0;
|
|
52
|
+
const random = typeof env.random === "function" ? env.random : Math.random;
|
|
53
|
+
const doc = env.document || null;
|
|
54
|
+
const hidden = () => !!(doc && doc.hidden);
|
|
55
|
+
const now = () => (env.performance && typeof env.performance.now === "function" ? env.performance.now() : Date.now());
|
|
29
56
|
let polling = null, pollAgain = false, pollSeq = 0; // the poll in flight, whether another was asked for meanwhile
|
|
30
57
|
let held = null; // live events held while a catch-up read is in flight
|
|
31
|
-
let catching = null, retryTimer = null;
|
|
58
|
+
let catching = null, retryTimer = null, retryWake = null;
|
|
59
|
+
// #1580 review: who owns the work in flight. `readEpoch` moves when the page lets everything go (suspend, end,
|
|
60
|
+
// close): a read, a catch-up or a queued poll begun before it neither emits, nor releases, nor queues, nor decides
|
|
61
|
+
// anything. `epoch` also moves when the stream comes back live: a fallback read's or a probe's late failure must not
|
|
62
|
+
// turn a healthy stream into "reconnecting" — those control decisions belong to the epoch they started in.
|
|
63
|
+
let readEpoch = 0, epoch = 0;
|
|
32
64
|
|
|
33
65
|
function emit(name, data, extra) {
|
|
34
66
|
const set = listeners.get(name);
|
|
@@ -69,12 +101,14 @@ export function createStream(opts) {
|
|
|
69
101
|
*/
|
|
70
102
|
function pollOnce(opts = {}) {
|
|
71
103
|
if (polling) { if (!opts.join) pollAgain = true; return polling; }
|
|
72
|
-
const mine = ++pollSeq;
|
|
104
|
+
const mine = ++pollSeq, ep = readEpoch;
|
|
105
|
+
let status = 0; // this read's HTTP status — its own, never another read's
|
|
73
106
|
if (sse && !held) held = [];
|
|
74
107
|
let limit = null;
|
|
75
108
|
const read = (async () => {
|
|
76
109
|
try {
|
|
77
110
|
const r = await env.fetch(`/ui/poll?after=${encodeURIComponent(state.cursor)}`, { cache: "no-store" });
|
|
111
|
+
status = r.status | 0;
|
|
78
112
|
if (closed || mine !== pollSeq || !r.ok) return false;
|
|
79
113
|
const d = await r.json();
|
|
80
114
|
if (closed || mine !== pollSeq) return false;
|
|
@@ -89,65 +123,168 @@ export function createStream(opts) {
|
|
|
89
123
|
} catch { return false; /* the next tick, or the catch-up's retry, tries again */ }
|
|
90
124
|
})();
|
|
91
125
|
const timedOut = new Promise((resolve) => { limit = env.setTimeout(() => { if (mine === pollSeq) pollSeq++; resolve(false); }, POLL_LIMIT_MS); });
|
|
92
|
-
polling = Promise.race([read, timedOut]).then((ok) => {
|
|
126
|
+
const p = polling = Promise.race([read, timedOut]).then((ok) => {
|
|
93
127
|
env.clearTimeout(limit);
|
|
128
|
+
// Let go of (suspend, end, close) while it was out: it decides nothing — and touches nothing that is newer.
|
|
129
|
+
if (ep !== readEpoch) return { ok: false, overflow: false, status, stale: true };
|
|
94
130
|
if (mine === pollSeq) pollSeq++; // whatever this read still does from here on is ignored
|
|
95
|
-
polling = null;
|
|
96
|
-
if (closed) { held = null; return { ok: false, overflow: false }; }
|
|
131
|
+
if (polling === p) polling = null;
|
|
132
|
+
if (closed) { held = null; return { ok: false, overflow: false, status }; }
|
|
133
|
+
// #1580: the session ended, whichever read heard it — everything stops here, queued work included.
|
|
134
|
+
if (status === 401) { held = null; end(); return { ok: false, overflow: false, status }; }
|
|
97
135
|
const overflow = release();
|
|
98
136
|
// Live events were dropped: what they changed is recovered by catching up — inside its lifecycle, so a failed
|
|
99
137
|
// recovery read is retried and shown, never taken for done (#1425 review). A catch-up awaiting this read loops.
|
|
100
138
|
if (overflow && !catching) catchUp();
|
|
101
139
|
if (pollAgain) { pollAgain = false; pollOnce(); }
|
|
102
|
-
return { ok, overflow };
|
|
140
|
+
return { ok, overflow, status };
|
|
103
141
|
});
|
|
104
|
-
return
|
|
142
|
+
return p;
|
|
105
143
|
}
|
|
106
|
-
|
|
107
|
-
|
|
144
|
+
/** #1580 review: the page lets go of everything in flight — reads, the queued poll, the catch-up and its retry wait. */
|
|
145
|
+
function cancelWork() {
|
|
146
|
+
readEpoch++; epoch++;
|
|
147
|
+
pollSeq++; polling = null; pollAgain = false; held = null;
|
|
148
|
+
env.clearTimeout(retryTimer); retryTimer = null;
|
|
149
|
+
const wake = retryWake; retryWake = null;
|
|
150
|
+
catching = null;
|
|
151
|
+
if (wake) wake(); // the old catch-up settles (false) instead of waiting forever
|
|
152
|
+
}
|
|
153
|
+
/** The 5 s poll: the public link's transport, and a reachable fleet whose stream does not get through. */
|
|
154
|
+
function startPolling(pollNow = true) {
|
|
155
|
+
if (pollTimer || closed || suspended || ended) return;
|
|
108
156
|
setConnection("polling");
|
|
109
|
-
|
|
110
|
-
pollTimer = env.setInterval(
|
|
157
|
+
if (pollNow) pollTick();
|
|
158
|
+
pollTimer = env.setInterval(pollTick, 5000);
|
|
159
|
+
}
|
|
160
|
+
async function pollTick() {
|
|
161
|
+
const ep = epoch;
|
|
162
|
+
const r = await pollOnce();
|
|
163
|
+
if (closed || suspended || ended || ep !== epoch || r.stale || r.ok || r.overflow) return;
|
|
164
|
+
// Not answered: the fleet is not there (a restart). The loop stops — no poll storm — and the backoff takes over.
|
|
165
|
+
stopPolling();
|
|
166
|
+
setConnection("reconnecting");
|
|
167
|
+
scheduleStep();
|
|
111
168
|
}
|
|
112
169
|
function stopPolling() { if (pollTimer) { env.clearInterval(pollTimer); pollTimer = null; } }
|
|
113
170
|
function alive() {
|
|
171
|
+
if (state.connection !== "live") epoch++; // back live: earlier fallback/probe failures decide nothing now
|
|
114
172
|
stopPolling();
|
|
115
|
-
|
|
173
|
+
attempts = 0;
|
|
174
|
+
env.clearTimeout(reconnectTimer); reconnectTimer = null; waitingVisible = false;
|
|
116
175
|
env.clearTimeout(silentTimer); silentTimer = env.setTimeout(startPolling, 30000);
|
|
117
176
|
setConnection("live");
|
|
118
177
|
}
|
|
119
178
|
|
|
179
|
+
// ── #1580: reconnecting ──
|
|
180
|
+
function closeStream() {
|
|
181
|
+
if (sse) { sse.onerror = null; try { sse.close(); } catch { /* already closed */ } }
|
|
182
|
+
sse = null;
|
|
183
|
+
env.clearTimeout(silentTimer); silentTimer = null;
|
|
184
|
+
}
|
|
185
|
+
/** Open the one EventSource. `again`: a reconnect — once it speaks, catch up on what was said while it was away. */
|
|
186
|
+
function openStream(again) {
|
|
187
|
+
closeStream();
|
|
188
|
+
const es = sse = new env.EventSource("/ui/events");
|
|
189
|
+
let spoke = false;
|
|
190
|
+
silentTimer = env.setTimeout(startPolling, 15000); // the server sends a status frame the moment a stream opens
|
|
191
|
+
for (const name of EVENTS) {
|
|
192
|
+
es.addEventListener(name, (e) => {
|
|
193
|
+
if (es !== sse) return; // a stream this page already let go of
|
|
194
|
+
let data;
|
|
195
|
+
try { data = JSON.parse(e.data); } catch { return; }
|
|
196
|
+
if (!spoke) { spoke = true; alive(); if (again) catchUp(); }
|
|
197
|
+
else alive();
|
|
198
|
+
live(name, data, name === "message" ? e.lastEventId : null);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
es.onerror = () => {
|
|
202
|
+
if (es !== sse) return;
|
|
203
|
+
closeStream(); // never the browser's own retry loop
|
|
204
|
+
if (closed || suspended || ended) return;
|
|
205
|
+
if (spoke) { setConnection("reconnecting"); scheduleStep(); return; }
|
|
206
|
+
probe();
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** The stream failed before it spoke: is the fleet there at all? One poll says. */
|
|
210
|
+
async function probe() {
|
|
211
|
+
if (pollTimer) { scheduleStep(); return; } // already polling: that loop is the probe
|
|
212
|
+
const ep = epoch;
|
|
213
|
+
const r = await pollOnce({ join: true });
|
|
214
|
+
if (closed || suspended || ended || ep !== epoch || r.stale) return;
|
|
215
|
+
if (r.ok) startPolling(false); // reachable, the stream does not get through: poll meanwhile
|
|
216
|
+
else setConnection("reconnecting");
|
|
217
|
+
scheduleStep();
|
|
218
|
+
}
|
|
219
|
+
function scheduleStep() {
|
|
220
|
+
if (reconnectTimer || closed || suspended || ended) return;
|
|
221
|
+
const ms = reconnectDelay(attempts++, random);
|
|
222
|
+
retryAt = now() + ms;
|
|
223
|
+
emit("retry", { inMs: ms });
|
|
224
|
+
reconnectTimer = env.setTimeout(() => { reconnectTimer = null; step(); }, ms);
|
|
225
|
+
}
|
|
226
|
+
function step() {
|
|
227
|
+
if (closed || suspended || ended) return;
|
|
228
|
+
if (hidden()) { waitingVisible = true; return; } // not while nobody looks: the next step waits to be shown
|
|
229
|
+
if (opts.transport === "poll") { pollProbe(); return; }
|
|
230
|
+
openStream(true);
|
|
231
|
+
}
|
|
232
|
+
/** The public link's reconnect step: one poll; answered → the 5 s loop again (it just read), else the next step. */
|
|
233
|
+
async function pollProbe() {
|
|
234
|
+
const ep = epoch;
|
|
235
|
+
const r = await pollOnce();
|
|
236
|
+
if (closed || suspended || ended || ep !== epoch || r.stale) return;
|
|
237
|
+
if (r.ok) { attempts = 0; startPolling(false); }
|
|
238
|
+
else scheduleStep();
|
|
239
|
+
}
|
|
240
|
+
function onVisible() {
|
|
241
|
+
if (!hidden() && waitingVisible) { waitingVisible = false; step(); }
|
|
242
|
+
}
|
|
243
|
+
function end() {
|
|
244
|
+
if (ended) return;
|
|
245
|
+
ended = true;
|
|
246
|
+
cancelWork();
|
|
247
|
+
closeStream(); stopPolling();
|
|
248
|
+
env.clearTimeout(reconnectTimer); reconnectTimer = null;
|
|
249
|
+
setConnection("ended");
|
|
250
|
+
}
|
|
251
|
+
|
|
120
252
|
/**
|
|
121
253
|
* Read what is current now, once, ordered against the live stream (see the top). Resolves true once a read was
|
|
122
254
|
* applied. While retries are spent it resolves false and the state stays "failed" until catchUp() is called again.
|
|
123
255
|
* It opens no transport: still the one EventSource, and no polling loop.
|
|
124
256
|
*/
|
|
125
257
|
function catchUp() {
|
|
126
|
-
if (closed) return Promise.resolve(false);
|
|
258
|
+
if (closed || ended) return Promise.resolve(false);
|
|
127
259
|
if (catching) return catching;
|
|
128
260
|
env.clearTimeout(retryTimer); retryTimer = null;
|
|
129
261
|
setHydration("catching");
|
|
130
|
-
|
|
262
|
+
const ep = readEpoch;
|
|
263
|
+
// Settles this catch-up; the page's current one is cleared only if it is still this one (a resume may have
|
|
264
|
+
// started a newer one meanwhile).
|
|
265
|
+
const done = (value, hydration) => { if (catching === me) { catching = null; if (hydration) setHydration(hydration); } return value; };
|
|
266
|
+
const me = (async () => {
|
|
131
267
|
let failures = 0, overflows = 0;
|
|
132
268
|
for (;;) {
|
|
133
269
|
// A poll already on its way will do (its answer reaches the listener that just attached); nothing is queued.
|
|
134
270
|
const { ok, overflow } = await pollOnce({ join: true });
|
|
135
|
-
if (closed) return false;
|
|
136
|
-
if (ok && !overflow)
|
|
271
|
+
if (closed || ep !== readEpoch) return done(false); // let go of (suspend, end — a 401 — or close): no retry
|
|
272
|
+
if (ok && !overflow) return done(true, "ok");
|
|
137
273
|
if (ok) {
|
|
138
274
|
// Read, but live events were dropped meanwhile: not done until a read without that succeeds. At once (nothing
|
|
139
275
|
// failed), and bounded: a stream that floods every read gives up as failed, with Retry.
|
|
140
|
-
if (++overflows > MAX_OVERFLOW_READS)
|
|
276
|
+
if (++overflows > MAX_OVERFLOW_READS) return done(false, "failed");
|
|
141
277
|
continue;
|
|
142
278
|
}
|
|
143
|
-
if (failures >= RETRY_DELAYS.length)
|
|
279
|
+
if (failures >= RETRY_DELAYS.length) return done(false, "failed");
|
|
144
280
|
setHydration("retrying");
|
|
145
|
-
await new Promise((resolve) => { retryTimer = env.setTimeout(resolve, RETRY_DELAYS[failures++]); });
|
|
146
|
-
retryTimer = null;
|
|
147
|
-
if (closed) return false;
|
|
281
|
+
await new Promise((resolve) => { retryWake = resolve; retryTimer = env.setTimeout(resolve, RETRY_DELAYS[failures++]); });
|
|
282
|
+
retryTimer = null; retryWake = null;
|
|
283
|
+
if (closed || ep !== readEpoch) return done(false);
|
|
148
284
|
}
|
|
149
285
|
})();
|
|
150
|
-
|
|
286
|
+
catching = me;
|
|
287
|
+
return me;
|
|
151
288
|
}
|
|
152
289
|
|
|
153
290
|
/** Open the transport. Called once every listener that must see the first frames is attached (the chat's store):
|
|
@@ -155,24 +292,10 @@ export function createStream(opts) {
|
|
|
155
292
|
function start() {
|
|
156
293
|
if (started || closed) return;
|
|
157
294
|
started = true;
|
|
295
|
+
if (opts.mode === "full" && doc && typeof doc.addEventListener === "function") doc.addEventListener("visibilitychange", onVisible);
|
|
158
296
|
if (opts.mode === "full" && opts.transport === "poll") startPolling();
|
|
159
|
-
else if (opts.mode === "full")
|
|
160
|
-
|
|
161
|
-
silentTimer = env.setTimeout(startPolling, 15000); // the server sends a status frame the moment a stream opens
|
|
162
|
-
for (const name of EVENTS) {
|
|
163
|
-
sse.addEventListener(name, (e) => {
|
|
164
|
-
alive();
|
|
165
|
-
let data;
|
|
166
|
-
try { data = JSON.parse(e.data); } catch { return; }
|
|
167
|
-
live(name, data, name === "message" ? e.lastEventId : null);
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
sse.onerror = () => {
|
|
171
|
-
if (state.connection === "live") setConnection("down");
|
|
172
|
-
// Armed once, not re-armed: the browser retries every few seconds and each retry fires this again.
|
|
173
|
-
if (!errorTimer) errorTimer = env.setTimeout(startPolling, 5000);
|
|
174
|
-
};
|
|
175
|
-
} else setConnection("none");
|
|
297
|
+
else if (opts.mode === "full") openStream(false);
|
|
298
|
+
else setConnection("none");
|
|
176
299
|
}
|
|
177
300
|
|
|
178
301
|
return {
|
|
@@ -189,13 +312,33 @@ export function createStream(opts) {
|
|
|
189
312
|
hydration: () => state.hydration,
|
|
190
313
|
cursor: () => state.cursor,
|
|
191
314
|
usesEventSource: () => sse !== null,
|
|
315
|
+
/** #1580: ms until the next reconnect step (0 when none is waiting). */
|
|
316
|
+
retryIn: () => (reconnectTimer ? Math.max(0, retryAt - now()) : 0),
|
|
317
|
+
/** #1580: the page is leaving (pagehide): the stream and every timer go — nothing is left half-open. */
|
|
318
|
+
suspend() {
|
|
319
|
+
if (closed || !started || suspended) return;
|
|
320
|
+
suspended = true;
|
|
321
|
+
cancelWork();
|
|
322
|
+
closeStream(); stopPolling();
|
|
323
|
+
env.clearTimeout(reconnectTimer); reconnectTimer = null; waitingVisible = false;
|
|
324
|
+
},
|
|
325
|
+
/** #1580: back from the back-forward cache (pageshow, persisted): reconnect at once and catch up. */
|
|
326
|
+
resume() {
|
|
327
|
+
if (closed || !suspended || ended) return;
|
|
328
|
+
suspended = false;
|
|
329
|
+
epoch++;
|
|
330
|
+
attempts = 0;
|
|
331
|
+
if (opts.transport === "poll") { startPolling(); return; }
|
|
332
|
+
openStream(true);
|
|
333
|
+
},
|
|
192
334
|
/** For tests and page unload: nothing is emitted after this. */
|
|
193
335
|
close() {
|
|
194
336
|
closed = true;
|
|
195
|
-
|
|
337
|
+
cancelWork();
|
|
196
338
|
stopPolling();
|
|
197
|
-
|
|
198
|
-
|
|
339
|
+
closeStream();
|
|
340
|
+
env.clearTimeout(reconnectTimer); env.clearTimeout(retryTimer);
|
|
341
|
+
if (doc && typeof doc.removeEventListener === "function") doc.removeEventListener("visibilitychange", onVisible);
|
|
199
342
|
},
|
|
200
343
|
_pollOnce: pollOnce,
|
|
201
344
|
};
|
package/dist/ui/shared/app.css
CHANGED
|
@@ -292,6 +292,20 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
|
|
|
292
292
|
.msg .att-img { cursor: zoom-in; }
|
|
293
293
|
/* #1565: a file from before the fleet restarted that could not be served again — its name only, nothing to fetch. */
|
|
294
294
|
.att-file.att-gone { opacity: 0.6; cursor: default; text-decoration: none; }
|
|
295
|
+
/* #1589: media plays inline (native controls); text attachments open in a card, escaped. */
|
|
296
|
+
.att-media { margin-top: var(--s-2); }
|
|
297
|
+
.att-media .att-video { display: block; max-width: min(100%, 480px); max-height: 360px; border-radius: var(--r-sm); background: #000; }
|
|
298
|
+
.att-media .att-audio { display: block; width: min(100%, 420px); }
|
|
299
|
+
.text-card { margin-top: var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: var(--s-2) var(--s-3); }
|
|
300
|
+
.text-card .tc-head { display: flex; align-items: center; gap: var(--s-2); }
|
|
301
|
+
.text-card .tc-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-xs); font-family: var(--font-mono); color: var(--text-2); }
|
|
302
|
+
.text-card .tc-body { margin-top: var(--s-2); max-height: 480px; overflow: auto; }
|
|
303
|
+
.text-card .tc-body[hidden] { display: none; }
|
|
304
|
+
.text-card .tc-pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: var(--fs-xs); }
|
|
305
|
+
.text-card .tc-table-wrap { overflow-x: auto; }
|
|
306
|
+
.text-card .tc-table { border-collapse: collapse; font-size: var(--fs-xs); }
|
|
307
|
+
.text-card .tc-table th, .text-card .tc-table td { border: 1px solid var(--border); padding: 2px 6px; text-align: left; vertical-align: top; }
|
|
308
|
+
.text-card .tc-more { margin-top: var(--s-2); }
|
|
295
309
|
/* A chat image, full size, in the page (image-lightbox.js): the whole viewport, dark, the image fitted to it. */
|
|
296
310
|
html.lb-open .scroller { overflow: hidden; }
|
|
297
311
|
.lb { position: fixed; inset: 0; width: 100vw; height: 100vh; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;
|
|
@@ -567,6 +581,8 @@ html.lb-open .scroller { overflow: hidden; }
|
|
|
567
581
|
.v-term { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 5px 6px; background: #000000; position: relative; }
|
|
568
582
|
.v-pre { margin: 0; min-width: 100%; min-height: 100%; font-family: var(--font-mono); line-height: 1.2; white-space: pre; color: #d0d0d0; }
|
|
569
583
|
.v-pre .ansi[data-b] { font-weight: var(--fw-bold); }
|
|
584
|
+
/* #1582: faint text (SGR 2) — a CLI's suggestion or placeholder, never input — stays faint here. */
|
|
585
|
+
.v-pre .ansi[data-dim] { opacity: .5; }
|
|
570
586
|
.v-term-note { position: absolute; left: var(--s-3); right: var(--s-3); top: var(--s-3); padding: var(--s-2) var(--s-3); border-radius: var(--r-sm); background: var(--warn-bg); border: 1px solid var(--warn-line); color: #f0f0f0; font-size: var(--fs-sm); }
|
|
571
587
|
.v-probe { position: absolute; visibility: hidden; white-space: pre; left: -9999px; top: 0; font-size: 100px; }
|
|
572
588
|
.v-card { flex-shrink: 0; display: flex; align-items: center; gap: var(--s-3); padding: var(--s-2) var(--s-4); background: var(--bg); padding-bottom: max(var(--s-2), env(safe-area-inset-bottom)); }
|
package/dist/ui/shared/app.js
CHANGED
|
@@ -37,6 +37,12 @@ if (mode === "full") {
|
|
|
37
37
|
stream.on("status", applyStatus);
|
|
38
38
|
stream.on("activity", applyActivity);
|
|
39
39
|
stream.on("connection", (connection) => appStore.set({ connection }));
|
|
40
|
+
// #1580: when the next reconnect try is (the connection line counts down to it; monotonic, like the stream's clock).
|
|
41
|
+
stream.on("retry", (r) => appStore.set({ retryAt: (typeof performance !== "undefined" ? performance.now() : Date.now()) + r.inMs }));
|
|
42
|
+
// #1580: a page that leaves closes its stream and every timer (nothing half-open behind it); one restored from the
|
|
43
|
+
// back-forward cache reconnects and catches up.
|
|
44
|
+
window.addEventListener("pagehide", () => stream.suspend());
|
|
45
|
+
window.addEventListener("pageshow", (e) => { if (e.persisted) stream.resume(); });
|
|
40
46
|
// A chat that loaded late catches up on what is open now; the shell shows that while it is not done, with Retry.
|
|
41
47
|
stream.on("hydration", (hydration) => appStore.set({ hydration }));
|
|
42
48
|
appStore.set({ retryHydration: () => stream.catchUp() });
|