@songsid/agend 2.3.0-beta.2 → 2.3.0-beta.4

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 (189) hide show
  1. package/dist/agent-whats-new.js +2 -0
  2. package/dist/agent-whats-new.js.map +1 -1
  3. package/dist/auth-api.d.ts +30 -1
  4. package/dist/auth-api.js +42 -12
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +2 -2
  7. package/dist/backend/claude-code.js +5 -4
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/types.d.ts +6 -0
  10. package/dist/backend/types.js +15 -0
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/backend-availability.d.ts +120 -0
  13. package/dist/backend-availability.js +396 -0
  14. package/dist/backend-availability.js.map +1 -0
  15. package/dist/backend-command.d.ts +21 -3
  16. package/dist/backend-command.js +23 -4
  17. package/dist/backend-command.js.map +1 -1
  18. package/dist/channel/adapters/discord.d.ts +7 -0
  19. package/dist/channel/adapters/discord.js +35 -0
  20. package/dist/channel/adapters/discord.js.map +1 -1
  21. package/dist/channel/mcp-tools.js +8 -4
  22. package/dist/channel/mcp-tools.js.map +1 -1
  23. package/dist/channel/types.d.ts +6 -0
  24. package/dist/classic-channel-manager.d.ts +8 -3
  25. package/dist/classic-channel-manager.js +10 -3
  26. package/dist/classic-channel-manager.js.map +1 -1
  27. package/dist/config-validator.js +73 -2
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/delivery-outbox.js +5 -0
  30. package/dist/delivery-outbox.js.map +1 -1
  31. package/dist/fleet-context.d.ts +10 -0
  32. package/dist/fleet-manager.d.ts +238 -5
  33. package/dist/fleet-manager.js +878 -71
  34. package/dist/fleet-manager.js.map +1 -1
  35. package/dist/fleet-traffic.d.ts +38 -0
  36. package/dist/fleet-traffic.js +96 -0
  37. package/dist/fleet-traffic.js.map +1 -0
  38. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +13 -5
  39. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  40. package/dist/general-knowledge/skills/web-appearance/SKILL.md +56 -11
  41. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
  42. package/dist/human-delivery-receipts.js +2 -1
  43. package/dist/human-delivery-receipts.js.map +1 -1
  44. package/dist/locale.js +48 -8
  45. package/dist/locale.js.map +1 -1
  46. package/dist/memory-hogs.d.ts +27 -0
  47. package/dist/memory-hogs.js +124 -0
  48. package/dist/memory-hogs.js.map +1 -0
  49. package/dist/outbound-handlers.d.ts +2 -0
  50. package/dist/outbound-handlers.js +40 -1
  51. package/dist/outbound-handlers.js.map +1 -1
  52. package/dist/outbound-schemas.d.ts +92 -0
  53. package/dist/outbound-schemas.js +55 -1
  54. package/dist/outbound-schemas.js.map +1 -1
  55. package/dist/public-web-gateway.js +1 -1
  56. package/dist/public-web-gateway.js.map +1 -1
  57. package/dist/public-web-link.d.ts +9 -0
  58. package/dist/public-web-link.js +8 -0
  59. package/dist/public-web-link.js.map +1 -1
  60. package/dist/quickstart-api.d.ts +10 -1
  61. package/dist/quickstart-api.js +12 -4
  62. package/dist/quickstart-api.js.map +1 -1
  63. package/dist/reply-buttons-controller.d.ts +2 -0
  64. package/dist/reply-buttons-controller.js +1 -1
  65. package/dist/reply-buttons-controller.js.map +1 -1
  66. package/dist/tmux-manager.d.ts +5 -0
  67. package/dist/tmux-manager.js +16 -0
  68. package/dist/tmux-manager.js.map +1 -1
  69. package/dist/tool-permissions.js +4 -0
  70. package/dist/tool-permissions.js.map +1 -1
  71. package/dist/topic-commands.d.ts +5 -0
  72. package/dist/topic-commands.js +16 -0
  73. package/dist/topic-commands.js.map +1 -1
  74. package/dist/types.d.ts +25 -1
  75. package/dist/ui/app-effects.js +33 -9
  76. package/dist/ui/chat-effects.js +62 -4
  77. package/dist/ui/chat-strings.js +40 -4
  78. package/dist/ui/chat-thread.js +17 -6
  79. package/dist/ui/effect-trial-panel.js +19 -16
  80. package/dist/ui/fleet-canvas-layout.js +111 -0
  81. package/dist/ui/fleet-canvas.js +489 -0
  82. package/dist/ui/fleet-org.js +27 -12
  83. package/dist/ui/fleet-traffic.js +224 -0
  84. package/dist/ui/panel-chat.js +12 -5
  85. package/dist/ui/panel-details.js +2 -1
  86. package/dist/ui/panel-fleet.js +32 -11
  87. package/dist/ui/panel-settings.js +6 -4
  88. package/dist/ui/preview.js +3 -2
  89. package/dist/ui/settings-appearance.js +34 -18
  90. package/dist/ui/settings-catalog.js +89 -0
  91. package/dist/ui/settings-dialogs.js +40 -12
  92. package/dist/ui/settings-effects.js +4 -3
  93. package/dist/ui/settings-learn.js +25 -0
  94. package/dist/ui/settings-model.js +6 -2
  95. package/dist/ui/settings-mods.js +4 -3
  96. package/dist/ui/settings-owner-effects.js +8 -8
  97. package/dist/ui/settings-strings.js +40 -38
  98. package/dist/ui/shared/app-i18n.js +2 -2
  99. package/dist/ui/shared/app-prefs.js +1 -1
  100. package/dist/ui/shared/app-shell.js +23 -1
  101. package/dist/ui/shared/app-store.js +12 -1
  102. package/dist/ui/shared/app-stream.js +2 -1
  103. package/dist/ui/shared/app.css +186 -11
  104. package/dist/ui/shared/card-strings.js +4 -0
  105. package/dist/ui/shared/effect-app-runtime.js +1 -1
  106. package/dist/ui/shared/effect-frame.js +10 -5
  107. package/dist/ui/shared/effect-geometry.js +24 -9
  108. package/dist/ui/shared/effect-policy.js +2 -1
  109. package/dist/ui/shared/effect-registry.js +71 -17
  110. package/dist/ui/shared/effect-stock-trust.js +23 -2
  111. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  112. package/dist/ui/shared/effect-trial-stage.js +15 -9
  113. package/dist/ui/shared/effect-trial.css +40 -0
  114. package/dist/ui/shared/identity-card.js +75 -4
  115. package/dist/ui/shared/identity-color.js +3 -3
  116. package/dist/ui/shared/instance-nav.js +20 -4
  117. package/dist/ui/shared/panel-view.js +24 -4
  118. package/dist/ui/shared/view-strings.js +22 -0
  119. package/dist/view-api.d.ts +32 -0
  120. package/dist/view-api.js +55 -4
  121. package/dist/view-api.js.map +1 -1
  122. package/dist/web-api.d.ts +19 -4
  123. package/dist/web-api.js +178 -20
  124. package/dist/web-api.js.map +1 -1
  125. package/dist/web-appearance-policy.d.ts +4 -6
  126. package/dist/web-appearance-policy.js +5 -12
  127. package/dist/web-appearance-policy.js.map +1 -1
  128. package/dist/web-appearance-tools.js +5 -1
  129. package/dist/web-appearance-tools.js.map +1 -1
  130. package/dist/web-appearance-view.d.ts +14 -1
  131. package/dist/web-appearance-view.js +98 -10
  132. package/dist/web-appearance-view.js.map +1 -1
  133. package/dist/web-appearance.d.ts +2 -2
  134. package/dist/web-appearance.js +5 -6
  135. package/dist/web-appearance.js.map +1 -1
  136. package/dist/web-auth.js +1 -1
  137. package/dist/web-auth.js.map +1 -1
  138. package/dist/web-card.d.ts +14 -0
  139. package/dist/web-card.js +40 -6
  140. package/dist/web-card.js.map +1 -1
  141. package/dist/web-channel-echo.d.ts +7 -1
  142. package/dist/web-channel-echo.js +6 -2
  143. package/dist/web-channel-echo.js.map +1 -1
  144. package/dist/web-chat-history.d.ts +40 -0
  145. package/dist/web-chat-history.js +66 -3
  146. package/dist/web-chat-history.js.map +1 -1
  147. package/dist/web-commands.d.ts +1 -0
  148. package/dist/web-effect-tools.js +2 -3
  149. package/dist/web-effect-tools.js.map +1 -1
  150. package/dist/web-effect-trials.d.ts +2 -2
  151. package/dist/web-effect-trials.js +3 -3
  152. package/dist/web-effect-trials.js.map +1 -1
  153. package/dist/web-guest-access.d.ts +125 -0
  154. package/dist/web-guest-access.js +232 -0
  155. package/dist/web-guest-access.js.map +1 -0
  156. package/dist/web-guest-rooms.d.ts +82 -0
  157. package/dist/web-guest-rooms.js +135 -0
  158. package/dist/web-guest-rooms.js.map +1 -0
  159. package/dist/web-identity-palette.d.ts +1 -1
  160. package/dist/web-identity-palette.js +2 -2
  161. package/dist/web-identity-palette.js.map +1 -1
  162. package/dist/web-login.d.ts +47 -0
  163. package/dist/web-login.js +115 -0
  164. package/dist/web-login.js.map +1 -1
  165. package/dist/web-mod-agent.d.ts +1 -1
  166. package/dist/web-mod-agent.js +2 -4
  167. package/dist/web-mod-agent.js.map +1 -1
  168. package/dist/web-mod-store.d.ts +5 -0
  169. package/dist/web-mod-store.js +6 -0
  170. package/dist/web-mod-store.js.map +1 -1
  171. package/dist/web-prefs.d.ts +8 -0
  172. package/dist/web-prefs.js +9 -0
  173. package/dist/web-prefs.js.map +1 -1
  174. package/dist/web-preview.d.ts +6 -2
  175. package/dist/web-preview.js +4 -2
  176. package/dist/web-preview.js.map +1 -1
  177. package/dist/web-returning-device.d.ts +39 -0
  178. package/dist/web-returning-device.js +46 -5
  179. package/dist/web-returning-device.js.map +1 -1
  180. package/dist/web-session.d.ts +58 -10
  181. package/dist/web-session.js +104 -22
  182. package/dist/web-session.js.map +1 -1
  183. package/dist/web-settings-tools.d.ts +81 -0
  184. package/dist/web-settings-tools.js +257 -0
  185. package/dist/web-settings-tools.js.map +1 -0
  186. package/dist/web-theme.d.ts +2 -0
  187. package/dist/web-theme.js +10 -1
  188. package/dist/web-theme.js.map +1 -1
  189. package/package.json +1 -1
@@ -10,15 +10,56 @@ import { toast } from "/assets/ui-toast.js";
10
10
  import { getAppEffects } from "./app-effects.js";
11
11
 
12
12
  register("chatFx", {
13
- en: { 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.", paused: "Paused", low: "Reduced quality" },
14
- "zh-TW": { title: "效果", loading: "正在確認效果…", empty: "這個聊天沒有放置效果。", failed: "無法確認效果資源,已停止播放。重新載入頁面後再試。", reload: "重新載入頁面", pause: "暫停", resume: "恢復", remove: "從聊天移除", removeFailed: "無法移除效果。請重新載入頁面確認狀態。", css: "自訂 CSS 開啟或無法確認時,代理的前景效果會暫停。", setting: "開啟網頁外觀設定", running: "播放中", starting: "啟動中", off: "已停止", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。", queued: "等待空位", unknown: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停效果。恢復仍保留三次暫停的安全上限。", paused: "已暫停", low: "降低畫質" },
13
+ en: { 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.", paused: "Paused", low: "Reduced quality",
14
+ typing: "Waiting while you type — it plays when you leave the message box.", covered: "Waiting while a menu or panel covers the chat.",
15
+ dialogOpen: "Paused while this dialog is open.", wasRunning: "Was running; paused while this dialog is open.", modal: "Waiting while a dialog is open.",
16
+ off3: "Turned off on this device after three automatic pauses. Resume cannot restart it here.", interrupted: "Stopped: the page closed while it was playing. Resume to play it again.",
17
+ cssOff: "Off while your custom CSS is on.", reducedMotion: "Off: this device asks for reduced motion.", saveData: "Off: data saver is on.",
18
+ previewOff: "Off: previews are not allowed on this device.", hiddenTab: "Waiting until this tab is visible.", offscreen: "Waiting until the chat is on screen.",
19
+ safeMode: "Off in safe mode.", publicLink: "Off on a public link.", disabled: "Effects are switched off." },
20
+ "zh-TW": { title: "效果", loading: "正在確認效果…", empty: "這個聊天沒有放置效果。", failed: "無法確認效果資源,已停止播放。重新載入頁面後再試。", reload: "重新載入頁面", pause: "暫停", resume: "恢復", remove: "從聊天移除", removeFailed: "無法移除效果。請重新載入頁面確認狀態。", css: "自訂 CSS 開啟或無法確認時,代理的前景效果會暫停。", setting: "開啟網頁外觀設定", running: "播放中", starting: "啟動中", off: "已停止", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。", queued: "等待空位", unknown: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停效果。恢復仍保留三次暫停的安全上限。", paused: "已暫停", low: "降低畫質",
21
+ typing: "輸入時暫停,離開訊息輸入框就會播放。", covered: "選單或面板蓋住聊天時暫停。",
22
+ dialogOpen: "開著這個對話框時暫停。", wasRunning: "原本在播放;開著這個對話框時暫停。", modal: "有對話框開著時暫停。",
23
+ off3: "已自動暫停三次,這台裝置已關閉此效果,無法在這裡恢復。", interrupted: "播放時頁面被關閉而停止。按「恢復」再播放一次。",
24
+ cssOff: "自訂 CSS 開啟時關閉。", reducedMotion: "已關閉:這台裝置要求減少動態效果。", saveData: "已關閉:數據節省模式開啟中。",
25
+ previewOff: "已關閉:這台裝置不允許預覽。", hiddenTab: "等這個分頁顯示時再播放。", offscreen: "等聊天顯示在畫面上時再播放。",
26
+ safeMode: "安全模式下關閉。", publicLink: "公開連結上關閉。", disabled: "效果已關閉。" },
15
27
  });
16
28
 
17
29
  /** Local read/admin sessions keep controls; public, View, poll and header-only pages have no runtime identity. */
18
30
  export function chatEffectsMenuAvailable() { return chatEffectIdentity(window) !== null; }
19
31
 
32
+ // #2055: what a row's state means, in words — never only "Stopped" when the runtime knows why.
33
+ const RECENT_MS = 5000;
34
+ const EDITABLE = "input, textarea, select, [contenteditable]:not([contenteditable=false])";
35
+ const REASON_KEY = { owner_css: "chatFx.cssOff", reduced_motion: "chatFx.reducedMotion", save_data: "chatFx.saveData", preview_unavailable: "chatFx.previewOff",
36
+ hidden: "chatFx.hiddenTab", offscreen: "chatFx.offscreen", safe_mode: "chatFx.safeMode", public_link: "chatFx.publicLink", disabled: "chatFx.disabled",
37
+ safety_off: "chatFx.off3", interrupted: "chatFx.interrupted" };
38
+ /** `ownOpen`: this Effects dialog is open; `wasRunning`: the effect was running when it opened (shown as past, never as current). */
39
+ export function effectRowText(row, { ownOpen = false, wasRunning = false, doc = typeof document === "undefined" ? null : document } = {}) {
40
+ if (!row) return t("chatFx.off");
41
+ // The sticky history first (it is what Resume answers), then a pause, then what is playing or queued.
42
+ if (row.reason === "safety_off" || row.reason === "interrupted") return t(REASON_KEY[row.reason]);
43
+ if (row.reason === "paused" || row.reason === "host_budget") return t("chatFx.paused");
44
+ if (row.phase === "running") return t("chatFx.running");
45
+ if (row.phase) return t("chatFx.starting");
46
+ if (row.verdict === "queued") return t("chatFx.queued");
47
+ if (row.reason === "stock_overlay" || row.reason === "modal") {
48
+ if (ownOpen) return t(wasRunning ? "chatFx.wasRunning" : "chatFx.dialogOpen");
49
+ if (row.reason === "modal") return t("chatFx.modal");
50
+ // Only a focused editable proves "typing"; a menu or another overlay is the same barrier, said generically.
51
+ const a = doc?.activeElement;
52
+ return t(a && a !== doc.body && typeof a.matches === "function" && (a.matches(EDITABLE) || a.isContentEditable === true) ? "chatFx.typing" : "chatFx.covered");
53
+ }
54
+ return REASON_KEY[row.reason] ? t(REASON_KEY[row.reason]) : t("chatFx.off");
55
+ }
56
+
20
57
  export function ChatEffects({ name, lease, element, store, open, onClose }) {
21
58
  const cssLife = useStore(ownerCssLifecycle);
59
+ // Which effects were running just before this dialog opened ("was running" is said in the past, never shown as
60
+ // current). The ⋯ menu that opens it is a barrier too, so "just before" is within RECENT_MS of the observed stop.
61
+ // Tracked where updates arrive (an unchanged snapshot is not republished while an effect keeps running).
62
+ const before = useRef(null), wasOpen = useRef(false), playing = useRef(new Map());
22
63
  const runtime = useRef(null), [state, setState] = useState(null), [canWrite, setCanWrite] = useState(false), [busy, setBusy] = useState(false);
23
64
  useLayoutEffect(() => {
24
65
  let live = true, api = null;
@@ -38,8 +79,19 @@ export function ChatEffects({ name, lease, element, store, open, onClose }) {
38
79
  const current = () => live && lease.current();
39
80
  if (!owner) { setState({ error: null, rows: [], effects: [], unavailable: true }); return cleanup; }
40
81
  releases.push(owner.onEnd(() => { if (current()) setState({ error: "resource_unavailable", rows: [], effects: [], unavailable: true }); }));
82
+ playing.current = new Map(); // evidence belongs to this name and lease only
83
+ const track = rows => {
84
+ const now = performance.now(), seen = new Set();
85
+ for (const r of Array.isArray(rows) ? rows : []) {
86
+ seen.add(r.id);
87
+ if (r.phase === "running") playing.current.set(r.id, null);
88
+ else if (playing.current.get(r.id) === null) playing.current.set(r.id, now);
89
+ }
90
+ for (const [id, at] of playing.current) if (!seen.has(id) && at === null) playing.current.set(id, now);
91
+ };
41
92
  const changed = value => {
42
93
  if (!current()) return;
94
+ track(value?.rows);
43
95
  const g = owner.gates();
44
96
  setState({ ...value, unavailable: !g.enabled || !g.show || !g.previewReady || !g.optIn || g.safe || g.publicLink || g.reducedMotion || g.saveData });
45
97
  };
@@ -57,16 +109,22 @@ export function ChatEffects({ name, lease, element, store, open, onClose }) {
57
109
  }
58
110
  }, [name, lease]);
59
111
  const rows = state?.rows ?? [], effects = state?.effects ?? [];
112
+ if (open && !wasOpen.current) {
113
+ const now = performance.now();
114
+ before.current = new Map([...playing.current].map(([id, stoppedAt]) => [id, stoppedAt === null || now - stoppedAt < RECENT_MS]));
115
+ }
116
+ if (!open) before.current = null;
117
+ wasOpen.current = open;
60
118
  const css = cssLife.state !== "off" && effects.some(p => p.by !== "human" && p.manifest.layer === "front");
61
119
  const remove = async id => { if (busy) return; setBusy(true); const api = runtime.current; const ok = await api?.remove?.(id); if (!lease.current() || runtime.current !== api) return; setBusy(false); if (!ok) toast(t("chatFx.removeFailed"), false); };
62
120
  return html`${open ? html`<${Dialog} title=${t("chatFx.title")} onClose=${onClose} busy=${busy}>
63
121
  ${state?.error ? html`<p>${t("chatFx.failed")}</p><button type="button" class="btn" onClick=${() => location.reload()}>${t("chatFx.reload")}</button>` : null}
64
122
  ${css ? html`<p>${t("chatFx.css")}</p><button type="button" class="btn" onClick=${() => { onClose(); navigate("/settings/appearance"); }}>${t("chatFx.setting")}</button>` : null}
65
123
  ${!effects.length && !state?.error ? html`<p>${t(state?.unavailable ? "chatFx.unknown" : state?.loading || !state ? "chatFx.loading" : "chatFx.empty")}</p>` : null}
66
- ${effects.map(p => { const row = rows.find(r => r.id === p.id), phase = row?.phase; return html`<section key=${p.id}><h3>${p.manifest.title}</h3><p>${t(row?.reason === "paused" || row?.reason === "host_budget" ? "chatFx.paused" : phase === "running" ? "chatFx.running" : phase ? "chatFx.starting" : row?.verdict === "queued" ? "chatFx.queued" : "chatFx.off")}${row?.quality === "low" ? ` · ${t("chatFx.low")}` : ""}</p>
124
+ ${effects.map(p => { const row = rows.find(r => r.id === p.id); return html`<section key=${p.id}><h3>${p.manifest.title}</h3><p>${effectRowText(row, { ownOpen: open, wasRunning: before.current?.get(p.id) === true })}${row?.quality === "low" ? ` · ${t("chatFx.low")}` : ""}</p>
67
125
  ${row?.reason === "host_budget" ? html`<p>${t("chatFx.budget")}</p>` : null}
68
126
  <button type="button" class="btn" disabled=${busy} onClick=${() => runtime.current?.pause?.(p.id)}>${t("chatFx.pause")}</button>
69
- <button type="button" class="btn" disabled=${busy} onClick=${() => { if (!runtime.current?.resume?.(p.id)) toast(t("chatFx.resumeFailed"), false); }}>${t("chatFx.resume")}</button>
127
+ <button type="button" class="btn" disabled=${busy} onClick=${() => { if (!runtime.current?.resume?.(p.id)) toast(t(rows.find(r => r.id === p.id)?.reason === "safety_off" ? "chatFx.off3" : "chatFx.resumeFailed"), false); }}>${t("chatFx.resume")}</button>
70
128
  ${canWrite ? html`<button type="button" class="btn" disabled=${busy} onClick=${() => remove(p.id)}>${t("chatFx.remove")}</button>` : null}</section>`; })}
71
129
  </${Dialog}>` : null}`;
72
130
  }
@@ -3,8 +3,24 @@ import { register } from "/assets/app-i18n.js";
3
3
 
4
4
  register("chat", {
5
5
  en: {
6
- effectTrial: "Effect trial", effectTrialLocal: "Try requires a local admin, enabled previews and custom CSS off.",
7
- effectTrialStart: "Try", effectTrialExpired: "This trial offer has expired.", effectTrialUnavailable: "Trial unavailable: access or playback settings may have changed.",
6
+ effectTrialReason_checking: "Checking this session…",
7
+ effectTrialReason_localAdmin: "Sign in as an admin on this computer to Try.",
8
+ effectTrialReason_chatChanged: "Open the chat that offered this trial.",
9
+ effectTrialReason_busy: "Stop the current trial before starting another.",
10
+ effectTrialReason_safe: "Turn off safe mode to Try.",
11
+ effectTrialReason_effectsOff: "Turn on effects to Try.",
12
+ effectTrialReason_reducedMotion: "Your device requests reduced motion.",
13
+ effectTrialReason_saveData: "Your device requests reduced data use.",
14
+ effectTrialReason_transport: "The local effect service is unavailable.",
15
+ effectTrialReason_unsupported: "Canvas Worker playback is unavailable in this browser.",
16
+ effectTrialReason_inactive: "Return to this page to Try.",
17
+ effectTrialReason_dialog: "Close the other menu or dialog to Try.",
18
+ effectTrialReason_expired: "This trial offer has expired.",
19
+ effectTrialSignIn: "Sign in here",
20
+ effectTrialStopOther: "Stop current trial",
21
+ effectTrialSettings: "Open playback settings",
22
+ 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.",
8
24
  cmdConfirm: "Clear", cmdConfirmBackend: "Switch and restart", cmdRunning: "Running /{0}…", cmdNotCommand: "Not a command here — Enter sends it as a message.", cmdDismiss: "Dismiss", cmdCommands: "Commands",
9
25
  compactRunning: "Compacting…", compactDone: "✅ Conversation compacted", compactUnknown: "This backend does not report when compaction ends.",
10
26
  compactTimeout: "No sign that compaction finished within {0} minutes — check the agent's own chat.",
@@ -71,6 +87,8 @@ register("chat", {
71
87
  "pvFail_bad-address": "Cloudflare gave an address that could not be used",
72
88
  pvFail_unknown: "an unexpected error",
73
89
  pvServer_notOffered: "Previews are not available on this page.",
90
+ pvServer_listenFailed: "Previews are unavailable: the fleet could not open its preview port ({0}). Another program may be using it — set web.preview_port, then restart AgEnD.",
91
+ pvServer_notListening: "Previews are not available on this fleet right now.",
74
92
  previews: "Allow HTML previews",
75
93
  clicked: "Clicked:", clickShow: "Show the message these buttons were on",
76
94
  copy: "Copy", copyMessage: "Copy this reply", copied: "Copied", copyFailed: "Copy failed", wrap: "Wrap", showAll: "Show all {0} lines", showMore: "Show more", showLess: "Show less", jumpLatest: "Latest", newBelow: "{0} new",
@@ -101,8 +119,24 @@ register("chat", {
101
119
  configured: "(configured)", fleetDefault: "(fleet default)",
102
120
  },
103
121
  "zh-TW": {
104
- effectTrial: "效果試播", effectTrialLocal: "需由本機管理員啟用預覽並關閉自訂 CSS,才能試播。",
105
- effectTrialStart: "試播", effectTrialExpired: "試播邀請已過期。", effectTrialUnavailable: "無法試播:存取權或播放設定可能已變更。",
122
+ effectTrialReason_checking: "正在確認工作階段…",
123
+ effectTrialReason_localAdmin: "請在這台電腦以管理員登入後試播。",
124
+ effectTrialReason_chatChanged: "請開啟發出試播邀請的聊天。",
125
+ effectTrialReason_busy: "請先停止目前的試播。",
126
+ effectTrialReason_safe: "請先關閉安全模式。",
127
+ effectTrialReason_effectsOff: "請先開啟效果。",
128
+ effectTrialReason_reducedMotion: "裝置目前要求減少動態效果。",
129
+ effectTrialReason_saveData: "裝置目前要求節省數據。",
130
+ effectTrialReason_transport: "本機效果服務目前無法使用。",
131
+ effectTrialReason_unsupported: "此瀏覽器無法播放 Canvas Worker 效果。",
132
+ effectTrialReason_inactive: "請回到本頁後試播。",
133
+ effectTrialReason_dialog: "請先關閉其他選單或對話框。",
134
+ effectTrialReason_expired: "試播邀請已過期。",
135
+ effectTrialSignIn: "在此登入",
136
+ effectTrialStopOther: "停止目前試播",
137
+ effectTrialSettings: "開啟播放設定",
138
+ effectTrial: "效果試播", effectTrialLocal: "僅在本頁試播,不會套用或儲存。",
139
+ effectTrialStart: "試播", effectTrialExpired: "試播邀請已過期。", effectTrialUnavailable: "此試播已無法使用。",
106
140
  cmdConfirm: "清空", cmdConfirmBackend: "切換並重新啟動", cmdRunning: "執行 /{0} 中…", cmdNotCommand: "這裡沒有這個指令——按 Enter 會當成一般訊息送出。", cmdDismiss: "關閉", cmdCommands: "指令",
107
141
  compactRunning: "壓縮中…", compactDone: "✅ 對話已壓縮", compactUnknown: "這個後端不會回報壓縮何時完成。",
108
142
  compactTimeout: "{0} 分鐘內沒有看到壓縮完成的紀錄,請到代理自己的對話確認。",
@@ -168,6 +202,8 @@ register("chat", {
168
202
  "pvFail_bad-address": "Cloudflare 給出的網址無法使用",
169
203
  pvFail_unknown: "發生了未預期的錯誤",
170
204
  pvServer_notOffered: "這個頁面不提供預覽。",
205
+ pvServer_listenFailed: "無法預覽:fleet 無法開啟預覽用的連接埠({0})。可能有其他程式正在使用它——請設定 web.preview_port,然後重新啟動 AgEnD。",
206
+ pvServer_notListening: "這個 fleet 目前無法預覽。",
171
207
  previews: "允許預覽 HTML",
172
208
  clicked: "點選了:", clickShow: "顯示這些按鈕所在的訊息",
173
209
  copy: "複製", copyMessage: "複製這則回覆", copied: "已複製", copyFailed: "複製失敗", wrap: "換行", showAll: "展開全部 {0} 行", showMore: "顯示更多", showLess: "收合", jumpLatest: "最新", newBelow: "{0} 則新訊息",
@@ -106,10 +106,17 @@ export function createThread(list, scroller, opts) {
106
106
  : `<span class="click-text">${inner}</span>`}</div>`;
107
107
  }
108
108
  function trialNow() { try { const t = opts.serverNow ? opts.serverNow() : Date.now(); return Number.isFinite(t) ? t : Infinity; } catch { return Infinity; } }
109
+ const trialReasons = new Set(["checking", "localAdmin", "chatChanged", "busy", "safe", "effectsOff", "reducedMotion", "saveData", "unsupported", "transport", "inactive", "dialog", "expired"]);
110
+ function trialStatus(message) {
111
+ const reason = opts.effectTrialStatus?.(message);
112
+ return reason === "ready" || trialReasons.has(reason) ? reason : opts.canTryEffect?.(message) === true ? "ready" : "localAdmin";
113
+ }
109
114
  function trialHtml(offer, time, message) {
110
- const expired = trialNow() >= offer.expiresAt, available = !PUBLIC_LINK() && !expired && opts.canTryEffect?.(message) === true;
111
- const button = available ? `<button type="button" class="btn btn-sm effect-trial-start" data-act="effectTrial" data-arg="${escAttr(msgKey(message))}">${escAttr(tr("chat.effectTrialStart"))}</button>` : "";
112
- 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(expired ? "chat.effectTrialExpired" : "chat.effectTrialLocal"))}</div>${button}</div></div></div>`;
115
+ const reason = trialNow() >= offer.expiresAt ? "expired" : PUBLIC_LINK() ? "localAdmin" : trialStatus(message);
116
+ 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";
118
+ 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>`;
113
120
  }
114
121
  function armTrials() {
115
122
  if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
@@ -564,7 +571,10 @@ export function createThread(list, scroller, opts) {
564
571
 
565
572
  function onScroll() {
566
573
  const bottom = nearBottom(), top = scroller.scrollTop;
567
- if (bottom) pinned = true;
574
+ // #2087: pinned only at the exact bottom — a reader still wheeling through the last 48 px was pinned there, so
575
+ // the next resize (a picture, a card, the dock) pulled the view the rest of the way down under them. Near the bottom
576
+ // still counts for a new message (render follows it) and for the pill; nothing else moves a view the reader moves.
577
+ if (atBottom()) pinned = true;
568
578
  else if (top < lastTop) pinned = false;
569
579
  lastTop = top;
570
580
  if (bottom) unseen = 0;
@@ -628,8 +638,9 @@ export function createThread(list, scroller, opts) {
628
638
  // Only the current host-produced keyed card and a real local click can request admission. No token is read from DOM.
629
639
  const key = el.dataset.arg, row = msgs.find(x => msgKey(x) === key), owner = nodes.get(key), node = owner?.node, offer = effectTrialMessage(row);
630
640
  if (PUBLIC_LINK() || e.defaultPrevented || e.isTrusted !== true || owner?.trialButton !== el || el.className !== "btn btn-sm effect-trial-start" || !node?.contains(el) || el.closest(".effect-trial-offer")?.getAttribute("data-trust") !== "thread-control"
631
- || !offer || trialNow() >= offer.expiresAt || opts.canTryEffect?.(row) !== true) return;
632
- opts.startEffectTrial?.(row);
641
+ || !offer || trialNow() >= offer.expiresAt) return;
642
+ if (trialStatus(row) === "ready" && opts.canTryEffect?.(row) === true) opts.startEffectTrial?.(row);
643
+ else opts.fixEffectTrial?.(row, trialStatus(row));
633
644
  } else if (act === "showButtons") { const x = msgs.find(y => y.buttons && y.buttons.id === el.dataset.arg); if (x) reveal(msgKey(x)); }
634
645
  }
635
646
 
@@ -1,15 +1,16 @@
1
1
  /** #1629: fixed host producer. Creates no frame or request; the admitted app controller owns its lease. */
2
2
  import { html, useLayoutEffect, useRef } from "/assets/app-html.js";
3
3
  import { register, t } from "/assets/app-i18n.js";
4
+ import { createEffectTrialShadow } from "/assets/effect-trial-shadow.js";
4
5
  import { createEffectTrialStage } from "/assets/effect-trial-stage.js";
5
6
  register("effectTrial", {
6
7
  en: { label: "Trial", stop: "Stop trial", close: "Close", note: "A bounded sample. Nothing is applied or saved." },
7
8
  "zh-TW": { label: "試播", stop: "停止試播", close: "關閉", note: "僅在此區塊試播,不會套用或儲存。" },
8
9
  });
9
10
  /** The caller owns authenticated admission, lease/deadline and shared pool. No script/HTML is rendered here. */
10
- export function EffectTrialPanel({ window: host = globalThis.window, binding, title, current, ownerCss, onStage, onStop, onClose }) {
11
- const panel = useRef(null), stage = useRef(null), life = useRef(null), latest = useRef(null);
12
- latest.current = { current, ownerCss, onStage, onStop, onClose };
11
+ export function EffectTrialPanel({ window: host = globalThis.window, binding, title, current, onStage, onStop, onClose }) {
12
+ const panel = useRef(null), life = useRef(null), latest = useRef(null);
13
+ latest.current = { current, onStage, onStop, onClose };
13
14
  const stop = close => {
14
15
  const entry = life.current; if (!entry || (close ? entry.closed : entry.dead)) return;
15
16
  if (close) entry.closed = true;
@@ -17,21 +18,23 @@ export function EffectTrialPanel({ window: host = globalThis.window, binding, ti
17
18
  try { (close ? entry.onClose : entry.onStop)?.("stopped"); } catch { /* lease already ended */ }
18
19
  };
19
20
  useLayoutEffect(() => {
20
- const entry = { dead: false, closed: false, api: null, onStop, onClose }; life.current = entry;
21
+ const entry = { dead: false, closed: false, api: null, shadow: null, onStop, onClose }; life.current = entry;
21
22
  const ended = reason => { if (entry.dead) return; entry.dead = true; try { entry.onStop?.(reason); } catch { /* never revive */ } };
22
23
  try {
23
- entry.api = createEffectTrialStage({ window: host, panel: panel.current, stage: stage.current, binding,
24
- current: own => !entry.dead && life.current === entry && latest.current.current?.(own) === true,
25
- ownerCss: () => latest.current.ownerCss?.(), onStop: ended });
26
- if (!entry.api) ended("stage_unavailable");
27
- else { latest.current.onStage?.(entry.api); }
24
+ entry.shadow = createEffectTrialShadow({ window: host, panel: panel.current, title,
25
+ labels: { label: t("effectTrial.label"), close: t("effectTrial.close"), stop: t("effectTrial.stop"), note: t("effectTrial.note") },
26
+ onStop: () => stop(false), onClose: () => stop(true), onError: ended,
27
+ onReady(shell) {
28
+ if (entry.dead || life.current !== entry) return;
29
+ entry.api = createEffectTrialStage({ window: host, panel: panel.current, stage: shell.stage, binding,
30
+ current: own => !entry.dead && life.current === entry && latest.current.current?.(own) === true, onStop: ended });
31
+ if (!entry.api) ended("stage_unavailable");
32
+ else { try { latest.current.onStage?.(entry.api); } catch { entry.api.dispose(); ended("stage_unavailable"); } }
33
+ } });
34
+ if (!entry.shadow) ended("stage_unavailable");
28
35
  } catch { entry.api?.dispose(); ended("stage_unavailable"); }
29
- return () => { entry.api?.dispose(); entry.dead = true; if (life.current === entry) life.current = null; };
36
+ return () => { entry.api?.dispose(); ended("stopped"); entry.shadow?.dispose(); entry.dead = true; if (life.current === entry) life.current = null; };
30
37
  }, [host, binding]);
31
- return html`<aside ref=${panel} data-trust="preview" class="pv-panel effect-trial-panel" aria-label=${t("effectTrial.label")}>
32
- <header class="effect-trial-head"><strong>${t("effectTrial.label")}</strong><span>${typeof title === "string" ? title : ""}</span><button type="button" class="btn" onClick=${() => stop(true)}>${t("effectTrial.close")}</button></header>
33
- <div class="effect-trial-controls"><button type="button" class="btn" onClick=${() => stop(false)}>${t("effectTrial.stop")}</button></div>
34
- <div class="effect-trial-body"><div ref=${stage} class="effect-trial-stage" aria-hidden="true"></div></div>
35
- <p class="effect-trial-note">${t("effectTrial.note")}</p>
36
- </aside>`;
38
+ useLayoutEffect(() => { life.current?.shadow?.text(title, { label: t("effectTrial.label"), close: t("effectTrial.close"), stop: t("effectTrial.stop"), note: t("effectTrial.note") }); });
39
+ return html`<aside ref=${panel} data-trust="preview" class="pv-panel effect-trial-panel" aria-label=${t("effectTrial.label")}></aside>`;
37
40
  }
@@ -0,0 +1,111 @@
1
+ // #1918: the org canvas's layout (fleet-canvas.js draws it; fleet-traffic.js routes lines through its empty lanes).
2
+ // Fixed slots, no layout engine: the same structure always gives the same picture.
3
+
4
+ /** Sizes in canvas units. NW×NH a member card, LW×LH a General card; FOLDED a folded (or empty) team's height. */
5
+ export const OC = { NW: 184, NH: 56, GAP: 18, PAD: 24, HEAD: 34, TEAM_GAP: 56, ROW_GAP: 96, ROW_MAX: 1400, ROW_MAX_NARROW: 460, LW: 232, LH: 68, TOP_Y: 20, BUS: 44, FOLDED: 64, TRUNK: -30 };
6
+ const colsFor = (n, narrow) => (n <= 1 ? 1 : n <= 4 || narrow ? 2 : 3);
7
+ /** The key a team folds by (per device): its name; the instances in no team have one of their own no team name can be. */
8
+ export const REST_KEY = "\u0000rest";
9
+
10
+ /**
11
+ * Where everything goes, from buildOrgView's answer and the folded teams' keys: `tops` (General's cards), `boxes`
12
+ * (each team: its slot, its header, its member cells unless folded), the tree lines (`tree`: SVG path data) and the
13
+ * size of the whole. `narrow`: a phone's packing.
14
+ */
15
+ export function canvasLayout(view, folded = new Set(), { narrow = false, label = (s, n) => `${n} ${s}` } = {}) {
16
+ const { NW, NH, GAP, PAD, HEAD } = OC;
17
+ const groups = [...view.teams.map((tm) => ({ key: tm.name, name: tm.name, loose: false, nodes: tm.nodes, counts: tm.counts })),
18
+ ...(view.rest.nodes.length ? [{ key: REST_KEY, name: null, loose: true, nodes: view.rest.nodes, counts: view.rest.counts }] : [])];
19
+ const rows = [];
20
+ let row = [], x = 0;
21
+ for (const g of groups) {
22
+ const n = g.nodes.length, cols = colsFor(n, narrow), lines = Math.ceil(n / cols);
23
+ const w = PAD * 2 + cols * NW + (cols - 1) * GAP;
24
+ const fullH = n ? HEAD + PAD + lines * NH + (lines - 1) * GAP + PAD : OC.FOLDED;
25
+ if (row.length && x + w > (narrow ? OC.ROW_MAX_NARROW : OC.ROW_MAX)) { rows.push(row); row = []; x = 0; }
26
+ row.push({ ...g, cols, w, fullH, x });
27
+ x += w + OC.TEAM_GAP;
28
+ }
29
+ if (row.length) rows.push(row);
30
+ const boxes = [];
31
+ let y = view.top.length ? OC.TOP_Y + OC.LH + 100 : OC.BUS + 16;
32
+ for (const r of rows) {
33
+ const slotH = Math.max(...r.map((b) => b.fullH));
34
+ for (const b of r) {
35
+ const isFolded = b.nodes.length > 0 && folded.has(b.key);
36
+ // Folded: its members' states in rows that fit the box (never wider; never taller than its open slot).
37
+ const summary = isFolded ? summaryRows(b.counts, b.w, label) : [];
38
+ const box = { key: b.key, name: b.name, loose: b.loose, nodes: b.nodes, counts: b.counts, cols: b.cols, x: b.x, y, w: b.w,
39
+ h: isFolded ? Math.min(b.fullH, Math.max(OC.FOLDED, HEAD + 10 + summary.length * SUM_LINE)) : b.fullH, slotH, folded: isFolded, summary,
40
+ busY: y - OC.BUS, cells: [] };
41
+ if (!isFolded) b.nodes.forEach((node, i) => box.cells.push({ node, key: `${b.key}/${node.name}`,
42
+ x: b.x + PAD + (i % b.cols) * (NW + GAP), y: y + HEAD + PAD + Math.floor(i / b.cols) * (NH + GAP), w: NW, h: NH }));
43
+ boxes.push(box);
44
+ }
45
+ y += slotH + OC.ROW_GAP;
46
+ }
47
+ const rowsW = boxes.length ? Math.max(...boxes.map((b) => b.x + b.w)) : 0;
48
+ const topW = view.top.length * OC.LW + Math.max(0, view.top.length - 1) * GAP;
49
+ const width = Math.max(rowsW, topW);
50
+ const tops = view.top.map((node, i) => ({ node, key: `\u0000top/${node.name}`, x: width / 2 - topW / 2 + i * (OC.LW + GAP), y: OC.TOP_Y, w: OC.LW, h: OC.LH }));
51
+ const height = boxes.length ? y - OC.ROW_GAP : tops.length ? OC.TOP_Y + OC.LH : 0;
52
+ return { tops, boxes, width, height, tree: treeLines(tops, boxes) };
53
+ }
54
+
55
+ /** A folded team's summary line height, and a label's width estimate (canvas units: wide for CJK, narrow otherwise). */
56
+ export const SUM_LINE = 20;
57
+ const textWidth = (s) => [...s].reduce((w, ch) => w + (ch.codePointAt(0) >= 0x2e80 ? 12 : 6.6), 0);
58
+ /**
59
+ * A folded team's states as rows of { s, n, label, dx } that fit a box `w` wide (dx from the box's left), most pressing
60
+ * first; an item never splits, and one wider than a row has the row to itself.
61
+ */
62
+ export function summaryRows(counts, w, label) {
63
+ const rows = [];
64
+ let row = [], x = 14;
65
+ for (const [s, n] of counts) {
66
+ const text = label(s, n), iw = 24 + textWidth(text);
67
+ if (row.length && x + iw > w - 14) { rows.push(row); row = []; x = 14; }
68
+ row.push({ s, n, label: text, dx: x });
69
+ x += iw;
70
+ }
71
+ if (row.length) rows.push(row);
72
+ return rows;
73
+ }
74
+
75
+ /** General's stem, a bus over each row of teams, the trunk joining the rows at the left, one drop per team. */
76
+ function treeLines(tops, boxes) {
77
+ const d = [];
78
+ const teamed = boxes.filter((b) => !b.loose);
79
+ if (!teamed.length) return d;
80
+ const buses = [...new Set(teamed.map((b) => b.busY))].sort((a, b) => a - b);
81
+ const first = buses[0], last = buses[buses.length - 1];
82
+ let stemX = null;
83
+ if (tops.length) {
84
+ const cx = tops.map((tp) => tp.x + tp.w / 2), bottom = tops[0].y + tops[0].h;
85
+ stemX = (Math.min(...cx) + Math.max(...cx)) / 2;
86
+ if (tops.length > 1) {
87
+ const joinY = bottom + 16;
88
+ for (const x of cx) d.push(`M${x} ${bottom} V${joinY}`);
89
+ d.push(`M${Math.min(...cx)} ${joinY} H${Math.max(...cx)}`, `M${stemX} ${joinY} V${first}`);
90
+ } else d.push(`M${stemX} ${bottom} V${first}`);
91
+ }
92
+ if (last > first) d.push(`M${OC.TRUNK} ${first} V${last}`);
93
+ for (const busY of buses) {
94
+ const xs = teamed.filter((b) => b.busY === busY).map((b) => b.x + b.w / 2);
95
+ const ends = busY === first ? [...xs, ...(stemX === null ? [] : [stemX]), ...(last > first ? [OC.TRUNK] : [])] : [...xs, OC.TRUNK];
96
+ d.push(`M${Math.min(...ends)} ${busY} H${Math.max(...ends)}`);
97
+ }
98
+ for (const b of teamed) d.push(`M${b.x + b.w / 2} ${b.busY} V${b.y}`);
99
+ return d;
100
+ }
101
+
102
+ /** The region the canvas opens on: General and the first row of teams (the whole canvas when there is neither). */
103
+ export function openingRegion(L) {
104
+ const firstBus = L.boxes.length ? Math.min(...L.boxes.map((b) => b.busY)) : null;
105
+ // The trunk at the left joins the rows: in view too, not cut by the edge.
106
+ const trunk = L.boxes.some((b) => b.busY !== firstBus) ? [{ x: OC.TRUNK - 8, y: firstBus, w: 1, h: 1 }] : [];
107
+ const parts = [...L.tops, ...L.boxes.filter((b) => b.busY === firstBus).map((b) => ({ x: b.x, y: b.y, w: b.w, h: b.slotH })), ...trunk];
108
+ if (!parts.length) return { x: 0, y: 0, w: Math.max(1, L.width), h: Math.max(1, L.height) };
109
+ const x0 = Math.min(...parts.map((p) => p.x)), y0 = Math.min(...parts.map((p) => p.y));
110
+ return { x: x0, y: y0, w: Math.max(...parts.map((p) => p.x + p.w)) - x0, h: Math.max(...parts.map((p) => p.y + p.h)) - y0 };
111
+ }