@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.
Files changed (43) hide show
  1. package/dist/ansi-faint.d.ts +25 -0
  2. package/dist/ansi-faint.js +90 -0
  3. package/dist/ansi-faint.js.map +1 -0
  4. package/dist/fleet-manager.d.ts +12 -0
  5. package/dist/fleet-manager.js +116 -28
  6. package/dist/fleet-manager.js.map +1 -1
  7. package/dist/general-knowledge/skills/fleet-health/SKILL.md +11 -2
  8. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +2 -1
  9. package/dist/locale.js +4 -0
  10. package/dist/locale.js.map +1 -1
  11. package/dist/outbound-handlers.js +8 -1
  12. package/dist/outbound-handlers.js.map +1 -1
  13. package/dist/tz-utils.d.ts +6 -0
  14. package/dist/tz-utils.js +23 -0
  15. package/dist/tz-utils.js.map +1 -1
  16. package/dist/ui/chat-render.js +29 -1
  17. package/dist/ui/chat-strings.js +2 -0
  18. package/dist/ui/chat-thread.js +128 -3
  19. package/dist/ui/panel-chat.js +1 -0
  20. package/dist/ui/shared/app-i18n.js +2 -0
  21. package/dist/ui/shared/app-shell.js +9 -1
  22. package/dist/ui/shared/app-stream.js +186 -43
  23. package/dist/ui/shared/app.css +16 -0
  24. package/dist/ui/shared/app.js +6 -0
  25. package/dist/ui/shared/panel-view.js +21 -8
  26. package/dist/ui/shared/usage-dialog.js +16 -1
  27. package/dist/ui/shared/view-strings.js +10 -0
  28. package/dist/usage/usage-api.d.ts +8 -1
  29. package/dist/usage/usage-api.js +100 -31
  30. package/dist/usage/usage-api.js.map +1 -1
  31. package/dist/web-api.d.ts +1 -1
  32. package/dist/web-api.js +67 -4
  33. package/dist/web-api.js.map +1 -1
  34. package/dist/web-chat-history.d.ts +7 -0
  35. package/dist/web-chat-history.js +19 -0
  36. package/dist/web-chat-history.js.map +1 -1
  37. package/dist/web-host-guard.d.ts +2 -0
  38. package/dist/web-host-guard.js +7 -1
  39. package/dist/web-host-guard.js.map +1 -1
  40. package/dist/web-upload.d.ts +17 -0
  41. package/dist/web-upload.js +38 -7
  42. package/dist/web-upload.js.map +1 -1
  43. package/package.json +1 -1
@@ -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): follow them while pinned.
208
- const resized = () => { if (pinned && !nearBottom()) toBottom(); reportJump(); };
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
 
@@ -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 === "live" || connection === "none") return null;
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, errorTimer = null, closed = false, started = false;
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 polling;
142
+ return p;
105
143
  }
106
- function startPolling() {
107
- if (pollTimer || closed) return;
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
- pollOnce();
110
- pollTimer = env.setInterval(pollOnce, 5000);
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
- env.clearTimeout(errorTimer); errorTimer = null;
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
- catching = (async () => {
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) { catching = null; setHydration("ok"); return true; }
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) { catching = null; setHydration("failed"); return false; }
276
+ if (++overflows > MAX_OVERFLOW_READS) return done(false, "failed");
141
277
  continue;
142
278
  }
143
- if (failures >= RETRY_DELAYS.length) { catching = null; setHydration("failed"); return false; }
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
- return catching;
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
- sse = new env.EventSource("/ui/events");
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
- held = null;
337
+ cancelWork();
196
338
  stopPolling();
197
- env.clearTimeout(silentTimer); env.clearTimeout(errorTimer); env.clearTimeout(retryTimer);
198
- if (sse) sse.close();
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
  };
@@ -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)); }
@@ -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() });