@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
@@ -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 TeamBox({ b, q, onFold }) {
152
- const name = b.name === null ? t("org.unteamed") : b.name;
153
- const found = q ? b.nodes.filter((n) => hits(n, q)).length : 0;
154
- const count = b.nodes.length === 1 ? tc("member") : tc("members", b.nodes.length);
155
- const toggle = (e) => { if (e.type === "click" || e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onFold(b.key); } };
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
- /** The traffic lines: dotted while queued, a dash and a pulse while delivering, fading once delivered; ×N on shared lines. */
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.phase === "fading" ? "fading" : e.hue})`} marker-end=${`url(#oc-ah-${e.phase === "fading" ? "fading" : e.hue})`} />
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
- /** The key to the lines: folded until the person opens it (open, it covers a corner of the canvas); kept per device. */
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`<details class="oc-legend" open=${open} onToggle=${(e) => save(KEY_KEY, e.currentTarget.open ? "1" : "0")}><summary>${tc("legend")}</summary>
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></details>`;
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} onFold=${onFold} />`)}</g>
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>`;
@@ -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 }) {
@@ -3,8 +3,14 @@
3
3
  // stream, every POLL_MS while the page is shown — the user's decision 1). Ids are opaque; there is never any text.
4
4
  //
5
5
  // - The store keeps each message's latest state: queued (a dotted line), delivering (a dash and a pulse), delivered
6
- // (the line stays FADE_MS while it fades, then goes); consumed, failed, uncertain or cancelled ends it at once (a
7
- // failed or uncertain delivery is Needs you's to show, #1386). A finished message never comes back.
6
+ // or consumed (the line stays FADE_MS while it fades, then goes — long enough to see: an MCP send is delivered and
7
+ // read almost at once); failed, uncertain or cancelled ends it at once (a failed or uncertain delivery is Needs you's
8
+ // to show, #1386). A finished message never comes back.
9
+ // - And the work (the server's Delegations, stream event `delegation`, the snapshot's `delegations`): a task's
10
+ // delegator → worker line stays, "in progress" (a moving dash), until its report comes back; then a short return
11
+ // pulse worker → delegator (RETURN_MS), and it goes. One that waits past DELEGATION_TTL_MS fades as "no report yet".
12
+ // - Every line ends on something drawn: a card, or a folded team's box (a name not on the canvas draws nothing), and
13
+ // runs through lanes that touch the boxes it joins — two cards in one box are joined inside it.
8
14
  // - Edges are routed metro-style through the lanes the layout keeps empty (beside the columns and between card rows
9
15
  // inside a box; the bus above each row of teams; the trunk at the left), so a line never crosses a card.
10
16
  // - Near: one edge per (from, to, kind, phase), ×N when several share it. Far (zoomed out): one edge per team pair.
@@ -12,14 +18,20 @@
12
18
  // recipients, or far, it is one edge per recipient team, ×N (the user's decision 3). At most MAX_EDGES are drawn.
13
19
  import { OC } from "./fleet-canvas-layout.js";
14
20
 
15
- export const FADE_MS = 3000;
21
+ export const FADE_MS = 12000;
22
+ export const RETURN_MS = 2400;
23
+ /** As the server's (src/fleet-traffic.ts): how long a delegation waits for its report. */
24
+ export const DELEGATION_TTL_MS = 4 * 3_600_000;
16
25
  export const POLL_MS = 5000;
17
26
  export const MAX_EDGES = 60;
18
27
  export const BUNDLE_AT = 8;
19
28
  export const FAR_K = 0.55;
20
- const MAX_ITEMS = 400, MAX_DONE = 2000;
29
+ const MAX_ITEMS = 400, MAX_DONE = 2000, MAX_WORK = 200;
30
+ const DSTATES = new Set(["open", "done", "expired", "cancelled"]);
21
31
  const LIVE = new Set(["queued", "delivering"]);
22
- const ENDS = new Set(["consumed", "failed", "uncertain", "cancelled"]);
32
+ const ENDS = new Set(["failed", "uncertain", "cancelled"]);
33
+ /** Delivered, or read: the line fades out (FADE_MS). */
34
+ const SETTLED = new Set(["delivered", "consumed"]);
23
35
  const KINDS = new Set(["task", "report", "query", "update", "send", "broadcast"]);
24
36
  const mono = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now());
25
37
 
@@ -28,55 +40,118 @@ export function itemOk(x) {
28
40
  if (!x || typeof x !== "object") return null;
29
41
  const { id, from, to, kind, state, op } = x;
30
42
  if (typeof id !== "string" || !/^[0-9a-f]{16}$/.test(id) || typeof from !== "string" || !from || typeof to !== "string" || !to) return null;
31
- if (!KINDS.has(kind) || !(LIVE.has(state) || state === "delivered" || ENDS.has(state))) return null;
43
+ if (!KINDS.has(kind) || !(LIVE.has(state) || SETTLED.has(state) || ENDS.has(state))) return null;
32
44
  return { id, from, to, kind, state, ...(typeof op === "string" && /^[0-9a-f]{16}$/.test(op) ? { op } : {}) };
33
45
  }
34
46
 
47
+ /** A delegation as the server sends it, checked; null for anything else. */
48
+ export function delegationOk(x) {
49
+ if (!x || typeof x !== "object") return null;
50
+ const { id, from, to, state, age_ms } = x;
51
+ if (typeof id !== "string" || !/^[0-9a-f]{16}$/.test(id) || typeof from !== "string" || !from || typeof to !== "string" || !to || from === to) return null;
52
+ if (!DSTATES.has(state) || typeof age_ms !== "number" || !Number.isFinite(age_ms) || age_ms < 0) return null;
53
+ return { id, from, to, state, age: age_ms };
54
+ }
55
+
35
56
  /**
36
- * The page's traffic: `apply(item, nowMs)` one stream item, `load(snapshot, nowMs)` a snapshot, `sweep(nowMs)` drops
37
- * faded lines; each returns whether anything shown changed. `items()` what to draw: { …item, fading }.
57
+ * The page's traffic: `apply(item, nowMs)` one stream item, `delegation(d, nowMs)` one delegation event,
58
+ * `load(snapshot, nowMs, since)` a snapshot, `sweep(nowMs)` drops faded lines; each returns whether anything shown
59
+ * changed. `items()` what to draw: { …item, fading }. `streamRev()` counts the stream's events: a snapshot asked for
60
+ * before the latest one (`since` older than it) may be older than what the stream already said, so it adds only what
61
+ * is new and ends or rewinds nothing (#2135 review).
62
+ * Ended work is remembered (bounded): a late snapshot never draws a delegation that is done, expired or taken back.
38
63
  */
39
64
  export function createTrafficStore() {
40
65
  const items = new Map(); // id → { item, fadeAt }
41
- const done = new Set(); // ids that ended: a late item for one is ignored
66
+ const work = new Map(); // delegation id → { d, phase: "work" | "return" | "stale", until }
67
+ const done = new Set(); // message ids that ended: a late item for one is ignored
68
+ const ended = new Set(); // delegation ids that ended: never drawn in progress again
69
+ let rev = 0;
42
70
  const finish = (id) => {
43
71
  items.delete(id);
44
72
  done.add(id);
45
73
  if (done.size > MAX_DONE) done.delete(done.values().next().value);
46
74
  };
47
- const store = {
48
- apply(raw, now = mono()) {
49
- const it = itemOk(raw);
50
- if (!it || done.has(it.id)) return false;
51
- const prev = items.get(it.id);
52
- if (ENDS.has(it.state)) { const had = !!prev; finish(it.id); return had; }
53
- if (prev && prev.item.state === "delivered") return false; // delivered never goes back
54
- if (prev && prev.item.state === it.state) return false;
55
- items.delete(it.id);
56
- items.set(it.id, { item: it, fadeAt: it.state === "delivered" ? now + FADE_MS : null });
57
- while (items.size > MAX_ITEMS) items.delete(items.keys().next().value);
75
+ const end = (id) => {
76
+ ended.add(id);
77
+ if (ended.size > MAX_DONE) ended.delete(ended.values().next().value);
78
+ };
79
+ const applyItem = (raw, now) => {
80
+ const it = itemOk(raw);
81
+ if (!it || done.has(it.id)) return false;
82
+ const prev = items.get(it.id);
83
+ if (ENDS.has(it.state)) { const had = !!prev; finish(it.id); return had; }
84
+ if (prev && prev.fadeAt !== null) return false; // fading: never goes back, nor starts again
85
+ if (prev && prev.item.state === it.state) return false;
86
+ if (it.state === "consumed" && !prev) { finish(it.id); return false; } // read before this page drew it
87
+ items.delete(it.id);
88
+ // A message keeps what it was (from, to, kind) through its states; only the state moves.
89
+ items.set(it.id, { item: prev ? { ...prev.item, state: it.state } : it, fadeAt: SETTLED.has(it.state) ? now + FADE_MS : null });
90
+ while (items.size > MAX_ITEMS) items.delete(items.keys().next().value);
91
+ return true;
92
+ };
93
+ const applyDelegation = (raw, now) => {
94
+ const d = delegationOk(raw);
95
+ if (!d || ended.has(d.id)) return false;
96
+ const prev = work.get(d.id);
97
+ if (d.state === "open") {
98
+ if (prev) return false;
99
+ work.set(d.id, { d, phase: "work", until: now + Math.max(0, DELEGATION_TTL_MS - d.age) });
100
+ while (work.size > MAX_WORK) { const oldest = work.keys().next().value; work.delete(oldest); end(oldest); }
58
101
  return true;
59
- },
60
- /** A snapshot is the truth for what is in flight: anything live it no longer lists has ended (missed while away). */
61
- load(snap, now = mono()) {
102
+ }
103
+ end(d.id);
104
+ if (d.state === "cancelled") return work.delete(d.id);
105
+ if (d.state === "done") { work.set(d.id, { d, phase: "return", until: now + RETURN_MS }); return true; }
106
+ if (!prev || prev.phase !== "work") return false; // expired: only one this page shows
107
+ work.set(d.id, { d, phase: "stale", until: now + FADE_MS });
108
+ return true;
109
+ };
110
+ const store = {
111
+ apply(raw, now = mono()) { rev++; return applyItem(raw, now); },
112
+ delegation(raw, now = mono()) { rev++; return applyDelegation(raw, now); },
113
+ streamRev: () => rev,
114
+ /**
115
+ * A snapshot is the truth for what is in flight: anything live it no longer lists has ended (missed while away) —
116
+ * unless the stream has spoken since it was asked for (`since`): then it may be older than what is drawn, and only
117
+ * what is new to this page is taken from it.
118
+ */
119
+ load(snap, now = mono(), since = rev) {
120
+ const fresh = since === rev;
62
121
  const list = snap && Array.isArray(snap.items) ? snap.items.map(itemOk).filter(Boolean) : [];
63
122
  const listed = new Set(list.map((i) => i.id));
64
123
  let changed = false;
65
- for (const [id, e] of items) if (!listed.has(id) && LIVE.has(e.item.state)) { finish(id); changed = true; }
66
- // Delivered, for a line drawn here: it fades now (with no stream, the snapshot is the only news of it). Any other
67
- // finished message is not drawn (it finished up to two minutes ago), and an ended one goes.
124
+ if (fresh) for (const [id, e] of items) if (!listed.has(id) && LIVE.has(e.item.state)) { finish(id); changed = true; }
125
+ // Delivered or read, for a line drawn here: it fades now (with no stream, the snapshot is the only news of it). Any
126
+ // other finished message is not drawn (it finished up to two minutes ago), and an ended one goes.
68
127
  for (const it of list) {
69
- if (LIVE.has(it.state) || (it.state === "delivered" && items.has(it.id))) changed = store.apply(it, now) || changed;
70
- else { const had = items.has(it.id); finish(it.id); changed = changed || had; }
128
+ if (!fresh && items.has(it.id)) continue; // the stream knows it better
129
+ if (LIVE.has(it.state) || (SETTLED.has(it.state) && items.has(it.id))) changed = applyItem(it, now) || changed;
130
+ else if (fresh) { const had = items.has(it.id); finish(it.id); changed = changed || had; }
71
131
  }
132
+ // The open delegations: the snapshot's list is the truth (one it no longer lists ended while this page was away).
133
+ const open = snap && Array.isArray(snap.delegations) ? snap.delegations.filter((d) => delegationOk(d)?.state === "open") : [];
134
+ const ids = new Set(open.map((d) => d.id));
135
+ if (fresh) for (const [id, e] of work) if (e.phase === "work" && !ids.has(id)) { work.delete(id); end(id); changed = true; }
136
+ for (const d of open) changed = applyDelegation(d, now) || changed;
72
137
  return changed;
73
138
  },
74
139
  sweep(now = mono()) {
75
140
  let changed = false;
76
141
  for (const [id, e] of items) if (e.fadeAt !== null && now >= e.fadeAt) { finish(id); changed = true; }
142
+ for (const [id, e] of work) {
143
+ if (now < e.until) continue;
144
+ if (e.phase === "work") { end(id); work.set(id, { ...e, phase: "stale", until: now + FADE_MS }); } // its wait ran out here
145
+ else work.delete(id);
146
+ changed = true;
147
+ }
77
148
  return changed;
78
149
  },
79
- items: () => [...items.values()].map((e) => ({ ...e.item, fading: e.fadeAt !== null })),
150
+ /** What to draw: the messages, then the work (a done one is its return, worker → delegator). */
151
+ items: () => [...[...items.values()].map((e) => ({ ...e.item, fading: e.fadeAt !== null })),
152
+ ...[...work.values()].map(({ d, phase }) => phase === "return"
153
+ ? { id: `r${d.id}`, from: d.to, to: d.from, kind: "report", state: "delivering", phase, fading: false }
154
+ : { id: `w${d.id}`, from: d.from, to: d.to, kind: "task", state: "delivering", phase, fading: false })],
80
155
  fadingCount: () => [...items.values()].filter((e) => e.fadeAt !== null).length,
81
156
  };
82
157
  return store;
@@ -103,6 +178,9 @@ function endOf(L, name) {
103
178
 
104
179
  // ── Routing: lanes the layout keeps empty ──
105
180
 
181
+ /** A side lane's offset from the middle of the box's padding: at least 6 px from the box's border and from its cards. */
182
+ const laneOffset = (o) => Math.max(-OC.PAD / 2 + 6, Math.min(OC.PAD / 2 - 6, o * 0.5));
183
+
106
184
  const firstBusOf = (L) => Math.min(...L.boxes.map((b) => b.busY));
107
185
  /** From an end up to its row's bus lane: the points, and the lane's y. `o` spreads lines sharing a lane. */
108
186
  function outward(L, end, o) {
@@ -118,7 +196,7 @@ function outward(L, end, o) {
118
196
  }
119
197
  const { PAD, GAP, NW, NH } = OC;
120
198
  const card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
121
- const io = Math.max(-PAD / 2 + 3, Math.min(PAD / 2 - 3, o * 0.5));
199
+ const io = laneOffset(o);
122
200
  const leftLane = b.x + PAD / 2 + io, rightLane = b.x + b.w - PAD / 2 + io;
123
201
  let pts;
124
202
  if (col === 0 || col === b.cols - 1) {
@@ -134,8 +212,43 @@ function outward(L, end, o) {
134
212
  pts.push({ x: top.x, y: b.busY + o });
135
213
  return { pts, bus: b.busY + o };
136
214
  }
137
- /** The points of a line from one end to the other: up to the bus, along it (and the trunk, between rows), down. */
215
+ /**
216
+ * Inside a box, from a card to one of its side lanes (x `lane`): straight out when the card is in that lane's column,
217
+ * else down into the gutter under its row (no card is there) and across. The last point is on the lane.
218
+ */
219
+ function toLane(L, end, lane, o) {
220
+ const { PAD, GAP, NW, NH } = OC;
221
+ const b = end.b, card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
222
+ const io = laneOffset(o);
223
+ const left = lane < b.x + b.w / 2;
224
+ if ((left && col === 0) || (!left && col === b.cols - 1)) {
225
+ const cy = card.y + NH / 2 + io;
226
+ return [{ x: left ? card.x : card.x + NW, y: cy }, { x: lane, y: cy }];
227
+ }
228
+ const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2 + io / 2, cx = card.x + NW / 2 + o;
229
+ return [{ x: cx, y: card.y + NH }, { x: cx, y: gy }, { x: lane, y: gy }];
230
+ }
231
+ /** Two cards in one open box: joined inside it, through the side lane nearer to them both (never out to the bus). */
232
+ function inBox(L, a, z, o) {
233
+ const b = a.b, { PAD } = OC;
234
+ const io = laneOffset(o);
235
+ const mid = ((a.i % b.cols) + (z.i % b.cols)) / 2;
236
+ const lane = mid <= (b.cols - 1) / 2 ? b.x + PAD / 2 + io : b.x + b.w - PAD / 2 + io;
237
+ return [...toLane(L, a, lane, o), ...toLane(L, z, lane, o).reverse()];
238
+ }
239
+ /** Two of General's cards: under them, through the gap above the first row (or the canvas's empty space below). */
240
+ function betweenTops(a, z, o) {
241
+ const y = a.c.y + a.c.h + 30 + o * 0.6;
242
+ const ax = a.c.x + a.c.w / 2 + o, zx = z.c.x + z.c.w / 2 + o;
243
+ return [{ x: ax, y: a.c.y + a.c.h }, { x: ax, y }, { x: zx, y }, { x: zx, y: z.c.y + z.c.h }];
244
+ }
245
+ /**
246
+ * The points of a line from one end to the other: up to the bus, along it (and the trunk, between rows), down — or,
247
+ * for two cards in one box, inside the box; for two of General's cards, just under them.
248
+ */
138
249
  export function route(L, a, z, o) {
250
+ if (a.kind === "cell" && z.kind === "cell" && a.b === z.b) return inBox(L, a, z, o);
251
+ if (a.kind === "top" && z.kind === "top") return betweenTops(a, z, o);
139
252
  const p = outward(L, a, o), q = outward(L, z, o);
140
253
  const back = [...q.pts].reverse();
141
254
  if (Math.abs(p.bus - q.bus) < 1) return [...p.pts, ...back];
@@ -169,8 +282,10 @@ export function badgeAt(pts) {
169
282
 
170
283
  /** The colour family of a kind: task, report, broadcast, and the rest (send, query, update). */
171
284
  export const hue = (kind) => (kind === "task" || kind === "report" || kind === "broadcast" ? kind : "send");
172
- const phaseOf = (it) => (it.fading ? "fading" : it.state === "queued" ? "queued" : "live");
173
- const RANK = { live: 0, queued: 1, fading: 2 };
285
+ // A message: queued, live, fading. The work: in progress (work), its report coming back (return), or no report (stale).
286
+ const phaseOf = (it) => it.phase || (it.fading ? "fading" : it.state === "queued" ? "queued" : "live");
287
+ const RANK = { live: 0, return: 0, work: 1, queued: 2, fading: 3, stale: 3 };
288
+ const settled = (phase) => (phase === "fading" || phase === "stale" ? "fading" : phase);
174
289
 
175
290
  /**
176
291
  * The lines to draw from the store's items on layout `L`. `far`: zoomed out (one line per team pair). Each edge:
@@ -178,6 +293,7 @@ const RANK = { live: 0, queued: 1, fading: 2 };
178
293
  */
179
294
  export function trafficEdges(items, L, { far = false, max = MAX_EDGES } = {}) {
180
295
  if (!L.boxes.length && L.tops.length < 2) return [];
296
+ // A top's way out is the first row's bus: with no box there is none, so only top ↔ top lines (betweenTops) are drawn.
181
297
  const groups = new Map();
182
298
  const add = (key, g) => { const cur = groups.get(key); if (cur) { cur.n += g.n; cur.names.push(...g.names); } else groups.set(key, g); };
183
299
  const teamKey = (name) => { const t = teamOf(L, name); return t === null ? "\u0000general" : t ? t.key : undefined; };
@@ -195,7 +311,9 @@ export function trafficEdges(items, L, { far = false, max = MAX_EDGES } = {}) {
195
311
  const k = far ? "team" : "broadcast";
196
312
  const a = far ? teamEnd(it.from) : endOf(L, it.from), z = teamEnd(it.to);
197
313
  if (!a || !z) continue;
198
- add(`${k}|${a.key}>${z.key}|${phase === "fading" ? "fading" : "live"}`, { a, z, hue: k === "team" ? "team" : "broadcast", phase: phase === "fading" ? "fading" : phase, n: 1, names });
314
+ // Queued and live messages share a line far away (as before); the work keeps its own.
315
+ const ph = settled(phase), lane = ph === "queued" ? "live" : ph;
316
+ add(`${k}|${a.key}>${z.key}|${lane}`, { a, z, hue: k === "team" ? "team" : "broadcast", phase: ph, n: 1, names });
199
317
  continue;
200
318
  }
201
319
  const a = endOf(L, it.from), z = endOf(L, it.to);
@@ -214,10 +332,11 @@ export function trafficEdges(items, L, { far = false, max = MAX_EDGES } = {}) {
214
332
  export function sendingBy(items) {
215
333
  const out = new Map();
216
334
  for (const it of items) {
217
- if (it.fading) continue;
335
+ if (it.fading || it.phase === "stale" || it.phase === "return") continue;
218
336
  const l = out.get(it.from) || [];
219
- const key = `${it.kind}>${it.to}>${it.state}`;
220
- if (!l.some((x) => x.key === key)) l.push({ key, kind: it.kind, to: it.to, queued: it.state === "queued" });
337
+ const working = it.phase === "work";
338
+ const key = `${it.kind}>${it.to}>${working ? "work" : it.state}`;
339
+ if (!l.some((x) => x.key === key)) l.push({ key, kind: it.kind, to: it.to, queued: it.state === "queued", working });
221
340
  out.set(it.from, l);
222
341
  }
223
342
  return out;