@songsid/agend 2.3.0-beta.21 → 2.3.0-beta.23

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 (300) hide show
  1. package/README.md +2 -2
  2. package/README.zh-TW.md +2 -2
  3. package/dist/agent-cli.js +4 -2
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +9 -0
  6. package/dist/agent-endpoint.js +15 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-whats-new.d.ts +5 -0
  9. package/dist/agent-whats-new.js +16 -1
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/auth-api.d.ts +7 -0
  12. package/dist/auth-api.js +10 -3
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/backend/codex-package.d.ts +30 -0
  15. package/dist/backend/codex-package.js +133 -0
  16. package/dist/backend/codex-package.js.map +1 -0
  17. package/dist/backend/codex.d.ts +2 -0
  18. package/dist/backend/codex.js +9 -2
  19. package/dist/backend/codex.js.map +1 -1
  20. package/dist/backend/grok.js +56 -3
  21. package/dist/backend/grok.js.map +1 -1
  22. package/dist/backend/kiro.d.ts +9 -1
  23. package/dist/backend/kiro.js +102 -1
  24. package/dist/backend/kiro.js.map +1 -1
  25. package/dist/backend/opencode-permission.js +1 -1
  26. package/dist/backend/opencode-permission.js.map +1 -1
  27. package/dist/backend/opencode.js +2 -1
  28. package/dist/backend/opencode.js.map +1 -1
  29. package/dist/binary-probe.js +1 -1
  30. package/dist/bug-report-cli.js +1 -1
  31. package/dist/bundled-skills.d.ts +7 -3
  32. package/dist/bundled-skills.js +16 -7
  33. package/dist/bundled-skills.js.map +1 -1
  34. package/dist/channel/adapters/discord.js +3 -3
  35. package/dist/channel/adapters/discord.js.map +1 -1
  36. package/dist/channel/mcp-server.js +12 -4
  37. package/dist/channel/mcp-server.js.map +1 -1
  38. package/dist/channel/mcp-tools.d.ts +3 -1
  39. package/dist/channel/mcp-tools.js +38 -2
  40. package/dist/channel/mcp-tools.js.map +1 -1
  41. package/dist/child-process.d.ts +21 -0
  42. package/dist/child-process.js +54 -0
  43. package/dist/child-process.js.map +1 -0
  44. package/dist/cli.js +167 -182
  45. package/dist/cli.js.map +1 -1
  46. package/dist/command-table.d.ts +1 -1
  47. package/dist/command-table.js +1 -1
  48. package/dist/command-table.js.map +1 -1
  49. package/dist/config-validator.js +28 -2
  50. package/dist/config-validator.js.map +1 -1
  51. package/dist/config.d.ts +2 -2
  52. package/dist/config.js +16 -2
  53. package/dist/config.js.map +1 -1
  54. package/dist/daemon.d.ts +14 -0
  55. package/dist/daemon.js +45 -8
  56. package/dist/daemon.js.map +1 -1
  57. package/dist/darwin-memory.js +1 -1
  58. package/dist/doctor.d.ts +5 -0
  59. package/dist/doctor.js +42 -0
  60. package/dist/doctor.js.map +1 -1
  61. package/dist/export-import.js +1 -1
  62. package/dist/export-import.js.map +1 -1
  63. package/dist/fleet-level-config.d.ts +1 -1
  64. package/dist/fleet-level-config.js +2 -0
  65. package/dist/fleet-level-config.js.map +1 -1
  66. package/dist/fleet-manager.d.ts +73 -2
  67. package/dist/fleet-manager.js +400 -33
  68. package/dist/fleet-manager.js.map +1 -1
  69. package/dist/full-restart.d.ts +5 -5
  70. package/dist/full-restart.js +5 -5
  71. package/dist/full-restart.js.map +1 -1
  72. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  73. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +8 -9
  74. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  75. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +319 -0
  76. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +529 -0
  77. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +19 -4
  78. package/dist/general-knowledge/steering/core-rules.md +1 -1
  79. package/dist/instance-config-impact.js +13 -3
  80. package/dist/instance-config-impact.js.map +1 -1
  81. package/dist/instance-lifecycle.js +6 -6
  82. package/dist/instance-lifecycle.js.map +1 -1
  83. package/dist/instructions.js +2 -1
  84. package/dist/instructions.js.map +1 -1
  85. package/dist/locale.js +137 -10
  86. package/dist/locale.js.map +1 -1
  87. package/dist/login-flows.js +1 -1
  88. package/dist/login-shell.js +3 -1
  89. package/dist/login-shell.js.map +1 -1
  90. package/dist/ls-rows.js +1 -1
  91. package/dist/needs-you-hub.d.ts +98 -1
  92. package/dist/needs-you-hub.js +313 -44
  93. package/dist/needs-you-hub.js.map +1 -1
  94. package/dist/needs-you-live.d.ts +5 -2
  95. package/dist/needs-you-live.js +6 -3
  96. package/dist/needs-you-live.js.map +1 -1
  97. package/dist/needs-you.d.ts +58 -3
  98. package/dist/needs-you.js +36 -3
  99. package/dist/needs-you.js.map +1 -1
  100. package/dist/outbound-handlers.js +25 -10
  101. package/dist/outbound-handlers.js.map +1 -1
  102. package/dist/outbound-schemas.d.ts +3 -3
  103. package/dist/provider-secret-registry.d.ts +5 -3
  104. package/dist/provider-secret-registry.js +8 -2
  105. package/dist/provider-secret-registry.js.map +1 -1
  106. package/dist/public-link-progress.d.ts +12 -1
  107. package/dist/public-link-progress.js +5 -10
  108. package/dist/public-link-progress.js.map +1 -1
  109. package/dist/public-tunnel-config.d.ts +11 -0
  110. package/dist/public-tunnel-config.js +68 -0
  111. package/dist/public-tunnel-config.js.map +1 -0
  112. package/dist/public-web-gateway.d.ts +2 -0
  113. package/dist/public-web-gateway.js +3 -2
  114. package/dist/public-web-gateway.js.map +1 -1
  115. package/dist/public-web-link.d.ts +13 -2
  116. package/dist/public-web-link.js +37 -10
  117. package/dist/public-web-link.js.map +1 -1
  118. package/dist/quickstart.js +14 -8
  119. package/dist/quickstart.js.map +1 -1
  120. package/dist/resource-report.js +1 -1
  121. package/dist/scheduler/task-board.d.ts +41 -1
  122. package/dist/scheduler/task-board.js +150 -4
  123. package/dist/scheduler/task-board.js.map +1 -1
  124. package/dist/sd-notify.js +2 -1
  125. package/dist/sd-notify.js.map +1 -1
  126. package/dist/secret-store.d.ts +2 -0
  127. package/dist/secret-store.js +21 -0
  128. package/dist/secret-store.js.map +1 -1
  129. package/dist/service-installer.d.ts +69 -0
  130. package/dist/service-installer.js +181 -0
  131. package/dist/service-installer.js.map +1 -1
  132. package/dist/service-owner.d.ts +44 -0
  133. package/dist/service-owner.js +48 -0
  134. package/dist/service-owner.js.map +1 -0
  135. package/dist/settings-api.d.ts +5 -0
  136. package/dist/settings-api.js +32 -10
  137. package/dist/settings-api.js.map +1 -1
  138. package/dist/settings-change.js +86 -3
  139. package/dist/settings-change.js.map +1 -1
  140. package/dist/settings-effect.js +6 -1
  141. package/dist/settings-effect.js.map +1 -1
  142. package/dist/settings-http-confirmation.d.ts +2 -0
  143. package/dist/settings-http-confirmation.js +4 -3
  144. package/dist/settings-http-confirmation.js.map +1 -1
  145. package/dist/settings-request-capability.d.ts +4 -1
  146. package/dist/settings-request-capability.js +11 -3
  147. package/dist/settings-request-capability.js.map +1 -1
  148. package/dist/setup-host.js +1 -1
  149. package/dist/setup-wizard.js +1 -1
  150. package/dist/setup-wizard.js.map +1 -1
  151. package/dist/task-board-summary.d.ts +101 -0
  152. package/dist/task-board-summary.js +177 -0
  153. package/dist/task-board-summary.js.map +1 -0
  154. package/dist/task-board-waker.d.ts +8 -1
  155. package/dist/task-board-waker.js +15 -6
  156. package/dist/task-board-waker.js.map +1 -1
  157. package/dist/tmux-control.js +1 -1
  158. package/dist/tmux-control.js.map +1 -1
  159. package/dist/tmux-exec-helper.d.ts +5 -1
  160. package/dist/tmux-exec-helper.js +13 -21
  161. package/dist/tmux-exec-helper.js.map +1 -1
  162. package/dist/tmux-manager.js +1 -1
  163. package/dist/tmux-read.js +1 -1
  164. package/dist/tmux-read.js.map +1 -1
  165. package/dist/topic-commands.d.ts +3 -0
  166. package/dist/topic-commands.js +29 -18
  167. package/dist/topic-commands.js.map +1 -1
  168. package/dist/transcript-monitor.js +5 -0
  169. package/dist/transcript-monitor.js.map +1 -1
  170. package/dist/transcript-sources.d.ts +2 -0
  171. package/dist/transcript-sources.js +4 -0
  172. package/dist/transcript-sources.js.map +1 -1
  173. package/dist/tunnel/cloudflared-install.js +3 -1
  174. package/dist/tunnel/cloudflared-install.js.map +1 -1
  175. package/dist/tunnel/cloudflared.d.ts +7 -16
  176. package/dist/tunnel/cloudflared.js +65 -13
  177. package/dist/tunnel/cloudflared.js.map +1 -1
  178. package/dist/tunnel/named-diagnostics.d.ts +13 -0
  179. package/dist/tunnel/named-diagnostics.js +52 -0
  180. package/dist/tunnel/named-diagnostics.js.map +1 -0
  181. package/dist/tunnel/types.d.ts +2 -1
  182. package/dist/tunnel/types.js.map +1 -1
  183. package/dist/types.d.ts +12 -0
  184. package/dist/ui/app-effects.js +10 -4
  185. package/dist/ui/app.html +2 -0
  186. package/dist/ui/chat-render.js +39 -0
  187. package/dist/ui/chat-store.js +8 -0
  188. package/dist/ui/chat-strings.js +5 -2
  189. package/dist/ui/chat-thread.js +15 -8
  190. package/dist/ui/effect-trial-offer.js +2 -31
  191. package/dist/ui/effects-waker.js +2 -70
  192. package/dist/ui/fleet-canvas.js +14 -6
  193. package/dist/ui/fleet-route-scene.js +86 -21
  194. package/dist/ui/fleet-task-card.js +5 -5
  195. package/dist/ui/fleet-task-common.js +41 -7
  196. package/dist/ui/fleet-task-work.js +107 -0
  197. package/dist/ui/fleet-tasks.js +46 -12
  198. package/dist/ui/fleet-traffic.js +57 -10
  199. package/dist/ui/icons/icon-192.png +0 -0
  200. package/dist/ui/icons/icon-512.png +0 -0
  201. package/dist/ui/image-lightbox.js +39 -1
  202. package/dist/ui/image-zoom.js +242 -0
  203. package/dist/ui/panel-chat.js +24 -13
  204. package/dist/ui/panel-details.js +2 -0
  205. package/dist/ui/panel-fleet.js +6 -2
  206. package/dist/ui/panel-needs.js +21 -6
  207. package/dist/ui/panel-settings.js +90 -15
  208. package/dist/ui/preview-panel.js +2 -1
  209. package/dist/ui/search-dialog.js +2 -1
  210. package/dist/ui/settings-appearance.js +159 -9
  211. package/dist/ui/settings-dialogs.js +33 -17
  212. package/dist/ui/settings-discord.js +6 -2
  213. package/dist/ui/settings-guests.js +2 -1
  214. package/dist/ui/settings-learn.js +1 -0
  215. package/dist/ui/settings-model.js +2 -11
  216. package/dist/ui/settings-mods.js +6 -3
  217. package/dist/ui/settings-strings.js +80 -0
  218. package/dist/ui/settings-token.js +3 -1
  219. package/dist/ui/settings-web-authority.js +6 -3
  220. package/dist/ui/settings-wizard.js +13 -7
  221. package/dist/ui/shared/app-i18n.js +2 -2
  222. package/dist/ui/shared/app-needs.js +2 -2
  223. package/dist/ui/shared/app-store.js +2 -2
  224. package/dist/ui/shared/app-stream.js +2 -1
  225. package/dist/ui/shared/app.css +125 -28
  226. package/dist/ui/shared/app.js +5 -1
  227. package/dist/ui/shared/effect-app-runtime.js +7 -5
  228. package/dist/ui/shared/effect-chat-runtime.js +2 -7
  229. package/dist/ui/shared/effect-cursor.js +3 -2
  230. package/dist/ui/shared/effect-display-metadata.js +39 -0
  231. package/dist/ui/shared/effect-registry.js +4 -2
  232. package/dist/ui/shared/effect-runtime-loader.js +3 -1
  233. package/dist/ui/shared/effect-stock-trust.js +1 -1
  234. package/dist/ui/shared/feature-flags.js +127 -0
  235. package/dist/ui/shared/identity-color.js +6 -3
  236. package/dist/ui/shared/instance-nav.js +3 -2
  237. package/dist/ui/shared/text-undo.js +210 -0
  238. package/dist/ui/shared/ui-dialog.js +4 -3
  239. package/dist/ui/shared/ui-icons.js +2 -0
  240. package/dist/ui/signin.html +2 -0
  241. package/dist/update-dispatch.js +1 -1
  242. package/dist/update-launch.d.ts +1 -1
  243. package/dist/update-launch.js +1 -2
  244. package/dist/update-launch.js.map +1 -1
  245. package/dist/usage/providers.js +1 -1
  246. package/dist/usage/providers.js.map +1 -1
  247. package/dist/view-api.js +1 -1
  248. package/dist/web-api.d.ts +12 -2
  249. package/dist/web-api.js +266 -18
  250. package/dist/web-api.js.map +1 -1
  251. package/dist/web-app-icon-worker.d.ts +1 -0
  252. package/dist/web-app-icon-worker.js +15 -0
  253. package/dist/web-app-icon-worker.js.map +1 -0
  254. package/dist/web-app-icon.d.ts +107 -0
  255. package/dist/web-app-icon.js +256 -0
  256. package/dist/web-app-icon.js.map +1 -0
  257. package/dist/web-appearance-tools.d.ts +1 -0
  258. package/dist/web-appearance-tools.js +7 -3
  259. package/dist/web-appearance-tools.js.map +1 -1
  260. package/dist/web-appearance.d.ts +4 -10
  261. package/dist/web-appearance.js +4 -3
  262. package/dist/web-appearance.js.map +1 -1
  263. package/dist/web-chat-history.d.ts +37 -0
  264. package/dist/web-chat-history.js +57 -2
  265. package/dist/web-chat-history.js.map +1 -1
  266. package/dist/web-experimental-http.d.ts +2 -0
  267. package/dist/web-experimental-http.js +12 -0
  268. package/dist/web-experimental-http.js.map +1 -0
  269. package/dist/web-experimental.d.ts +11 -0
  270. package/dist/web-experimental.js +49 -0
  271. package/dist/web-experimental.js.map +1 -0
  272. package/dist/web-guest-access.d.ts +1 -0
  273. package/dist/web-guest-access.js +11 -3
  274. package/dist/web-guest-access.js.map +1 -1
  275. package/dist/web-icons.d.ts +33 -1
  276. package/dist/web-icons.js +83 -11
  277. package/dist/web-icons.js.map +1 -1
  278. package/dist/web-mod-store.d.ts +2 -0
  279. package/dist/web-mod-store.js +10 -0
  280. package/dist/web-mod-store.js.map +1 -1
  281. package/dist/web-png.d.ts +29 -0
  282. package/dist/web-png.js +321 -0
  283. package/dist/web-png.js.map +1 -0
  284. package/dist/web-preview.d.ts +2 -0
  285. package/dist/web-preview.js +2 -0
  286. package/dist/web-preview.js.map +1 -1
  287. package/dist/web-reactions.d.ts +34 -0
  288. package/dist/web-reactions.js +47 -0
  289. package/dist/web-reactions.js.map +1 -0
  290. package/dist/web-settings-patch.d.ts +2 -1
  291. package/dist/web-settings-patch.js +5 -1
  292. package/dist/web-settings-patch.js.map +1 -1
  293. package/dist/web-settings-tools.d.ts +1 -0
  294. package/dist/web-settings-tools.js +14 -10
  295. package/dist/web-settings-tools.js.map +1 -1
  296. package/dist/web-terminal.js +1 -1
  297. package/dist/web-theme-store.d.ts +2 -2
  298. package/dist/web-theme-store.js +6 -3
  299. package/dist/web-theme-store.js.map +1 -1
  300. package/package.json +1 -1
@@ -8,8 +8,9 @@
8
8
  // #1665 first: the host's preferences go into this browser's cache before any module below reads its key.
9
9
  import { bootPrefs } from "./app-prefs.js";
10
10
  import { html, render } from "./app-html.js";
11
- import { Shell, handleKey, handleSearchKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener } from "./app-shell.js";
11
+ import { Shell, handleKey, handleSearchKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener, isMacLike } from "./app-shell.js";
12
12
  import { installSwipe } from "./app-swipe.js";
13
+ import { installTextUndo } from "./text-undo.js";
13
14
  import { startRouter, navStore, navigate } from "./app-nav.js";
14
15
  import { createStream } from "./app-stream.js";
15
16
  import { appStore, applyStatus, applyActivity, guestPage } from "./app-store.js";
@@ -125,6 +126,9 @@ if (!guest) installOwnerCss();
125
126
  document.addEventListener("keydown", handleKey);
126
127
  // #2293: Ctrl+K / ⌘K (and Ctrl+F in a chat) — an admin's full app only; the server refuses a guest's search anyway.
127
128
  if (mode === "full" && !guest) document.addEventListener("keydown", handleSearchKey);
129
+ // #2342: Ctrl/⌘+Z and Ctrl+Shift+Z / Ctrl+Y / ⌘⇧Z in every text field, one history per field (the browser's is one
130
+ // for the whole page, and any script write wipes it).
131
+ installTextUndo({ mac: isMacLike() });
128
132
  // #1605: on a phone (the sidebar is a drawer), swipe right in the main area to open it, left to close it — the same
129
133
  // open/close as ☰ and the scrim. Nothing that owns a sideways drag (code, tables, the terminal, inputs, dialogs) and
130
134
  // never the left edge (iOS: "back").
@@ -13,7 +13,7 @@ const episodes = new WeakSet();
13
13
 
14
14
  /** `current` belongs to the page/session episode. Route cleanup only releases its contribution. */
15
15
  export function createAppEffectRuntime({ window: host, current, observe, onChange = () => {}, onDispose = () => {}, resolveOwnerTarget = null, onOwnerUnavailable = () => {}, factories = {} }) {
16
- let dead = false, error = null, loader = null, registry = null, writer = null, source = null, personal = null, trial = null, pending = null, lastMetadata = null, serial = 0, revision = 0, stamp = null, syncing = false, queued = false;
16
+ let dead = false, claimed = false, error = null, loader = null, registry = null, writer = null, source = null, personal = null, trial = null, pending = null, lastMetadata = null, serial = 0, revision = 0, stamp = null, syncing = false, queued = false;
17
17
  const bindings = new Map(), profiles = new Map();
18
18
  let lastContext = null, playbackUnavailable = null;
19
19
  const identity = effectPlaybackIdentity(host), auth = host?.AgendAuth;
@@ -26,7 +26,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
26
26
  };
27
27
  const placementIdentity = p => JSON.stringify([p.by, p.manifest.layer, p.params]);
28
28
  const clock = () => host.performance.now();
29
- let offAuth = null;
29
+ let offAuth = null, disposed = null;
30
30
  const snapshot = s => {
31
31
  const effects = s?.metadata?.effects ?? [], known = registry?.snapshot?.() ?? [];
32
32
  const rows = effects.map(p => {
@@ -76,16 +76,18 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
76
76
  if (pending?.source === s) safe(() => pending.controller.abort());
77
77
  revision++; stamp = null;
78
78
  }
79
- function dispose() {
80
- if (dead) return; dead = true; discard(source);
79
+ function dispose(restartable = false) {
80
+ if (dead) return disposed; dead = true; discard(source);
81
81
  safe(() => trial?.dispose()); safe(() => pending?.controller.abort()); safe(() => registry?.dispose?.()); safe(() => loader?.dispose?.()); safe(() => personal?.dispose()); safe(() => writer?.dispose()); safe(() => offAuth?.());
82
82
  safe(() => host?.removeEventListener?.("pagehide", dispose));
83
83
  for (const row of profiles.values()) safe(() => row.collector.dispose()); profiles.clear(); bindings.clear(); safe(() => onDispose({ error }));
84
+ disposed = Promise.all([safe(() => loader?.whenDisposed?.()), safe(() => registry?.whenDisposed?.())]).then(() => { if (restartable === true && claimed) episodes.delete(host); });
85
+ return disposed;
84
86
  }
85
87
  function fail(reason) { if (dead) return; error = reason; dispose(); safe(() => onChange({ error, rows: [], effects: [], loading: false })); }
86
88
  const unavailable = { ok: false, sync() {}, changedChat() {}, reply() {}, detachChat() {}, attachChat() { return null; }, attachTrial() { return null; }, stopTrial() {}, trialSnapshot: () => ({ rows: [] }), snapshot: () => snapshot(null), dispose };
87
89
  if (!host || typeof host !== "object" || episodes.has(host) || !identity || typeof current !== "function" || typeof observe !== "function" || typeof auth?.onSessionEnd !== "function" || typeof auth?.sessionEnded !== "function") return unavailable;
88
- episodes.add(host); // Claim before invoking current/auth callbacks: one attempt per Window episode.
90
+ episodes.add(host); claimed = true; // Claim before invoking current/auth callbacks: one attempt per Window episode.
89
91
  if (!alive()) { dispose(); return unavailable; }
90
92
  try {
91
93
  offAuth = auth.onSessionEnd(dispose);
@@ -1,3 +1,4 @@
1
+ import { chatEffectIdentity } from "./effect-display-metadata.js";
1
2
  /** #1629: one committed local Chat lease joins the reviewed host-only effect primitives. */
2
3
  import { createEffectRuntimeLoader } from "./effect-runtime-loader.js";
3
4
  import { createEffectRegistry } from "./effect-registry.js";
@@ -43,13 +44,7 @@ export function createEffectWriter(host, current) {
43
44
  }
44
45
 
45
46
  /** Frozen, server-rendered session identity only. A header-token/public/View load has no identity. */
46
- export function chatEffectIdentity(host) {
47
- try {
48
- const b = host.document.body.dataset, x = JSON.parse(host.document.documentElement.getAttribute("data-prefs") || "null");
49
- if (b.mode !== "full" || b.tier === "guest" || b.publicLink === "1" || b.webTransport === "poll" || !["person", "operator"].includes(x?.ownerScope) || x.unreadable === true || typeof x.ownerEpoch !== "string" || !/^[a-f0-9]{16}$/.test(x.ownerEpoch)) return null;
50
- return Object.freeze({ epoch: x.ownerEpoch, scope: x.ownerScope });
51
- } catch { return null; }
52
- }
47
+ export { chatEffectIdentity } from "./effect-display-metadata.js";
53
48
  /** Chat-only public/guest capability stays separate from personal preferences and management. */
54
49
  export function effectPlaybackIdentity(host) {
55
50
  try {
@@ -25,9 +25,10 @@ export function createEffectCursor({ window: host, current, readImage, onChange
25
25
  const unavailable = { ok: false, update() {}, snapshot: () => ({ status: "unavailable", id: null }), dispose() {} };
26
26
  if (!host?.document || owners.has(host) || ![current, readImage, onChange, host.getComputedStyle, host.Blob, host.URL?.createObjectURL, host.URL?.revokeObjectURL, host.AbortController, host.setTimeout, host.clearTimeout, host.performance?.now].every(x => typeof x === "function")) return unavailable;
27
27
  const token = {}; owners.set(host, token);
28
+ let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
28
29
  const doc = host.document; let dead = false, previous = null, wanted = null, pending = null, active = null, blocked = null, generation = 0, checking = false, notifying = false, status = "off";
29
30
  const safe = fn => { try { return fn(); } catch { return null; } };
30
- const release = () => { if (dead && !pending && owners.get(host) === token) owners.delete(host); };
31
+ const release = () => { if (dead && !pending) { if (owners.get(host) === token) owners.delete(host); finishDisposed(); } };
31
32
  function alive() {
32
33
  if (dead || checking) return false;
33
34
  checking = true; const value = safe(() => current() === true && host.document === doc); checking = false;
@@ -121,5 +122,5 @@ export function createEffectCursor({ window: host, current, readImage, onChange
121
122
  }
122
123
  start(); tell();
123
124
  }
124
- return { ok: true, update, snapshot, dispose };
125
+ return { ok: true, update, snapshot, dispose, whenDisposed: () => disposed };
125
126
  }
@@ -0,0 +1,39 @@
1
+ /** #1629: closed host display metadata. Neither this object nor its token grants a trial read. */
2
+ const KEYS = ["v", "offer", "id", "title", "expiresAt"], ID = /^[0-9a-f]{32}$/;
3
+ const FORBIDDEN = /(?!\u200d)[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
4
+ const VISIBLE = /[^\p{M}\p{C}\p{Z}\u115f\u1160\u3164\uffa0\u2800]/u;
5
+ let graphemes = null;
6
+ export function checkEffectTrialOffer(value) {
7
+ try {
8
+ if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return null;
9
+ const keys = Reflect.ownKeys(value);
10
+ if (keys.length !== KEYS.length || keys.some(k => typeof k !== "string" || !KEYS.includes(k))) return null;
11
+ const x = Object.create(null);
12
+ for (const key of KEYS) {
13
+ const d = Object.getOwnPropertyDescriptor(value, key);
14
+ if (!d || !("value" in d) || !d.enumerable) return null;
15
+ x[key] = d.value;
16
+ }
17
+ if (x.v !== 1 || typeof x.offer !== "string" || !ID.test(x.offer) || typeof x.id !== "string" || !ID.test(x.id)
18
+ || typeof x.title !== "string" || x.title.length > 400 || x.title.normalize("NFC") !== x.title || x.title.trim() !== x.title
19
+ || FORBIDDEN.test(x.title) || !VISIBLE.test(x.title) || !Number.isSafeInteger(x.expiresAt) || x.expiresAt < 0) return null;
20
+ graphemes ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
21
+ let count = 0; for (const _part of graphemes.segment(x.title)) if (++count > 40) return null;
22
+ if (count < 1) return null;
23
+ return Object.freeze({ v: 1, offer: x.offer, id: x.id, title: x.title, expiresAt: x.expiresAt });
24
+ } catch { return null; }
25
+ }
26
+ /** Role and sender are host fields, never inferred from a message's text. Start must separately authorize. */
27
+ export function effectTrialMessage(message) {
28
+ if (!message || message.role !== "status" || message.sender !== "agend" || typeof message.instance !== "string"
29
+ || message.instance.length < 1 || message.instance.length > 128) return null;
30
+ return checkEffectTrialOffer(message.effectTrial);
31
+ }
32
+
33
+ export function chatEffectIdentity(host) {
34
+ try {
35
+ const b = host.document.body.dataset, x = JSON.parse(host.document.documentElement.getAttribute("data-prefs") || "null");
36
+ if (b.mode !== "full" || b.tier === "guest" || b.publicLink === "1" || b.webTransport === "poll" || !["person", "operator"].includes(x?.ownerScope) || x.unreadable === true || typeof x.ownerEpoch !== "string" || !/^[a-f0-9]{16}$/.test(x.ownerEpoch)) return null;
37
+ return Object.freeze({ epoch: x.ownerEpoch, scope: x.ownerScope });
38
+ } catch { return null; }
39
+ }
@@ -42,6 +42,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
42
42
  try { if (!host?.document || !host.document.body || registries.has(host) || typeof load !== "function" || typeof stockSnapshot !== "function" || typeof onChange !== "function" || typeof onProfile !== "function" || typeof onMount !== "function" || typeof onRetire !== "function" || typeof clock !== "function" || !safety || ["allowed", "begin", "finish", "pause", "resume"].some(k => typeof safety[k] !== "function") || ["requestAnimationFrame", "cancelAnimationFrame", "setTimeout", "clearTimeout", "MutationObserver", "ResizeObserver", "AbortController"].some(k => typeof host[k] !== "function")) return { ok: false, reason: "registry_unsupported" }; } catch { return { ok: false, reason: "registry_unsupported" }; }
43
43
  let dead = false, blocked = null, revision = 0, last = null, lastView = null, frameSerial = 0, context = null, descriptors = [], raf = null, timer = null;
44
44
  const slots = new Map(), loads = new Set(), paused = new Map(), offs = [], observed = new Set(), failures = new Map(), results = new Map();
45
+ let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
45
46
  let readingStock = false, stockFailure = "stock_evidence_invalid", warned = null;
46
47
  let root, geometry, observer, resize, longTasks = null, longTasksSupported = false;
47
48
  const time = () => {
@@ -342,7 +343,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
342
343
  }).catch(() => { if (current(s)) retire(s, "resource_unavailable"); }).finally(() => {
343
344
  if (request.timer !== null) host.clearTimeout(request.timer);
344
345
  loads.delete(request); if (s.request === request) s.request = null;
345
- if (!dead) reconcile(); else if (loads.size === 0 && registries.get(host) === api) registries.delete(host);
346
+ if (!dead) reconcile(); else if (loads.size === 0) { if (registries.get(host) === api) registries.delete(host); finishDisposed(); }
346
347
  });
347
348
  }
348
349
  schedule(); tell();
@@ -530,11 +531,12 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
530
531
  }
531
532
  return sent;
532
533
  },
534
+ whenDisposed: () => disposed,
533
535
  dispose() {
534
536
  if (dead) return; dead = true; revision++; stopAll("disposed");
535
537
  if (raf !== null) host.cancelAnimationFrame(raf); if (timer !== null) host.clearTimeout(timer);
536
538
  observer.disconnect(); resize.disconnect(); longTasks?.disconnect(); for (const off of offs) off();
537
- geometry.dispose(); root.remove(); observed.clear(); if (loads.size === 0 && registries.get(host) === api) registries.delete(host);
539
+ geometry.dispose(); root.remove(); observed.clear(); if (loads.size === 0) { if (registries.get(host) === api) registries.delete(host); finishDisposed(); }
538
540
  },
539
541
  };
540
542
  // Read-only host geometry seam; no selector guessing among poster and live roots.
@@ -143,7 +143,8 @@ export function createEffectRuntimeLoader({ window: host, ownerEpoch, current, o
143
143
  if (!host || typeof host !== "object" || loaders.has(host) || typeof host.fetch !== "function" || typeof host.AbortController !== "function" || typeof host.setTimeout !== "function" || typeof host.clearTimeout !== "function" || typeof ownerEpoch !== "string" || !EPOCH.test(ownerEpoch) || typeof current !== "function" || typeof onRevoke !== "function") return { ok: false };
144
144
  const reservation = {}; loaders.set(host, reservation);
145
145
  let dead = false, revoked = false, previous = null; const pending = new Set();
146
- const releaseWindow = () => { if (dead && pending.size === 0 && loaders.get(host) === reservation) loaders.delete(host); };
146
+ let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
147
+ const releaseWindow = () => { if (dead && pending.size === 0) { if (loaders.get(host) === reservation) loaders.delete(host); finishDisposed(); } };
147
148
  // Terminal state is latched before callbacks. Abort releases logical callers, never physical reservations.
148
149
  function revoke(reason) {
149
150
  if (dead || revoked) return;
@@ -215,6 +216,7 @@ export function createEffectRuntimeLoader({ window: host, ownerEpoch, current, o
215
216
  }
216
217
  const ownerRead = (signal, settled = false) => run("placements", `/ui/effects/owner?ownerEpoch=${ownerEpoch}`, checkEffectOwnerSlots, signal, settled);
217
218
  return { ok: true,
219
+ whenDisposed: () => disposed,
218
220
  owner(signal) { return ownerRead(signal); },
219
221
  placements(instance, signal) { return placementRead(instance, signal); },
220
222
  resource(input, signal) { return resourceRead(input, signal); },
@@ -11,7 +11,7 @@ const SPECS = [
11
11
  ["confirmation", ".shell > .pending-stack, .shell .p-settings > .panel-body > .col > .pending-stack.inline"], ["preview", ".shell .chat-split > .pv-panel, .shell .chat-split > .pv-divider"],
12
12
  ["preview-menu", ".shell .thread details.pv-menu"], ["palette", ".shell .dock .cmd-palette"],
13
13
  ["drop", ".shell .p-chat > .drop-overlay"],
14
- ["thread-control", ".shell .chat-main > .amb-note, .shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
14
+ ["thread-control", ".shell .chat-main > .amb-note, .shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .msg > div.msg-actions, .shell .thread .msg > button.msg-quote, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
15
15
  ["link", ".shell .thread .md a, .shell .thread .st-text > a.st-link, .shell .dock .prompts .txt > a.st-link"], ["attachment", ".shell .thread .atts"], ["media", ".shell .thread .att-media"],
16
16
  ["panel-navigation", ".shell .p-fleet > .seg, .shell .p-settings > .seg"],
17
17
  ["panel-content", ".shell .p-fleet > .panel-body, .shell .p-needs > .panel-body, .shell .p-settings > .panel-body, .shell .p-log > .panel-body, .shell .p-details > .panel-body, .shell .p-view > .panel-body, .shell .p-view > .v-term, .shell .p-view > .v-id, .shell .p-view > .v-card"],
@@ -0,0 +1,127 @@
1
+ // Product feature policy only: this module imports no experimental runtime.
2
+ import { appStore } from "./app-store.js";
3
+ import { navStore } from "./app-nav.js";
4
+ import { ownerEffectsChosen, onOwnerEffectsChanged } from "./app-prefs.js";
5
+ import { register } from "./app-i18n.js";
6
+ import { html, useEffect, useState } from "./app-html.js";
7
+ register("cwControls", { en: { title: "Effects" }, "zh-TW": { title: "效果" } });
8
+ export function canvasWorkerEnabled() { return appStore.get().canvasWorker === true; }
9
+ export function onCanvasWorkerChange(fn) {
10
+ let value = canvasWorkerEnabled();
11
+ return appStore.subscribe(() => { const next = canvasWorkerEnabled(); if (next !== value) { value = next; fn(next); } });
12
+ }
13
+ export function useCanvasWorker() {
14
+ const [value, setValue] = useState(canvasWorkerEnabled);
15
+ useEffect(() => { setValue(canvasWorkerEnabled()); return onCanvasWorkerChange(setValue); }, []);
16
+ return value;
17
+ }
18
+ // Never evaluate the optional module before admission. An obsolete import may finish, but cannot mount.
19
+ export function experimentalComponent(load, select) {
20
+ return function ExperimentalComponent(props) {
21
+ const enabled = useCanvasWorker(), [component, setComponent] = useState(null);
22
+ useEffect(() => {
23
+ let current = true;
24
+ if (enabled) load().then(module => { if (current && canvasWorkerEnabled()) setComponent(() => select(module)); }, () => {});
25
+ return () => { current = false; };
26
+ }, [enabled]);
27
+ return enabled && component ? html`<${component} ...${props} />` : null;
28
+ };
29
+ }
30
+
31
+ /** effect_chat_v of a chat with no placements: sha256(JSON.stringify([])), first 32 hex (fleet-manager's status row). */
32
+ export const EMPTY_CHAT_EFFECTS = "4f53cda18c2baa0c0354bb5f9a3ecbe5";
33
+
34
+ let mod = null, waking = null, chatStore = null, started = false, generation = 0, awakeGeneration = -1, retiring = Promise.resolve();
35
+ const awakeListeners = new Set();
36
+
37
+ /** Whether `name`'s chat has Canvas Worker placements (as the status rows say). */
38
+ export function chatHasEffects(name, app = appStore.get()) {
39
+ const v = name ? app.instances?.find(i => i.name === name)?.effect_chat_v : undefined;
40
+ return canvasWorkerEnabled() && typeof v === "string" && /^[a-f0-9]{32}$/.test(v) && v !== EMPTY_CHAT_EFFECTS;
41
+ }
42
+
43
+ /** The running effects owner, or null while dormant (or when it could not start: as getAppEffects always was). */
44
+ export function getAppEffects(host) { return canvasWorkerEnabled() && mod ? mod.getAppEffects(host) : null; }
45
+
46
+ /** Whether the runtime has been asked to start on this page. */
47
+ export function effectsAwake() { return waking !== null; }
48
+ /** Whether its module has loaded (started, or found it could not: getAppEffects() then says which). */
49
+ export function effectsLoaded() { return mod !== null && awakeGeneration === generation; }
50
+
51
+ /** Follow every completed runtime episode, including a replacement after an experimental off/on. */
52
+ export function onEffectsAwake(fn) {
53
+ if (typeof fn !== "function") return () => {};
54
+ awakeListeners.add(fn);
55
+ if (effectsLoaded()) { try { fn(); } catch { /* a listener's error is its own */ } }
56
+ return () => awakeListeners.delete(fn);
57
+ }
58
+
59
+ /** Load lazily; each explicitly enabled episode owns its startup and cancellation. */
60
+ export function wakeEffects() {
61
+ if (!canvasWorkerEnabled()) return Promise.resolve(null);
62
+ if (waking) return waking;
63
+ const episode = generation;
64
+ waking = (mod ? Promise.resolve(mod) : import("/ui/js/app-effects.js")).then(async m => {
65
+ mod = m;
66
+ await retiring;
67
+ if (episode !== generation || !canvasWorkerEnabled()) return null;
68
+ const owner = chatStore ? m.startAppEffects({ store: chatStore }) : null;
69
+ awakeGeneration = episode;
70
+ for (const fn of [...awakeListeners]) { try { fn(); } catch { /* the rest still hear */ } }
71
+ return owner;
72
+ }, () => null);
73
+ return waking;
74
+ }
75
+
76
+ /** Wake when the open chat has placements or the person has a personal effect. */
77
+ function check() {
78
+ if (!canvasWorkerEnabled()) {
79
+ generation++;
80
+ const owner = mod?.getAppEffects();
81
+ if (owner) retiring = Promise.resolve(owner.dispose?.(true));
82
+ waking = null;
83
+ return;
84
+ }
85
+ if (waking) return;
86
+ const route = navStore.get().route;
87
+ if ((route?.panel === "chat" && chatHasEffects(route.instance)) || ownerEffectsChosen()) void wakeEffects();
88
+ }
89
+
90
+ /** Boot (once per page, with the Chat store the runtime binds to). Starts nothing but the watch. */
91
+ export function startEffectsWaker({ store }) {
92
+ chatStore = store;
93
+ if (started) return;
94
+ started = true;
95
+ appStore.subscribe(check); navStore.subscribe(check); onOwnerEffectsChanged(check);
96
+ check();
97
+ }
98
+
99
+ // Full Canvas Worker metadata stays in the experimental graph, never in ordinary chat boot.
100
+ let metadata = null, metadataPending = null;
101
+ const metadataListeners = new Set();
102
+ export function onCanvasMetadataChange(fn) { metadataListeners.add(fn); return () => metadataListeners.delete(fn); }
103
+ export function loadCanvasWorkerMetadata() {
104
+ if (!canvasWorkerEnabled()) return Promise.resolve(null);
105
+ if (metadata) return Promise.resolve(metadata);
106
+ if (metadataPending?.episode === generation) return metadataPending.promise;
107
+ const episode = generation, op = { episode, promise: null };
108
+ metadataPending = op;
109
+ op.promise = import("./effect-display-metadata.js").then(value => {
110
+ if (episode !== generation || !canvasWorkerEnabled()) return null;
111
+ metadata = value;
112
+ for (const fn of [...metadataListeners]) { try { fn(); } catch { /* independent viewers */ } }
113
+ appStore.set({ canvasMetadataEpoch: episode });
114
+ return metadata;
115
+ }, () => null).finally(() => { if (metadataPending === op) metadataPending = null; });
116
+ return op.promise;
117
+ }
118
+ export function readCanvasTrialMessage(message) {
119
+ if (!canvasWorkerEnabled()) return null;
120
+ if (!metadata) { void loadCanvasWorkerMetadata(); return undefined; }
121
+ return metadata.effectTrialMessage(message);
122
+ }
123
+ export function canvasWorkerMenuAvailable(host) {
124
+ if (!canvasWorkerEnabled()) return false;
125
+ if (!metadata) { void loadCanvasWorkerMetadata(); return false; }
126
+ return metadata.chatEffectIdentity(host) !== null;
127
+ }
@@ -196,6 +196,8 @@ function ColorDialog({ name, it, onClose }) {
196
196
  }, [name]);
197
197
  // The preview: the instance as it would be drawn with the choice here.
198
198
  const preview = identityOf({ ...(it || {}), color: { ...((it && it.color) || {}), person: color || undefined }, color_show: { ...((it && it.color_show) || {}), person: places || undefined } }, { off: false });
199
+ // #2342: a swatch (or "no pick") is a choice, not text: it starts the hex field's undo history afresh (undoing the
200
+ // text back to a typed colour would leave the box and the chosen colour apart — Save would send the other one).
199
201
  const pick = (c) => { setColor(c); setHex(c && HEX.test(c) ? c : ""); setError(""); };
200
202
  const togglePlace = (p) => {
201
203
  const base = places || preview.show;
@@ -211,7 +213,8 @@ function ColorDialog({ name, it, onClose }) {
211
213
  onClose();
212
214
  } catch (err) { setError(t("color.failed", (err && err.message) || "network")); setBusy(false); }
213
215
  }
214
- const onHex = (v) => { setHex(v); const c = /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : null; if (c) { setColor(c); setError(""); } };
216
+ // The text as typed (#2342: trimming it in the box would rewrite it, losing its undo); spaces around it are ignored.
217
+ const onHex = (v) => { setHex(v); const w = v.trim(), c = /^#[0-9a-fA-F]{6}$/.test(w) ? w.toLowerCase() : null; if (c) { setColor(c); setError(""); } };
215
218
  const off = colorsOff();
216
219
  return html`<${Dialog} title=${t("color.title", name)} onClose=${onClose} busy=${busy}
217
220
  actions=${html`<button type="button" class="btn" disabled=${busy || !loaded} onClick=${() => save({ color: null, color_show: null })}>${t("color.reset")}</button>
@@ -226,8 +229,8 @@ function ColorDialog({ name, it, onClose }) {
226
229
  ref=${(el) => { if (el && el.style) { el.style.setProperty("--sw-light", p.light); el.style.setProperty("--sw-dark", p.dark); } }}
227
230
  onClick=${() => pick(`preset:${p.id}`)}></button>`)}
228
231
  <label class="idc-custom">${t("color.custom")}
229
- <input type="color" value=${HEX.test(hex) ? hex : "#808080"} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.custom")} />
230
- <input type="text" class="idc-hex" inputmode="text" spellcheck=${false} maxlength="7" placeholder="#rrggbb" value=${hex} onInput=${(e) => onHex(e.currentTarget.value.trim())} aria-label=${t("color.hex")} /></label>
232
+ <input type="color" value=${HEX.test(hex.trim()) ? hex.trim() : "#808080"} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.custom")} />
233
+ <input type="text" class="idc-hex" inputmode="text" spellcheck=${false} maxlength="7" placeholder="#rrggbb" value=${hex} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.hex")} /></label>
231
234
  <button type="button" class="btn btn-sm" disabled=${!color} onClick=${() => pick(null)}>${t("color.noPick")}</button>
232
235
  </fieldset>
233
236
  <fieldset class="idc-places"><legend>${t("color.places")}</legend>
@@ -9,6 +9,7 @@
9
9
  // Public (/assets/): an anonymous View reader gets the same list, fed by View's roster read instead of status frames.
10
10
  import { html, useLayoutEffect, useRef, useState } from "./app-html.js";
11
11
  import { t } from "./app-i18n.js";
12
+ import { recordEdit } from "./text-undo.js";
12
13
  import { appStore, createStore, guestPage, useStore } from "./app-store.js";
13
14
  import { chatPath, detailsPath, logPath, viewPath } from "./app-route.js";
14
15
  import { Icon } from "./ui-icons.js";
@@ -648,9 +649,9 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
648
649
  <label class="v-filter-box"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("filterPh")}</span>
649
650
  <input ref=${filterRef} id="filterInput" type="text" autocomplete="off" spellcheck=${false} placeholder=${tn("filterPh")} value=${f.q}
650
651
  onInput=${(e) => setNavFilter({ q: e.target.value })}
651
- onKeyDown=${(e) => { if (e.key === "Escape" && f.q) { e.preventDefault(); e.stopPropagation(); setNavFilter({ q: "" }); } }} />
652
+ onKeyDown=${(e) => { if (e.key === "Escape" && f.q) { e.preventDefault(); e.stopPropagation(); recordEdit(e.target); setNavFilter({ q: "" }); } }} />
652
653
  ${f.q ? html`<button type="button" class="icon-btn v-filter-clear" aria-label=${tn("filterClear")} title=${tn("filterClear")}
653
- onClick=${() => { setNavFilter({ q: "" }); filterRef.current && filterRef.current.focus(); }}><${Icon} name="close" size=${14} /></button>` : null}
654
+ onClick=${() => { recordEdit(filterRef.current); setNavFilter({ q: "" }); filterRef.current && filterRef.current.focus(); }}><${Icon} name="close" size=${14} /></button>` : null}
654
655
  </label>
655
656
  <div class="nav-facets">
656
657
  <div class="nav-facet-groups">
@@ -0,0 +1,210 @@
1
+ // #2342: undo and redo in every text field — one history per field, kept here instead of the browser's.
2
+ //
3
+ // The browser's own undo is one stack for the whole page (Chromium and WebKit alike): Ctrl+Z in one box undoes the
4
+ // last edit in whichever box had it, and any script write to a field's value (a completion, a clear, a re-render
5
+ // that differs from the box) wipes the stack. So the keys are taken here, but only in a text field:
6
+ // - undo: Ctrl+Z (⌘Z on a Mac); redo: Ctrl+Shift+Z or Ctrl+Y (⌘⇧Z on a Mac, where Ctrl+Y is not redo). Anywhere else
7
+ // (a button, the page, a select) nothing is taken; a key with Alt, or mid-IME-composition, is left alone too.
8
+ // - a field's steps are recorded from its edits (beforeinput, input): typing is grouped into one step until a pause
9
+ // (UNDO.PAUSE_MS), a space, a different kind of edit (typing ↔ deleting), a paste, a line break or the caret moving
10
+ // elsewhere; an IME composition is one step, recorded when it ends.
11
+ // - the browser's own Undo and Redo commands (its Edit menu, a context menu: a trusted beforeinput historyUndo /
12
+ // historyRedo) go through the same history: cancelled and done here. Where a browser cannot cancel them (or a script
13
+ // runs execCommand("undo"), with no beforeinput at all), its own undo goes ahead and what it leaves resets the field's
14
+ // history (never a step that would undo the undo).
15
+ // - a script's change to a field's value the person should be able to undo (a completion, a pick, putting text back)
16
+ // calls recordEdit(field) first: the change becomes a step. Any other change (a clear after send, a reload after
17
+ // save) resets the field's history, as the browser's own undo does — a sent message is never undone back into the box.
18
+ // - undo and redo write the value and the caret back and dispatch `input`, so the page's state (Preact) follows.
19
+ // - password fields are left to the browser: no secret is kept here.
20
+
21
+ import { useState } from "./app-html.js";
22
+
23
+ export const UNDO = { PAUSE_MS: 1000, MAX_STEPS: 100 };
24
+
25
+ const TEXT_TYPES = new Set(["text", "search", "url", "email", "tel", "number", ""]);
26
+ /** A field this keeps a history for: a textarea, or a text-like input (never a password) that is not read-only or disabled. */
27
+ export function isTextField(el) {
28
+ if (!el || el.readOnly || el.disabled) return false;
29
+ const tag = (el.tagName || "").toLowerCase();
30
+ if (tag === "textarea") return true;
31
+ return tag === "input" && TEXT_TYPES.has((el.getAttribute && el.getAttribute("type") || el.type || "").toLowerCase());
32
+ }
33
+
34
+ const histories = new WeakMap();
35
+ /** The value and the caret of `el`. A number input has no selection: its caret is null. */
36
+ function snap(el) {
37
+ let s = null, e = null;
38
+ try { s = el.selectionStart; e = el.selectionEnd; } catch { /* a number input */ }
39
+ return { v: String(el.value ?? ""), s: s ?? null, e: e ?? null };
40
+ }
41
+ function historyFor(el) {
42
+ let h = histories.get(el);
43
+ if (!h) { h = { undo: [], redo: [], last: snap(el), kind: null, at: 0, marked: null, composing: false }; histories.set(el, h); }
44
+ return h;
45
+ }
46
+ /**
47
+ * Bring the history up to the field's value: a change made by a script since the last edit is a step when recordEdit
48
+ * marked it, and otherwise resets the history (what the browser does too).
49
+ */
50
+ function sync(el, h) {
51
+ const now = String(el.value ?? "");
52
+ if (now === h.last.v) { h.marked = null; return; }
53
+ if (h.marked) { push(h.undo, h.marked); h.redo = []; }
54
+ else { h.undo = []; h.redo = []; }
55
+ h.marked = null;
56
+ h.last = snap(el);
57
+ h.kind = null;
58
+ }
59
+ function push(list, s) {
60
+ list.push(s);
61
+ if (list.length > UNDO.MAX_STEPS) list.shift();
62
+ }
63
+
64
+ /** The kind of an edit, for grouping: consecutive typing (or deleting) of one kind is one step. */
65
+ function kindOf(e) {
66
+ const t = e.inputType || "";
67
+ if (t === "insertText") return e.data && /\s/.test(e.data) ? "space" : "type";
68
+ if (t === "deleteContentBackward" || t === "deleteContentForward") return "delete";
69
+ return "other"; // a paste, a drop, a cut, a line break, a word delete: its own step
70
+ }
71
+
72
+ /** Called before a script changes `el`'s value in a way the person may undo: the value now is the step to go back to. */
73
+ export function recordEdit(el) {
74
+ if (!isTextField(el)) return;
75
+ const h = historyFor(el);
76
+ sync(el, h);
77
+ h.marked = snap(el);
78
+ h.kind = null;
79
+ }
80
+
81
+ /** The history kept for `el` (tests). */
82
+ export function historyOf(el) {
83
+ const h = histories.get(el);
84
+ return h ? { undo: h.undo.map((s) => s.v), redo: h.redo.map((s) => s.v), last: h.last.v } : null;
85
+ }
86
+
87
+ /** Put snapshot `s` into `el` and tell the page (an `input` event, as an edit would). */
88
+ function apply(el, s, type, win) {
89
+ el.value = s.v;
90
+ if (s.s != null) { try { el.setSelectionRange(s.s, s.e ?? s.s); } catch { /* a number input */ } }
91
+ const Ev = win && typeof win.InputEvent === "function" ? win.InputEvent : win && win.Event;
92
+ if (Ev) el.dispatchEvent(new Ev("input", { bubbles: true, ...(Ev === win.InputEvent ? { inputType: type } : {}) }));
93
+ }
94
+
95
+ /**
96
+ * Undo (or redo) on `el`. False when there is nothing to do.
97
+ */
98
+ export function step(el, redo, win = typeof window !== "undefined" ? window : null) {
99
+ if (!isTextField(el)) return false;
100
+ const h = historyFor(el);
101
+ sync(el, h);
102
+ const from = redo ? h.redo : h.undo, to = redo ? h.undo : h.redo;
103
+ if (!from.length) return false;
104
+ to.push(h.last);
105
+ h.last = from.pop();
106
+ h.kind = null;
107
+ h.applying = true;
108
+ try { apply(el, h.last, redo ? "historyRedo" : "historyUndo", win); } finally { h.applying = false; }
109
+ h.last = snap(el);
110
+ return true;
111
+ }
112
+
113
+ /** Which history key `e` is, or null: "undo", "redo". `mac`: ⌘ is the modifier and Ctrl+Y is not redo. */
114
+ export function historyKey(e, mac) {
115
+ if (e.altKey || e.isComposing || e.keyCode === 229) return null;
116
+ const mod = mac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
117
+ if (!mod) return null;
118
+ const k = (e.key || "").toLowerCase();
119
+ if (k === "z") return e.shiftKey ? "redo" : "undo";
120
+ if (k === "y" && !e.shiftKey && !mac) return "redo";
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Keep a history for every text field under `doc` and take the undo/redo keys in them. Returns an uninstall function.
126
+ */
127
+ export function installTextUndo({ doc = document, win = typeof window !== "undefined" ? window : null, mac = false } = {}) {
128
+ const field = (e) => (isTextField(e.target) ? e.target : null);
129
+ const onBefore = (e) => {
130
+ const el = field(e);
131
+ // WebKit commits a composition after compositionend (deleteCompositionText, insertFromComposition): its step is
132
+ // the one compositionstart began.
133
+ if (!el || e.isComposing || /Composition/.test(e.inputType || "")) return;
134
+ const h = historyFor(el);
135
+ if (h.applying || h.composing) return; // a browser that does not flag its composition events
136
+ if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
137
+ if (e.cancelable) { e.preventDefault(); step(el, e.inputType === "historyRedo", win); }
138
+ else h.native = true;
139
+ return;
140
+ }
141
+ sync(el, h);
142
+ const kind = kindOf(e), now = Date.now(), at = snap(el);
143
+ // The same run of typing (or deleting) goes on: one step. A space ends a word's step (the next word is a new one).
144
+ // Anything else starts a new step from the value now.
145
+ const same = kind === "space" ? h.kind === "type" : kind !== "other" && kind === h.kind;
146
+ const goesOn = same && now - h.at < UNDO.PAUSE_MS && at.s === h.last.s && at.e === h.last.e;
147
+ if (!goesOn) { push(h.undo, at); h.redo = []; }
148
+ h.kind = kind;
149
+ h.at = now;
150
+ };
151
+ const onInput = (e) => {
152
+ const el = field(e);
153
+ if (!el) return;
154
+ const h = historyFor(el);
155
+ if (h.applying) return;
156
+ // The browser's own undo or redo went ahead (one it could not cancel, or a script's execCommand("undo"), which has no
157
+ // beforeinput): what it left is a change of its own — the history starts afresh.
158
+ if (h.native || e.inputType === "historyUndo" || e.inputType === "historyRedo") { h.native = false; h.undo = []; h.redo = []; h.kind = null; }
159
+ h.last = snap(el); // mid-composition too: the value the next edit starts from
160
+ };
161
+ const onCompStart = (e) => {
162
+ const el = field(e);
163
+ if (!el) return;
164
+ const h = historyFor(el);
165
+ sync(el, h);
166
+ push(h.undo, snap(el)); h.redo = [];
167
+ h.composing = true; h.kind = null;
168
+ };
169
+ const onCompEnd = (e) => {
170
+ const el = field(e);
171
+ if (!el) return;
172
+ const h = historyFor(el);
173
+ h.composing = false;
174
+ h.last = snap(el); // WebKit's last input comes after compositionend: onInput updates it again
175
+ };
176
+ const onKey = (e) => {
177
+ const el = field(e);
178
+ if (!el) return;
179
+ const which = historyKey(e, mac);
180
+ if (!which || historyFor(el).composing) return;
181
+ e.preventDefault(); // never the browser's page-wide stack, even when this field has no step
182
+ step(el, which === "redo", win);
183
+ };
184
+ doc.addEventListener("beforeinput", onBefore, true);
185
+ doc.addEventListener("input", onInput, true);
186
+ doc.addEventListener("compositionstart", onCompStart, true);
187
+ doc.addEventListener("compositionend", onCompEnd, true);
188
+ doc.addEventListener("keydown", onKey, true);
189
+ return () => {
190
+ doc.removeEventListener("beforeinput", onBefore, true);
191
+ doc.removeEventListener("input", onInput, true);
192
+ doc.removeEventListener("compositionstart", onCompStart, true);
193
+ doc.removeEventListener("compositionend", onCompEnd, true);
194
+ doc.removeEventListener("keydown", onKey, true);
195
+ };
196
+ }
197
+
198
+ const trimmed = (v) => v.trim();
199
+ /**
200
+ * A field whose owner keeps its value normalized (trimmed, or a number): the box shows the text as typed and the owner
201
+ * gets `norm(text)`. Binding the box to the normalized value would rewrite it while the person types (a space vanishes,
202
+ * "1.50" turns into "1.5") and lose the field's undo history. When the owner's value becomes something the text does not
203
+ * normalize to (a clear, a load), the box shows `show(value)`.
204
+ * Returns [text for the box, onText(typed) → the normalized value].
205
+ */
206
+ export function useTypedText(value, norm = trimmed, show = (v) => (v == null ? "" : String(v))) {
207
+ const [raw, setRaw] = useState(null);
208
+ const text = raw !== null && Object.is(norm(raw), value) ? raw : show(value);
209
+ return [text, (typed) => { setRaw(typed); return norm(typed); }];
210
+ }
@@ -1,6 +1,7 @@
1
1
  // #1408 §7: one dialog for the whole app — the native <dialog> opened with showModal(), so the browser traps focus,
2
2
  // makes the rest inert and closes it on Esc. Focus goes back to whatever opened it. On a phone it is a bottom sheet
3
- // with its own × (app.css). Text and buttons only; no style attribute (#1300).
3
+ // with its own × (app.css). Text and buttons only; no style attribute (#1300). `drawer`: on a desk it comes in from the
4
+ // right edge at full height, the page still in view beside it (the task board's card); a phone keeps the bottom sheet.
4
5
  import { html, useEffect, useLayoutEffect, useRef } from "./app-html.js";
5
6
  import { Icon } from "./ui-icons.js";
6
7
  import { t } from "./app-i18n.js";
@@ -9,7 +10,7 @@ import { t } from "./app-i18n.js";
9
10
  * <${Dialog} title="Create task" onClose=${…} actions=${html`…`}>…body…</${Dialog}>
10
11
  * Rendered = open; the parent unmounts it to close. `busy` keeps Esc and the backdrop from closing it mid-request.
11
12
  */
12
- export function Dialog({ title, onClose, actions, children, busy = false, wide = false, label }) {
13
+ export function Dialog({ title, onClose, actions, children, busy = false, wide = false, drawer = false, label }) {
13
14
  const ref = useRef(null);
14
15
  const opener = useRef(null);
15
16
  const busyRef = useRef(busy);
@@ -35,7 +36,7 @@ export function Dialog({ title, onClose, actions, children, busy = false, wide =
35
36
  d.addEventListener("click", onClick);
36
37
  return () => { d.removeEventListener("cancel", onCancel); d.removeEventListener("click", onClick); };
37
38
  }, [onClose]);
38
- return html`<dialog data-trust="dialog" ref=${ref} class=${`dlg${wide ? " dlg-wide" : ""}`} aria-label=${label || title}>
39
+ return html`<dialog data-trust="dialog" ref=${ref} class=${`dlg${wide ? " dlg-wide" : ""}${drawer ? " dlg-drawer" : ""}`} aria-label=${label || title}>
39
40
  <div class="dlg-box">
40
41
  <div class="dlg-head"><h2 class="dlg-title">${title}</h2>
41
42
  <button type="button" class="icon-btn dlg-x" aria-label=${t("app.close")} title=${t("app.close")} onClick=${() => { if (!busyRef.current) onClose(); }}><${Icon} name="close" /></button></div>