@songsid/agend 2.3.0-beta.7 → 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.
- package/dist/agent-cli-instructions.md +2 -1
- package/dist/agent-cli.js +3 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-whats-new.js +2 -0
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/channel/mcp-tools.js +3 -3
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +29 -9
- package/dist/daemon.js.map +1 -1
- package/dist/decision-list.d.ts +25 -0
- package/dist/decision-list.js +30 -0
- package/dist/decision-list.js.map +1 -0
- package/dist/fleet-manager.d.ts +9 -2
- package/dist/fleet-manager.js +44 -12
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-traffic.d.ts +36 -0
- package/dist/fleet-traffic.js +100 -0
- package/dist/fleet-traffic.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +17 -10
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +32 -13
- package/dist/instance-config-impact.js +1 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/outbound-handlers.js +4 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +6 -4
- package/dist/outbound-schemas.js +7 -3
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/settings-api.js +4 -0
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +4 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +2 -0
- package/dist/settings-effect.js.map +1 -1
- package/dist/tool-permissions.js +9 -42
- package/dist/tool-permissions.js.map +1 -1
- package/dist/ui/app-effects.js +9 -5
- package/dist/ui/chat-ambient.js +8 -7
- package/dist/ui/chat-effect-posters.js +3 -2
- package/dist/ui/chat-effects.js +22 -15
- package/dist/ui/chat-render.js +5 -2
- package/dist/ui/chat-strings.js +6 -4
- package/dist/ui/chat-thread.js +134 -17
- package/dist/ui/effect-trial-controller.js +13 -8
- package/dist/ui/fleet-canvas.js +54 -18
- package/dist/ui/fleet-org.js +3 -3
- package/dist/ui/fleet-traffic.js +156 -37
- package/dist/ui/fleet-usage.js +401 -0
- package/dist/ui/panel-chat.js +101 -47
- package/dist/ui/panel-fleet.js +6 -5
- package/dist/ui/panel-settings.js +6 -1
- package/dist/ui/settings-owner-effects.js +1 -1
- package/dist/ui/settings-strings.js +2 -0
- package/dist/ui/shared/agend-auth.js +1 -1
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app-stream.js +3 -2
- package/dist/ui/shared/app.css +89 -9
- package/dist/ui/shared/effect-app-runtime.js +3 -3
- package/dist/ui/shared/effect-chat-runtime.js +1 -1
- package/dist/ui/shared/effect-policy.js +5 -6
- package/dist/ui/shared/effect-profiler.js +31 -9
- package/dist/ui/shared/effect-registry.js +23 -31
- package/dist/ui/shared/effect-stock-snapshot.js +9 -0
- package/dist/ui/shared/effect-stock-trust.js +37 -6
- package/dist/ui/shared/effect-trial-source.js +1 -1
- package/dist/ui/shared/effect-trial-stage.js +27 -21
- package/dist/ui/shared/effect-trial.css +1 -1
- package/dist/ui/shared/fx-motion.js +12 -3
- package/dist/ui/shared/tokens.css +9 -3
- package/dist/ui/shared/usage-dialog.js +8 -0
- package/dist/ui/shared/view-strings.js +4 -0
- package/dist/ui/web-terminal/terminal.css +4 -4
- package/dist/usage-report.d.ts +12 -1
- package/dist/usage-report.js +39 -8
- package/dist/usage-report.js.map +1 -1
- package/dist/web-ambient.js +9 -2
- package/dist/web-ambient.js.map +1 -1
- package/dist/web-api.d.ts +2 -0
- package/dist/web-api.js +8 -3
- package/dist/web-api.js.map +1 -1
- package/dist/web-settings-patch.d.ts +3 -1
- package/dist/web-settings-patch.js +4 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
package/dist/ui/chat-render.js
CHANGED
|
@@ -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.
|
|
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 = /<(a?):([A-Za-z0-9_]{2,32}):(\d{15,25})>/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
|
-
|
|
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];
|
package/dist/ui/chat-strings.js
CHANGED
|
@@ -9,7 +9,7 @@ register("chat", {
|
|
|
9
9
|
effectTrialReason_busy: "Stop the current trial before starting another.",
|
|
10
10
|
effectTrialReason_safe: "Turn off safe mode to Try.",
|
|
11
11
|
effectTrialReason_effectsOff: "Turn on effects to Try.",
|
|
12
|
-
effectTrialReason_reducedMotion: "
|
|
12
|
+
effectTrialReason_reducedMotion: "Effects follow this device’s reduced-motion setting.",
|
|
13
13
|
effectTrialReason_saveData: "Your device requests reduced data use.",
|
|
14
14
|
effectTrialReason_transport: "The isolated effect player is unavailable. Reload this page.",
|
|
15
15
|
effectTrialReason_unsupported: "Canvas Worker playback is unavailable in this browser.",
|
|
@@ -19,8 +19,9 @@ register("chat", {
|
|
|
19
19
|
effectTrialSignIn: "Sign in here",
|
|
20
20
|
effectTrialStopOther: "Stop current trial",
|
|
21
21
|
effectTrialSettings: "Open playback settings",
|
|
22
|
+
effectTrialPlayMotion: "Play effects anyway",
|
|
22
23
|
effectTrial: "Effect trial", effectTrialLocal: "A sample on this page. Nothing is applied or saved.",
|
|
23
|
-
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.",
|
|
24
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",
|
|
25
26
|
compactRunning: "Compacting…", compactDone: "✅ Conversation compacted", compactUnknown: "This backend does not report when compaction ends.",
|
|
26
27
|
compactTimeout: "No sign that compaction finished within {0} minutes — check the agent's own chat.",
|
|
@@ -133,7 +134,7 @@ register("chat", {
|
|
|
133
134
|
effectTrialReason_busy: "請先停止目前的試播。",
|
|
134
135
|
effectTrialReason_safe: "請先關閉安全模式。",
|
|
135
136
|
effectTrialReason_effectsOff: "請先開啟效果。",
|
|
136
|
-
effectTrialReason_reducedMotion: "
|
|
137
|
+
effectTrialReason_reducedMotion: "效果跟隨這台裝置的「減少動態效果」設定。",
|
|
137
138
|
effectTrialReason_saveData: "裝置目前要求節省數據。",
|
|
138
139
|
effectTrialReason_transport: "隔離效果播放器目前無法使用。請重新載入頁面。",
|
|
139
140
|
effectTrialReason_unsupported: "此瀏覽器無法播放 Canvas Worker 效果。",
|
|
@@ -143,8 +144,9 @@ register("chat", {
|
|
|
143
144
|
effectTrialSignIn: "在此登入",
|
|
144
145
|
effectTrialStopOther: "停止目前試播",
|
|
145
146
|
effectTrialSettings: "開啟播放設定",
|
|
147
|
+
effectTrialPlayMotion: "仍然播放效果",
|
|
146
148
|
effectTrial: "效果試播", effectTrialLocal: "僅在本頁試播,不會套用或儲存。",
|
|
147
|
-
effectTrialStart: "試播", effectTrialExpired: "試播邀請已過期。", effectTrialUnavailable: "此試播已無法使用。",
|
|
149
|
+
effectTrialStopped: "試播已停止", effectTrialStart: "試播", effectTrialExpired: "試播邀請已過期。", effectTrialUnavailable: "此試播已無法使用。",
|
|
148
150
|
cmdConfirm: "清空", cmdConfirmBackend: "切換並重新啟動", cmdRunning: "執行 /{0} 中…", cmdNotCommand: "這裡沒有這個指令——按 Enter 會當成一般訊息送出。", cmdDismiss: "關閉", cmdCommands: "指令",
|
|
149
151
|
compactRunning: "壓縮中…", compactDone: "✅ 對話已壓縮", compactUnknown: "這個後端不會回報壓縮何時完成。",
|
|
150
152
|
compactTimeout: "{0} 分鐘內沒有看到壓縮完成的紀錄,請到代理自己的對話確認。",
|
package/dist/ui/chat-thread.js
CHANGED
|
@@ -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
|
-
|
|
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">${
|
|
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
|
-
const label = available ? "chat.effectTrialStart" : reason === "busy" ? "chat.effectTrialStopOther" : reason === "localAdmin" ? "chat.effectTrialSignIn" : "chat.effectTrialSettings";
|
|
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 =
|
|
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 =
|
|
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
|
|
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;
|
|
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
|
-
|
|
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;
|
|
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;
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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;
|
|
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]);
|
package/dist/ui/fleet-canvas.js
CHANGED
|
@@ -37,6 +37,7 @@ register("orgc", {
|
|
|
37
37
|
openChat: "Open chat", minimap: "Overview: click to move there",
|
|
38
38
|
live: "Live", liveOn: "Hide live traffic", liveOff: "Show live traffic", lgTask: "delegate_task", lgSend: "send_to_instance",
|
|
39
39
|
lgReport: "report_result", lgBroadcast: "broadcast", lgQueued: "queued", lgFading: "delivered", lgTeam: "between teams", legend: "Key",
|
|
40
|
+
lgWork: "in progress (until its report)", lgReturn: "done (report back)", lgStale: "no report yet",
|
|
40
41
|
},
|
|
41
42
|
"zh-TW": {
|
|
42
43
|
view: "檢視", canvas: "畫布", list: "清單", find: "尋找 instance", findShort: "尋找", found: "找到 {0} 個", foundOne: "找到 1 個", noMatch: "沒有符合的",
|
|
@@ -46,6 +47,7 @@ register("orgc", {
|
|
|
46
47
|
openChat: "開啟聊天", minimap: "總覽:點一下移到那裡",
|
|
47
48
|
live: "即時", liveOn: "隱藏即時往來", liveOff: "顯示即時往來", lgTask: "delegate_task", lgSend: "send_to_instance",
|
|
48
49
|
lgReport: "report_result", lgBroadcast: "broadcast", lgQueued: "排隊中", lgFading: "已送達", lgTeam: "team 之間", legend: "圖例",
|
|
50
|
+
lgWork: "進行中(直到回報)", lgReturn: "完成(回報送回)", lgStale: "尚未回報",
|
|
49
51
|
},
|
|
50
52
|
});
|
|
51
53
|
const tc = (k, ...v) => t(`orgc.${k}`, ...v);
|
|
@@ -148,14 +150,31 @@ function Card({ c, lead, cls, onOpen }) {
|
|
|
148
150
|
</g>`;
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
function teamName(b) { return b.name === null ? t("org.unteamed") : b.name; }
|
|
154
|
+
const foundIn = (b, q) => (q ? b.nodes.filter((n) => hits(n, q)).length : 0);
|
|
155
|
+
|
|
156
|
+
/** A team's box: its frame, header band and (folded) summary — under the traffic lines. */
|
|
157
|
+
function TeamBox({ b, q }) {
|
|
158
|
+
const found = foundIn(b, q);
|
|
156
159
|
return html`<g class=${`oc-team${b.loose ? " loose" : ""}${q && !found ? " dim" : ""}`} data-team=${b.key}>
|
|
157
160
|
<rect class=${`oc-box${b.folded && found ? " match" : ""}`} x=${b.x} y=${b.y} width=${b.w} height=${b.h} rx="12" />
|
|
158
161
|
<rect class="oc-head" x=${b.x + 1} y=${b.y + 1} width=${b.w - 2} height=${OC.HEAD - 2} rx="11" />
|
|
162
|
+
${!b.nodes.length ? html`<text class="oc-team-sum" x=${b.x + 14} y=${b.y + OC.HEAD + 18}>${tc("noMembers")}</text>` : null}
|
|
163
|
+
${b.folded ? b.summary.flatMap((row, i) => row.map((it) => html`<g key=${it.s}>
|
|
164
|
+
<circle class=${`oc-dot st-${it.s}`} cx=${b.x + it.dx + 5} cy=${b.y + OC.HEAD + 14 + i * SUM_LINE} r="5" />
|
|
165
|
+
<text class="oc-team-sum" x=${b.x + it.dx + 14} y=${b.y + OC.HEAD + 18 + i * SUM_LINE}>${it.label}</text></g>`)) : null}
|
|
166
|
+
</g>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A team's header: the fold control, its name and count — above the traffic lines (#1918 review: a line leaving the box
|
|
171
|
+
* through its header ran over the count), so it stays readable and clickable.
|
|
172
|
+
*/
|
|
173
|
+
function TeamHead({ b, q, onFold }) {
|
|
174
|
+
const name = teamName(b), found = foundIn(b, q);
|
|
175
|
+
const count = b.nodes.length === 1 ? tc("member") : tc("members", b.nodes.length);
|
|
176
|
+
const toggle = (e) => { if (e.type === "click" || e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onFold(b.key); } };
|
|
177
|
+
return html`<g class=${`oc-team-head${q && !found ? " dim" : ""}`} data-team=${b.key}>
|
|
159
178
|
<g class=${`oc-toggle${b.nodes.length ? "" : " empty"}`} tabindex=${b.nodes.length ? "0" : undefined} role=${b.nodes.length ? "button" : undefined}
|
|
160
179
|
aria-expanded=${b.nodes.length ? String(!b.folded) : undefined} aria-label=${`${name}, ${count}.${b.nodes.length ? ` ${b.folded ? tc("expand") : tc("collapse")}.` : ""}`}
|
|
161
180
|
onClick=${b.nodes.length ? toggle : undefined} onKeyDown=${b.nodes.length ? toggle : undefined}>
|
|
@@ -164,10 +183,6 @@ function TeamBox({ b, q, onFold }) {
|
|
|
164
183
|
<text class="oc-team-name" x=${b.x + (b.nodes.length ? 28 : 14)} y=${b.y + 22}>${clip(name, Math.max(8, Math.floor((b.w - 90) / 7.5)))}</text>
|
|
165
184
|
<text class="oc-team-meta" x=${b.x + b.w - 12} y=${b.y + 22} text-anchor="end">${b.folded && found ? tc("inFolded", found) : String(b.nodes.length)}</text>
|
|
166
185
|
</g>
|
|
167
|
-
${!b.nodes.length ? html`<text class="oc-team-sum" x=${b.x + 14} y=${b.y + OC.HEAD + 18}>${tc("noMembers")}</text>` : null}
|
|
168
|
-
${b.folded ? b.summary.flatMap((row, i) => row.map((it) => html`<g key=${it.s}>
|
|
169
|
-
<circle class=${`oc-dot st-${it.s}`} cx=${b.x + it.dx + 5} cy=${b.y + OC.HEAD + 14 + i * SUM_LINE} r="5" />
|
|
170
|
-
<text class="oc-team-sum" x=${b.x + it.dx + 14} y=${b.y + OC.HEAD + 18 + i * SUM_LINE}>${it.label}</text></g>`)) : null}
|
|
171
186
|
</g>`;
|
|
172
187
|
}
|
|
173
188
|
|
|
@@ -176,28 +191,42 @@ const cardsOf = (L) => [...L.tops, ...L.boxes.flatMap((b) => b.cells)];
|
|
|
176
191
|
|
|
177
192
|
const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
178
193
|
|
|
179
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* The traffic lines: dotted while queued, a dash and a pulse while delivering, fading once delivered; a delegation's
|
|
196
|
+
* slow moving dash while its work is in progress, one pulse back when its report comes, a faint dash when none came;
|
|
197
|
+
* ×N on shared lines.
|
|
198
|
+
*/
|
|
199
|
+
const MARKER = (e) => (e.phase === "fading" || e.phase === "stale" ? "fading" : e.hue);
|
|
180
200
|
function TrafficLayer({ edges, q }) {
|
|
181
201
|
const still = reducedMotion();
|
|
182
202
|
return html`<g class="oc-flows" aria-hidden="true">${edges.map((e) => {
|
|
183
203
|
const dim = q && !e.names.some((n) => n.toLowerCase().includes(q));
|
|
184
204
|
return html`<g key=${e.key} class=${`oc-fl${dim ? " dim" : ""}`}>
|
|
185
205
|
<path class="oc-flow-halo" d=${e.d} />
|
|
186
|
-
<path class=${`oc-flow h-${e.hue} ph-${e.phase}`} d=${e.d} marker-start=${`url(#oc-src-${e
|
|
206
|
+
<path class=${`oc-flow h-${e.hue} ph-${e.phase}`} d=${e.d} marker-start=${`url(#oc-src-${MARKER(e)})`} marker-end=${`url(#oc-ah-${MARKER(e)})`} />
|
|
187
207
|
${e.phase === "live" && !still ? html`<circle class=${`oc-pulse h-${e.hue}`} r="4"><animateMotion dur=${`${Math.max(1.6, e.pts.length * 0.3)}s`} repeatCount="indefinite" path=${e.d} /></circle>` : null}
|
|
208
|
+
${e.phase === "return" && !still ? html`<circle class=${`oc-pulse h-${e.hue}`} r="5"><animateMotion dur="1.6s" repeatCount="1" fill="freeze" path=${e.d} /></circle>` : null}
|
|
188
209
|
${e.badge ? html`<g class="oc-badge" transform=${`translate(${e.badge.x - 14} ${e.badge.y - 10})`}><rect width="28" height="20" rx="10" /><text x="14" y="14" text-anchor="middle">${`×${e.n}`}</text></g>` : null}
|
|
189
210
|
</g>`;
|
|
190
211
|
})}</g>`;
|
|
191
212
|
}
|
|
192
213
|
const ARROWS = ["task", "send", "report", "broadcast", "team", "fading"];
|
|
193
214
|
|
|
194
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* The key to the lines (#2052): a **Key** toggle in the toolbar; open, its rows are a strip between the toolbar and the
|
|
217
|
+
* canvas — never over the canvas, so it covers no card (it used to sit in the canvas's bottom-left corner, over the
|
|
218
|
+
* team there at the opening view). Folded until the person opens it; kept per device.
|
|
219
|
+
*/
|
|
220
|
+
function readKeyOpen() { try { return localStorage.getItem(KEY_KEY) === "1"; } catch { return false; } }
|
|
221
|
+
function KeyToggle({ open, onToggle }) {
|
|
222
|
+
return html`<button type="button" class=${`btn btn-sm oc-key${open ? " on" : ""}`} aria-expanded=${open ? "true" : "false"} aria-controls="oc-legend"
|
|
223
|
+
onClick=${onToggle}>${tc("legend")}</button>`;
|
|
224
|
+
}
|
|
195
225
|
function Legend() {
|
|
196
|
-
const [open] = useState(() => { try { return localStorage.getItem(KEY_KEY) === "1"; } catch { return false; } });
|
|
197
226
|
const row = (cls, k) => html`<span class="oc-lg"><i class=${`oc-sw ${cls}`} aria-hidden="true"></i>${tc(k)}</span>`;
|
|
198
|
-
return html`<
|
|
227
|
+
return html`<div id="oc-legend" class="oc-legend" role="region" aria-label=${tc("legend")}>
|
|
199
228
|
<div class="oc-lg-rows">${row("h-task", "lgTask")}${row("h-send", "lgSend")}${row("h-report", "lgReport")}${row("h-broadcast", "lgBroadcast")}
|
|
200
|
-
${row("ph-queued", "lgQueued")}${row("ph-fading", "lgFading")}</div></
|
|
229
|
+
${row("ph-queued", "lgQueued")}${row("ph-fading", "lgFading")}${row("h-task ph-work", "lgWork")}${row("h-report ph-return", "lgReturn")}${row("ph-stale", "lgStale")}</div></div>`;
|
|
201
230
|
}
|
|
202
231
|
|
|
203
232
|
/**
|
|
@@ -212,11 +241,13 @@ function useTraffic(lease, on) {
|
|
|
212
241
|
if (!on || !lease) return undefined;
|
|
213
242
|
const bump = () => setRev((n) => n + 1);
|
|
214
243
|
const read = async () => {
|
|
244
|
+
// What the stream had said when this was asked for: a stream event after it makes the answer possibly older.
|
|
245
|
+
const since = store.streamRev();
|
|
215
246
|
try {
|
|
216
247
|
const r = await lease.fetch("/ui/traffic", { method: "GET", headers: { "Content-Type": "application/json" } });
|
|
217
248
|
if (!lease.current() || !r.ok) return;
|
|
218
249
|
const d = await r.json();
|
|
219
|
-
if (lease.current() && store.load(d)) bump();
|
|
250
|
+
if (lease.current() && store.load(d, undefined, since)) bump();
|
|
220
251
|
} catch { /* the next poll, or the stream, brings it */ }
|
|
221
252
|
};
|
|
222
253
|
void read();
|
|
@@ -226,6 +257,7 @@ function useTraffic(lease, on) {
|
|
|
226
257
|
const offs = [];
|
|
227
258
|
if (sub) {
|
|
228
259
|
offs.push(lease.hold(sub("traffic", (item) => { if (store.apply(item)) bump(); })));
|
|
260
|
+
offs.push(lease.hold(sub("delegation", (d) => { if (store.delegation(d)) bump(); })));
|
|
229
261
|
offs.push(lease.hold(sub("connection", (c) => { if (c === "live") void read(); })));
|
|
230
262
|
}
|
|
231
263
|
const hs = [
|
|
@@ -383,15 +415,15 @@ function Canvas({ view, folded, onFold, q, jump, traffic }) {
|
|
|
383
415
|
<circle cx="5" cy="5" r="4" class=${`oc-ah h-${a}`} /></marker>`)}</defs>
|
|
384
416
|
<g ref=${worldRef} class="oc-world">
|
|
385
417
|
<g class="oc-tree">${L.tree.map((d, i) => html`<path key=${i} d=${d} />`)}</g>
|
|
386
|
-
<g class="oc-teams">${L.boxes.map((b) => html`<${TeamBox} key=${b.key} b=${b} q=${q}
|
|
418
|
+
<g class="oc-teams">${L.boxes.map((b) => html`<${TeamBox} key=${b.key} b=${b} q=${q} />`)}</g>
|
|
387
419
|
<${TrafficLayer} edges=${edges} q=${q} />
|
|
420
|
+
<g class="oc-heads">${L.boxes.map((b) => html`<${TeamHead} key=${b.key} b=${b} q=${q} onFold=${onFold} />`)}</g>
|
|
388
421
|
<g class="oc-cards">
|
|
389
422
|
${L.tops.map((c) => html`<${Card} key=${c.key} c=${c} lead=${true} cls=${q ? (hits(c.node, q) ? "match" : "dim") : ""} onOpen=${open} />`)}
|
|
390
423
|
${L.boxes.flatMap((b) => b.cells.map((c) => html`<${Card} key=${c.key} c=${c} lead=${false} cls=${q ? (hits(c.node, q) ? "match" : "dim") : ""} onOpen=${open} />`))}
|
|
391
424
|
</g>
|
|
392
425
|
</g>
|
|
393
426
|
</svg>
|
|
394
|
-
${traffic ? html`<${Legend} />` : null}
|
|
395
427
|
<div class="oc-zoom" role="group" aria-label=${tc("view")}>
|
|
396
428
|
<button type="button" class="icon-btn" aria-label=${tc("zoomIn")} title=${tc("zoomIn")} onClick=${() => zoomBy(1.25)}><${Icon} name="plus" size=${16} /></button>
|
|
397
429
|
<button type="button" class="icon-btn" aria-label=${tc("zoomOut")} title=${tc("zoomOut")} onClick=${() => zoomBy(0.8)}><span aria-hidden="true">−</span></button>
|
|
@@ -469,6 +501,8 @@ export function OrgCanvas({ org, app, lease }) {
|
|
|
469
501
|
if (holder && folded.has(holder[0])) onFold(holder[0]);
|
|
470
502
|
setJump({ name: first.name, at: Date.now() });
|
|
471
503
|
};
|
|
504
|
+
const [keyOpen, setKeyOpen] = useState(readKeyOpen);
|
|
505
|
+
const toggleKey = () => setKeyOpen((v) => { save(KEY_KEY, v ? "0" : "1"); return !v; });
|
|
472
506
|
const modeBtn = (m) => html`<button type="button" class=${`btn btn-sm${mode === m ? " btn-primary" : ""}`} aria-pressed=${mode === m ? "true" : "false"}
|
|
473
507
|
onClick=${() => setMode(m)}>${tc(m)}</button>`;
|
|
474
508
|
return html`<div class=${`org oc oc-${mode}`}>
|
|
@@ -478,11 +512,13 @@ export function OrgCanvas({ org, app, lease }) {
|
|
|
478
512
|
${mode === "canvas" ? html`<label class="s-search oc-find"><${Icon} name="search" size=${14} /><span class="sr-only">${tc("find")}</span>
|
|
479
513
|
<input type="search" value=${query} placeholder=${tc("findShort")} autocomplete="off" onInput=${(e) => setQuery(e.target.value)} onKeyDown=${onFind} />
|
|
480
514
|
${q ? html`<span class="oc-found" role="status">${found ? (found === 1 ? tc("foundOne") : tc("found", found)) : tc("noMatch")}</span>` : null}</label>` : null}
|
|
515
|
+
${mode === "canvas" && live ? html`<${KeyToggle} open=${keyOpen} onToggle=${toggleKey} />` : null}
|
|
481
516
|
<button type="button" class=${`btn btn-sm oc-live${live ? " on" : ""}`} aria-pressed=${live ? "true" : "false"} title=${live ? tc("liveOn") : tc("liveOff")}
|
|
482
517
|
onClick=${() => setLive((v) => !v)}><span class="oc-live-dot" aria-hidden="true"></span>${tc("live")}</button>
|
|
483
518
|
<div class="seg-inline" role="group" aria-label=${tc("view")}>${modeBtn("canvas")}${modeBtn("list")}</div>
|
|
484
519
|
</div>
|
|
485
520
|
</div>
|
|
521
|
+
${mode === "canvas" && live && keyOpen ? html`<${Legend} />` : null}
|
|
486
522
|
${mode === "list" ? html`<${OrgChart} org=${org} app=${app} bare=${true} sending=${sending} />`
|
|
487
523
|
: html`<${Canvas} view=${view} folded=${folded} onFold=${onFold} q=${q} jump=${jump} traffic=${live ? traffic : null} />`}
|
|
488
524
|
</div>`;
|
package/dist/ui/fleet-org.js
CHANGED
|
@@ -20,7 +20,7 @@ register("org", {
|
|
|
20
20
|
missing: "Not in the fleet", thread_discord: "Discord thread", thread_telegram: "Telegram topic", openChat: "Open chat",
|
|
21
21
|
classic: "ClassicBot room", empty: "No instances yet", emptyHint: "Instances and teams show here once the fleet has them.",
|
|
22
22
|
noTeams: "No teams yet — define them in Fleet → Teams to group the chart.", newTab: "(opens in a new tab)",
|
|
23
|
-
sending: "Sending:", queuedNote: "(queued)",
|
|
23
|
+
sending: "Sending:", queuedNote: "(queued)", workingNote: "(in progress)",
|
|
24
24
|
},
|
|
25
25
|
"zh-TW": {
|
|
26
26
|
title: "組織圖", general: "General", unteamed: "未加入 team", members: "{0} 名成員", member: "1 名成員", instances: "{0} 個 instance", instance: "1 個 instance",
|
|
@@ -28,7 +28,7 @@ register("org", {
|
|
|
28
28
|
missing: "不在 fleet 中", thread_discord: "Discord 討論串", thread_telegram: "Telegram topic", openChat: "開啟聊天",
|
|
29
29
|
classic: "ClassicBot 頻道", empty: "還沒有 instance", emptyHint: "fleet 有 instance 和 team 後會顯示在這裡。",
|
|
30
30
|
noTeams: "還沒有 team——到 Fleet → Teams 定義後,組織圖會依 team 分組。", newTab: "(在新分頁開啟)",
|
|
31
|
-
sending: "傳送中:", queuedNote: "(排隊中)",
|
|
31
|
+
sending: "傳送中:", queuedNote: "(排隊中)", workingNote: "(進行中)",
|
|
32
32
|
},
|
|
33
33
|
});
|
|
34
34
|
const to = (k, ...v) => t(`org.${k}`, ...v);
|
|
@@ -95,7 +95,7 @@ export function OrgSummary({ counts }) {
|
|
|
95
95
|
/** #1918: what an instance is sending now, in words — the canvas's live traffic for the list (and a screen reader). */
|
|
96
96
|
const KIND_WORD = { task: "delegate_task", report: "report_result", query: "request_information", update: "send_to_instance", send: "send_to_instance", broadcast: "broadcast" };
|
|
97
97
|
function Sending({ list }) {
|
|
98
|
-
return html`<p class="org-sending"><span class="muted">${to("sending")}</span> ${list.map((s, i) => html`<span key=${s.key} class=${`org-send k-${s.kind}`}>${i ? ", " : ""}${KIND_WORD[s.kind] || s.kind} → ${s.to}${s.queued ? ` ${to("queuedNote")}` : ""}</span>`)}</p>`;
|
|
98
|
+
return html`<p class="org-sending"><span class="muted">${to("sending")}</span> ${list.map((s, i) => html`<span key=${s.key} class=${`org-send k-${s.kind}`}>${i ? ", " : ""}${KIND_WORD[s.kind] || s.kind} → ${s.to}${s.working ? ` ${to("workingNote")}` : s.queued ? ` ${to("queuedNote")}` : ""}</span>`)}</p>`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function Node({ n, sending }) {
|