@songsid/agend 2.3.0-beta.8 → 2.3.0-beta.9

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 (52) hide show
  1. package/dist/agent-cli-instructions.md +2 -1
  2. package/dist/agent-cli.js +3 -0
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-whats-new.js +2 -0
  5. package/dist/agent-whats-new.js.map +1 -1
  6. package/dist/channel/mcp-tools.js +2 -2
  7. package/dist/channel/mcp-tools.js.map +1 -1
  8. package/dist/decision-list.d.ts +25 -0
  9. package/dist/decision-list.js +30 -0
  10. package/dist/decision-list.js.map +1 -0
  11. package/dist/fleet-manager.d.ts +9 -2
  12. package/dist/fleet-manager.js +44 -12
  13. package/dist/fleet-manager.js.map +1 -1
  14. package/dist/fleet-traffic.d.ts +36 -0
  15. package/dist/fleet-traffic.js +100 -0
  16. package/dist/fleet-traffic.js.map +1 -1
  17. package/dist/general-knowledge/skills/web-appearance/SKILL.md +4 -0
  18. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +17 -5
  19. package/dist/outbound-schemas.d.ts +6 -4
  20. package/dist/outbound-schemas.js +6 -2
  21. package/dist/outbound-schemas.js.map +1 -1
  22. package/dist/tool-permissions.js +9 -42
  23. package/dist/tool-permissions.js.map +1 -1
  24. package/dist/ui/app-effects.js +4 -0
  25. package/dist/ui/chat-effects.js +5 -4
  26. package/dist/ui/chat-render.js +5 -2
  27. package/dist/ui/chat-strings.js +2 -2
  28. package/dist/ui/chat-thread.js +133 -16
  29. package/dist/ui/effect-trial-controller.js +13 -8
  30. package/dist/ui/fleet-canvas.js +54 -18
  31. package/dist/ui/fleet-org.js +3 -3
  32. package/dist/ui/fleet-traffic.js +156 -37
  33. package/dist/ui/panel-chat.js +94 -45
  34. package/dist/ui/shared/agend-auth.js +1 -1
  35. package/dist/ui/shared/app-stream.js +3 -2
  36. package/dist/ui/shared/app.css +22 -9
  37. package/dist/ui/shared/effect-policy.js +2 -2
  38. package/dist/ui/shared/effect-registry.js +16 -7
  39. package/dist/ui/shared/effect-stock-snapshot.js +9 -0
  40. package/dist/ui/shared/effect-stock-trust.js +35 -4
  41. package/dist/ui/shared/effect-trial-source.js +1 -1
  42. package/dist/ui/shared/effect-trial-stage.js +27 -21
  43. package/dist/ui/shared/effect-trial.css +1 -1
  44. package/dist/ui/shared/tokens.css +9 -3
  45. package/dist/ui/web-terminal/terminal.css +4 -4
  46. package/dist/web-ambient.js +9 -2
  47. package/dist/web-ambient.js.map +1 -1
  48. package/dist/web-api.d.ts +1 -0
  49. package/dist/web-api.js +1 -1
  50. package/dist/web-api.js.map +1 -1
  51. package/dist/workflow-templates/default.md +1 -1
  52. package/package.json +1 -1
@@ -12,7 +12,7 @@ import { setFollowsReducedMotion } from "/assets/fx-motion.js";
12
12
  import { getAppEffects } from "./app-effects.js";
13
13
 
14
14
  register("chatFx", {
15
- en: { transport: "Off: the isolated effect frame could not start. Reload to try again.", enableHere: "Turn effects on here", playMotion: "Play effects anyway", leaveSafe: "Leave safe mode", viewerShow: "Show themes, effects and widgets here", clock: "Off: the page timing clock is unavailable. Reload to try again.", gatesUnknown: "Off: the playback conditions could not be verified.", resource: "Off: this effect could not be loaded or verified. Reload to try again.", frontQueue: "Waiting for a foreground slot.", consent: "This page has older playback information. Reload to use the isolated effect player.", previewMissing: "Off: the isolated effect frame is unavailable. Reload to try again.", unsupported: "Off: this browser lacks required Canvas Worker or layout support.", calibration: "Starting the effect.", baseline: "This page has older playback information. Reload to try again.", safetyUnknown: "Stopped: this device's safety history could not be verified. Reload to check it.", rawReason: "Playback blocked: {0}", missingRow: "Playback state is unavailable (runtime_unavailable).", region: "Off: this effect does not declare the chat region.", blocked: "Placed effects are blocked for this viewer:", deviceSetting: "Open device preferences", webSetting: "Open Web settings", reloadHint: "Reload page",
15
+ en: { stock: "Off: the page layout could not be verified ({0}). Reload and report this code if it persists.", transport: "Off: the isolated effect frame could not start. Reload to try again.", enableHere: "Turn effects on here", playMotion: "Play effects anyway", leaveSafe: "Leave safe mode", viewerShow: "Show themes, effects and widgets here", clock: "Off: the page timing clock is unavailable. Reload to try again.", gatesUnknown: "Off: the playback conditions could not be verified.", resource: "Off: this effect could not be loaded or verified. Reload to try again.", frontQueue: "Waiting for a foreground slot.", consent: "This page has older playback information. Reload to use the isolated effect player.", previewMissing: "Off: the isolated effect frame is unavailable. Reload to try again.", unsupported: "Off: this browser lacks required Canvas Worker or layout support.", calibration: "Starting the effect.", baseline: "This page has older playback information. Reload to try again.", safetyUnknown: "Stopped: this device's safety history could not be verified. Reload to check it.", rawReason: "Playback blocked: {0}", missingRow: "Playback state is unavailable (runtime_unavailable).", region: "Off: this effect does not declare the chat region.", blocked: "Placed effects are blocked for this viewer:", deviceSetting: "Open device preferences", webSetting: "Open Web settings", reloadHint: "Reload page",
16
16
  title: "Effects", loading: "Checking effects…", empty: "No effects in this chat.", failed: "Effects stopped because their resources could not be verified. Reload the page to try again.", reload: "Reload page", pause: "Pause", resume: "Resume", remove: "Remove from chat", removeFailed: "Could not remove this effect. Reload the page to check its state.", css: "Agent foreground effects are paused while your custom CSS is on or cannot be verified.", setting: "Open Web appearance settings", running: "Running", starting: "Starting", off: "Stopped", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.", queued: "Waiting for a slot", unknown: "Playback is off or unavailable here.", budget: "Paused to protect page responsiveness. Resume keeps the three-pauses safety limit.", pauseHelp: "Resume keeps this device’s automatic-pause history and three-pause safety limit.", paused: "Paused", low: "Reduced quality",
17
17
  typing: "Waiting while you type — it plays when you leave the message box.", covered: "Waiting while a menu or panel covers the chat.",
18
18
  dialogOpen: "Paused while this dialog is open.", wasRunning: "Was running; paused while this dialog is open.", modal: "Waiting while a dialog is open.",
@@ -20,7 +20,7 @@ register("chatFx", {
20
20
  cssOff: "Off while your custom CSS is on.", reducedMotion: "Off: you chose to follow the system's reduced motion setting.", saveData: "This page has older playback information. Reload to try again.",
21
21
  hiddenTab: "Waiting until this tab is visible.", offscreen: "Waiting until the chat is on screen.",
22
22
  safeMode: "Off in safe mode.", publicLink: "Off on a public link.", disabled: "Effects are switched off." },
23
- "zh-TW": { transport: "已關閉:無法啟動隔離效果框架。請重新載入再試。", enableHere: "在這裡開啟效果", playMotion: "仍然播放效果", leaveSafe: "離開安全模式", viewerShow: "在這裡顯示主題、效果與小工具", clock: "已關閉:頁面計時無法使用。請重新載入再試。", gatesUnknown: "已關閉:無法確認播放條件。", resource: "已關閉:無法載入或驗證此效果。請重新載入再試。", frontQueue: "等待前景空位。", consent: "這個頁面的播放資訊已過期。請重新載入以使用隔離效果播放器。", previewMissing: "已關閉:隔離效果框架無法使用。請重新載入再試。", unsupported: "已關閉:這個瀏覽器缺少 Canvas Worker 或版面驗證支援。", calibration: "正在啟動效果。", baseline: "這個頁面的播放資訊已過期,請重新載入再試。", safetyUnknown: "已停止:無法確認這台裝置的安全紀錄。請重新載入確認。", rawReason: "播放受阻:{0}", missingRow: "無法取得播放狀態(runtime_unavailable)。", region: "已關閉:此效果未宣告聊天區域。", blocked: "放置的效果目前無法在這個畫面播放:", deviceSetting: "開啟裝置偏好設定", webSetting: "開啟網頁設定", reloadHint: "重新載入頁面",
23
+ "zh-TW": { stock: "已關閉:無法驗證頁面版面({0})。請重新載入;若持續發生,請回報此代碼。", transport: "已關閉:無法啟動隔離效果框架。請重新載入再試。", enableHere: "在這裡開啟效果", playMotion: "仍然播放效果", leaveSafe: "離開安全模式", viewerShow: "在這裡顯示主題、效果與小工具", clock: "已關閉:頁面計時無法使用。請重新載入再試。", gatesUnknown: "已關閉:無法確認播放條件。", resource: "已關閉:無法載入或驗證此效果。請重新載入再試。", frontQueue: "等待前景空位。", consent: "這個頁面的播放資訊已過期。請重新載入以使用隔離效果播放器。", previewMissing: "已關閉:隔離效果框架無法使用。請重新載入再試。", unsupported: "已關閉:這個瀏覽器缺少 Canvas Worker 或版面驗證支援。", calibration: "正在啟動效果。", baseline: "這個頁面的播放資訊已過期,請重新載入再試。", safetyUnknown: "已停止:無法確認這台裝置的安全紀錄。請重新載入確認。", rawReason: "播放受阻:{0}", missingRow: "無法取得播放狀態(runtime_unavailable)。", region: "已關閉:此效果未宣告聊天區域。", blocked: "放置的效果目前無法在這個畫面播放:", deviceSetting: "開啟裝置偏好設定", webSetting: "開啟網頁設定", reloadHint: "重新載入頁面",
24
24
  title: "效果", loading: "正在確認效果…", empty: "這個聊天沒有放置效果。", failed: "無法確認效果資源,已停止播放。重新載入頁面後再試。", reload: "重新載入頁面", pause: "暫停", resume: "恢復", remove: "從聊天移除", removeFailed: "無法移除效果。請重新載入頁面確認狀態。", css: "自訂 CSS 開啟或無法確認時,代理的前景效果會暫停。", setting: "開啟網頁外觀設定", running: "播放中", starting: "啟動中", off: "已停止", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。", queued: "等待空位", unknown: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停效果。恢復仍保留三次暫停的安全上限。", pauseHelp: "恢復仍保留這台裝置的自動暫停紀錄與三次暫停安全上限。", paused: "已暫停", low: "降低畫質",
25
25
  typing: "輸入時暫停,離開訊息輸入框就會播放。", covered: "選單或面板蓋住聊天時暫停。",
26
26
  dialogOpen: "開著這個對話框時暫停。", wasRunning: "原本在播放;開著這個對話框時暫停。", modal: "有對話框開著時暫停。",
@@ -43,12 +43,13 @@ const REASON_KEY = { __proto__: null, transport_unavailable: "chatFx.transport",
43
43
  /** `ownOpen`: this Effects dialog is open; `wasRunning`: the effect was running when it opened (shown as past, never as current). */
44
44
  export function effectRowText(row, { ownOpen = false, wasRunning = false, doc = typeof document === "undefined" ? null : document } = {}) {
45
45
  if (!row) return t("chatFx.missingRow");
46
+ if (typeof row.reason === "string" && row.reason !== "stock_overlay" && /^(stock_|trust_|geometry_)/.test(row.reason)) return t("chatFx.stock", row.reason.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80));
46
47
  // The sticky history first (it is what Resume answers), then a pause, then what is playing or queued.
47
48
  if (row.reason === "safety_off" || row.reason === "interrupted") return t(REASON_KEY[row.reason]);
48
49
  if (row.reason === "paused" || row.reason === "host_budget") return t("chatFx.paused");
49
50
  if (REASON_KEY[row.reason]) return t(REASON_KEY[row.reason]);
50
51
  if (row.reason != null && !["stock_overlay", "modal", "frame_limit"].includes(row.reason))
51
- return t("chatFx.rawReason", typeof row.reason === "string" ? row.reason.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : "unknown");
52
+ return t("chatFx.rawReason", typeof row.reason === "string" ? row.reason.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : "invalid_reason_type");
52
53
  if (row.phase === "running") return t("chatFx.running");
53
54
  if (row.phase) return t("chatFx.starting");
54
55
  if (row.verdict === "queued") return t("chatFx.queued");
@@ -57,7 +58,7 @@ export function effectRowText(row, { ownOpen = false, wasRunning = false, doc =
57
58
  if (row.reason === "modal") return t("chatFx.modal");
58
59
  return t("chatFx.covered");
59
60
  }
60
- return t("chatFx.rawReason", typeof row.reason === "string" ? row.reason.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : "unknown");
61
+ return t("chatFx.rawReason", typeof row.reason === "string" ? row.reason.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : "invalid_reason_type");
61
62
  }
62
63
 
63
64
  /** Fixed local actions: no server/Worker-provided destination or raw reason in a URL. */
@@ -53,11 +53,14 @@
53
53
 
54
54
  // #1630: a Discord server emoji, `<:name:id>` / `<a:name:id>` — escaped by now — drawn as its picture. Only an id the
55
55
  // page knows (the fleet's servers: /ui/emojis) and only from the dashboard's own route; the src is built from the
56
- // matched digits, the name (word characters only) goes into alt/title. An unknown id stays the literal text.
56
+ // matched digits, the name (word characters only) goes into alt/title. Any other — an id the page does not know, or
57
+ // before the list has come — reads `:name:` (never the raw markup); so does a picture that does not load
58
+ // (chat-thread.js swaps it: an <img> sized to an emoji showed an empty box, the user's "tofu").
57
59
  var EMOJI_RE = /&lt;(a?):([A-Za-z0-9_]{2,32}):(\d{15,25})&gt;/g;
58
60
  function emojiImg(id, name) {
59
61
  return '<img class="emoji" src="/ui/img/emoji/' + id + '" alt=":' + name + ':" title=":' + name + ':" decoding="async">';
60
62
  }
63
+ function emojiText(name) { return '<span class="emoji-text" title=":' + name + ':">:' + name + ':</span>'; }
61
64
 
62
65
  // #1682: Discord mentions — `<@id>` / `<@!id>` (a user), `<@&id>` (a role), `<#id>` (a channel) — escaped by now (the
63
66
  // role's & too), drawn as a pill: the name the page knows (store.mentions(kind, id), inserted escaped), else @user /
@@ -82,7 +85,7 @@
82
85
  // path held its whole column wide.
83
86
  s = s.replace(/`([^`\n]+)`/g, function (_m, code) { return stash(store, "<code>" + (store.inCell ? code.replace(/\//g, "/<wbr>") : code) + "</code>"); });
84
87
  var known = store.emojis;
85
- if (known) s = s.replace(EMOJI_RE, function (m, _a, name, id) { return known(id) ? stash(store, emojiImg(id, name)) : m; });
88
+ s = s.replace(EMOJI_RE, function (_m, _a, name, id) { return stash(store, known && known(id) ? emojiImg(id, name) : emojiText(name)); });
86
89
  var names = store.mentions;
87
90
  s = s.replace(MENTION_RE, function (_m, sigil, id) {
88
91
  var kind = MENTION_KIND[sigil];
@@ -21,7 +21,7 @@ register("chat", {
21
21
  effectTrialSettings: "Open playback settings",
22
22
  effectTrialPlayMotion: "Play effects anyway",
23
23
  effectTrial: "Effect trial", effectTrialLocal: "A sample on this page. Nothing is applied or saved.",
24
- effectTrialStart: "Try", effectTrialExpired: "This trial offer has expired.", effectTrialUnavailable: "This trial is no longer available.",
24
+ effectTrialStopped: "Try stopped", effectTrialStart: "Try", effectTrialExpired: "This trial offer has expired.", effectTrialUnavailable: "This trial is no longer available.",
25
25
  cmdConfirm: "Clear", cmdConfirmBackend: "Switch and restart", cmdRunning: "Running /{0}…", cmdNotCommand: "Not a command here — Enter sends it as a message.", cmdDismiss: "Dismiss", cmdCommands: "Commands",
26
26
  compactRunning: "Compacting…", compactDone: "✅ Conversation compacted", compactUnknown: "This backend does not report when compaction ends.",
27
27
  compactTimeout: "No sign that compaction finished within {0} minutes — check the agent's own chat.",
@@ -146,7 +146,7 @@ register("chat", {
146
146
  effectTrialSettings: "開啟播放設定",
147
147
  effectTrialPlayMotion: "仍然播放效果",
148
148
  effectTrial: "效果試播", effectTrialLocal: "僅在本頁試播,不會套用或儲存。",
149
- effectTrialStart: "試播", effectTrialExpired: "試播邀請已過期。", effectTrialUnavailable: "此試播已無法使用。",
149
+ effectTrialStopped: "試播已停止", effectTrialStart: "試播", effectTrialExpired: "試播邀請已過期。", effectTrialUnavailable: "此試播已無法使用。",
150
150
  cmdConfirm: "清空", cmdConfirmBackend: "切換並重新啟動", cmdRunning: "執行 /{0} 中…", cmdNotCommand: "這裡沒有這個指令——按 Enter 會當成一般訊息送出。", cmdDismiss: "關閉", cmdCommands: "指令",
151
151
  compactRunning: "壓縮中…", compactDone: "✅ 對話已壓縮", compactUnknown: "這個後端不會回報壓縮何時完成。",
152
152
  compactTimeout: "{0} 分鐘內沒有看到壓縮完成的紀錄,請到代理自己的對話確認。",
@@ -39,6 +39,41 @@ export const msgKey = (x) => `${x.boot}-${x.id}`;
39
39
  */
40
40
  export const THREAD_TAIL = 24;
41
41
  export const THREAD_CHUNK = 40;
42
+ /** #2158: a reader's scroll (smooth, momentum) is theirs until it has been this quiet since their last input or scroll. */
43
+ export const READER_QUIET_MS = 250;
44
+ const SCROLL_KEYS = new Set(["PageUp", "PageDown", "ArrowUp", "ArrowDown", "Home", "End", " "]);
45
+
46
+ /**
47
+ * #2053: a message's rendered Markdown, kept across renders and chat switches. A draw compares each message's html with
48
+ * its node's, so every render of a thread rendered every drawn message's Markdown again (~320 calls per 200-message
49
+ * switch). renderMarkdown's output depends only on the text, whether its HTML cards are on (an agent's message), and —
50
+ * for a text that has them — whether the page knows each server-emoji id and the name it has for each mention: the key
51
+ * holds exactly those, so a cached html is always what renderMarkdown would return now. Bounded: least recently used out.
52
+ */
53
+ export const MD_CACHE_MAX = 4000;
54
+ const mdCache = new Map();
55
+ const EMOJI_IDS = /<a?:[A-Za-z0-9_]{2,32}:(\d{15,25})>/g;
56
+ const MENTION_IDS = /<(@!?|@&|#)(\d{15,25})>/g;
57
+ const MENTION_KINDS = { "@": "u", "@!": "u", "@&": "r", "#": "c" };
58
+ export function renderedMarkdown(text, cards, emojis, mentions) {
59
+ const src = typeof text === "string" ? text : String(text == null ? "" : text);
60
+ let sig = cards ? "c" : "-";
61
+ if (src.includes("<")) {
62
+ // Read as renderMarkdown reads it: it drops NUL first (its placeholder mark), so "<@\0id>" is a mention there too.
63
+ const seen = src.includes("\u0000") ? src.replace(/\u0000/g, "") : src;
64
+ for (const m of seen.matchAll(EMOJI_IDS)) sig += `|e${m[1]}${emojis && emojis(m[1]) ? 1 : 0}`;
65
+ for (const m of seen.matchAll(MENTION_IDS)) { const name = mentions ? mentions(MENTION_KINDS[m[1]], m[2]) : null; sig += `|m${m[1]}${m[2]}=${typeof name === "string" ? name.length + ":" + name : "-"}`; }
66
+ }
67
+ const key = `${sig}\u0000${src}`;
68
+ let html = mdCache.get(key);
69
+ if (html === undefined) html = R().renderMarkdown(src, { htmlCards: cards, emojis, mentions });
70
+ else mdCache.delete(key);
71
+ mdCache.set(key, html);
72
+ while (mdCache.size > MD_CACHE_MAX) mdCache.delete(mdCache.keys().next().value);
73
+ return html;
74
+ }
75
+ /** Tests only: forget every rendered Markdown. */
76
+ export function clearRenderedMarkdown() { mdCache.clear(); }
42
77
 
43
78
  /**
44
79
  * Bind a renderer to `list` (the thread element) inside `scroller`. opts: { t, tf, isUser(x), onJump({show, unseen}),
@@ -47,6 +82,17 @@ export const THREAD_CHUNK = 40;
47
82
  */
48
83
  export function createThread(list, scroller, opts) {
49
84
  const nodes = new Map(); // key → { html, node }
85
+ // A server emoji's picture that does not load reads `:name:` (its alt) instead of an empty box (error does not bubble:
86
+ // caught on the way down).
87
+ list.addEventListener("error", (e) => {
88
+ const img = e.target;
89
+ if (!img || img.tagName !== "IMG" || !img.classList || !img.classList.contains("emoji")) return;
90
+ const span = list.ownerDocument.createElement("span");
91
+ span.className = "emoji-text";
92
+ span.textContent = img.getAttribute("alt") || "";
93
+ span.title = img.getAttribute("title") || span.textContent;
94
+ img.replaceWith(span);
95
+ }, true);
50
96
  let msgs = [];
51
97
  let unseen = 0;
52
98
  // The reader is at the newest message. While pinned, content that grows after it was drawn — an image loading, a
@@ -54,12 +100,15 @@ export function createThread(list, scroller, opts) {
54
100
  // Only a move UP unpins: the browser's own scroll anchoring also fires scroll events when content grows (the view
55
101
  // moves down or stays), and those must not count as the reader leaving the bottom. Reaching the bottom pins again.
56
102
  let pinned = true;
57
- let lastTop = 0;
103
+ // Where the view was (and how tall it was) at the last scroll the page saw or made: a move up from there unpins.
104
+ let lastTop = 0, lastView = 0;
105
+ const mark = () => { lastTop = scroller.scrollTop; lastView = scroller.clientHeight; };
58
106
  // The oldest message drawn (by key): everything after it is drawn, so new messages never push the window. Null: none
59
107
  // drawn yet — the next render draws the newest THREAD_TAIL and the rest follows (grow).
60
108
  let firstKey = null;
61
- let growTimer = null, growFrame = null, disposed = false;
109
+ let growTimer = null, growFrame = null, disposed = false, parked = false;
62
110
  const cardRefresh = new WeakMap(); // card node → re-read availability (weak: a card that left takes its source with it)
111
+ const cardDrop = new WeakMap(); // card node → drop the HTML read it is waiting on (the chat left: park)
63
112
  const tr = opts.t, trf = opts.tf;
64
113
  const stopIn = (node) => (P() ? P().stopIn(node) : []);
65
114
  /** #1770: write a message node's time label for now; unchanged text is not touched. */
@@ -92,7 +141,7 @@ export function createThread(list, scroller, opts) {
92
141
  const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
93
142
  // A person's click on a reply button shows as the button's label, never the line the agent was given (its value).
94
143
  const clicked = x.role === "user" && x.click && typeof x.click.label === "string";
95
- return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div><div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${R().renderMarkdown(x.text, { htmlCards: x.role === "agent", emojis: opts.emojis, mentions: opts.mentions })}</div>`}${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
144
+ return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div><div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${renderedMarkdown(x.text, x.role === "agent", opts.emojis, opts.mentions)}</div>`}${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
96
145
  }
97
146
  /**
98
147
  * A button click as a chip: 🔘 and the label (escaped: the label is the agent's text). With `of` (live, while its
@@ -113,10 +162,11 @@ export function createThread(list, scroller, opts) {
113
162
  }
114
163
  function trialHtml(offer, time, message) {
115
164
  const reason = trialNow() >= offer.expiresAt ? "expired" : PUBLIC_LINK() ? "localAdmin" : trialStatus(message);
165
+ const rawFailure = opts.effectTrialFailure?.(message), failure = typeof rawFailure === "string" ? rawFailure.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : null;
116
166
  const available = reason === "ready", fix = !PUBLIC_LINK() && !available && reason !== "expired" && reason !== "checking" && typeof opts.fixEffectTrial === "function";
117
167
  const label = available ? "chat.effectTrialStart" : reason === "busy" ? "chat.effectTrialStopOther" : reason === "localAdmin" ? "chat.effectTrialSignIn" : reason === "reducedMotion" ? "chat.effectTrialPlayMotion" : "chat.effectTrialSettings";
118
168
  const button = available || fix ? `<button type="button" class="btn btn-sm effect-trial-start" data-act="effectTrial" data-arg="${escAttr(msgKey(message))}">${escAttr(tr(label))}</button>` : "";
119
- return `<div class="msg status-line"><div class="meta">${time}</div><div class="body"><div class="effect-trial-offer" data-trust="thread-control"><strong>${escAttr(tr("chat.effectTrial"))}: ${escAttr(offer.title)}</strong><div>${escAttr(tr(available ? "chat.effectTrialLocal" : "chat.effectTrialReason_" + reason))}</div>${button}</div></div></div>`;
169
+ return `<div class="msg status-line"><div class="meta">${time}</div><div class="body"><div class="effect-trial-offer" data-trust="thread-control"><strong>${escAttr(tr("chat.effectTrial"))}: ${escAttr(offer.title)}</strong><div>${escAttr(tr(available ? "chat.effectTrialLocal" : "chat.effectTrialReason_" + reason))}</div>${failure ? `<div class="effect-trial-failure" role="status">${escAttr(tr("chat.effectTrialStopped"))}: ${escAttr(failure)}</div>` : ""}${button}</div></div></div>`;
120
170
  }
121
171
  function armTrials() {
122
172
  if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
@@ -276,7 +326,7 @@ export function createThread(list, scroller, opts) {
276
326
  const note = el("div", "pv-note"); note.setAttribute("data-trust", "thread-control");
277
327
  const body = el("div", "tc-body"); body.hidden = true;
278
328
  ph.append(head, note, body);
279
- let text = null, reading = false, all = false;
329
+ let text = null, reading = 0, reads = 0, all = false;
280
330
  const draw = () => {
281
331
  body.textContent = "";
282
332
  const lines = text.split(/\r?\n/);
@@ -312,12 +362,15 @@ export function createThread(list, scroller, opts) {
312
362
  body.append(more);
313
363
  }
314
364
  };
365
+ // #2053: as an HTML card's read (cardDrop), one still waiting when the chat left is dropped, the card not left Loading.
366
+ cardDrop.set(ph, () => { if (reading) { reading = 0; note.textContent = ""; } });
315
367
  show.onclick = async () => {
316
368
  if (reading) return;
317
369
  if (text !== null) { body.hidden = !body.hidden; show.textContent = tr(body.hidden ? "chat.tcShow" : "chat.tcHide"); return; }
318
- reading = true; note.textContent = tr("chat.pvLoading");
370
+ const mine = reading = ++reads; note.textContent = tr("chat.pvLoading");
319
371
  const r = await loadHtmlAttachment({ id: att.id, name: att.name, size: att.size });
320
- reading = false;
372
+ if (reading !== mine) return; // dropped (its chat left meanwhile): a new click reads again
373
+ reading = 0;
321
374
  if (!ph.isConnected) return; // the card left the page while it was read: nothing to draw
322
375
  if (!r.ok) { note.textContent = r.reason === "over" ? tr("chat.tcOver") : attReason(r.reason); return; }
323
376
  note.textContent = "";
@@ -455,6 +508,10 @@ export function createThread(list, scroller, opts) {
455
508
  optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
456
509
  neverB.onclick = () => { menu.open = false; P().setNever(!P().never()); refreshCards(); };
457
510
  cardRefresh.set(ph, refresh);
511
+ // #2053: a kept card is shown again later. A read still waiting when its chat left is dropped then: its answer —
512
+ // whenever it comes — starts nothing (no preview, no panel without a new click), and the card is not left Loading
513
+ // (shown again, it is refreshed: refreshCards).
514
+ cardDrop.set(ph, () => { reading = 0; });
458
515
  refresh(stoppedKeys.includes(key) ? tr("chat.pvChanged") : null);
459
516
  }
460
517
  /** Re-read this device's preview choice on every card in this thread. */
@@ -469,12 +526,32 @@ export function createThread(list, scroller, opts) {
469
526
  // newest bubble clipped behind it, and the next growth past 48 px put the pill over it.
470
527
  const atBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight, 1);
471
528
  function reportJump() { opts.onJump({ show: !nearBottom(), unseen }); }
472
- function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; lastTop = scroller.scrollTop; }
529
+ function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; mark(); }
473
530
  // Sizes change without a scroll event (layout after the render, the dock under the thread): while pinned, the view
474
531
  // stays flush with the bottom; a reader who scrolled up is never moved.
475
532
  const resized = () => { if (pinned && !atBottom()) toBottom(); reportJump(); };
476
- const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
533
+ let ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
477
534
  if (ro) { ro.observe(list); ro.observe(scroller); }
535
+ // #2158: the reader's own hand on the thread — a wheel, a touch, the scrollbar (a pointer held), a scrolling key
536
+ // outside a text field — and the scroll that follows it until it has been quiet READER_QUIET_MS.
537
+ let inputAt = -Infinity, held = false;
538
+ const touched = () => { inputAt = performance.now(); };
539
+ const hold = (on) => () => { held = on; touched(); };
540
+ const onKey = (ev) => {
541
+ const el = ev.target;
542
+ if (SCROLL_KEYS.has(ev.key) && !(el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || "")))) touched();
543
+ };
544
+ const onScroller = [["wheel", touched], ["touchstart", hold(true)], ["touchend", hold(false)], ["touchcancel", hold(false)], ["pointerdown", hold(true)]];
545
+ const onDocument = [["pointerup", hold(false)], ["pointercancel", hold(false)], ["keydown", onKey]];
546
+ /** Listen for the reader's input on `sc` (and its document: a pointer let go anywhere, a key), or stop. */
547
+ function watchReader(sc, on) {
548
+ const doc = sc.ownerDocument, how = on ? "addEventListener" : "removeEventListener";
549
+ for (const [type, fn] of onScroller) sc[how](type, fn, { passive: true });
550
+ if (doc) for (const [type, fn] of onDocument) doc[how](type, fn, true);
551
+ if (!on) held = false;
552
+ }
553
+ const readerActive = () => held || performance.now() - inputAt < READER_QUIET_MS;
554
+ watchReader(scroller, true);
478
555
 
479
556
  /** Where drawing starts in `msgs`: at the oldest drawn message, or (none yet) the newest THREAD_TAIL. */
480
557
  function startIndex() {
@@ -488,14 +565,14 @@ export function createThread(list, scroller, opts) {
488
565
  /** Draw the next chunk of older messages, keeping the reader where they were; then the next, until all are drawn. */
489
566
  function grow() {
490
567
  growTimer = growFrame = null;
491
- if (disposed) return; // the chat left: nothing more is drawn
568
+ if (disposed || parked) return; // the chat left: nothing more is drawn (a kept one fills in when shown again)
492
569
  const at = startIndex();
493
570
  if (at <= 0) return;
494
571
  firstKey = msgKey(msgs[Math.max(0, at - THREAD_CHUNK)]);
495
572
  const fromBottom = scroller.scrollHeight - scroller.scrollTop;
496
573
  draw({ older: true }); // drawn above the reader: never "new below" (render alone counts those)
497
574
  if (pinned) toBottom();
498
- else { scroller.scrollTop = scroller.scrollHeight - fromBottom; lastTop = scroller.scrollTop; }
575
+ else { scroller.scrollTop = scroller.scrollHeight - fromBottom; mark(); }
499
576
  scheduleGrow();
500
577
  }
501
578
  /** After the next frame has painted (the click's own frame stays light), draw a chunk more. */
@@ -536,7 +613,7 @@ export function createThread(list, scroller, opts) {
536
613
  if ("restore" in renderOpts) {
537
614
  unseen = 0;
538
615
  if (renderOpts.restore == null) toBottom();
539
- else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); lastTop = scroller.scrollTop; }
616
+ else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); mark(); }
540
617
  } else if (stick) toBottom();
541
618
  else unseen += added;
542
619
  reportJump();
@@ -570,13 +647,23 @@ export function createThread(list, scroller, opts) {
570
647
  }
571
648
 
572
649
  function onScroll() {
650
+ // #2158: the thread got shorter under a pinned view (text settling on a cold load, a card) and the browser clamped
651
+ // scrollTop to its new end — for the view as tall as it was then; the view got shorter too before WebKit sent that
652
+ // clamp's scroll event (it comes before the ResizeObserver). A view resting exactly at the bottom of the view as it
653
+ // was at the last mark, with no hand of the reader's on the thread, was put there by the browser: still pinned, back
654
+ // at the bottom. Geometry alone can't tell (a reader's move up by the dock's growth lands there too): any input of
655
+ // theirs, and the scroll after it, is theirs and unpins as before. The page's own scrolls (reveal) have no input but
656
+ // don't land there.
657
+ const reader = readerActive();
658
+ if (reader) touched(); // the reader's scroll goes on (smooth, momentum): still theirs while it does
659
+ else if (pinned && !atBottom() && Math.abs(scroller.scrollHeight - lastView - scroller.scrollTop) <= 1) toBottom();
573
660
  const bottom = nearBottom(), top = scroller.scrollTop;
574
661
  // #2087: pinned only at the exact bottom — a reader still wheeling through the last 48 px was pinned there, so
575
662
  // the next resize (a picture, a card, the dock) pulled the view the rest of the way down under them. Near the bottom
576
663
  // still counts for a new message (render follows it) and for the pill; nothing else moves a view the reader moves.
577
664
  if (atBottom()) pinned = true;
578
665
  else if (top < lastTop) pinned = false;
579
- lastTop = top;
666
+ mark();
580
667
  if (bottom) unseen = 0;
581
668
  reportJump();
582
669
  return bottom ? null : scroller.scrollTop;
@@ -645,15 +732,45 @@ export function createThread(list, scroller, opts) {
645
732
  }
646
733
 
647
734
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
648
- function dispose() {
735
+ /**
736
+ * #2053: kept for a later visit — the chat left as it always did (every preview stops: "leave"), but its drawn nodes
737
+ * stay. Nothing of it runs meanwhile: no timer (day labels, polls, effect trials, filling in), no size observer; the
738
+ * caller drops its subscriptions. Shown again: attach, then render — only what changed is drawn.
739
+ */
740
+ function park() {
741
+ parked = true;
742
+ watchReader(scroller, false);
743
+ if (P()) P().stopAll("leave");
744
+ for (const node of list.querySelectorAll(".html-card[data-att], .text-card[data-att]")) { const f = cardDrop.get(node); if (f) f(); }
745
+ if (ro) { ro.disconnect(); ro = null; }
746
+ if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
747
+ if (growFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(growFrame);
748
+ if (growTimer !== null) clearTimeout(growTimer);
749
+ growTimer = growFrame = null;
750
+ if (offDay) { offDay(); offDay = null; }
751
+ if (pollTick) { clearInterval(pollTick); pollTick = null; }
752
+ }
753
+ /** In view in `sc` (the view's scroller): sizes watched there from now on; the caller restores the position. */
754
+ function attach(sc) {
755
+ if (!parked) watchReader(scroller, false); // moved from the scroller it was made with
756
+ parked = false;
757
+ scroller = sc;
758
+ watchReader(sc, true);
759
+ if (ro) ro.disconnect();
760
+ ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
761
+ if (ro) { ro.observe(list); ro.observe(sc); }
762
+ }
763
+ /** `own`: only this thread's previews stop (a thread let go while another chat is in view); else every one, as leaving. */
764
+ function dispose(own = false) {
649
765
  disposed = true;
766
+ if (!parked) watchReader(scroller, false);
650
767
  if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
651
768
  if (ro) ro.disconnect();
652
- if (P()) P().stopAll("leave");
769
+ if (P()) { if (own) P().stopIn(list); else P().stopAll("leave"); }
653
770
  nodes.clear();
654
771
  armDay();
655
772
  armPolls();
656
773
  }
657
774
 
658
- return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, dispose, nodes, get unseen() { return unseen; } };
775
+ return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, park, attach, dispose, nodes, get unseen() { return unseen; } };
659
776
  }
@@ -5,7 +5,7 @@ import { chatEffectIdentity } from "/assets/effect-chat-runtime.js";
5
5
  const owners = new WeakSet(), safe = fn => { try { return fn(); } catch { return null; } };
6
6
  const LIMIT = 128 * 1024;
7
7
  export function createEffectTrialController({ window: host, current, allowed, onStart, onChange = () => {} }) {
8
- let dead = false, active = null, physical = null, previous = null, broken = false, clockBusy = false;
8
+ let dead = false, active = null, physical = null, previous = null, broken = false, clockBusy = false, endedReason = null, endedOffer = null;
9
9
  const cleanup = [], releases = [], identity = safe(() => chatEffectIdentity(host));
10
10
  const tell = () => { if (!dead) safe(onChange); };
11
11
  function clock() {
@@ -26,11 +26,16 @@ export function createEffectTrialController({ window: host, current, allowed, on
26
26
  }
27
27
  function end(op, reason = "stopped") {
28
28
  if (!op || op.dead) return false;
29
- op.dead = true; if (active === op) active = null; safe(() => op.resolve?.(false));
29
+ op.dead = true; if (active === op) active = null;
30
+ endedReason = typeof reason === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(reason) ? reason : "trial_failed";
31
+ endedOffer = op.offer.offer;
32
+ safe(() => op.resolve?.(false));
30
33
  safe(() => host.clearTimeout(op.timer)); safe(() => op.controller.abort());
31
34
  if (op.reply && !op.stopQueued) { op.stopQueued = true; cleanup.push(op); }
32
35
  // Physical paint is removed before callbacks, cleanup I/O or another explicit click.
33
- safe(() => op.dispose?.()); op.dispose = null; tell(); drain(); return true;
36
+ safe(() => op.dispose?.()); op.dispose = null;
37
+ if (!["stopped", "replaced"].includes(endedReason)) safe(() => host.console?.warn("[AgEnD effects] Try stopped:", endedReason));
38
+ tell(); drain(); return true;
34
39
  }
35
40
  function arm(op) {
36
41
  safe(() => host.clearTimeout(op.timer));
@@ -67,7 +72,7 @@ export function createEffectTrialController({ window: host, current, allowed, on
67
72
  if (dead) return; dead = true; end(active); for (const off of releases.splice(0)) safe(off); tell();
68
73
  }
69
74
  const api = { ok: false, start: async () => false, stop: () => end(active), sync() { const op = active; if (op && !eligible(op)) end(op, "trial_ended"); },
70
- snapshot: () => Object.freeze({ busy: !!physical || cleanup.length > 0, phase: active ? active.reply ? "running" : "starting" : "idle" }), dispose };
75
+ snapshot: () => Object.freeze({ busy: !!physical || cleanup.length > 0, phase: active ? active.reply ? "running" : "starting" : "idle", reason: active ? null : endedReason, offer: active ? active.offer.offer : endedOffer }), dispose };
71
76
  if (!identity || owners.has(host) || typeof current !== "function" || typeof allowed !== "function" || typeof onStart !== "function"
72
77
  || typeof host.AgendAuth?.trialSession !== "function" || typeof host.AgendAuth?.onSessionEnd !== "function") return Object.freeze(api);
73
78
  owners.add(host);
@@ -75,7 +80,7 @@ export function createEffectTrialController({ window: host, current, allowed, on
75
80
  const off = host.AgendAuth.onSessionEnd(dispose);
76
81
  if (dead) { safe(off); return Object.freeze(api); }
77
82
  releases.push(off);
78
- for (const [target, type, fn] of [[host, "blur", () => end(active)], [host, "pagehide", dispose], [host.document, "visibilitychange", () => { if (host.document.visibilityState !== "visible") end(active); }]]) {
83
+ for (const [target, type, fn] of [[host, "blur", () => end(active, "trial_window_blur")], [host, "pagehide", dispose], [host.document, "visibilitychange", () => { if (host.document.visibilityState !== "visible") end(active, "hidden"); }]]) {
79
84
  target.addEventListener(type, fn); releases.push(() => target.removeEventListener(type, fn));
80
85
  }
81
86
  } catch { dispose(); return Object.freeze(api); }
@@ -86,7 +91,7 @@ export function createEffectTrialController({ window: host, current, allowed, on
86
91
  const offer = safe(() => effectTrialMessage(message)), instance = safe(() => message.instance), began = clock();
87
92
  if (!offer || began === null || began + 10000 > Number.MAX_SAFE_INTEGER || safe(() => host.Date.now() < offer.expiresAt) !== true) return false;
88
93
  const op = { instance, offer, began, until: began + 10000, controller: new host.AbortController(), timer: null, reply: null, dead: false, stopQueued: false, dispose: null };
89
- active = op; const token = {}; physical = token;
94
+ endedReason = null; endedOffer = null; active = op; const token = {}; physical = token;
90
95
  const cancelled = new Promise(resolve => { op.resolve = resolve; });
91
96
  const work = Promise.resolve().then(async () => {
92
97
  if (!eligible(op)) return false;
@@ -105,12 +110,12 @@ export function createEffectTrialController({ window: host, current, allowed, on
105
110
  op.until = began + reply.remainingMs;
106
111
  if (op.until > Number.MAX_SAFE_INTEGER || !eligible(op)) return false;
107
112
  arm(op);
108
- const handle = Object.freeze({ reply, startedAt: began, instance, current: () => eligible(op), stop: () => end(op), });
113
+ const handle = Object.freeze({ reply, startedAt: began, instance, current: () => eligible(op), stop: (reason = "stopped") => end(op, reason), });
109
114
  const disposer = onStart(handle);
110
115
  if (typeof disposer !== "function") return false;
111
116
  if (!eligible(op)) { safe(disposer); return false; }
112
117
  op.dispose = disposer; tell(); return eligible(op);
113
- }).catch(() => false).then(ok => { if (!ok) end(op); return ok; }).finally(() => { if (physical === token) physical = null; tell(); drain(); });
118
+ }).catch(() => false).then(ok => { if (!ok) end(op, "trial_start_failed"); return ok; }).finally(() => { if (physical === token) physical = null; tell(); drain(); });
114
119
  // The logical caller may stop immediately, while the physical request/cancel still owns the lane.
115
120
  tell();
116
121
  return Promise.race([work, cancelled]);