@songsid/agend 2.3.2-beta.1 → 2.3.2-beta.10

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 (266) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/dist/agent-cli.js +21 -2
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +5 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/antigravity-usage-scan.d.ts +58 -0
  7. package/dist/antigravity-usage-scan.js +257 -0
  8. package/dist/antigravity-usage-scan.js.map +1 -0
  9. package/dist/antigravity-usage-worker.d.ts +1 -0
  10. package/dist/antigravity-usage-worker.js +10 -0
  11. package/dist/antigravity-usage-worker.js.map +1 -0
  12. package/dist/auth-api.js +4 -0
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/backend/kiro-identity.d.ts +64 -0
  15. package/dist/backend/kiro-identity.js +101 -0
  16. package/dist/backend/kiro-identity.js.map +1 -1
  17. package/dist/backend/kiro-retired-model.d.ts +62 -0
  18. package/dist/backend/kiro-retired-model.js +160 -0
  19. package/dist/backend/kiro-retired-model.js.map +1 -0
  20. package/dist/backend/kiro.d.ts +71 -2
  21. package/dist/backend/kiro.js +352 -11
  22. package/dist/backend/kiro.js.map +1 -1
  23. package/dist/backend/types.d.ts +59 -0
  24. package/dist/backend/types.js.map +1 -1
  25. package/dist/backend-signin-guide.d.ts +1 -0
  26. package/dist/backend-signin-guide.js +9 -0
  27. package/dist/backend-signin-guide.js.map +1 -0
  28. package/dist/backend-signin-pending.d.ts +10 -0
  29. package/dist/backend-signin-pending.js +44 -0
  30. package/dist/backend-signin-pending.js.map +1 -0
  31. package/dist/browser-backend-signins.d.ts +56 -0
  32. package/dist/browser-backend-signins.js +189 -0
  33. package/dist/browser-backend-signins.js.map +1 -0
  34. package/dist/cache-ledger.d.ts +38 -2
  35. package/dist/cache-ledger.js +133 -8
  36. package/dist/cache-ledger.js.map +1 -1
  37. package/dist/cache-prices.d.ts +2 -1
  38. package/dist/cache-prices.js +5 -2
  39. package/dist/cache-prices.js.map +1 -1
  40. package/dist/cache-service.d.ts +68 -0
  41. package/dist/cache-service.js +355 -13
  42. package/dist/cache-service.js.map +1 -1
  43. package/dist/channel/adapters/discord.d.ts +11 -0
  44. package/dist/channel/adapters/discord.js +71 -14
  45. package/dist/channel/adapters/discord.js.map +1 -1
  46. package/dist/channel/mcp-tools.js +29 -2
  47. package/dist/channel/mcp-tools.js.map +1 -1
  48. package/dist/channel/tool-router.js +2 -1
  49. package/dist/channel/tool-router.js.map +1 -1
  50. package/dist/classic-channel-manager.d.ts +11 -0
  51. package/dist/classic-channel-manager.js +39 -1
  52. package/dist/classic-channel-manager.js.map +1 -1
  53. package/dist/cli.js +21 -2
  54. package/dist/cli.js.map +1 -1
  55. package/dist/config-validator.d.ts +7 -1
  56. package/dist/config-validator.js +53 -10
  57. package/dist/config-validator.js.map +1 -1
  58. package/dist/config.d.ts +19 -0
  59. package/dist/config.js +31 -1
  60. package/dist/config.js.map +1 -1
  61. package/dist/cpu-quota.d.ts +23 -0
  62. package/dist/cpu-quota.js +86 -0
  63. package/dist/cpu-quota.js.map +1 -0
  64. package/dist/daemon.d.ts +23 -0
  65. package/dist/daemon.js +106 -7
  66. package/dist/daemon.js.map +1 -1
  67. package/dist/dashboard-permissions.d.ts +20 -6
  68. package/dist/dashboard-permissions.js +58 -9
  69. package/dist/dashboard-permissions.js.map +1 -1
  70. package/dist/doctor.js +2 -1
  71. package/dist/doctor.js.map +1 -1
  72. package/dist/fleet-manager.d.ts +72 -0
  73. package/dist/fleet-manager.js +507 -89
  74. package/dist/fleet-manager.js.map +1 -1
  75. package/dist/forged-envelope-scan.d.ts +9 -0
  76. package/dist/forged-envelope-scan.js +30 -5
  77. package/dist/forged-envelope-scan.js.map +1 -1
  78. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +5 -2
  79. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +14 -1
  80. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +24 -1
  81. package/dist/general-knowledge/skills/web-appearance/SKILL.md +24 -1
  82. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +37 -9
  83. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +44 -11
  84. package/dist/instance-config-impact.js +10 -5
  85. package/dist/instance-config-impact.js.map +1 -1
  86. package/dist/instance-lifecycle.d.ts +19 -0
  87. package/dist/instance-lifecycle.js +55 -12
  88. package/dist/instance-lifecycle.js.map +1 -1
  89. package/dist/kiro-db-reader.d.ts +5 -0
  90. package/dist/kiro-db-reader.js +18 -0
  91. package/dist/kiro-db-reader.js.map +1 -1
  92. package/dist/kiro-engine-status.d.ts +2 -0
  93. package/dist/kiro-engine-status.js +9 -0
  94. package/dist/kiro-engine-status.js.map +1 -1
  95. package/dist/kiro-migrate.d.ts +49 -0
  96. package/dist/kiro-migrate.js +105 -0
  97. package/dist/kiro-migrate.js.map +1 -0
  98. package/dist/kiro-transcript-lane.d.ts +2 -0
  99. package/dist/kiro-transcript-lane.js.map +1 -1
  100. package/dist/kiro-transcript-worker.js +1 -0
  101. package/dist/kiro-transcript-worker.js.map +1 -1
  102. package/dist/kiro-tui-forensic.d.ts +7 -0
  103. package/dist/kiro-tui-forensic.js +110 -0
  104. package/dist/kiro-tui-forensic.js.map +1 -0
  105. package/dist/kiro-usage-run.d.ts +7 -0
  106. package/dist/kiro-usage-run.js +39 -0
  107. package/dist/kiro-usage-run.js.map +1 -0
  108. package/dist/kiro-usage-scan.d.ts +49 -0
  109. package/dist/kiro-usage-scan.js +223 -0
  110. package/dist/kiro-usage-scan.js.map +1 -0
  111. package/dist/kiro-usage-worker.d.ts +1 -0
  112. package/dist/kiro-usage-worker.js +10 -0
  113. package/dist/kiro-usage-worker.js.map +1 -0
  114. package/dist/kiro-usage.d.ts +58 -0
  115. package/dist/kiro-usage.js +310 -0
  116. package/dist/kiro-usage.js.map +1 -0
  117. package/dist/locale.js +88 -8
  118. package/dist/locale.js.map +1 -1
  119. package/dist/log-service.js +1 -1
  120. package/dist/log-service.js.map +1 -1
  121. package/dist/login-url.d.ts +4 -0
  122. package/dist/login-url.js +22 -0
  123. package/dist/login-url.js.map +1 -0
  124. package/dist/needs-you-hub.d.ts +2 -1
  125. package/dist/needs-you-hub.js +1 -1
  126. package/dist/needs-you-hub.js.map +1 -1
  127. package/dist/needs-you.d.ts +8 -2
  128. package/dist/needs-you.js +6 -0
  129. package/dist/needs-you.js.map +1 -1
  130. package/dist/notice-delivery.d.ts +16 -0
  131. package/dist/notice-delivery.js +35 -0
  132. package/dist/notice-delivery.js.map +1 -0
  133. package/dist/office-share-page.d.ts +16 -0
  134. package/dist/office-share-page.js +85 -0
  135. package/dist/office-share-page.js.map +1 -0
  136. package/dist/office-share-service.d.ts +91 -0
  137. package/dist/office-share-service.js +176 -0
  138. package/dist/office-share-service.js.map +1 -0
  139. package/dist/office-share.d.ts +258 -0
  140. package/dist/office-share.js +549 -0
  141. package/dist/office-share.js.map +1 -0
  142. package/dist/opencode-usage-scan.d.ts +28 -0
  143. package/dist/opencode-usage-scan.js +92 -0
  144. package/dist/opencode-usage-scan.js.map +1 -0
  145. package/dist/opencode-usage-worker.d.ts +1 -0
  146. package/dist/opencode-usage-worker.js +10 -0
  147. package/dist/opencode-usage-worker.js.map +1 -0
  148. package/dist/outbound-handlers.js +22 -2
  149. package/dist/outbound-handlers.js.map +1 -1
  150. package/dist/outbound-schemas.d.ts +26 -2
  151. package/dist/outbound-schemas.js +27 -2
  152. package/dist/outbound-schemas.js.map +1 -1
  153. package/dist/public-web-gateway.js +7 -1
  154. package/dist/public-web-gateway.js.map +1 -1
  155. package/dist/quickstart-api.js +13 -2
  156. package/dist/quickstart-api.js.map +1 -1
  157. package/dist/quickstart.js +2 -1
  158. package/dist/quickstart.js.map +1 -1
  159. package/dist/remote-card-answer.d.ts +83 -0
  160. package/dist/remote-card-answer.js +208 -0
  161. package/dist/remote-card-answer.js.map +1 -0
  162. package/dist/reply-triggers.d.ts +2 -0
  163. package/dist/reply-triggers.js +3 -0
  164. package/dist/reply-triggers.js.map +1 -0
  165. package/dist/settings-api.d.ts +2 -0
  166. package/dist/settings-api.js +41 -16
  167. package/dist/settings-api.js.map +1 -1
  168. package/dist/settings-change.js +17 -14
  169. package/dist/settings-change.js.map +1 -1
  170. package/dist/settings-effect.js +7 -1
  171. package/dist/settings-effect.js.map +1 -1
  172. package/dist/settings-instance-patch.js +4 -1
  173. package/dist/settings-instance-patch.js.map +1 -1
  174. package/dist/setup-form.d.ts +1 -1
  175. package/dist/setup-form.js +55 -6
  176. package/dist/setup-form.js.map +1 -1
  177. package/dist/setup-host.d.ts +9 -0
  178. package/dist/setup-host.js +127 -6
  179. package/dist/setup-host.js.map +1 -1
  180. package/dist/setup-signin-assets.d.ts +2 -0
  181. package/dist/setup-signin-assets.js +6 -0
  182. package/dist/setup-signin-assets.js.map +1 -0
  183. package/dist/setup-wizard.js +2 -0
  184. package/dist/setup-wizard.js.map +1 -1
  185. package/dist/structured-signin-transport.d.ts +8 -0
  186. package/dist/structured-signin-transport.js +79 -0
  187. package/dist/structured-signin-transport.js.map +1 -0
  188. package/dist/structured-signin.d.ts +131 -0
  189. package/dist/structured-signin.js +388 -0
  190. package/dist/structured-signin.js.map +1 -0
  191. package/dist/tool-permissions.js +12 -0
  192. package/dist/tool-permissions.js.map +1 -1
  193. package/dist/topic-commands.js +6 -2
  194. package/dist/topic-commands.js.map +1 -1
  195. package/dist/transcript-sources.d.ts +26 -2
  196. package/dist/transcript-sources.js +103 -40
  197. package/dist/transcript-sources.js.map +1 -1
  198. package/dist/turn-reply-guard.d.ts +1 -1
  199. package/dist/turn-reply-guard.js +1 -1
  200. package/dist/turn-reply-guard.js.map +1 -1
  201. package/dist/types.d.ts +28 -8
  202. package/dist/ui/backend-signin.js +63 -10
  203. package/dist/ui/chat-strings.js +2 -2
  204. package/dist/ui/fleet-office-editor.js +14 -9
  205. package/dist/ui/fleet-office-layout.js +70 -32
  206. package/dist/ui/fleet-office-model.js +6 -3
  207. package/dist/ui/fleet-office-overview.js +167 -0
  208. package/dist/ui/fleet-office-pack.d.ts +7 -2
  209. package/dist/ui/fleet-office-pack.js +119 -15
  210. package/dist/ui/fleet-office-skin.js +98 -23
  211. package/dist/ui/fleet-office-store.js +39 -5
  212. package/dist/ui/fleet-office-view.js +5 -4
  213. package/dist/ui/fleet-office-zones.js +172 -12
  214. package/dist/ui/fleet-office.js +547 -186
  215. package/dist/ui/fleet-usage.js +19 -15
  216. package/dist/ui/office-share-dialog.js +145 -0
  217. package/dist/ui/office-share-page.js +164 -0
  218. package/dist/ui/panel-chat.js +8 -1
  219. package/dist/ui/panel-log.js +2 -2
  220. package/dist/ui/panel-needs.js +4 -4
  221. package/dist/ui/panel-settings.js +37 -11
  222. package/dist/ui/settings-dialogs.js +8 -1
  223. package/dist/ui/settings-guests.js +17 -7
  224. package/dist/ui/settings-model.js +14 -6
  225. package/dist/ui/settings-registry.js +4 -0
  226. package/dist/ui/settings-strings.js +16 -2
  227. package/dist/ui/shared/app-i18n.js +4 -2
  228. package/dist/ui/shared/app-shell.js +14 -6
  229. package/dist/ui/shared/app-stream.js +2 -1
  230. package/dist/ui/shared/app.css +67 -2
  231. package/dist/ui/shared/office/cozy.png +0 -0
  232. package/dist/ui/shared/side-panel.js +95 -0
  233. package/dist/ui/structured-signin.js +115 -0
  234. package/dist/usage-report.d.ts +20 -0
  235. package/dist/usage-report.js +54 -17
  236. package/dist/usage-report.js.map +1 -1
  237. package/dist/usage-rollup.d.ts +9 -1
  238. package/dist/usage-rollup.js +20 -4
  239. package/dist/usage-rollup.js.map +1 -1
  240. package/dist/web-api.d.ts +18 -0
  241. package/dist/web-api.js +201 -0
  242. package/dist/web-api.js.map +1 -1
  243. package/dist/web-auth.js +1 -1
  244. package/dist/web-auth.js.map +1 -1
  245. package/dist/web-backend-login.d.ts +1 -7
  246. package/dist/web-backend-login.js +2 -24
  247. package/dist/web-backend-login.js.map +1 -1
  248. package/dist/web-office-acts.d.ts +173 -0
  249. package/dist/web-office-acts.js +285 -0
  250. package/dist/web-office-acts.js.map +1 -0
  251. package/dist/web-office-skins.d.ts +3 -2
  252. package/dist/web-office-skins.js +3 -2
  253. package/dist/web-office-skins.js.map +1 -1
  254. package/dist/web-office-store.js +40 -7
  255. package/dist/web-office-store.js.map +1 -1
  256. package/dist/web-settings-patch.d.ts +2 -1
  257. package/dist/web-settings-patch.js +4 -1
  258. package/dist/web-settings-patch.js.map +1 -1
  259. package/dist/working-recovery.d.ts +2 -2
  260. package/dist/working-recovery.js +43 -8
  261. package/dist/working-recovery.js.map +1 -1
  262. package/dist/working-state-journal.d.ts +2 -0
  263. package/dist/working-state-journal.js +16 -1
  264. package/dist/working-state-journal.js.map +1 -1
  265. package/package.json +3 -2
  266. package/templates/windows-shortcuts.ps1 +155 -140
@@ -5,12 +5,13 @@
5
5
  // session that started a sign-in gets its terminal link, access code and device code; every other admin page only that
6
6
  // one runs, on which device, and may cancel it. The stream's `backend_login` event carries nothing — it only says to
7
7
  // read again. Starting is two steps, as in chat: check (the server keeps what it found) and start (with its id).
8
- import { html, useEffect, useState } from "/assets/app-html.js";
8
+ import { html, useEffect, useState, useRef } from "/assets/app-html.js";
9
9
  import { t, register } from "/assets/app-i18n.js";
10
10
  import { appStore, createStore, useStore } from "/assets/app-store.js";
11
11
  import { closeDialog, shellStore, showDialog } from "/assets/app-shell.js";
12
12
  import { Dialog } from "/assets/ui-dialog.js";
13
13
  import { toast } from "/assets/ui-toast.js";
14
+ import { mountStructuredSignIns } from "./structured-signin.js";
14
15
 
15
16
  register("signin", {
16
17
  en: {
@@ -49,6 +50,8 @@ register("signin", {
49
50
  notOffered: "A public link is not offered for this sign-in.",
50
51
  failed: "That did not work.",
51
52
  listTitle: "Sign-ins",
53
+ browserRunning: "Signing in to {0} on this machine",
54
+ consoleFallback: "Use console fallback",
52
55
  listHint: "Each backend's login on this machine, and each credential profile an agent uses.",
53
56
  stUsable: "signed in",
54
57
  stNotSignedIn: "not signed in",
@@ -98,6 +101,8 @@ register("signin", {
98
101
  notOffered: "這次登入不提供公開連結。",
99
102
  failed: "沒有成功。",
100
103
  listTitle: "登入",
104
+ browserRunning: "正在這台機器登入 {0}",
105
+ consoleFallback: "改用主控台備援",
101
106
  listHint: "這台機器上每個 backend 的登入,以及代理使用的每個憑證設定檔。",
102
107
  stUsable: "已登入",
103
108
  stNotSignedIn: "未登入",
@@ -116,7 +121,7 @@ const tn = (k, ...v) => t(`signin.${k}`, ...v);
116
121
  const shownOf = (backend, profile) => (profile ? tn("profile", backend, profile) : backend);
117
122
 
118
123
  /** What this page knows: the current sign-in as this session may see it, and Settings' list. */
119
- export const signInStore = createStore({ loaded: false, login: null, targets: [] });
124
+ export const signInStore = createStore({ loaded: false, login: null, targets: [], structured: null });
120
125
 
121
126
  let reading = null, again = false;
122
127
  /**
@@ -125,9 +130,13 @@ let reading = null, again = false;
125
130
  */
126
131
  export function refreshSignIn() {
127
132
  if (reading) { again = true; return reading; }
128
- reading = fetch("/ui/backend-login", { cache: "no-store" })
129
- .then((r) => (r.ok ? r.json() : null))
130
- .then((b) => { if (b) signInStore.set({ loaded: true, login: b.login || null, targets: Array.isArray(b.targets) ? b.targets : [] }); return b; })
133
+ reading = Promise.all([
134
+ fetch("/ui/backend-login", { cache: "no-store" }).then(r => r.ok ? r.json() : null).catch(() => null),
135
+ fetch("/ui/browser-backend-signins", { cache: "no-store" }).then(r => r.ok ? r.json() : null).catch(() => null),
136
+ ]).then(([b, structured]) => {
137
+ if (b) signInStore.set({ loaded: true, login: b.login || null, targets: Array.isArray(b.targets) ? b.targets : [] });
138
+ signInStore.set({ structured: structured && Array.isArray(structured.selected) && Array.isArray(structured.pending) ? structured : null }); return b;
139
+ })
131
140
  .catch(() => null)
132
141
  .finally(() => { reading = null; if (again) { again = false; void refreshSignIn(); } });
133
142
  return reading;
@@ -140,13 +149,25 @@ export function installSignIn() {
140
149
  installed = true;
141
150
  const on = appStore.get().streamOn;
142
151
  if (typeof on === "function") on("backend_login", () => { void refreshSignIn(); });
143
- void refreshSignIn();
152
+ void refreshSignIn().then(() => {
153
+ // A navigation hint from the installer, never authority to start a CLI or cancel another writer.
154
+ const backends = installerSignInBackends(typeof location === "undefined" ? "" : location.search);
155
+ if (backends && signInStore.get().structured) showDialog(BrowserSignInDialog, { backends });
156
+ });
144
157
  shellStore.set({ banner: SignInLine });
145
158
  }
146
159
 
160
+ export function installerSignInBackends(search) {
161
+ const csv = new URLSearchParams(search).get("backend_signins");
162
+ if (!csv || csv.length > 100) return null;
163
+ const ids = csv.split(",");
164
+ return ids.length <= 4 && ids.every(b => ["kiro-cli", "claude-code", "codex", "antigravity"].includes(b)) ? [...new Set(ids)] : null;
165
+ }
166
+
147
167
  /** Open the dialog for one login (a backend's default one, or a credential profile). */
148
168
  export function openSignIn({ backend, profile }) {
149
- showDialog(SignInDialog, { backend, ...(profile ? { profile } : {}) });
169
+ if (!profile && ["kiro-cli", "claude-code", "codex"].includes(backend)) showDialog(BrowserSignInDialog, { backend });
170
+ else showDialog(SignInDialog, { backend, ...(profile ? { profile } : {}) });
150
171
  }
151
172
 
152
173
  /** Open the dialog on the running sign-in (its starter's view, or the cancel-only one). */
@@ -191,6 +212,32 @@ function Running({ login }) {
191
212
  </div>`;
192
213
  }
193
214
 
215
+ /** The same closed controls as first setup. Profile-specific logins retain their existing flow. */
216
+ export function BrowserSignInDialog({ backend, backends, onClose }) {
217
+ const root = useRef(null), close = onClose || closeDialog;
218
+ useEffect(() => {
219
+ let cards, last = "";
220
+ cards = mountStructuredSignIns(root.current, { endpoint: "/ui/browser-backend-signins", changed: next => {
221
+ if (backends) { if (backends.some(b => !next.selected.includes(b))) void cards.addBackends(backends); }
222
+ else if (!next.selected.includes(backend)) void cards.addBackend(backend);
223
+ const signature = JSON.stringify([next.login, next.pending]);
224
+ if (signature !== last) { last = signature; void refreshSignIn(); }
225
+ } });
226
+ return () => cards.dispose();
227
+ }, [backend, backends]);
228
+ const fallback = async (chosen = backend) => {
229
+ const result = await post("/ui/browser-backend-signins", { operation: "cancel" });
230
+ if (!result.ok) { toast(tn("failed")); return; }
231
+ showDialog(SignInDialog, { backend: chosen });
232
+ };
233
+ return html`<${Dialog} title=${backends ? tn("listTitle") : tn("title", backend)} onClose=${close}>
234
+ <div ref=${root}></div>
235
+ ${backends ? backends.filter(b => b !== "antigravity").map(b => html`<button type="button" class="btn" onClick=${() => fallback(b)}>${b} · ${tn("consoleFallback")}</button>`) : null}
236
+ <div class="s-actions">${backend ? html`<button type="button" class="btn" onClick=${() => fallback(backend)}>${tn("consoleFallback")}</button>` : null}
237
+ <button type="button" class="btn" onClick=${close}>${tn("close")}</button></div>
238
+ </${Dialog}>`;
239
+ }
240
+
194
241
  export function SignInDialog({ backend, profile, resume = false, onClose }) {
195
242
  const s = useStore(signInStore);
196
243
  const [step, setStep] = useState(resume ? { kind: "login" } : { kind: "checking" });
@@ -265,7 +312,12 @@ export function SignInDialog({ backend, profile, resume = false, onClose }) {
265
312
 
266
313
  /** The line every admin page shows while a sign-in runs (the shell's banner slot). */
267
314
  export function SignInLine() {
268
- const { login } = useStore(signInStore);
315
+ const { login, structured } = useStore(signInStore);
316
+ const active = structured?.login;
317
+ if ((!login || login.phase !== "running") && active && active.phase !== "done" && !(active.phase === "failed" && active.reason)) {
318
+ return html`<div data-trust="status" class="conn op-line signin-line" role="status"><span>${tn("browserRunning", active.backend)}</span>
319
+ <button type="button" class="btn btn-sm" onClick=${() => openSignIn({ backend: active.backend })}>${tn("open")}</button></div>`;
320
+ }
269
321
  if (!login || login.phase !== "running") return null;
270
322
  return html`<div data-trust="status" class="conn op-line signin-line" role="status">
271
323
  <span>${tn("running", shownOf(login.backend, login.profile), login.startedOn)}</span>
@@ -276,13 +328,14 @@ const STATE_KEY = { usable: "stUsable", not_signed_in: "stNotSignedIn", not_inst
276
328
 
277
329
  /** Settings → Agents: each login on this machine, with Sign in where the dashboard can do it. */
278
330
  export function SignInList() {
279
- const { targets, loaded, login } = useStore(signInStore);
331
+ const { targets, loaded, login, structured } = useStore(signInStore);
280
332
  // Not over the temporary public link: only the dashboard reached directly signs a backend in, and the link does not
281
333
  // serve this read (it was a 404 in the console there, found by smoke-1939-authorize-defaults).
282
334
  const offered = signInOffered();
283
335
  useEffect(() => { if (offered) void refreshSignIn(); }, []);
284
336
  if (!offered || !loaded || !targets.length) return null;
285
- const running = login && login.phase === "running";
337
+ const active = structured?.login;
338
+ const running = (login && login.phase === "running") || (active && active.phase !== "done" && !(active.phase === "failed" && active.reason));
286
339
  return html`<section class="s-group signin-list" aria-label=${tn("listTitle")}>
287
340
  <div class="list-head"><span>${tn("listTitle")}</span></div>
288
341
  <p class="sub">${tn("listHint")}</p>
@@ -116,7 +116,7 @@ register("chat", {
116
116
  file_empty: "empty file", file_too_large: "over {0} MB", file_too_large_public: "over {0} MB — the most the public link carries in one upload", file_too_many: "at most {0} files per message", file_too_much: "the files together would be over {0} MB", removeFile: "Remove", putBack: "Put back",
117
117
  noMessages: "Type a task below.",
118
118
  pick: "Choose an agent to start chatting.", noAgents: "Create an agent to start chatting.", newAgent: "New agent", notFound: "No instance called “{0}”", notFoundHint: "It may have been deleted or renamed.", backToList: "Choose an agent",
119
- details: "Instance details", start: "Start instance", stopInstance: "Stop instance", restart: "Restart instance", delete: "Delete instance…",
119
+ details: "Instance details", fullWidth: "Full width", start: "Start instance", stopInstance: "Stop instance", restart: "Restart instance", delete: "Delete instance…",
120
120
  started: "{0} started", stoppedInst: "{0} stopped", restarted: "{0} restarted", instanceDeleted: "{0} was deleted",
121
121
  deleteTitle: "Delete {0}?", deleteHelp: "This removes the instance from the fleet and stops it. Its working directory is kept. Type “delete {0}” to confirm.", deleteGo: "Delete", cancel: "Cancel",
122
122
  loadFailed: "Failed to load", tour: "Chat tour", tourBtnTitle: "Show the chat tour on an agent's chat", tourNoAgent: "Choose an agent before opening the chat tour.",
@@ -255,7 +255,7 @@ register("chat", {
255
255
  file_empty: "空檔案", file_too_large: "超過 {0} MB", file_too_large_public: "超過 {0} MB,這是公開連結單次上傳的上限", file_too_many: "每則訊息最多 {0} 個檔案", file_too_much: "檔案合計會超過 {0} MB", removeFile: "移除", putBack: "放回輸入框",
256
256
  noMessages: "直接在下方輸入任務。",
257
257
  pick: "選一個 Agent 開始聊天。", noAgents: "先建立一個 Agent,再開始聊天。", newAgent: "新增 Agent", notFound: "沒有叫「{0}」的 instance", notFoundHint: "它可能已被刪除或改名。", backToList: "選擇 Agent",
258
- details: "Instance 詳細資料", start: "啟動 instance", stopInstance: "停止 instance", restart: "重新啟動 instance", delete: "刪除 instance…",
258
+ details: "Instance 詳細資料", fullWidth: "全寬", start: "啟動 instance", stopInstance: "停止 instance", restart: "重新啟動 instance", delete: "刪除 instance…",
259
259
  started: "{0} 已啟動", stoppedInst: "{0} 已停止", restarted: "{0} 已重新啟動", instanceDeleted: "{0} 已刪除",
260
260
  deleteTitle: "要刪除 {0} 嗎?", deleteHelp: "這會把 instance 從 fleet 移除並停止它,工作目錄會保留。請輸入「delete {0}」確認。", deleteGo: "刪除", cancel: "取消",
261
261
  loadFailed: "載入失敗", tour: "聊天導覽", tourBtnTitle: "在 Agent 的聊天上開啟聊天導覽", tourNoAgent: "請先選一個 Agent,再開啟聊天導覽。",
@@ -8,9 +8,10 @@
8
8
  // the public link or a read-only session (the host refuses them too).
9
9
  import { html, useMemo, useState } from "/assets/app-html.js";
10
10
  import { t, register } from "/assets/app-i18n.js";
11
- import { Dialog } from "/assets/ui-dialog.js";
11
+ import { SidePanel } from "/assets/side-panel.js";
12
12
  import { toast } from "/assets/ui-toast.js";
13
13
  import { nodeText } from "./fleet-org.js";
14
+ import { BUILT_IN_SKINS } from "./fleet-office-skin.js";
14
15
 
15
16
  register("orge", {
16
17
  en: {
@@ -48,7 +49,7 @@ register("orge", {
48
49
  });
49
50
  const te = (k, ...v) => t(`orge.${k}`, ...v);
50
51
  export const MAX_OFFICES = 20;
51
- const STYLES = ["pixel", "studio"];
52
+ const STYLES = BUILT_IN_SKINS;
52
53
 
53
54
  /** A file's bytes as base64 (in slices: a large picture never goes through one huge string call). */
54
55
  export function base64Of(bytes) {
@@ -147,8 +148,11 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
147
148
  // the rev under an older draft (a save would then overwrite what it never showed). After a 409 the person chooses
148
149
  // (#2431): keep their edits (on the newer rev: Save then replaces what was saved meanwhile, knowingly) or load the
149
150
  // newer list — never their draft thrown away unasked.
150
- const [base, setBase] = useState(() => ({ rev, draft: offices.map((o) => ({ ...o, members: [...o.members] })) }));
151
+ // `saved`: the list the draft is measured against (as read, or the newer one loaded) — the draft holds changes when
152
+ // it differs (#2585: leaving the page beside the docked editor then asks first).
153
+ const [base, setBase] = useState(() => ({ rev, saved: JSON.stringify(offices), draft: offices.map((o) => ({ ...o, members: [...o.members] })) }));
151
154
  const draft = base.draft;
155
+ const dirty = JSON.stringify(draft) !== base.saved;
152
156
  const setDraft = (next) => setBase((b) => ({ ...b, draft: typeof next === "function" ? next(b.draft) : next }));
153
157
  const [open, setOpen] = useState(null); // the office whose members are shown
154
158
  const [busy, setBusy] = useState(false);
@@ -157,14 +161,14 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
157
161
  const copy = (list) => list.map((o) => ({ ...o, members: [...o.members] }));
158
162
  // The choice takes the snapshot in force when it is made: the stream may have read a newer one since the 409 (#2449).
159
163
  const inForce = () => current() || conflict;
160
- const keepMine = () => { const now = inForce(); setBase((b) => ({ rev: now.rev, draft: b.draft })); setConflict(null); setMsg(te("keptMine")); };
161
- const loadTheirs = () => { const now = inForce(); setBase({ rev: now.rev, draft: copy(now.offices) }); setOpen(null); setConflict(null); setMsg(""); };
164
+ const keepMine = () => { const now = inForce(); setBase((b) => ({ rev: now.rev, saved: JSON.stringify(now.offices), draft: b.draft })); setConflict(null); setMsg(te("keptMine")); };
165
+ const loadTheirs = () => { const now = inForce(); setBase({ rev: now.rev, saved: JSON.stringify(now.offices), draft: copy(now.offices) }); setOpen(null); setConflict(null); setMsg(""); };
162
166
  const everyone = useMemo(() => { const m = new Map(); for (const n of [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes]) if (!m.has(n.name)) m.set(n.name, n); return [...m.values()]; }, [view]);
163
167
  const set = (id, patch) => setDraft((d) => d.map((o) => (o.id === id ? { ...o, ...patch } : o)));
164
168
  const add = () => {
165
169
  if (draft.length >= MAX_OFFICES) return;
166
170
  const id = newOfficeId(new Set(draft.map((o) => o.id)));
167
- setDraft((d) => [...d, { id, name: te("newName"), skin: "pixel", members: [], layout: null }]);
171
+ setDraft((d) => [...d, { id, name: te("newName"), skin: "cozy", members: [], layout: null }]); // #2631: a new office is cozy
168
172
  setOpen(id);
169
173
  };
170
174
  // Everything is frozen from Save until its answer (and a 409's re-read) is in (#2424 review): no edit made meanwhile
@@ -191,7 +195,8 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
191
195
  };
192
196
  const actions = html`<button type="button" class="btn" onClick=${onClose} disabled=${busy}>${te("cancel")}</button>
193
197
  <button type="button" class="btn btn-primary" id="oeSave" onClick=${save} disabled=${busy || !!conflict}>${te("save")}</button>`;
194
- return html`<${Dialog} title=${te("title")} onClose=${onClose} actions=${actions} busy=${busy} wide=${true}>
198
+ // #2585: docked on the right on a wide desk (the page beside it in use); the centred dialog elsewhere.
199
+ return html`<${SidePanel} title=${te("title")} onClose=${onClose} actions=${actions} busy=${busy} dirty=${dirty}>
195
200
  <p class="note">${te("lead")}</p>
196
201
  <fieldset class="oe-form" disabled=${busy}>
197
202
  ${draft.length ? null : html`<p class="note" id="oeNone">${te("none")}</p>`}
@@ -201,7 +206,7 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
201
206
  <input type="text" class="oe-name" maxlength="60" value=${o.name} onInput=${(e) => set(o.id, { name: e.target.value })} /></label>
202
207
  <label class="oe-field"><span class="oe-label">${te("styleLabel")}</span>
203
208
  <select class="tb-select oe-style" value=${o.skin} onChange=${(e) => set(o.id, { skin: e.target.value })}>
204
- ${STYLES.map((s) => html`<option key=${s} value=${s}>${t(`orgo.style_${s}`)}</option>`)}
209
+ ${STYLES.filter((s) => !packs.some((pk) => pk.id === s)).map((s) => html`<option key=${s} value=${s}>${t(`orgo.style_${s}`)}</option>`)}
205
210
  ${packs.map((pk) => html`<option key=${`pack:${pk.id}`} value=${pk.id}>${pk.name}</option>`)}
206
211
  ${STYLES.includes(o.skin) || packs.some((pk) => pk.id === o.skin) ? null : html`<option key=${o.skin} value=${o.skin}>${te("styleUnknown", o.skin)}</option>`}</select></label>
207
212
  <button type="button" class="btn btn-sm oe-members-btn" aria-expanded=${open === o.id ? "true" : "false"} onClick=${() => setOpen(open === o.id ? null : o.id)}>
@@ -220,5 +225,5 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
220
225
  <button type="button" class="btn btn-sm" id="oeKeepMine" onClick=${keepMine} disabled=${busy}>${te("keepMine")}</button>
221
226
  <button type="button" class="btn btn-sm" id="oeLoadTheirs" onClick=${loadTheirs} disabled=${busy}>${te("loadTheirs")}</button>
222
227
  </div>` : null}
223
- </${Dialog}>`;
228
+ </${SidePanel}>`;
224
229
  }
@@ -4,13 +4,18 @@
4
4
  // overridesBetween), saved with the whole list and the rev read (PUT /ui/offices; a change made meanwhile is a 409).
5
5
  // Reset goes back to the pack's own positions. A "show coordinates" overlay draws the 1280×720 logical grid.
6
6
  //
7
+ // #2665: an item with a `night` (#2568) has a night handle too — its night strip drawn faint on the canvas (as the
8
+ // seats' figures, #2558), a moon, and a thin line to its day place. One that sleeps in place shows it on its day place
9
+ // (and it follows the item); dragged away, the item has its own night place; its Reset puts back the pack's.
10
+ //
7
11
  // Every handle is a button for the keyboard too: Tab to it, the arrow keys move it (Shift: by 10), Delete removes a
8
12
  // waypoint; the selected waypoint's corridors are listed with a way to join or unjoin each. Never offered to a guest,
9
13
  // the public link or a read-only session (the host refuses their saves too).
10
14
  //
11
15
  // The pure part (handles, moving, adding and removing) is exported for tests; LayoutEditor is the overlay.
12
- import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
16
+ import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
13
17
  import { t, register } from "/assets/app-i18n.js";
18
+ import { SidePanel } from "/assets/side-panel.js";
14
19
  import { LOGICAL, overridesBetween, own } from "./fleet-office-pack.js";
15
20
 
16
21
  register("orgl", {
@@ -18,7 +23,8 @@ register("orgl", {
18
23
  edit: "Edit layout", title: "Edit layout", coords: "Show coordinates", addWaypoint: "Add waypoint", removeWaypoint: "Remove waypoint",
19
24
  joinTo: "Join to", join: "Join", unjoin: "Unjoin {0}", corridors: "Corridors", none: "Nothing selected: pick a seat, an item, the door or a waypoint.",
20
25
  reset: "Reset to the pack's layout", save: "Save layout", cancel: "Cancel", saved: "Layout saved.", hint: "Drag, or use the arrow keys (Shift: by 10).",
21
- seat: "Seat {0} in {1}", furniture: "Item {0}", door: "Door", waypoint: "Waypoint {0}", at: "{0} at {1}, {2}",
26
+ seat: "Seat {0} in {1}", furniture: "Item {0}", night: "Item {0} (night)", door: "Door", waypoint: "Waypoint {0}", at: "{0} at {1}, {2}",
27
+ nightReset: "Back to the pack's night place",
22
28
  changed: "Someone changed the offices meanwhile. Your layout is kept: Save again puts it on the current list.",
23
29
  failed: "The layout was not saved ({0}).", rateLimited: "Too many saves. Try again in a minute.",
24
30
  stale: "This office was removed or its style changed meanwhile: this layout can no longer be saved. Cancel closes it.",
@@ -27,7 +33,8 @@ register("orgl", {
27
33
  edit: "編輯配置", title: "編輯配置", coords: "顯示座標", addWaypoint: "新增路徑點", removeWaypoint: "移除路徑點",
28
34
  joinTo: "連到", join: "連接", unjoin: "取消連接 {0}", corridors: "走道", none: "尚未選取:請選座位、家具、門或路徑點。",
29
35
  reset: "還原成 pack 的配置", save: "儲存配置", cancel: "取消", saved: "配置已儲存。", hint: "拖曳,或用方向鍵移動(Shift:一次 10)。",
30
- seat: "{1} 的座位 {0}", furniture: "家具 {0}", door: "門", waypoint: "路徑點 {0}", at: "{0},位置 {1}, {2}",
36
+ seat: "{1} 的座位 {0}", furniture: "家具 {0}", night: "家具 {0}(夜間)", door: "門", waypoint: "路徑點 {0}", at: "{0},位置 {1}, {2}",
37
+ nightReset: "回到 pack 的夜間位置",
31
38
  changed: "有人在這期間修改了辦公室。你的配置已保留:再按一次儲存,會套用到目前的清單。",
32
39
  failed: "配置沒有儲存({0})。", rateLimited: "儲存太頻繁,請一分鐘後再試。",
33
40
  stale: "這個辦公室在這期間被移除,或它的風格已改變:這份配置無法再儲存。按取消關閉。",
@@ -38,11 +45,21 @@ const tl = (k, ...v) => t(`orgl.${k}`, ...v);
38
45
  // ── The pure part ──
39
46
 
40
47
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, Math.round(v)));
41
- /** Every handle on a pack: { key, kind, label, x, y } — seats by area, furniture, the door, the waypoints. */
48
+ /** An item's night place (#2665): its own, or (in place) its day place; `own` whether it has one. */
49
+ const nightAt = (f) => (f.night.x !== null && f.night.x !== undefined && f.night.y !== null && f.night.y !== undefined ? { x: f.night.x, y: f.night.y, own: true } : { x: f.x, y: f.y, own: false });
50
+ /**
51
+ * Every handle on a pack: { key, kind, label, x, y } — seats by area, furniture, each item's night place (#2665: an
52
+ * item with a `night`; `day` its day place, `inPlace` while it sleeps there), the door, the waypoints.
53
+ */
42
54
  export function handlesOf(pack) {
43
55
  const out = [];
44
56
  for (const [name, a] of Object.entries(pack.areas)) a.seats.forEach((s, i) => out.push({ key: `seat:${name}:${i}`, kind: "seat", label: tl("seat", i + 1, name), x: s.x, y: s.y }));
45
57
  for (const f of pack.furniture) out.push({ key: `furn:${f.id}`, kind: "furniture", label: tl("furniture", f.id), x: f.x, y: f.y });
58
+ for (const f of pack.furniture) {
59
+ if (!f.night) continue;
60
+ const n = nightAt(f);
61
+ out.push({ key: `night:${f.id}`, kind: "night", label: tl("night", f.id), x: n.x, y: n.y, day: { x: f.x, y: f.y }, inPlace: !n.own });
62
+ }
46
63
  out.push({ key: "door", kind: "door", label: tl("door"), x: pack.door.x, y: pack.door.y });
47
64
  for (const w of pack.waypoints) out.push({ key: `wp:${w.id}`, kind: "waypoint", label: tl("waypoint", w.id), x: w.x, y: w.y });
48
65
  return out;
@@ -58,6 +75,7 @@ export function moveHandle(pack, key, x, y) {
58
75
  return { ...pack, areas: { ...pack.areas, [a]: { ...area, seats: area.seats.map((s, j) => (j === i ? p : s)) } } };
59
76
  }
60
77
  if (kind === "furn") return { ...pack, furniture: pack.furniture.map((f) => (f.id === a ? { ...f, ...inCanvas() } : f)) };
78
+ if (kind === "night") return { ...pack, furniture: pack.furniture.map((f) => (f.id === a && f.night ? { ...f, night: { ...f.night, ...inCanvas() } } : f)) };
61
79
  if (kind === "door") return { ...pack, door: inCanvas() };
62
80
  if (kind === "wp") return { ...pack, waypoints: pack.waypoints.map((w) => (w.id === a ? { ...w, ...inCanvas() } : w)) };
63
81
  return pack;
@@ -80,12 +98,23 @@ export function toggleEdge(pack, a, b) {
80
98
  const has = (e) => (e[0] === a && e[1] === b) || (e[0] === b && e[1] === a);
81
99
  return { ...pack, edges: pack.edges.some(has) ? pack.edges.filter((e) => !has(e)) : [...pack.edges, [a, b]] };
82
100
  }
101
+ /** #2665: item `id`'s night place back to the pack's (`base`): its own x/y, or in place again. */
102
+ export function resetNight(pack, base, id) {
103
+ const o = base.furniture.find((f) => f.id === id);
104
+ if (!o || !o.night) return pack;
105
+ return { ...pack, furniture: pack.furniture.map((f) => (f.id === id && f.night ? { ...f, night: { ...f.night, x: o.night.x, y: o.night.y } } : f)) };
106
+ }
83
107
  /** What Save keeps: the overrides between the pack and the edited layout (null: the pack's own). */
84
108
  export const layoutOverrides = (base, edited) => overridesBetween(base, edited);
85
109
 
86
110
  // ── The overlay ──
87
111
 
88
112
  const GRID = 80, LABEL = 160;
113
+ /**
114
+ * A night handle's moon (#2665): a crescent centred near (x, y). Night handles are drawn under the others: one in place
115
+ * is a wider ring round its item's square (the item stays grabbable), its moon at the ring's corner.
116
+ */
117
+ const moonPath = (x, y) => `M ${x + 1} ${y - 5} A 5 5 0 1 0 ${x + 1} ${y + 5} A 4 4 0 1 1 ${x + 1} ${y - 5} Z`;
89
118
  const KEY_STEP = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
90
119
 
91
120
  /**
@@ -96,7 +125,7 @@ const KEY_STEP = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], Arr
96
125
  * moves (the canvas redraws it); `onSave(overrides | null)` → { ok } or { ok: false, message }; `onClose()`. `stale` (a message): the
97
126
  * office or its style changed underneath the session — no handles, nothing to save, only Cancel.
98
127
  */
99
- export function LayoutEditor({ base, start, place = null, stale = "", onChange, onSave, onClose }) {
128
+ export function LayoutEditor({ base, start, place = null, stale = "", dock = true, onChange, onSave, onClose }) {
100
129
  const [edited, setEdited] = useState(start);
101
130
  const [sel, setSel] = useState(null);
102
131
  const [coords, setCoords] = useState(true);
@@ -130,6 +159,7 @@ export function LayoutEditor({ base, start, place = null, stale = "", onChange,
130
159
  };
131
160
  const add = () => { const at = selected ? { x: selected.x + 40, y: selected.y } : { x: LOGICAL.w / 2, y: LOGICAL.h / 2 }; const r = addWaypoint(edited, at.x, at.y); change(r.pack); setSel(`wp:${r.id}`); };
132
161
  const wpSel = selected && selected.kind === "waypoint" ? selected.key.slice(3) : null;
162
+ const nightSel = selected && selected.kind === "night" ? selected.key.slice(6) : null;
133
163
  const save = async () => {
134
164
  if (busy) return;
135
165
  setBusy(true); setMsg("");
@@ -149,49 +179,57 @@ export function LayoutEditor({ base, start, place = null, stale = "", onChange,
149
179
  }
150
180
  const at = new Map(edited.waypoints.map((w) => [w.id, w]));
151
181
  const edgeKey = (e) => `${e[0]}\u0000${e[1]}`;
182
+ // #2585: the controls dock in the side panel on a wide desk (the canvas left whole for the handles), else they are
183
+ // the bar over the canvas as before. Esc (in the panel, or on the canvas's handles) is Cancel, never mid-save.
184
+ const esc = (e) => { if (e.key === "Escape" && !e.defaultPrevented && !busy) { e.preventDefault(); onClose(); } };
185
+ const dirty = useMemo(() => JSON.stringify(edited) !== JSON.stringify(start), [edited, start]);
186
+ const panel = (controls, actions, tail) => html`<${SidePanel} title=${tl("title")} dock=${dock} busy=${busy} dirty=${dirty} onClose=${onClose} actions=${actions}
187
+ fallback=${html`<div class="ol-bar">${controls}<span class="ol-actions">${actions}</span>${tail}</div>`}>
188
+ <div class="ol-side">${controls}${tail}</div>
189
+ </${SidePanel}>`;
152
190
  if (stale) {
153
- return html`<div class="ol" role="group" aria-label=${tl("title")}><div class="ol-bar">
154
- <p class="note ol-msg ol-stale" role="status">${stale}</p>
155
- <span class="ol-actions"><button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button></span>
156
- </div></div>`;
191
+ return html`<div class="ol" role="group" aria-label=${tl("title")} onKeyDown=${esc}>
192
+ ${panel(null, html`<button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button>`,
193
+ html`<p class="note ol-msg ol-stale" role="status">${stale}</p>`)}
194
+ </div>`;
157
195
  }
158
- return html`<div class="ol" role="group" aria-label=${tl("title")}>
196
+ const controls = html`<label class="check"><input type="checkbox" class="ol-coords" checked=${coords} onChange=${() => setCoords((v) => !v)} /><span>${tl("coords")}</span></label>
197
+ <button type="button" class="btn btn-sm ol-add" onClick=${add} disabled=${busy}>${tl("addWaypoint")}</button>
198
+ <span class="muted ol-sel" aria-live="polite">${selected ? tl("at", selected.label, selected.x, selected.y) : tl("none")}</span>
199
+ ${nightSel ? html`<button type="button" class="btn btn-sm ol-night-reset" onClick=${() => change(resetNight(edited, base, nightSel))} disabled=${busy}>${tl("nightReset")}</button>` : null}
200
+ ${wpSel ? html`<button type="button" class="btn btn-sm btn-danger ol-remove" onClick=${() => { change(removeWaypoint(edited, wpSel)); setSel(null); }} disabled=${busy}>${tl("removeWaypoint")}</button>
201
+ <label class="oo-pick"><span class="oo-pick-label">${tl("joinTo")}</span>
202
+ <select class="tb-select ol-join-to" value=${joinTo} onChange=${(e) => setJoinTo(e.target.value)}>
203
+ <option value="">—</option>
204
+ ${edited.waypoints.filter((w) => w.id !== wpSel).map((w) => html`<option key=${w.id} value=${w.id}>${w.id}</option>`)}
205
+ </select></label>
206
+ <button type="button" class="btn btn-sm ol-join" disabled=${busy || !joinTo || joinTo === wpSel} onClick=${() => { change(toggleEdge(edited, wpSel, joinTo)); setJoinTo(""); }}>${tl("join")}</button>
207
+ <span class="ol-corridors">${edited.edges.filter((e) => e[0] === wpSel || e[1] === wpSel).map((e) => { const other = e[0] === wpSel ? e[1] : e[0];
208
+ return html`<button key=${edgeKey(e)} type="button" class="btn btn-sm ol-unjoin" data-other=${other} disabled=${busy} onClick=${() => change(toggleEdge(edited, wpSel, other))}>${tl("unjoin", other)}</button>`; })}</span>` : null}`;
209
+ const actions = html`<button type="button" class="btn btn-sm ol-reset" onClick=${reset} disabled=${busy}>${tl("reset")}</button>
210
+ <button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button>
211
+ <button type="button" class="btn btn-sm btn-primary ol-save" onClick=${save} disabled=${busy}>${tl("save")}</button>`;
212
+ const tail = html`<span class="muted oo-note">${tl("hint")}</span>
213
+ ${msg ? html`<p class="note ol-msg" role="status">${msg}</p>` : null}`;
214
+ return html`<div class="ol" role="group" aria-label=${tl("title")} onKeyDown=${esc}>
159
215
  <div class="ol-overlay" ref=${overlay}>
160
216
  <svg ref=${svg} class="ol-svg" viewBox=${`0 0 ${LOGICAL.w} ${LOGICAL.h}`} width="100%" height="100%"
161
217
  onPointerMove=${move} onPointerUp=${up} onPointerCancel=${up} onPointerLeave=${up}>
162
218
  ${grid}
163
219
  ${Object.values(edited.areas).map((a) => html`<rect key=${`a:${a.name}`} class="ol-area" x=${a.rect.x} y=${a.rect.y} width=${a.rect.w} height=${a.rect.h} />`)}
164
220
  ${edited.edges.map((e) => { const p = at.get(e[0]), q = at.get(e[1]); return p && q ? html`<line key=${`e:${edgeKey(e)}`} class="ol-edge" x1=${p.x} y1=${p.y} x2=${q.x} y2=${q.y} />` : null; })}
165
- ${handles.map((h) => html`<g key=${h.key} class=${`ol-handle ol-${h.kind}${sel === h.key ? " on" : ""}`} data-handle=${h.key} tabindex="0" role="button"
221
+ ${handles.filter((h) => h.kind === "night" && !h.inPlace).map((h) => html`<line key=${`nl:${h.key}`} class="ol-night-line" x1=${h.day.x} y1=${h.day.y} x2=${h.x} y2=${h.y} />`)}
222
+ ${[...handles.filter((h) => h.kind === "night"), ...handles.filter((h) => h.kind !== "night")].map((h) => html`<g key=${h.key} class=${`ol-handle ol-${h.kind}${sel === h.key ? " on" : ""}`} data-handle=${h.key} tabindex="0" role="button"
166
223
  aria-label=${tl("at", h.label, h.x, h.y)} aria-pressed=${sel === h.key ? "true" : "false"}
167
224
  onPointerDown=${down(h.key)} onKeyDown=${keyOn(h)} onFocus=${() => setSel(h.key)}>
168
225
  ${h.kind === "door" ? html`<rect x=${h.x - 9} y=${h.y - 9} width="18" height="18" transform=${`rotate(45 ${h.x} ${h.y})`} />`
169
226
  : h.kind === "furniture" ? html`<rect x=${h.x - 8} y=${h.y - 8} width="16" height="16" />`
227
+ : h.kind === "night" ? html`<circle cx=${h.x} cy=${h.y} r=${h.inPlace ? 15 : 8} /><path class="ol-moon" d=${h.inPlace ? moonPath(h.x + 10, h.y - 10) : moonPath(h.x, h.y)} />`
170
228
  : html`<circle cx=${h.x} cy=${h.y} r=${h.kind === "seat" ? 9 : 7} />`}
171
229
  ${coords && sel === h.key ? html`<text class="ol-coord" x=${h.x + 12} y=${h.y - 10}>${h.x}, ${h.y}</text>` : null}
172
230
  </g>`)}
173
231
  </svg>
174
232
  </div>
175
- <div class="ol-bar">
176
- <label class="check"><input type="checkbox" class="ol-coords" checked=${coords} onChange=${() => setCoords((v) => !v)} /><span>${tl("coords")}</span></label>
177
- <button type="button" class="btn btn-sm ol-add" onClick=${add} disabled=${busy}>${tl("addWaypoint")}</button>
178
- <span class="muted ol-sel" aria-live="polite">${selected ? tl("at", selected.label, selected.x, selected.y) : tl("none")}</span>
179
- ${wpSel ? html`<button type="button" class="btn btn-sm btn-danger ol-remove" onClick=${() => { change(removeWaypoint(edited, wpSel)); setSel(null); }} disabled=${busy}>${tl("removeWaypoint")}</button>
180
- <label class="oo-pick"><span class="oo-pick-label">${tl("joinTo")}</span>
181
- <select class="tb-select ol-join-to" value=${joinTo} onChange=${(e) => setJoinTo(e.target.value)}>
182
- <option value="">—</option>
183
- ${edited.waypoints.filter((w) => w.id !== wpSel).map((w) => html`<option key=${w.id} value=${w.id}>${w.id}</option>`)}
184
- </select></label>
185
- <button type="button" class="btn btn-sm ol-join" disabled=${busy || !joinTo || joinTo === wpSel} onClick=${() => { change(toggleEdge(edited, wpSel, joinTo)); setJoinTo(""); }}>${tl("join")}</button>
186
- <span class="ol-corridors">${edited.edges.filter((e) => e[0] === wpSel || e[1] === wpSel).map((e) => { const other = e[0] === wpSel ? e[1] : e[0];
187
- return html`<button key=${edgeKey(e)} type="button" class="btn btn-sm ol-unjoin" data-other=${other} disabled=${busy} onClick=${() => change(toggleEdge(edited, wpSel, other))}>${tl("unjoin", other)}</button>`; })}</span>` : null}
188
- <span class="ol-actions">
189
- <button type="button" class="btn btn-sm ol-reset" onClick=${reset} disabled=${busy}>${tl("reset")}</button>
190
- <button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button>
191
- <button type="button" class="btn btn-sm btn-primary ol-save" onClick=${save} disabled=${busy}>${tl("save")}</button>
192
- </span>
193
- <span class="muted oo-note">${tl("hint")}</span>
194
- ${msg ? html`<p class="note ol-msg" role="status">${msg}</p>` : null}
195
- </div>
233
+ ${panel(controls, actions, tail)}
196
234
  </div>`;
197
235
  }
@@ -199,7 +199,9 @@ export function autoLayout(rooms, { width = 960 } = {}) {
199
199
  }
200
200
  });
201
201
  const right = out.length ? Math.max(...out.map((r) => r.x + r.w)) : 0, bottom = out.length ? Math.max(...out.map((r) => r.y + r.h)) : 0;
202
- return { rooms: out, waypoints, edges, width: Math.max(width, right + M), height: bottom + GAP / 2 + M };
202
+ // As wide as its rooms (rows wrap at `width`): a scene narrower than the stage is centred in it (clampPan), never
203
+ // left at the stage's left edge with the rest of the stage empty (#2585 B).
204
+ return { rooms: out, waypoints, edges, width: out.length ? right + M : width, height: bottom + GAP / 2 + M };
203
205
  }
204
206
 
205
207
  /**
@@ -318,7 +320,8 @@ const MAX_SEEN = 2000;
318
320
  * The items in the air. `sync(items, now, still)` takes fleet-traffic.js's store items: each new one (a message, a
319
321
  * delegation's work, its report coming back) starts one trip from its sender's desk to its receiver's; a delegation with
320
322
  * no report (stale) and anything seen before start none; `still` (reduced motion) starts none. `frame(now, L)` the
321
- * trips under way on layout L: { id, hue, kind, x, y, t }, finished ones dropped; a trip whose desks are not drawn goes.
323
+ * trips under way on layout L: { id, hue, kind, from, x, y, t } (from: who sent it — a pack can draw it as theirs),
324
+ * finished ones dropped; a trip whose desks are not drawn goes.
322
325
  */
323
326
  export function createFlights({ max = FLIGHT.MAX } = {}) {
324
327
  const seen = new Set();
@@ -344,7 +347,7 @@ export function createFlights({ max = FLIGHT.MAX } = {}) {
344
347
  if (!f.pts) return false;
345
348
  const t = (now - f.start) / f.ms;
346
349
  if (t >= 1) return false;
347
- if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, x: p.x, y: p.y, t }); }
350
+ if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, from: f.from, x: p.x, y: p.y, t }); }
348
351
  return true;
349
352
  });
350
353
  return out;