@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.20

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 (389) hide show
  1. package/README.md +40 -24
  2. package/README.zh-TW.md +40 -24
  3. package/dist/agent-cli-instructions.md +6 -2
  4. package/dist/agent-cli.js +15 -7
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.js +3 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-whats-new.js +16 -4
  9. package/dist/agent-whats-new.js.map +1 -1
  10. package/dist/auth-api.d.ts +3 -1
  11. package/dist/auth-api.js +17 -1
  12. package/dist/auth-api.js.map +1 -1
  13. package/dist/backend/antigravity.d.ts +33 -0
  14. package/dist/backend/antigravity.js +126 -18
  15. package/dist/backend/antigravity.js.map +1 -1
  16. package/dist/backend/claude-carry.d.ts +22 -0
  17. package/dist/backend/claude-carry.js +170 -0
  18. package/dist/backend/claude-carry.js.map +1 -0
  19. package/dist/backend/claude-code.d.ts +65 -2
  20. package/dist/backend/claude-code.js +226 -33
  21. package/dist/backend/claude-code.js.map +1 -1
  22. package/dist/backend/codex-metadata.d.ts +33 -0
  23. package/dist/backend/codex-metadata.js +122 -18
  24. package/dist/backend/codex-metadata.js.map +1 -1
  25. package/dist/backend/codex.d.ts +3 -0
  26. package/dist/backend/codex.js +17 -1
  27. package/dist/backend/codex.js.map +1 -1
  28. package/dist/backend/credential-profile.d.ts +40 -3
  29. package/dist/backend/credential-profile.js +113 -5
  30. package/dist/backend/credential-profile.js.map +1 -1
  31. package/dist/backend/effort-metadata.d.ts +37 -1
  32. package/dist/backend/effort-metadata.js +86 -15
  33. package/dist/backend/effort-metadata.js.map +1 -1
  34. package/dist/backend/grok.d.ts +4 -0
  35. package/dist/backend/grok.js +14 -0
  36. package/dist/backend/grok.js.map +1 -1
  37. package/dist/backend/kiro.d.ts +27 -1
  38. package/dist/backend/kiro.js +63 -10
  39. package/dist/backend/kiro.js.map +1 -1
  40. package/dist/backend/launch-effort.d.ts +61 -0
  41. package/dist/backend/launch-effort.js +170 -0
  42. package/dist/backend/launch-effort.js.map +1 -0
  43. package/dist/backend/muse.d.ts +84 -0
  44. package/dist/backend/muse.js +449 -53
  45. package/dist/backend/muse.js.map +1 -1
  46. package/dist/backend/types.d.ts +48 -2
  47. package/dist/backend/types.js +12 -1
  48. package/dist/backend/types.js.map +1 -1
  49. package/dist/backend-command.d.ts +91 -0
  50. package/dist/backend-command.js +122 -0
  51. package/dist/backend-command.js.map +1 -0
  52. package/dist/background-work-ended.d.ts +18 -0
  53. package/dist/background-work-ended.js +30 -0
  54. package/dist/background-work-ended.js.map +1 -0
  55. package/dist/cache-service.d.ts +2 -0
  56. package/dist/cache-service.js +2 -1
  57. package/dist/cache-service.js.map +1 -1
  58. package/dist/channel/adapters/discord.d.ts +26 -1
  59. package/dist/channel/adapters/discord.js +103 -3
  60. package/dist/channel/adapters/discord.js.map +1 -1
  61. package/dist/channel/adapters/telegram.d.ts +35 -3
  62. package/dist/channel/adapters/telegram.js +122 -23
  63. package/dist/channel/adapters/telegram.js.map +1 -1
  64. package/dist/channel/mcp-tools.js +5 -3
  65. package/dist/channel/mcp-tools.js.map +1 -1
  66. package/dist/channel/message-edit-errors.d.ts +5 -0
  67. package/dist/channel/message-edit-errors.js +24 -0
  68. package/dist/channel/message-edit-errors.js.map +1 -0
  69. package/dist/channel/telegram-markdown.d.ts +6 -0
  70. package/dist/channel/telegram-markdown.js +104 -0
  71. package/dist/channel/telegram-markdown.js.map +1 -0
  72. package/dist/channel/tool-router.d.ts +2 -0
  73. package/dist/channel/tool-router.js +6 -5
  74. package/dist/channel/tool-router.js.map +1 -1
  75. package/dist/channel/types.d.ts +46 -4
  76. package/dist/cli.js +184 -34
  77. package/dist/cli.js.map +1 -1
  78. package/dist/command-table.d.ts +2 -2
  79. package/dist/command-table.js +5 -2
  80. package/dist/command-table.js.map +1 -1
  81. package/dist/compact-watch.d.ts +86 -0
  82. package/dist/compact-watch.js +288 -0
  83. package/dist/compact-watch.js.map +1 -0
  84. package/dist/config-validator.js +55 -5
  85. package/dist/config-validator.js.map +1 -1
  86. package/dist/config.d.ts +1 -0
  87. package/dist/config.js +2 -1
  88. package/dist/config.js.map +1 -1
  89. package/dist/cross-instance-envelope.js +2 -0
  90. package/dist/cross-instance-envelope.js.map +1 -1
  91. package/dist/daemon.d.ts +129 -1
  92. package/dist/daemon.js +754 -94
  93. package/dist/daemon.js.map +1 -1
  94. package/dist/delivery-outbox.d.ts +64 -8
  95. package/dist/delivery-outbox.js +315 -24
  96. package/dist/delivery-outbox.js.map +1 -1
  97. package/dist/delivery-reconciliation.d.ts +18 -1
  98. package/dist/delivery-reconciliation.js +67 -2
  99. package/dist/delivery-reconciliation.js.map +1 -1
  100. package/dist/doctor.js +7 -0
  101. package/dist/doctor.js.map +1 -1
  102. package/dist/fleet-context.d.ts +12 -1
  103. package/dist/fleet-manager.d.ts +392 -19
  104. package/dist/fleet-manager.js +2030 -275
  105. package/dist/fleet-manager.js.map +1 -1
  106. package/dist/forged-envelope-scan.d.ts +4 -3
  107. package/dist/forged-envelope-scan.js +4 -3
  108. package/dist/forged-envelope-scan.js.map +1 -1
  109. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +84 -16
  110. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +16 -1
  111. package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
  112. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -0
  113. package/dist/host-memory.d.ts +10 -0
  114. package/dist/host-memory.js +33 -1
  115. package/dist/host-memory.js.map +1 -1
  116. package/dist/human-delivery-receipts.d.ts +83 -0
  117. package/dist/human-delivery-receipts.js +194 -0
  118. package/dist/human-delivery-receipts.js.map +1 -0
  119. package/dist/install-lock.d.ts +18 -1
  120. package/dist/install-lock.js +30 -3
  121. package/dist/install-lock.js.map +1 -1
  122. package/dist/instance-avatar.d.ts +5 -4
  123. package/dist/instance-avatar.js +12 -8
  124. package/dist/instance-avatar.js.map +1 -1
  125. package/dist/instance-config-impact.js +1 -0
  126. package/dist/instance-config-impact.js.map +1 -1
  127. package/dist/instance-lifecycle.d.ts +25 -6
  128. package/dist/instance-lifecycle.js +76 -27
  129. package/dist/instance-lifecycle.js.map +1 -1
  130. package/dist/instructions.d.ts +1 -1
  131. package/dist/instructions.js +3 -3
  132. package/dist/instructions.js.map +1 -1
  133. package/dist/kiro-compact-pane.d.ts +38 -0
  134. package/dist/kiro-compact-pane.js +57 -0
  135. package/dist/kiro-compact-pane.js.map +1 -0
  136. package/dist/kiro-log-gate.d.ts +42 -0
  137. package/dist/kiro-log-gate.js +39 -0
  138. package/dist/kiro-log-gate.js.map +1 -0
  139. package/dist/launchd-handoff.d.ts +43 -0
  140. package/dist/launchd-handoff.js +56 -0
  141. package/dist/launchd-handoff.js.map +1 -0
  142. package/dist/locale.js +268 -42
  143. package/dist/locale.js.map +1 -1
  144. package/dist/log-service.d.ts +99 -0
  145. package/dist/log-service.js +243 -0
  146. package/dist/log-service.js.map +1 -0
  147. package/dist/log-steps.d.ts +169 -0
  148. package/dist/log-steps.js +858 -0
  149. package/dist/log-steps.js.map +1 -0
  150. package/dist/login-controller.d.ts +20 -3
  151. package/dist/login-controller.js +71 -19
  152. package/dist/login-controller.js.map +1 -1
  153. package/dist/login-flows.d.ts +5 -2
  154. package/dist/login-flows.js +5 -4
  155. package/dist/login-flows.js.map +1 -1
  156. package/dist/login-shell.d.ts +21 -0
  157. package/dist/login-shell.js +98 -0
  158. package/dist/login-shell.js.map +1 -0
  159. package/dist/memory-pressure.d.ts +13 -1
  160. package/dist/memory-pressure.js +50 -10
  161. package/dist/memory-pressure.js.map +1 -1
  162. package/dist/needs-you-hub.js +2 -0
  163. package/dist/needs-you-hub.js.map +1 -1
  164. package/dist/needs-you-live.d.ts +6 -0
  165. package/dist/needs-you-live.js +23 -3
  166. package/dist/needs-you-live.js.map +1 -1
  167. package/dist/outbound-handlers.d.ts +100 -0
  168. package/dist/outbound-handlers.js +277 -93
  169. package/dist/outbound-handlers.js.map +1 -1
  170. package/dist/outbound-schemas.d.ts +30 -15
  171. package/dist/outbound-schemas.js +23 -5
  172. package/dist/outbound-schemas.js.map +1 -1
  173. package/dist/provider-probe.d.ts +29 -0
  174. package/dist/provider-probe.js +198 -2
  175. package/dist/provider-probe.js.map +1 -1
  176. package/dist/public-link-progress.d.ts +1 -1
  177. package/dist/public-link-progress.js +8 -2
  178. package/dist/public-link-progress.js.map +1 -1
  179. package/dist/public-preview.d.ts +101 -0
  180. package/dist/public-preview.js +236 -0
  181. package/dist/public-preview.js.map +1 -0
  182. package/dist/public-web-gateway.d.ts +2 -0
  183. package/dist/public-web-gateway.js +20 -6
  184. package/dist/public-web-gateway.js.map +1 -1
  185. package/dist/public-web-link.d.ts +24 -1
  186. package/dist/public-web-link.js +63 -9
  187. package/dist/public-web-link.js.map +1 -1
  188. package/dist/quickstart-api.d.ts +1 -1
  189. package/dist/quickstart-api.js +8 -6
  190. package/dist/quickstart-api.js.map +1 -1
  191. package/dist/quickstart.d.ts +12 -0
  192. package/dist/quickstart.js +36 -6
  193. package/dist/quickstart.js.map +1 -1
  194. package/dist/reply-buttons-controller.d.ts +89 -5
  195. package/dist/reply-buttons-controller.js +273 -8
  196. package/dist/reply-buttons-controller.js.map +1 -1
  197. package/dist/reply-buttons.d.ts +48 -3
  198. package/dist/reply-buttons.js +105 -10
  199. package/dist/reply-buttons.js.map +1 -1
  200. package/dist/reply-polls.d.ts +85 -0
  201. package/dist/reply-polls.js +154 -0
  202. package/dist/reply-polls.js.map +1 -0
  203. package/dist/restart-guard.d.ts +6 -3
  204. package/dist/restart-guard.js +6 -3
  205. package/dist/restart-guard.js.map +1 -1
  206. package/dist/restart-progress.d.ts +33 -2
  207. package/dist/restart-progress.js +30 -2
  208. package/dist/restart-progress.js.map +1 -1
  209. package/dist/sd-notify.d.ts +22 -1
  210. package/dist/sd-notify.js +63 -10
  211. package/dist/sd-notify.js.map +1 -1
  212. package/dist/service-activation.d.ts +15 -7
  213. package/dist/service-activation.js +42 -26
  214. package/dist/service-activation.js.map +1 -1
  215. package/dist/service-installer.d.ts +55 -0
  216. package/dist/service-installer.js +90 -3
  217. package/dist/service-installer.js.map +1 -1
  218. package/dist/service-plan.d.ts +11 -0
  219. package/dist/service-plan.js +23 -0
  220. package/dist/service-plan.js.map +1 -1
  221. package/dist/settings-api.js +14 -4
  222. package/dist/settings-api.js.map +1 -1
  223. package/dist/settings-change.js +4 -2
  224. package/dist/settings-change.js.map +1 -1
  225. package/dist/settings-effect.js +2 -0
  226. package/dist/settings-effect.js.map +1 -1
  227. package/dist/spawn-gate.js +5 -1
  228. package/dist/spawn-gate.js.map +1 -1
  229. package/dist/spawn-hold-reply.d.ts +12 -0
  230. package/dist/spawn-hold-reply.js +39 -0
  231. package/dist/spawn-hold-reply.js.map +1 -0
  232. package/dist/systemd-peer-bus.d.ts +95 -0
  233. package/dist/systemd-peer-bus.js +430 -0
  234. package/dist/systemd-peer-bus.js.map +1 -0
  235. package/dist/systemd-runtime.d.ts +27 -1
  236. package/dist/systemd-runtime.js +69 -7
  237. package/dist/systemd-runtime.js.map +1 -1
  238. package/dist/tips.js +1 -1
  239. package/dist/tips.js.map +1 -1
  240. package/dist/tool-permissions.js +3 -3
  241. package/dist/tool-permissions.js.map +1 -1
  242. package/dist/tool-progress.js +3 -2
  243. package/dist/tool-progress.js.map +1 -1
  244. package/dist/topic-commands.d.ts +71 -4
  245. package/dist/topic-commands.js +167 -34
  246. package/dist/topic-commands.js.map +1 -1
  247. package/dist/transcript-delta-reader.d.ts +3 -3
  248. package/dist/transcript-delta-reader.js +8 -8
  249. package/dist/transcript-delta-reader.js.map +1 -1
  250. package/dist/transcript-jsonl.d.ts +85 -3
  251. package/dist/transcript-jsonl.js +156 -13
  252. package/dist/transcript-jsonl.js.map +1 -1
  253. package/dist/transcript-monitor.d.ts +11 -0
  254. package/dist/transcript-monitor.js +61 -13
  255. package/dist/transcript-monitor.js.map +1 -1
  256. package/dist/transcript-sources.d.ts +24 -0
  257. package/dist/transcript-sources.js +211 -56
  258. package/dist/transcript-sources.js.map +1 -1
  259. package/dist/tunnel/cloudflared.d.ts +7 -15
  260. package/dist/tunnel/cloudflared.js +36 -1
  261. package/dist/tunnel/cloudflared.js.map +1 -1
  262. package/dist/tunnel/purpose-lane.d.ts +5 -1
  263. package/dist/tunnel/purpose-lane.js +14 -7
  264. package/dist/tunnel/purpose-lane.js.map +1 -1
  265. package/dist/tunnel/types.d.ts +5 -1
  266. package/dist/tunnel/types.js.map +1 -1
  267. package/dist/turn-resume.d.ts +2 -0
  268. package/dist/turn-resume.js +1 -1
  269. package/dist/turn-resume.js.map +1 -1
  270. package/dist/types.d.ts +22 -0
  271. package/dist/ui/chat-commands.js +1 -0
  272. package/dist/ui/chat-mentions.js +63 -0
  273. package/dist/ui/chat-render.js +80 -10
  274. package/dist/ui/chat-store.js +70 -6
  275. package/dist/ui/chat-strings.js +62 -32
  276. package/dist/ui/chat-thread.js +199 -24
  277. package/dist/ui/chat-time.js +80 -0
  278. package/dist/ui/chat-tour.js +17 -14
  279. package/dist/ui/panel-chat.js +45 -21
  280. package/dist/ui/panel-details.js +2 -0
  281. package/dist/ui/panel-fleet.js +213 -58
  282. package/dist/ui/panel-log.js +428 -0
  283. package/dist/ui/panel-needs.js +14 -10
  284. package/dist/ui/panel-settings.js +82 -17
  285. package/dist/ui/preview-panel.js +55 -15
  286. package/dist/ui/preview-status.js +28 -0
  287. package/dist/ui/preview.js +44 -3
  288. package/dist/ui/settings-dialogs.js +11 -6
  289. package/dist/ui/settings-model.js +3 -1
  290. package/dist/ui/settings-strings.js +32 -12
  291. package/dist/ui/settings-token.js +8 -1
  292. package/dist/ui/settings-wizard.js +5 -3
  293. package/dist/ui/shared/app-i18n.js +41 -15
  294. package/dist/ui/shared/app-prefs.js +250 -0
  295. package/dist/ui/shared/app-route.js +6 -1
  296. package/dist/ui/shared/app-shell.js +165 -8
  297. package/dist/ui/shared/app-store.js +5 -1
  298. package/dist/ui/shared/app-stream.js +26 -5
  299. package/dist/ui/shared/app.css +170 -30
  300. package/dist/ui/shared/app.js +11 -1
  301. package/dist/ui/shared/fonts/README.md +1 -1
  302. package/dist/ui/shared/header-tools.js +83 -13
  303. package/dist/ui/shared/instance-nav.js +178 -51
  304. package/dist/ui/shared/instance-switch.js +8 -5
  305. package/dist/ui/shared/panel-view.js +21 -4
  306. package/dist/ui/shared/signin.js +15 -9
  307. package/dist/ui/shared/theme.js +39 -7
  308. package/dist/ui/shared/tokens.css +1 -1
  309. package/dist/ui/shared/ui-icons.js +2 -0
  310. package/dist/ui/shared/usage-dialog.js +23 -5
  311. package/dist/ui/shared/view-strings.js +40 -4
  312. package/dist/ui/signin.html +8 -3
  313. package/dist/unrecognised-prompt.d.ts +23 -0
  314. package/dist/unrecognised-prompt.js +74 -0
  315. package/dist/unrecognised-prompt.js.map +1 -0
  316. package/dist/update-check.d.ts +31 -0
  317. package/dist/update-check.js +47 -0
  318. package/dist/update-check.js.map +1 -1
  319. package/dist/update-install.d.ts +13 -1
  320. package/dist/update-install.js +16 -4
  321. package/dist/update-install.js.map +1 -1
  322. package/dist/update-launch.d.ts +48 -1
  323. package/dist/update-launch.js +159 -5
  324. package/dist/update-launch.js.map +1 -1
  325. package/dist/update-marker.d.ts +51 -0
  326. package/dist/update-marker.js +61 -0
  327. package/dist/update-marker.js.map +1 -1
  328. package/dist/usage/i18n-keys.d.ts +1 -1
  329. package/dist/usage/i18n-keys.js +1 -1
  330. package/dist/usage/i18n-keys.js.map +1 -1
  331. package/dist/usage/providers.d.ts +62 -13
  332. package/dist/usage/providers.js +256 -68
  333. package/dist/usage/providers.js.map +1 -1
  334. package/dist/usage/statusline-usage.d.ts +6 -2
  335. package/dist/usage/statusline-usage.js +10 -4
  336. package/dist/usage/statusline-usage.js.map +1 -1
  337. package/dist/view-api.js +31 -9
  338. package/dist/view-api.js.map +1 -1
  339. package/dist/web-api.d.ts +82 -4
  340. package/dist/web-api.js +632 -88
  341. package/dist/web-api.js.map +1 -1
  342. package/dist/web-auth.js +5 -2
  343. package/dist/web-auth.js.map +1 -1
  344. package/dist/web-chat-history.d.ts +26 -1
  345. package/dist/web-chat-history.js +47 -8
  346. package/dist/web-chat-history.js.map +1 -1
  347. package/dist/web-commands.d.ts +41 -5
  348. package/dist/web-commands.js +25 -7
  349. package/dist/web-commands.js.map +1 -1
  350. package/dist/web-login.d.ts +14 -0
  351. package/dist/web-login.js +4 -2
  352. package/dist/web-login.js.map +1 -1
  353. package/dist/web-mentions.d.ts +44 -0
  354. package/dist/web-mentions.js +75 -0
  355. package/dist/web-mentions.js.map +1 -0
  356. package/dist/web-prefs.d.ts +81 -0
  357. package/dist/web-prefs.js +297 -0
  358. package/dist/web-prefs.js.map +1 -0
  359. package/dist/web-preview.d.ts +14 -1
  360. package/dist/web-preview.js +35 -13
  361. package/dist/web-preview.js.map +1 -1
  362. package/dist/web-profile.d.ts +32 -0
  363. package/dist/web-profile.js +132 -0
  364. package/dist/web-profile.js.map +1 -0
  365. package/dist/web-reply-files.d.ts +45 -0
  366. package/dist/web-reply-files.js +223 -0
  367. package/dist/web-reply-files.js.map +1 -0
  368. package/dist/web-schedules.d.ts +12 -0
  369. package/dist/web-schedules.js +34 -0
  370. package/dist/web-schedules.js.map +1 -0
  371. package/dist/web-server-drain.d.ts +10 -0
  372. package/dist/web-server-drain.js +50 -0
  373. package/dist/web-server-drain.js.map +1 -0
  374. package/dist/web-session.d.ts +14 -0
  375. package/dist/web-session.js +11 -1
  376. package/dist/web-session.js.map +1 -1
  377. package/dist/web-settings-patch.d.ts +3 -1
  378. package/dist/web-settings-patch.js +4 -1
  379. package/dist/web-settings-patch.js.map +1 -1
  380. package/dist/web-shell-routes.d.ts +4 -0
  381. package/dist/web-shell-routes.js +9 -0
  382. package/dist/web-shell-routes.js.map +1 -1
  383. package/dist/web-upload.d.ts +135 -7
  384. package/dist/web-upload.js +315 -51
  385. package/dist/web-upload.js.map +1 -1
  386. package/launcher/install-admission.cjs +61 -6
  387. package/package.json +3 -3
  388. package/templates/launchd.plist.ejs +3 -1
  389. package/templates/systemd.service.ejs +2 -1
@@ -9,6 +9,7 @@
9
9
  import "./chat-render.js";
10
10
  import "./preview.js";
11
11
  import { loadHtmlAttachment } from "./html-attachment.js";
12
+ import { chatTime, chatTimeFull, onDayChange } from "./chat-time.js";
12
13
 
13
14
  const R = () => globalThis.AgendChatRender;
14
15
  /** #1589: the public link shows attachments as downloads only (its page says so: <body data-public-link="1">). */
@@ -22,16 +23,25 @@ export const TEXT_PREVIEW_NODES = 4000;
22
23
  /** A csv/tsv wider than this is shown as text, as Markdown tables are capped at 20 columns. */
23
24
  export const TEXT_PREVIEW_COLUMNS = 50;
24
25
  const P = () => globalThis.AgendPreview;
26
+ /** #1672: a status line longer than this (lines, or characters) is folded until it is opened. */
27
+ export const STATUS_FOLD_LINES = 6;
28
+ export const STATUS_FOLD_CHARS = 600;
25
29
  const COPY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
26
30
 
27
31
  /** Escapes & " ' < >: safe for text and inside a quoted attribute. */
28
32
  export function escAttr(s) { return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
29
33
  export const msgKey = (x) => `${x.boot}-${x.id}`;
34
+ /**
35
+ * Board 00ef2430: a thread opens with only its newest messages drawn — enough for a screen — so switching chats paints
36
+ * at once; the older ones are drawn after that, a chunk at a time between frames, the reader's place kept.
37
+ */
38
+ export const THREAD_TAIL = 24;
39
+ export const THREAD_CHUNK = 40;
30
40
 
31
41
  /**
32
42
  * Bind a renderer to `list` (the thread element) inside `scroller`. opts: { t, tf, isUser(x), onJump({show, unseen}),
33
43
  * setPreviewOptIn(on), copyText(text) → Promise<boolean>, download(code), panel? }. panel (#1481): { shown() → the card
34
- * key the side panel shows, or null; open(spec, { run }) } — without it a card has no "Open in panel".
44
+ * key the side panel shows, or null; open(spec, { run }); version(); claim(); claims() } — without it a card has no "Open in panel".
35
45
  */
36
46
  export function createThread(list, scroller, opts) {
37
47
  const nodes = new Map(); // key → { html, node }
@@ -43,20 +53,68 @@ export function createThread(list, scroller, opts) {
43
53
  // moves down or stays), and those must not count as the reader leaving the bottom. Reaching the bottom pins again.
44
54
  let pinned = true;
45
55
  let lastTop = 0;
56
+ // The oldest message drawn (by key): everything after it is drawn, so new messages never push the window. Null: none
57
+ // drawn yet — the next render draws the newest THREAD_TAIL and the rest follows (grow).
58
+ let firstKey = null;
59
+ let growTimer = null, growFrame = null, disposed = false;
46
60
  const cardRefresh = new WeakMap(); // card node → re-read availability (weak: a card that left takes its source with it)
47
61
  const tr = opts.t, trf = opts.tf;
48
62
  const stopIn = (node) => (P() ? P().stopIn(node) : []);
63
+ /** #1770: write a message node's time label for now; unchanged text is not touched. */
64
+ function timeLabel(node) {
65
+ const el = node.querySelector(".meta .time[data-ts]");
66
+ if (!el) return;
67
+ const v = chatTime(el.dataset.ts, { yesterday: (hm) => trf("chat.yesterdayAt", hm) });
68
+ if (el.textContent !== v) el.textContent = v;
69
+ }
70
+ // #1770: "today" ends at the fleet's midnight; the labels are rewritten then (and on every render), while there are any.
71
+ let offDay = null;
72
+ function armDay() {
73
+ if (nodes.size && !offDay) offDay = onDayChange(() => { for (const v of nodes.values()) timeLabel(v.node); });
74
+ else if (!nodes.size && offDay) { offDay(); offDay = null; }
75
+ }
49
76
 
50
77
  const TICKS = () => ({ queued: tr("chat.tick_queued"), processing: tr("chat.tick_processing"), delivered: tr("chat.tick_delivered"), failed: tr("chat.tick_failed"), cancelled: tr("chat.tick_cancelled") });
51
78
 
52
79
  function msgHtml(x) {
80
+ // #1770: the label ("17:38", "Yesterday 17:38", "10/11 17:38") depends on today, so it is not part of the html a node
81
+ // is kept by: it is written into the node (timeLabels) — crossing midnight relabels in place and reloads no preview.
82
+ const time = x.ts ? `<span class="time" data-ts="${escAttr(x.ts)}" title="${escAttr(chatTimeFull(x.ts))}"></span>` : `<span class="time"></span>`;
83
+ if (x.role === "status") return statusHtml(x, time);
53
84
  const u = opts.isUser(x);
54
- const time = x.ts ? new Date(x.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
55
85
  // renderMarkdown escapes the whole text before it adds any tag of its own (chat-render.js).
56
86
  const ticks = u && x.delivery ? R().deliveryHtml(x.delivery, TICKS()) : "";
57
87
  const tools = u ? "" : `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
58
88
  const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
59
- return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, { htmlCards: x.role === "agent", emojis: opts.emojis })}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
89
+ // A person's click on a reply button shows as the button's label, never the line the agent was given (its value).
90
+ const clicked = x.role === "user" && x.click && typeof x.click.label === "string";
91
+ return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div><div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${R().renderMarkdown(x.text, { htmlCards: x.role === "agent", emojis: opts.emojis, mentions: opts.mentions })}</div>`}${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
92
+ }
93
+ /**
94
+ * A button click as a chip: 🔘 and the label (escaped: the label is the agent's text). With `of` (live, while its
95
+ * reply is in this thread) it is a button that shows that reply; restored from disk, plain.
96
+ */
97
+ function clickHtml(click) {
98
+ const inner = `<span class="click-mark" aria-hidden="true">🔘</span><span class="sr-only">${escAttr(tr("chat.clicked"))} </span>${escAttr(click.label)}`;
99
+ const of = typeof click.of === "string" && /^[0-9a-f]{32}$/.test(click.of) ? click.of : null;
100
+ return `<div class="click-chip">${of
101
+ ? `<button type="button" class="click-link" data-act="showButtons" data-arg="${escAttr(of)}" title="${escAttr(tr("chat.clickShow"))}">${inner}</button>`
102
+ : `<span class="click-text">${inner}</span>`}</div>`;
103
+ }
104
+ /**
105
+ * #1672: a status line (another instance's message, a daemon notice) is muted, read-only plain text — never Markdown,
106
+ * a preview card, a button or a person's bubble. Checked before isUser, which reads the sender: a fleet name need not
107
+ * start with "agend". A long one is folded until it is opened.
108
+ */
109
+ function statusHtml(x, time) {
110
+ const text = String(x.text == null ? "" : x.text);
111
+ const lines = R().lineCount(text);
112
+ const manyLines = lines > STATUS_FOLD_LINES;
113
+ const more = manyLines || text.length > STATUS_FOLD_CHARS
114
+ ? `<button type="button" class="st-more" data-act="toggleFold"${manyLines ? ` data-lines="${escAttr(lines)}"` : ""}>${escAttr(manyLines ? trf("chat.showAll", lines) : tr("chat.showMore"))}</button>`
115
+ : "";
116
+ const tools = `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
117
+ return `<div class="msg status-line${more ? " folded" : ""}"><div class="meta">${time}</div><div class="body"><div class="st-text">${escAttr(text)}</div>${more}</div>${tools}</div>`;
60
118
  }
61
119
  /**
62
120
  * #1266: an agent reply's buttons. Labels are text (escaped, never Markdown); the click names the set and the index,
@@ -64,6 +122,7 @@ export function createThread(list, scroller, opts) {
64
122
  */
65
123
  function replyButtonsHtml(b) {
66
124
  if (!b || typeof b.id !== "string" || !Array.isArray(b.labels) || !b.labels.length) return "";
125
+ if (b.poll && typeof b.poll === "object") return pollHtml(b);
67
126
  const busy = !!(opts.replyButtonBusy && opts.replyButtonBusy(b.id));
68
127
  const off = b.state !== "open" || busy;
69
128
  const items = b.labels.map((label, i) => {
@@ -73,11 +132,51 @@ export function createThread(list, scroller, opts) {
73
132
  const note = b.state === "chosen" ? trf("chat.rbChosen", b.by || "") : b.state === "expired" ? tr("chat.rbExpired") : "";
74
133
  return `<div class="rb-row" role="group" aria-label="${escAttr(tr("chat.rbGroup"))}">${items}${note ? `<span class="rb-note">${escAttr(note)}</span>` : ""}</div>`;
75
134
  }
135
+ /**
136
+ * #1823: a poll. Anyone here may click while it is open; the page marks its own vote (opts.pollMine), which no one else
137
+ * sees. Counts only when the server sends them (revealed: live, or closed); names only at close with show_voters. The
138
+ * time left is not in the html (a node is kept by its html): pollLeft writes it, every second.
139
+ */
140
+ function pollHtml(b) {
141
+ const p = b.poll, closed = b.state === "closed";
142
+ const busy = !!(opts.replyButtonBusy && opts.replyButtonBusy(b.id));
143
+ const mine = (opts.pollMine && opts.pollMine(b.id)) || [];
144
+ const counts = Array.isArray(p.counts) && p.counts.length === b.labels.length ? p.counts : null;
145
+ const items = b.labels.map((label, i) => {
146
+ const me = !closed && mine.includes(i);
147
+ const tally = counts ? ` · ${counts[i]}${closed && p.answered ? ` (${Math.round((counts[i] / p.answered) * 100)}%)` : ""}` : "";
148
+ return `<button type="button" class="btn btn-sm rb-btn${me ? " chosen" : ""}" data-act="replyButton" data-arg="${escAttr(`${b.id}:${i}`)}" aria-pressed="${me ? "true" : "false"}"${closed || busy ? " disabled" : ""}>${escAttr(`${me ? "✓ " : ""}${label}${tally}`)}</button>`;
149
+ }).join("");
150
+ const status = closed
151
+ ? escAttr(trf("chat.pollClosed", p.answered))
152
+ : `${escAttr(trf("chat.pollAnswered", p.answered))} · ⏳ <span class="poll-left" data-deadline="${escAttr(p.deadline)}" title="${escAttr(tr("chat.pollLeft"))}"></span>`;
153
+ const voters = closed && Array.isArray(p.voters)
154
+ ? `<ul class="poll-voters" aria-label="${escAttr(tr("chat.pollVoters"))}">${b.labels.map((label, i) => `<li>${escAttr(`${label}: ${(p.voters[i] || []).join(", ") || "—"}`)}</li>`).join("")}</ul>`
155
+ : "";
156
+ return `<div class="rb-row poll" role="group" aria-label="${escAttr(tr("chat.pollGroup"))}">${items}<span class="rb-note">${status}</span></div>${voters}`;
157
+ }
158
+ /** #1823: the time left on this node's open polls, now. */
159
+ function pollLeft(node) {
160
+ for (const el of node.querySelectorAll(".poll-left[data-deadline]")) {
161
+ const v = R().pollRemaining(Number(el.dataset.deadline) - (opts.serverNow ? opts.serverNow() : Date.now()));
162
+ if (el.textContent !== v) el.textContent = v;
163
+ }
164
+ }
165
+ // #1823: while an open poll is on screen, its time left is rewritten every second.
166
+ let pollTick = null;
167
+ function armPolls() {
168
+ const open = nodes.size > 0 && !!list.querySelector(".poll-left[data-deadline]");
169
+ if (open && !pollTick) pollTick = setInterval(() => pollLeft(list), 1000);
170
+ else if (!open && pollTick) { clearInterval(pollTick); pollTick = null; }
171
+ }
172
+
76
173
  function msgNode(html, x, stoppedKeys) {
77
174
  const tpl = list.ownerDocument.createElement("template");
78
175
  tpl.innerHTML = html;
79
176
  const node = tpl.content.firstElementChild;
80
177
  decorateCode(node);
178
+ timeLabel(node);
179
+ pollLeft(node);
81
180
  if (x) decorateHtmlCards(node, x, stoppedKeys || []);
82
181
  if (x) decorateTextCards(node, x);
83
182
  return node;
@@ -299,14 +398,32 @@ export function createThread(list, scroller, opts) {
299
398
  };
300
399
  const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
301
400
  // Nothing is read for Preview on a device that does not allow previews: the button is not even shown there.
302
- run.onclick = () => { if (P().availability().ok) withCode((c) => P().start(key, holder, c, ui)); };
401
+ // A Preview click is newer than any Open in panel still reading (#1704): it claims the panel's turn too — and, its
402
+ // own HTML read, starts only if no newer click claimed meanwhile and no preview was started since (the panel's own
403
+ // Preview): a late read never undoes a newer choice. A close is neither, so it does not drop this click.
404
+ run.onclick = () => {
405
+ if (!P().availability().ok || reading) return; // this card already reading: that click stays the one
406
+ const mine = panel ? (panel.claim(), panel.claims()) : 0, started = P().startCount();
407
+ withCode((c) => {
408
+ if ((panel && panel.claims() !== mine) || P().startCount() !== started) { refresh(); return; }
409
+ P().start(key, holder, c, ui);
410
+ });
411
+ };
303
412
  stopB.onclick = () => { if (reading) { reading = 0; refresh(); } else P().stop(key, "stopped", ""); };
304
- // A running preview moves: it stops here and starts in the panel (the click that ran it). An idle one opens the
305
- // panel with its own Preview to click.
413
+ // #1704: this click runs it in the panel, as Preview there would — a running preview moves (it stops here and
414
+ // starts there), an idle one starts there. Whether it can run is the panel's to say: a device that has not allowed
415
+ // previews is offered Allow first (and allowing runs it); where previews are off, nothing runs.
416
+ // An attachment's HTML is read first. The click claims the panel at once, so a newer click (even one still reading)
417
+ // wins: if the panel changed (another click, block, close) or any preview started meanwhile, this one is dropped —
418
+ // a late read never undoes a newer click.
306
419
  if (toPanel) toPanel.onclick = () => {
307
- const moving = P().running(key);
308
- if (moving) P().stop(key, "stopped", "");
309
- withCode((c) => panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts }, { run: moving }));
420
+ if (reading) return; // this card already reading: that click stays the one
421
+ if (P().running(key)) P().stop(key, "stopped", "");
422
+ const at = panel.claim(), started = P().startCount();
423
+ withCode((c) => {
424
+ if (panel.version() !== at || P().startCount() !== started) { refresh(); return; }
425
+ panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts }, { run: true });
426
+ });
310
427
  };
311
428
  if (dl) dl.onclick = () => opts.download(code);
312
429
  ack.onclick = () => P().ackBanner(); // every card and the panel hear it (onChange)
@@ -334,6 +451,44 @@ export function createThread(list, scroller, opts) {
334
451
  const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
335
452
  if (ro) { ro.observe(list); ro.observe(scroller); }
336
453
 
454
+ /** Where drawing starts in `msgs`: at the oldest drawn message, or (none yet) the newest THREAD_TAIL. */
455
+ function startIndex() {
456
+ const i = firstKey === null ? -1 : msgs.findIndex((x) => msgKey(x) === firstKey);
457
+ // Whatever is drawn stays drawn: the list may have reordered (merged across fleet restarts) or lost its oldest to
458
+ // the cap — so the window starts at the earlier of the oldest drawn key and the first message still drawn.
459
+ const still = nodes.size ? msgs.findIndex((x) => nodes.has(msgKey(x))) : -1;
460
+ const at = [i, still].filter((n) => n >= 0);
461
+ return at.length ? Math.min(...at) : Math.max(0, msgs.length - THREAD_TAIL);
462
+ }
463
+ /** Draw the next chunk of older messages, keeping the reader where they were; then the next, until all are drawn. */
464
+ function grow() {
465
+ growTimer = growFrame = null;
466
+ if (disposed) return; // the chat left: nothing more is drawn
467
+ const at = startIndex();
468
+ if (at <= 0) return;
469
+ firstKey = msgKey(msgs[Math.max(0, at - THREAD_CHUNK)]);
470
+ const fromBottom = scroller.scrollHeight - scroller.scrollTop;
471
+ draw({ older: true }); // drawn above the reader: never "new below" (render alone counts those)
472
+ if (pinned) toBottom();
473
+ else { scroller.scrollTop = scroller.scrollHeight - fromBottom; lastTop = scroller.scrollTop; }
474
+ scheduleGrow();
475
+ }
476
+ /** After the next frame has painted (the click's own frame stays light), draw a chunk more. */
477
+ function scheduleGrow() {
478
+ if (growTimer !== null || growFrame !== null || startIndex() <= 0) return;
479
+ const later = () => { growFrame = null; growTimer = setTimeout(grow, 0); };
480
+ if (typeof requestAnimationFrame === "function") growFrame = requestAnimationFrame(later); else later();
481
+ }
482
+ /** Everything drawn now (a remembered scroll position, a message to show that is not drawn yet). */
483
+ function growAll() {
484
+ if (startIndex() <= 0) return;
485
+ if (growFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(growFrame);
486
+ if (growTimer !== null) clearTimeout(growTimer);
487
+ growTimer = growFrame = null;
488
+ firstKey = msgs.length ? msgKey(msgs[0]) : null;
489
+ draw({ older: true });
490
+ }
491
+
337
492
  /** Draw `next` (this instance's messages). `restore`: the scroll position to return to (null = the bottom). */
338
493
  function render(next, renderOpts = {}) {
339
494
  const stick = pinned || nearBottom();
@@ -341,35 +496,51 @@ export function createThread(list, scroller, opts) {
341
496
  if (!msgs.length) {
342
497
  for (const v of nodes.values()) stopIn(v.node);
343
498
  nodes.clear();
499
+ firstKey = null;
500
+ armDay();
344
501
  list.textContent = "";
345
502
  opts.onEmpty(true);
346
503
  return;
347
504
  }
348
505
  opts.onEmpty(false);
349
- const keep = new Set(msgs.map(msgKey));
506
+ // A position to return to that is not the bottom needs the messages above it: all of them, at once (as before).
507
+ if ("restore" in renderOpts && renderOpts.restore != null) firstKey = msgKey(msgs[0]);
508
+ const added = draw({});
509
+ if (firstKey === null || startIndex() > 0) { firstKey = msgKey(msgs[startIndex()]); scheduleGrow(); }
510
+ if ("restore" in renderOpts) {
511
+ unseen = 0;
512
+ if (renderOpts.restore == null) toBottom();
513
+ else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); lastTop = scroller.scrollTop; }
514
+ } else if (stick) toBottom();
515
+ else unseen += added;
516
+ reportJump();
517
+ }
518
+ /** Put the messages from startIndex() on in the list (nodes reused when unchanged). Returns how many are new. */
519
+ function draw(drawOpts) {
520
+ const shown = msgs.slice(startIndex());
521
+ const keep = new Set(shown.map(msgKey));
350
522
  for (const [k, v] of nodes) if (!keep.has(k)) { stopIn(v.node); v.node.remove(); nodes.delete(k); }
351
523
  let prev = null, added = 0;
352
- for (const x of msgs) {
353
- const key = msgKey(x), html = msgHtml(x);
524
+ for (const x of shown) {
525
+ const key = msgKey(x);
354
526
  let have = nodes.get(key);
527
+ // Filling in older messages: a node drawn from this very message object is current — not drawn again.
528
+ if (drawOpts.older && have && have.x === x) { prev = have.node; continue; }
529
+ const html = msgHtml(x);
355
530
  if (!have || have.html !== html) {
356
531
  const stopped = have ? stopIn(have.node) : [];
357
532
  const node = msgNode(html, x, stopped);
358
533
  if (have) have.node.replaceWith(node); else added++;
359
- have = { html, node }; nodes.set(key, have);
360
- }
534
+ have = { html, node, x }; nodes.set(key, have);
535
+ } else { have.x = x; timeLabel(have.node); }
361
536
  const want = prev ? prev.nextSibling : list.firstChild;
362
537
  // A node already in the list is being moved: its preview stops first. A new node (still in its template) has none.
363
538
  if (have.node !== want) { if (have.node.parentNode === list) stopIn(have.node); list.insertBefore(have.node, want); }
364
539
  prev = have.node;
365
540
  }
366
- if ("restore" in renderOpts) {
367
- unseen = 0;
368
- if (renderOpts.restore == null) toBottom();
369
- else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); lastTop = scroller.scrollTop; }
370
- } else if (stick) toBottom();
371
- else unseen += added;
372
- reportJump();
541
+ armDay();
542
+ armPolls();
543
+ return added;
373
544
  }
374
545
 
375
546
  function onScroll() {
@@ -384,6 +555,7 @@ export function createThread(list, scroller, opts) {
384
555
  function jumpLatest() { toBottom(); unseen = 0; reportJump(); }
385
556
  /** Show one message (the panel's "from …" link, #1481): scrolled into view and briefly marked. */
386
557
  function reveal(key) {
558
+ if (!nodes.has(key) && msgs.some((x) => msgKey(x) === key)) growAll(); // an older message not drawn yet
387
559
  const have = nodes.get(key); if (!have) return false;
388
560
  if (typeof have.node.scrollIntoView === "function") have.node.scrollIntoView({ block: "center" });
389
561
  have.node.classList.add("flash");
@@ -426,21 +598,24 @@ export function createThread(list, scroller, opts) {
426
598
  if (act === "copyMsg") { const x = msgs.find(y => msgKey(y) === el.dataset.arg); if (x) copyFrom(el, x.text); }
427
599
  else if (act === "copyCode") { const pre = el.closest(".codeblock")?.querySelector("pre"); if (pre) copyFrom(el, pre.textContent); }
428
600
  else if (act === "toggleFold") {
429
- const box = el.closest(".codeblock"); if (!box) return;
601
+ const box = el.closest(".codeblock, .msg.status-line"); if (!box) return;
430
602
  const folded = box.classList.toggle("folded");
431
- el.textContent = folded ? trf("chat.showAll", el.dataset.lines) : tr("chat.showLess");
603
+ el.textContent = !folded ? tr("chat.showLess") : el.dataset.lines ? trf("chat.showAll", el.dataset.lines) : tr("chat.showMore");
432
604
  } else if (act === "toggleWrap") opts.toggleWrap();
433
605
  else if (act === "replyButton") {
434
606
  const m = /^([0-9a-f]{32}):(\d{1,2})$/.exec(el.dataset.arg || "");
435
607
  if (m && opts.clickReplyButton) opts.clickReplyButton(m[1], Number(m[2]));
436
- }
608
+ } else if (act === "showButtons") { const x = msgs.find(y => y.buttons && y.buttons.id === el.dataset.arg); if (x) reveal(msgKey(x)); }
437
609
  }
438
610
 
439
611
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
440
612
  function dispose() {
613
+ disposed = true;
441
614
  if (ro) ro.disconnect();
442
615
  if (P()) P().stopAll("leave");
443
616
  nodes.clear();
617
+ armDay();
618
+ armPolls();
444
619
  }
445
620
 
446
621
  return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, dispose, nodes, get unseen() { return unseen; } };
@@ -0,0 +1,80 @@
1
+ // #1770: the time a chat line shows, read in the fleet's zone (the one chat-logs, fleet.log and the #1586 DMs use) —
2
+ // "17:38" today, "Yesterday 17:38", "10/11 17:38" earlier this year, "2025/10/11 17:38" before. The hour and minute keep
3
+ // the browser's own format (12- or 24-hour), as they always had. No imports: the thread, the preview panel and the
4
+ // tests read it as it is.
5
+
6
+ const cache = new Map();
7
+ /** One formatter per (kind, zone, locale): a thread may label 500 lines on every render. */
8
+ function fmt(kind, tz, locale) {
9
+ const key = `${kind}|${tz || ""}|${locale || ""}`;
10
+ let f = cache.get(key);
11
+ if (!f) {
12
+ const o = kind === "hm" ? { hour: "2-digit", minute: "2-digit" }
13
+ : kind === "ymd" ? { year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric", hourCycle: "h23" }
14
+ : { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", timeZoneName: "short" };
15
+ f = new Intl.DateTimeFormat(kind === "ymd" ? "en-US" : (locale || []), { ...o, timeZone: tz || undefined });
16
+ cache.set(key, f);
17
+ }
18
+ return f;
19
+ }
20
+ /** `tz` when this browser knows it, else undefined (the browser's own zone): a label must still be drawn. */
21
+ function known(tz) {
22
+ if (!tz) return undefined;
23
+ try { fmt("ymd", tz); return tz; } catch { return undefined; }
24
+ }
25
+ /** The fleet's zone, as the page was served it (<body data-fleet-tz>); undefined when absent or unknown here. */
26
+ export function fleetTz() { return known(globalThis.document?.body?.dataset?.fleetTz); }
27
+
28
+ const toMs = (ts) => (typeof ts === "number" ? ts : Date.parse(String(ts || "")));
29
+ function wall(ms, tz) {
30
+ const p = {};
31
+ for (const x of fmt("ymd", tz).formatToParts(ms)) p[x.type] = Number(x.value);
32
+ return p;
33
+ }
34
+
35
+ /**
36
+ * The label for `ts` (ISO string or epoch ms), "" when it cannot be read. opts: tz (default the fleet's), now (epoch ms),
37
+ * yesterday(hm) → the word for "yesterday" around hm (default: hm alone), locale (tests).
38
+ */
39
+ export function chatTime(ts, opts = {}) {
40
+ const ms = toMs(ts);
41
+ if (!Number.isFinite(ms)) return "";
42
+ const tz = "tz" in opts ? known(opts.tz) : fleetTz();
43
+ const hm = fmt("hm", tz, opts.locale).format(ms);
44
+ const d = wall(ms, tz), today = wall(opts.now ?? Date.now(), tz);
45
+ if (d.year === today.year && d.month === today.month && d.day === today.day) return hm;
46
+ const y = new Date(Date.UTC(today.year, today.month - 1, today.day - 1));
47
+ if (d.year === y.getUTCFullYear() && d.month === y.getUTCMonth() + 1 && d.day === y.getUTCDate()) return opts.yesterday ? opts.yesterday(hm) : hm;
48
+ return d.year === today.year ? `${d.month}/${d.day} ${hm}` : `${d.year}/${d.month}/${d.day} ${hm}`;
49
+ }
50
+
51
+ /** The full date, time and zone of `ts`, for a tooltip; "" when it cannot be read. */
52
+ export function chatTimeFull(ts, opts = {}) {
53
+ const ms = toMs(ts);
54
+ if (!Number.isFinite(ms)) return "";
55
+ return fmt("full", "tz" in opts ? known(opts.tz) : fleetTz(), opts.locale).format(ms);
56
+ }
57
+
58
+ /** Milliseconds until the fleet's next midnight (when "today" labels change), at most an hour (a DST day self-corrects). */
59
+ export function msToNextDay(now = Date.now(), tz = fleetTz()) {
60
+ const w = wall(now, known(tz));
61
+ const into = ((w.hour * 60 + w.minute) * 60 + w.second) * 1000 + (now % 1000);
62
+ return Math.max(1000, Math.min(3_600_000, 86_400_000 - into + 1000));
63
+ }
64
+
65
+ /**
66
+ * Call `fn` when the fleet's day may have changed — at its next midnight, then again for each one after (an hour at
67
+ * most between looks, so a DST day or a late timer corrects itself). Each subscriber holds its own timer, and no
68
+ * module-wide state: the unsubscribe it returns leaves nothing armed.
69
+ */
70
+ export function onDayChange(fn) {
71
+ let timer = null, live = true;
72
+ const arm = () => {
73
+ timer = setTimeout(() => {
74
+ timer = null;
75
+ try { fn(); } finally { if (live) arm(); }
76
+ }, msToNextDay());
77
+ };
78
+ arm();
79
+ return () => { live = false; if (timer !== null) { clearTimeout(timer); timer = null; } };
80
+ }
@@ -1,7 +1,8 @@
1
- // #1366: the first sign-in tour, in the app shell (#1408). Shown once on this device (agend_tour_done), dismissed by
2
- // Got it, Skip or Esc, and replayed from Tour in the sidebar. A card that outlines one thing at a time: no overlay,
3
- // nothing blocked, and no style attribute — the outline is a class (#1300). When this browser's storage cannot be read
4
- // it is not shown at all: a tour that comes back on every load is worse than none.
1
+ // #1366: the chat tour, in the app shell (#1408). Since #1854 (rank 6) it never starts by itself: a first visit opens
2
+ // on a quiet page, and Settings → This device → Chat tour starts it on an agent's chat. It walks only the steps whose
3
+ // target is on the page. Got it, Skip or Esc ends it and records it (agend_tour_done, kept as before: no new key). A
4
+ // card that outlines one thing at a time: no overlay, nothing blocked, and no style attribute — the outline is a class
5
+ // (#1300).
5
6
  //
6
7
  // Esc order: the phone drawer covers the card, so it closes first (the shell); then the tour; only then may Esc reach
7
8
  // the chat and stop a reply. The Esc that closed the tour is spent: its key repeats (a held key) must not go on to stop
@@ -24,6 +25,8 @@ const STEPS = [
24
25
  : document.querySelector(".badge-await") || byId("instanceList")) },
25
26
  ];
26
27
  let at = -1, spot = null, returnTo = null, escHeld = false, installed = false;
28
+ /** The steps of the running tour: those whose target was on the page when it started (#1854: target-aware). */
29
+ let steps = STEPS;
27
30
 
28
31
  export function tourDone() { try { return localStorage.getItem(TOUR_KEY) === "1"; } catch { return true; } }
29
32
  export function tourStep() { return at; }
@@ -32,13 +35,15 @@ export function startTour() {
32
35
  // Replayed from the drawer: the drawer closes first (focus goes back to ☰), so the card is seen.
33
36
  closeDrawer();
34
37
  if (at < 0) returnTo = document.activeElement;
38
+ steps = STEPS.filter((s) => s.spot());
39
+ if (!steps.length) return;
35
40
  show(0);
36
41
  }
37
42
 
38
43
  /** The current step's outline follows a redraw (a new badge, Send swapped for Stop reply) — same card, same focus. */
39
44
  export function refreshTourSpot() {
40
45
  if (at < 0) return;
41
- const next = STEPS[at].spot() || null;
46
+ const next = steps[at].spot() || null;
42
47
  if (next === spot && (!next || next.classList.contains("tour-spot"))) return;
43
48
  if (spot) spot.classList.remove("tour-spot");
44
49
  spot = next;
@@ -46,10 +51,10 @@ export function refreshTourSpot() {
46
51
  }
47
52
 
48
53
  function show(i) {
49
- if (i < 0 || i >= STEPS.length) return;
54
+ if (i < 0 || i >= steps.length) return;
50
55
  at = i;
51
56
  if (spot) spot.classList.remove("tour-spot");
52
- spot = STEPS[i].spot() || null;
57
+ spot = steps[i].spot() || null;
53
58
  if (spot) spot.classList.add("tour-spot");
54
59
  let card = byId("tour");
55
60
  if (!card) {
@@ -60,18 +65,18 @@ function show(i) {
60
65
  }
61
66
  card.textContent = "";
62
67
  const title = document.createElement("h2"); title.id = "tourTitle"; title.textContent = t("chat.tourTitle");
63
- const text = document.createElement("p"); text.className = "tour-text"; text.setAttribute("aria-live", "polite"); text.textContent = t(STEPS[i].text);
68
+ const text = document.createElement("p"); text.className = "tour-text"; text.setAttribute("aria-live", "polite"); text.textContent = t(steps[i].text);
64
69
  const foot = document.createElement("div"); foot.className = "tour-foot";
65
- const count = document.createElement("span"); count.className = "tour-count"; count.textContent = t("chat.tourStep", i + 1, STEPS.length);
70
+ const count = document.createElement("span"); count.className = "tour-count"; count.textContent = t("chat.tourStep", i + 1, steps.length);
66
71
  const button = (label, act, arg, cls) => {
67
72
  const b = document.createElement("button"); b.type = "button"; b.className = `btn btn-sm ${cls}`; b.dataset.tour = act;
68
73
  if (arg != null) b.dataset.arg = String(arg);
69
74
  b.textContent = t(label); return b;
70
75
  };
71
76
  foot.append(count);
72
- if (i < STEPS.length - 1) foot.append(button("chat.tourSkip", "end", null, "btn-ghost"));
77
+ if (i < steps.length - 1) foot.append(button("chat.tourSkip", "end", null, "btn-ghost"));
73
78
  if (i > 0) foot.append(button("chat.tourBack", "move", -1, "btn-ghost"));
74
- const next = i < STEPS.length - 1 ? button("chat.tourNext", "move", 1, "btn-primary") : button("chat.tourDone", "end", null, "btn-primary");
79
+ const next = i < steps.length - 1 ? button("chat.tourNext", "move", 1, "btn-primary") : button("chat.tourDone", "end", null, "btn-primary");
75
80
  foot.append(next);
76
81
  card.append(title, text, foot);
77
82
  next.focus();
@@ -105,12 +110,10 @@ export function onTourKey(e) {
105
110
  if (at >= 0) { e.preventDefault(); e.stopPropagation(); endTour(); escHeld = true; }
106
111
  }
107
112
 
108
- /** Once per page: the key handler, the language follow-up, and the first showing on a device that has not seen it. */
113
+ /** Once per page: the key handler and the language follow-up. Nothing is shown: the tour is started by a person (#1854). */
109
114
  export function installTour() {
110
115
  if (installed) return;
111
116
  installed = true;
112
117
  document.addEventListener("keydown", onTourKey, true);
113
118
  onLang(() => { if (at >= 0) show(at); });
114
- // Shown first on the chat (it points at the chat's own controls), never over View or another panel.
115
- if (!tourDone() && /^\/ui(\/chat\/|$)/.test(location.pathname)) setTimeout(startTour, 0);
116
119
  }