@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1

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 (311) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
package/public/ui.js ADDED
@@ -0,0 +1,2721 @@
1
+ /* Tiny progressive-enhancement layer on top of HTMX (no frameworks). */
2
+
3
+ // Preferences — theme, the rail, the assistant, the page width — are stored by the pre-paint script in
4
+ // a COOKIE on the hub domain, because localStorage is per-origin and this dashboard spans
5
+ // <workspace>.<hub> subdomains: kept there, the theme was a different theme on every workspace page.
6
+ // These two are defined in <head> so the first paint can read them; this is the guard for a page that
7
+ // somehow loads this file without it.
8
+ const wsPref = window.wsPref || ((name, fallback) => { try { return localStorage.getItem(name) ?? fallback; } catch (_) { return fallback; } });
9
+ const wsPrefSet = window.wsPrefSet || ((name, value) => { try { localStorage.setItem(name, value); } catch (_) { /* ignore */ } });
10
+
11
+ // Every important action — remove, delete, reset to the defaults, a command that asks before it runs — goes through
12
+ // one dialog: "Wait! Are you sure you want to proceed with this action?", Yes or No. The button says what it is in
13
+ // the dialog, so the answer is given to a named thing rather than to a button that changed its own label.
14
+ //
15
+ // It replaces a two-step arm, where the first click swapped the label for "⚠️ Sure?" and a second click within five
16
+ // seconds went through: a double click did the whole thing, and nothing ever said what was about to happen.
17
+ let pendingConfirm = null;
18
+
19
+ function runConfirmed(btn) {
20
+ pendingConfirm = null;
21
+ if (!btn) return;
22
+ window.htmx && window.htmx.trigger(btn, 'confirmed-remove');
23
+ }
24
+
25
+ document.addEventListener('click', (e) => {
26
+ const btn = e.target.closest('.arm-step');
27
+ if (!btn) return;
28
+ // Neither this click nor the dialog's may navigate: these are links as well as buttons, and a link with an empty
29
+ // href reloads the page, losing the toast the request answers with.
30
+ e.preventDefault();
31
+ pendingConfirm = btn;
32
+ // What the action is: its title where it has one (an icon-only trash can), else its own label — and what it
33
+ // belongs to, from the row or card it sits in, so "Delete" is never the whole of what the dialog says.
34
+ const label = (btn.getAttribute('title') || btn.dataset.tipHint || btn.textContent || '').replace(/\s+/g, ' ').trim();
35
+ const card = btn.closest('.project-row, .cc-row, #cc-command, .row-item, .editor-card, .settings-row');
36
+ // The card's own heading, whatever that card calls it: a project row has an h3.project-title, a
37
+ // command row a .cmd-title, a file row a bold span. First match wins, and never one inside the button.
38
+ const name = card && [...card.querySelectorAll('.project-title, .cmd-title, h1, h2, h3, .item-row-body .uk-text-bold')]
39
+ .find((el) => !btn.contains(el) && el.textContent.trim());
40
+ const belongsTo = name ? name.textContent.replace(/\s+/g, ' ').trim().slice(0, 60) : '';
41
+ const what = belongsTo && !label.includes(belongsTo) ? `${label} — ${belongsTo}` : label;
42
+ const where = document.getElementById('confirm-what');
43
+ if (where) where.textContent = what;
44
+ if (window.UIkit && document.getElementById('confirm-modal')) {
45
+ window.UIkit.modal('#confirm-modal').show();
46
+ return;
47
+ }
48
+ // No UIkit on the page (a fragment rendered on its own): the browser's own dialog rather than acting unasked.
49
+ if (window.confirm(`Are you sure you want to proceed with this action?${what ? `\n\n${what}` : ''}`)) runConfirmed(btn);
50
+ else pendingConfirm = null;
51
+ });
52
+
53
+ document.addEventListener('click', (e) => {
54
+ if (e.target.closest('.confirm-yes')) {
55
+ const btn = pendingConfirm;
56
+ e.preventDefault();
57
+ if (window.UIkit) window.UIkit.modal('#confirm-modal').hide();
58
+ runConfirmed(btn);
59
+ } else if (e.target.closest('.confirm-no')) {
60
+ pendingConfirm = null;
61
+ }
62
+ });
63
+
64
+ // Closing it any other way — the X, Escape, the backdrop — is No.
65
+ document.addEventListener('hidden', (e) => {
66
+ if (e.target && e.target.id === 'confirm-modal') pendingConfirm = null;
67
+ }, true);
68
+
69
+ // The field microphones live in public/mic.js — they and the assistant's microphone now read the
70
+ // same resolved config (window.WS_SPEECH, from speech.js), so `speech_language` reaches both.
71
+
72
+
73
+ // The icon search. The chooser offers one library, fetched as you type: 4736 tiles
74
+ // rendered inline would be a multi-megabyte page, and nobody scans four thousand
75
+ // icons — they type.
76
+ let iconSearchTimer = null;
77
+ document.addEventListener('input', (e) => {
78
+ if (!e.target.closest('.icon-filter')) return;
79
+ // Debounced, so typing a word is one request rather than one per keystroke.
80
+ clearTimeout(iconSearchTimer);
81
+ iconSearchTimer = setTimeout(() => {
82
+ document.body.dispatchEvent(new CustomEvent('search-icons'));
83
+ }, 220);
84
+ });
85
+
86
+ // Give each video the aspect ratio it actually has.
87
+ //
88
+ // The browser is the only party here that knows a recording's dimensions: the app
89
+ // container has no ffprobe/ffmpeg, so the server cannot emit width/height attributes,
90
+ // and UIKit's media reset derives `aspect-ratio` from exactly those missing attributes
91
+ // (see .video-player in style.css — without a stated ratio the element lays out 2px
92
+ // tall). CSS states 16/9 so the box is never zero; this replaces it with the real ratio
93
+ // as soon as the metadata arrives, so a 16:10 screencast is not letterboxed for nothing.
94
+ function sizeVideoPlayers(scope) {
95
+ (scope || document).querySelectorAll('video.video-player').forEach((v) => {
96
+ const apply = () => {
97
+ if (v.videoWidth && v.videoHeight) v.style.aspectRatio = `${v.videoWidth} / ${v.videoHeight}`;
98
+ };
99
+ if (v.readyState >= 1) apply(); // metadata already in
100
+ v.addEventListener('loadedmetadata', apply, { once: true }); // or when it lands
101
+ });
102
+ }
103
+
104
+ // htmx 4 dispatches htmx:after:swap on the element that MADE the request, not on
105
+ // the one that was swapped — the swapped element is what htmx:after:settle names.
106
+ // Every listener below wants the element whose content just changed, so it asks
107
+ // for it once, here, rather than trusting e.target.
108
+ function swappedEl(e) {
109
+ return (e.detail && e.detail.target) || e.target;
110
+ }
111
+
112
+ // Open the editor modal whenever something is swapped into it, and put the list back
113
+ // in step when it closes — a save may have renamed or changed what the list shows.
114
+ /**
115
+ * In the clone form, the "also answer at <name>-<workspace>" label has to follow
116
+ * the name field: the address is derived from the name, and a label showing
117
+ * "<name>-dev" while the field says "blog2" makes people check the wrong thing.
118
+ */
119
+ function wireCloneAddressPreview(root) {
120
+ const scope = root && root.querySelector ? root : document;
121
+ const name = scope.querySelector('form [name="to"]');
122
+ const host = scope.querySelector('.clone-ws-host');
123
+ if (!name || !host || host.dataset.wired === '1') return;
124
+ host.dataset.wired = '1';
125
+ const ws = host.textContent.replace(/^&lt;name&gt;-|^<name>-/, '').replace('.ddev.site', '');
126
+ const paint = () => { host.textContent = `${name.value || '<name>'}-${ws}.ddev.site`; };
127
+ name.addEventListener('input', paint);
128
+ paint();
129
+ }
130
+
131
+ function onEditorSwap(e) {
132
+ const el = swappedEl(e);
133
+ if (!el || !el.id) return;
134
+ if (el.id === 'editor-modal-body' && window.UIkit) {
135
+ // The dropdown the item was clicked in stays open behind — UIkit only closes a
136
+ // dropdown on an outside click, and opening a modal is not one. The result is a
137
+ // menu sitting on top of the dialog it just opened, which reads as a rendering
138
+ // bug. Every row menu that opens this modal has it, so it is closed here rather
139
+ // than by each caller.
140
+ document.querySelectorAll('.uk-dropdown.uk-open').forEach((d) => {
141
+ try { window.UIkit.dropdown(d).hide(false); } catch (_) { /* not a dropdown any more */ }
142
+ });
143
+ // Showing an open modal again is a no-op, so both events may fire.
144
+ window.UIkit.modal('#editor-modal').show();
145
+ sizeVideoPlayers(el);
146
+ wireCloneAddressPreview(el);
147
+ }
148
+ // A save result is short and the editor above it is tall, so scroll it into view —
149
+ // otherwise a successful save is invisible and reads as nothing having happened.
150
+ if (el.id === 'editor-result') {
151
+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
152
+ // Keep the list behind the modal in step with what was just written.
153
+ document.body.dispatchEvent(new CustomEvent('refresh-projects'));
154
+ }
155
+ }
156
+ document.body.addEventListener('htmx:after:swap', onEditorSwap);
157
+ document.body.addEventListener('htmx:after:settle', onEditorSwap);
158
+
159
+ // The Generate voice form shows the options of the mode and engine chosen, and counts what is typed.
160
+ document.addEventListener('change', (e) => {
161
+ if (!e.target.matches || !e.target.matches('.voice-form input[name="engine"], .voice-form input[name="kind"], .voice-form select[name="voice_choice"]')) return;
162
+ const form = e.target.form;
163
+ const checked = (name) => (form.querySelector(`input[name="${name}"]:checked`) || {}).value;
164
+ const picker = form.querySelector('select[name="voice_choice"]');
165
+ const engine = picker ? picker.value.split(':')[0] : checked('engine');
166
+ const modeField = form.querySelector('input[name="kind"]:checked, input[type="hidden"][name="kind"]');
167
+ const mode = (modeField && modeField.value) || 'text';
168
+ form.querySelectorAll('[data-engine-pane]').forEach((el) => { el.hidden = el.dataset.enginePane !== engine; });
169
+ form.querySelectorAll('[data-mode-pane]').forEach((el) => { el.hidden = el.dataset.modePane !== mode; });
170
+ form.querySelectorAll('[data-engine-button]').forEach((el) => { el.hidden = el.dataset.engineButton !== engine || (el.dataset.modeButton || 'text') !== mode; });
171
+ form.querySelectorAll('[data-plan-button]').forEach((el) => { el.hidden = mode !== 'video'; });
172
+ const plan = document.getElementById('voice-plan');
173
+ if (plan) plan.innerHTML = '';
174
+ });
175
+ // A plan is only true for the form it was made from.
176
+ ['change', 'input'].forEach((type) => document.addEventListener(type, (e) => {
177
+ if (!e.target.closest || !e.target.closest('.voice-form')) return;
178
+ const plan = document.getElementById('voice-plan');
179
+ if (plan) plan.innerHTML = '';
180
+ }));
181
+ document.addEventListener('input', (e) => {
182
+ if (!e.target.matches || !e.target.matches('[data-voice-text]')) return;
183
+ const n = e.target.form && e.target.form.querySelector('[data-voice-count]');
184
+ if (n) n.textContent = e.target.value.length;
185
+ });
186
+
187
+ // The editor modal is a dialog: named after its heading, and the page behind it is out of reach while it is
188
+ // open. Without that, Tab from the last button went to the page behind the overlay and a screen reader had
189
+ // a dialog with no name.
190
+ function containEditorModal(open) {
191
+ const modal = document.getElementById('editor-modal');
192
+ if (!modal) return;
193
+ for (const el of Array.from(document.body.children)) {
194
+ if (el === modal || el.contains(modal) || el.id === 'confirm-modal' || el.tagName === 'SCRIPT'
195
+ || el.classList.contains('uk-notification')) continue;
196
+ if (open) {
197
+ if (!('wasInert' in el.dataset)) el.dataset.wasInert = el.inert ? '1' : '';
198
+ el.inert = true;
199
+ } else if ('wasInert' in el.dataset) {
200
+ el.inert = el.dataset.wasInert === '1';
201
+ delete el.dataset.wasInert;
202
+ }
203
+ }
204
+ }
205
+ function nameEditorModal() {
206
+ // UIkit puts role="dialog" aria-modal="true" on the inner .uk-modal-dialog, so that is the one that is named.
207
+ const dialog = document.querySelector('#editor-modal .uk-modal-dialog');
208
+ const h = dialog && dialog.querySelector('.modal-head h3, h3, h2');
209
+ if (dialog && h) dialog.setAttribute('aria-label', h.textContent.replace(/\s+/g, ' ').trim());
210
+ }
211
+ document.addEventListener('shown', (e) => {
212
+ if (!e.target || e.target.id !== 'editor-modal') return;
213
+ containEditorModal(true);
214
+ nameEditorModal();
215
+ });
216
+ document.body.addEventListener('htmx:after:swap', (e) => {
217
+ if (swappedEl(e)?.id === 'editor-modal-body') nameEditorModal();
218
+ });
219
+
220
+ document.addEventListener('hidden', (e) => {
221
+ if (!e.target || e.target.id !== 'editor-modal') return;
222
+ containEditorModal(false);
223
+ const body = document.getElementById('editor-modal-body');
224
+ // Pause first: emptying the container detaches the element but a playing video can
225
+ // keep its audio going, which is startling when the modal has visibly closed.
226
+ body?.querySelectorAll('video, audio').forEach((m) => { try { m.pause(); } catch (_) { /* gone already */ } });
227
+ if (body) body.innerHTML = ''; // drop the editor, and any unsaved text with it
228
+ document.body.dispatchEvent(new CustomEvent('refresh-projects'));
229
+ });
230
+
231
+ // Show error responses instead of discarding them.
232
+ //
233
+ // HTMX only swaps a 2xx by default, so every 4xx this dashboard returns — 67 of them,
234
+ // plus 9 fragment ones — was silently dropped: the refused save, the 409 when a
235
+ // settings file changed underneath, "Unknown workspace", the bash -n syntax report.
236
+ // The user clicked and nothing happened, which reads as the button being broken.
237
+ //
238
+ // The server already returns a rendered message with each of these, so swap it in and
239
+ // let the status stay honest.
240
+ document.body.addEventListener('htmx:before:swap', (e) => {
241
+ const status = e.detail.xhr && e.detail.xhr.status;
242
+ if (status >= 400 && status < 600) {
243
+ e.detail.shouldSwap = true;
244
+ e.detail.isError = false; // it is a handled response, not a transport failure
245
+ }
246
+ });
247
+
248
+ // Copy a shown command to the clipboard. The dashboard prints commands it deliberately does not
249
+ // run — MCP registration writes to ~/.claude, which belongs to the person at the keyboard, not to
250
+ // a web request — so the useful button is "copy it", not "do it".
251
+ document.body.addEventListener('click', async (e) => {
252
+ const btn = e.target.closest('.copy-cmd');
253
+ if (!btn) return;
254
+ e.preventDefault();
255
+ const text = btn.dataset.copy || (btn.previousElementSibling && btn.previousElementSibling.textContent) || '';
256
+ try {
257
+ await navigator.clipboard.writeText(text.trim());
258
+ const was = btn.innerHTML;
259
+ btn.innerHTML = '<span uk-icon="icon: check; ratio: .7"></span> Copied';
260
+ setTimeout(() => { btn.innerHTML = was; }, 1500);
261
+ } catch (_) {
262
+ // Clipboard access needs a secure context and permission; the command is on screen either way.
263
+ btn.textContent = 'Select and copy it';
264
+ }
265
+ });
266
+
267
+ // Generate SVG: picking an example fills the prompt and the style, and both stay editable.
268
+ document.addEventListener('change', (e) => {
269
+ const sel = e.target && e.target.matches && e.target.matches('select[data-svg-example]') ? e.target : null;
270
+ const o = sel && sel.selectedOptions[0];
271
+ if (!o || !o.dataset.prompt) return;
272
+ const form = sel.closest('form');
273
+ const ta = form && form.querySelector('textarea[name="prompt"]');
274
+ if (ta) {
275
+ ta.value = o.dataset.prompt;
276
+ if (ta._aceEditor) ta._aceEditor.setValue(o.dataset.prompt, 1);
277
+ }
278
+ const style = form && form.querySelector('select[name="style"]');
279
+ if (style && o.dataset.style) style.value = o.dataset.style;
280
+ });
281
+
282
+ // An animated SVG in the viewer starts when a quarter of it is in view, and Replay starts it again: an SVG shown
283
+ // as an image restarts its animation when it is loaded anew.
284
+ function playSvg(img) {
285
+ const src = img.dataset.src || img.getAttribute('src');
286
+ img.setAttribute('src', `${src}${src.includes('?') ? '&' : '?'}play=${Date.now()}`);
287
+ }
288
+ function watchSvgMotion(root) {
289
+ (root || document).querySelectorAll('img[data-svg-motion]:not([data-svg-watched])').forEach((img) => {
290
+ img.dataset.svgWatched = '1';
291
+ if (!window.IntersectionObserver) return;
292
+ const io = new IntersectionObserver((entries) => {
293
+ if (!entries.some((en) => en.isIntersecting)) return;
294
+ io.disconnect();
295
+ playSvg(img);
296
+ }, { threshold: Number(img.dataset.threshold) || 0.25 });
297
+ io.observe(img);
298
+ });
299
+ }
300
+ document.addEventListener('DOMContentLoaded', () => watchSvgMotion());
301
+ document.body.addEventListener('htmx:after:settle', () => watchSvgMotion());
302
+ document.body.addEventListener('click', (e) => {
303
+ const btn = e.target.closest('[data-svg-replay]');
304
+ if (!btn) return;
305
+ const card = btn.closest('[data-img-viewer]');
306
+ const img = card && card.querySelector('img[data-svg-motion]');
307
+ if (img) playSvg(img);
308
+ });
309
+
310
+ // Opening a menu asks for what is in it NOW.
311
+ //
312
+ // The screens menu polls in the background so the row's hand and agent badge stay current, and that
313
+ // poll is deliberately slow — one request per project row every few seconds is what pinned this
314
+ // dashboard once before. Slow in the background is fine as long as OPENING the menu is not: a list of
315
+ // sessions is the one thing you open the menu to read, and reading a thirty-second-old one is how you
316
+ // end up clicking a session that has already finished.
317
+ //
318
+ // UIkit emits `beforeshow` on the dropdown, and htmx listens for `menu-opened` on the list inside it.
319
+ document.addEventListener('beforeshow', (e) => {
320
+ const list = e.target.querySelector && e.target.querySelector('[hx-trigger*="menu-opened"]');
321
+ if (list) list.dispatchEvent(new CustomEvent('menu-opened'));
322
+ }, true);
323
+
324
+ // A pick closes the menu it was made in.
325
+ //
326
+ // UIkit closes a dropdown on an OUTSIDE click, and almost nothing these menus do is one: opening a
327
+ // modal is not, opening a terminal window is not, and posting an action that swaps the output panel is
328
+ // not. So the menu stayed open, floating over the thing it had just opened — which reads as a control
329
+ // that did not take, and on the screens menu it sat on top of the terminal it had just started.
330
+ //
331
+ // This used to be scoped to items carrying `uk-toggle` (the modal openers), which is why the screens
332
+ // menu was never covered: its items carry `data-open-terminal` instead. A menu closing when you choose
333
+ // something from it is what a menu does, so the rule is now the general one — every link and button
334
+ // inside any dropdown. Inputs, selects and labels are deliberately NOT included: a dropdown that holds
335
+ // a filter box (the icon picker, the command picker) must stay open while it is being typed in.
336
+ document.body.addEventListener('click', (e) => {
337
+ if (!window.UIkit) return;
338
+ const item = e.target.closest('.uk-dropdown a, .uk-dropdown button');
339
+ if (!item) return;
340
+ const drop = item.closest('[uk-dropdown]') || item.closest('.uk-dropdown');
341
+ if (!drop) return;
342
+ // The pager and the filter inside a dropdown-based picker re-render it in place; closing on those
343
+ // would shut the picker on the first page turn.
344
+ if (item.closest('[data-keep-open]')) return;
345
+ try { window.UIkit.dropdown(drop).hide(0); } catch (_) { /* not a dropdown any more */ }
346
+ });
347
+
348
+ // Ace (ajaxorg/ace, BSD-3) for the files that are code: .sh, .yml, .md.
349
+ //
350
+ // It attaches to a textarea marked data-ace="<mode>" and writes every change straight
351
+ // back into it, so every existing form keeps posting the same field and no endpoint
352
+ // changed. If Ace fails to load, the textarea is simply still there and still works —
353
+ // which is why it replaces rather than removes it.
354
+ //
355
+ // Loaded on demand: 780KB of editor has no business on a page that is not editing.
356
+
357
+ // ---------------------------------------------------------------- mentions --
358
+ // One fetch per source, cached: the list changes when someone installs an agent,
359
+ // not between keystrokes.
360
+ const _mentionCache = new Map();
361
+ function mentionsFor(url) {
362
+ if (!_mentionCache.has(url)) {
363
+ _mentionCache.set(url, fetch(url).then((r) => r.json()).catch(() => []));
364
+ }
365
+ return _mentionCache.get(url);
366
+ }
367
+
368
+ function attachMentions(ace, editor, url) {
369
+ let tools;
370
+ try { tools = ace.require('ace/ext/language_tools'); } catch (_) { return; }
371
+ if (!tools) return;
372
+ editor.setOptions({ enableBasicAutocompletion: true, enableLiveAutocompletion: true });
373
+ // The same endpoint and the same rules as the composer's popup (suggest.js), so the two
374
+ // fields cannot offer different things: "@" for agents and skills, "/" for commands and
375
+ // skills. It used to be "@" only, and it inserted `@"name (kind)"` — a shape neither the
376
+ // CLI nor the assistant understands, so what you picked was not what you could send.
377
+ let items = { '@': [], '/': [] };
378
+ const load = (trigger) => fetch(`${url}${url.includes('?') ? '&' : '?'}trigger=${encodeURIComponent(trigger)}`,
379
+ { headers: { Accept: 'application/json' } })
380
+ .then((r) => (r.ok ? r.json() : []))
381
+ .then((list) => { items[trigger] = list || []; })
382
+ .catch(() => { /* no completions; the editor still edits */ });
383
+ load('@');
384
+ load('/');
385
+ tools.addCompleter({
386
+ // Only after a trigger — this is a prompt field, not a code editor, and suggesting on
387
+ // every word would fight the typing.
388
+ identifierRegexps: [/[@/a-zA-Z0-9_.-]/],
389
+ getCompletions(ed, session, pos, prefix, callback) {
390
+ const line = session.getLine(pos.row).slice(0, pos.column);
391
+ let best = null;
392
+ for (const trigger of ['@', '/']) {
393
+ const at = line.lastIndexOf(trigger);
394
+ if (at === -1) continue;
395
+ const before = at === 0 ? '' : line[at - 1];
396
+ if (before && !/\s/.test(before)) continue; // an email, or a path
397
+ const typed = line.slice(at + 1);
398
+ if (/[\s"']/.test(typed)) continue; // the token is finished
399
+ if (trigger === '/' && at !== 0 && !/^\s*$/.test(line.slice(0, at))) continue;
400
+ if (!best || at > best.at) best = { trigger, at, typed: typed.toLowerCase() };
401
+ }
402
+ if (!best) return callback(null, []);
403
+ const hits = (items[best.trigger] || [])
404
+ .filter((m) => m.name.toLowerCase().includes(best.typed))
405
+ .slice(0, 40)
406
+ .map((m, _i, arr) => ({
407
+ // The trigger and the name — plus the kind when the name alone is ambiguous.
408
+ // A few items exist as both an agent and a skill and are not interchangeable.
409
+ caption: `${best.trigger}${m.name}${arr.filter((o) => o.name === m.name).length > 1 ? ` (${m.kind})` : ''}`,
410
+ value: `${best.trigger}${m.name}${arr.filter((o) => o.name === m.name).length > 1 ? ` (${m.kind})` : ''}`,
411
+ meta: `${m.kind} · ${m.where}`,
412
+ docHTML: m.description
413
+ ? `<div style="max-width:38rem;white-space:normal">${m.description.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]))}</div>`
414
+ : undefined,
415
+ score: m.where === 'this project' ? 1000 : m.where === 'installed' ? 500 : 100,
416
+ }));
417
+ callback(null, hits);
418
+ },
419
+ });
420
+ }
421
+
422
+ let acePromise = null;
423
+ function loadAce() {
424
+ if (acePromise) return acePromise;
425
+ acePromise = new Promise((resolve, reject) => {
426
+ const el = document.createElement('script');
427
+ el.src = '/vendor/ace/ace.js';
428
+ el.onload = () => {
429
+ window.ace.config.set('basePath', '/vendor/ace');
430
+ // Ace resolves worker and mode files itself from basePath; only the yaml worker
431
+ // is vendored, so the others must not be reached for.
432
+ window.ace.config.setModuleUrl('ace/mode/yaml_worker', '/vendor/ace/worker-yaml.js');
433
+ // The completion extension is part of loading Ace, not a race beside it:
434
+ // an editor that mounts first would find no completer to register with.
435
+ const lt = document.createElement('script');
436
+ lt.src = '/vendor/ace/ext-language_tools.js';
437
+ lt.onload = () => resolve(window.ace);
438
+ lt.onerror = () => resolve(window.ace); // no completions, still an editor
439
+ document.head.appendChild(lt);
440
+ };
441
+ el.onerror = reject;
442
+ document.head.appendChild(el);
443
+ });
444
+ return acePromise;
445
+ }
446
+
447
+ const ACE_MODES = { sh: 'sh', yaml: 'yaml', yml: 'yaml', markdown: 'markdown', md: 'markdown' };
448
+
449
+ function aceTheme() {
450
+ // settings.yml's style.editor_theme can pin the editor independently of the page:
451
+ // 'auto' follows the mode, 'light'/'dark' force it — a code editor is one place
452
+ // people often want dark even on a light page.
453
+ const forced = document.documentElement.dataset.editorTheme;
454
+ const dark = forced === 'dark' ? true
455
+ : forced === 'light' ? false
456
+ : document.documentElement.classList.contains('dark');
457
+ return dark ? 'ace/theme/tomorrow_night' : 'ace/theme/textmate';
458
+ }
459
+
460
+ function attachAce(root) {
461
+ // A swap can name a target that is not an element (a polling card replaces itself); there is nothing to attach to then.
462
+ if (root && typeof root.querySelectorAll !== 'function') return;
463
+ const fields = (root || document).querySelectorAll('textarea[data-ace]:not([data-ace-ready])');
464
+ if (!fields.length) return;
465
+ // Claim each field NOW, synchronously, before awaiting the loader — the guard above
466
+ // is only honest if it is set before anything can await. Both `htmx:after:swap` and
467
+ // `htmx:after:settle` call this for the same swap, and on the FIRST open Ace is a
468
+ // 780KB fetch: the two calls used to query, both match the same textarea while the
469
+ // load was still in flight, and both attach an editor to it. Two stacked editors, the
470
+ // same prompt visible twice. Never reproducible once Ace is cached, because then the
471
+ // loader resolves in a microtask and the first claim lands before the second event.
472
+ fields.forEach((ta) => { ta.dataset.aceReady = '1'; });
473
+ loadAce().then((ace) => {
474
+ fields.forEach((ta) => {
475
+ const mode = ACE_MODES[ta.dataset.ace] || 'sh';
476
+ // data-ace-single: one line (a command line) — no Enter, no gutter, and a pasted line break becomes a space.
477
+ // readonly: code shown on a page, to read and copy, not to type into.
478
+ const single = ta.dataset.aceSingle === '1';
479
+ const readOnly = ta.readOnly || ta.dataset.aceReadonly === '1';
480
+ const host = document.createElement('div');
481
+ host.className = `ace-host${single ? ' ace-host-single' : ''}${readOnly ? ' ace-host-readonly' : ''}`;
482
+ // A short field (a prompt, a few steps) gets a short editor: the 260px
483
+ // floor is for the file editors, not for a form row. A one-line editor sizes itself.
484
+ if (!single) host.style.height = `${ta.rows <= 8 ? Math.max(90, ta.rows * 19) : Math.max(260, Math.min(620, ta.rows * 19))}px`;
485
+ ta.parentNode.insertBefore(host, ta);
486
+ ta.style.display = 'none'; // kept in the DOM: it is what submits
487
+
488
+ const editor = ace.edit(host);
489
+ editor.session.setMode(`ace/mode/${mode}`);
490
+ // "@" offers the agents, skills and prompts this machine and this project
491
+ // actually have — the same names Claude Code answers to, so a prompt can
492
+ // name one without anybody remembering how it is spelled.
493
+ if (ta.dataset.mentions) attachMentions(ace, editor, ta.dataset.mentions);
494
+ editor.setTheme(aceTheme());
495
+ editor.session.setValue(ta.value);
496
+ editor.session.setUseSoftTabs(true);
497
+ editor.session.setTabSize(2);
498
+ editor.setShowPrintMargin(false);
499
+ editor.setOption('useWorker', mode === 'yaml' && !readOnly); // only the yaml worker is vendored
500
+ if (ta.placeholder && editor.$options && editor.$options.placeholder) editor.setOption('placeholder', ta.placeholder);
501
+ if (single) {
502
+ editor.setOptions({ minLines: 1, maxLines: 1, showGutter: false, highlightActiveLine: false });
503
+ editor.commands.addCommand({ name: 'oneLine', bindKey: { win: 'Enter|Shift-Enter', mac: 'Enter|Shift-Enter' }, exec: () => {} });
504
+ editor.on('paste', (ev) => { ev.text = String(ev.text).replace(/[\r\n]+/g, ' '); });
505
+ }
506
+ if (readOnly) editor.setOptions({ readOnly: true, highlightActiveLine: false, highlightGutterLine: false });
507
+ editor.session.on('change', () => { ta.value = editor.session.getValue(); });
508
+ // An editor drawn inside a closed <details> or a hidden tab has no size yet: draw it again when it gets one.
509
+ if (window.ResizeObserver) new ResizeObserver(() => editor.resize()).observe(host);
510
+ ta._aceEditor = editor;
511
+ });
512
+ }).catch(() => {
513
+ // No Ace: hand the fields back so the textarea is a plain textarea again and a
514
+ // later attempt (another swap) can still try.
515
+ fields.forEach((ta) => { delete ta.dataset.aceReady; });
516
+ });
517
+ }
518
+
519
+ // Add a row and add a button are forms in the modal, and both answer with the rows editor behind it — so the modal
520
+ // has nothing left to show. Closed from here rather than with an hx-on on each button, whose `event.detail.successful`
521
+ // does not exist in htmx 4. `after:settle` is the event that names the element that was swapped: `after:swap` reports
522
+ // the element that MADE the request, which here is the button inside the modal.
523
+ document.body.addEventListener('htmx:after:settle', (e) => {
524
+ if (!e.target || e.target.id !== 'row-actions-editor') return;
525
+ if (window.UIkit && document.querySelector('#editor-modal.uk-open')) window.UIkit.modal('#editor-modal').hide();
526
+ });
527
+
528
+ document.addEventListener('DOMContentLoaded', () => attachAce());
529
+ // Start AI's prompt gets Ace only once Start AI is ticked, so a workspace page does not load the editor for nothing.
530
+ document.addEventListener('change', (e) => {
531
+ const box = e.target && e.target.matches && e.target.matches('input[name="ai_after"]') ? e.target : null;
532
+ if (!box || !box.checked) return;
533
+ const form = box.closest('form');
534
+ const ta = form && form.querySelector('textarea[data-ace-on-demand]:not([data-ace])');
535
+ if (!ta) return;
536
+ ta.dataset.ace = ta.dataset.aceOnDemand;
537
+ attachAce(form);
538
+ });
539
+ document.body.addEventListener('htmx:after:swap', (e) => attachAce(swappedEl(e)));
540
+ document.body.addEventListener('htmx:after:settle', (e) => attachAce(swappedEl(e)));
541
+
542
+ // Follow the theme toggle, since the editor paints its own background.
543
+ document.addEventListener('click', (e) => {
544
+ if (!e.target.closest('.theme-toggle')) return;
545
+ setTimeout(() => {
546
+ document.querySelectorAll('textarea[data-ace-ready]').forEach((ta) => {
547
+ if (ta._aceEditor) ta._aceEditor.setTheme(aceTheme());
548
+ });
549
+ }, 50);
550
+ });
551
+
552
+ // The actions rail. Collapsed state is remembered, like the theme, because it is
553
+ // a layout preference and having it reset on every navigation would be noise.
554
+ function applyActionsCollapsed(collapsed) {
555
+ document.documentElement.classList.toggle('actions-collapsed', collapsed);
556
+ const toggle = document.querySelector('.actions-toggle');
557
+ if (toggle) {
558
+ toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
559
+ toggle.title = collapsed ? 'Expand the actions rail' : 'Collapse the actions rail';
560
+ }
561
+ }
562
+
563
+ // Collapsed is the default: the rail is on every page now, and the content — a
564
+ // project table, a diff, a job's output — wants the width more than the labels do.
565
+ // Only an explicit '0' (the user expanded it) keeps it open.
566
+ try {
567
+ applyActionsCollapsed(wsPref('actions-collapsed', '1') !== '0');
568
+ } catch (_) { applyActionsCollapsed(true); }
569
+
570
+ document.addEventListener('click', (e) => {
571
+ if (!e.target.closest('.actions-toggle')) return;
572
+ const collapsed = !document.documentElement.classList.contains('actions-collapsed');
573
+ applyActionsCollapsed(collapsed);
574
+ wsPrefSet('actions-collapsed', collapsed ? '1' : '0');
575
+ });
576
+
577
+ // The rail's names and its tooltip. Collapsed, a tile is a glyph: its name has to be there for a screen
578
+ // reader (aria-label, the visible label) and for the eye (a tooltip, at once and on keyboard focus — the
579
+ // native title waits about a second and never shows on focus). The sentence the title held moves to
580
+ // aria-description and the tooltip, so nothing is lost and nothing shows twice.
581
+ (function railTips() {
582
+ const rail = document.getElementById('actions-rail');
583
+ if (!rail) return;
584
+ rail.querySelectorAll('a.action-btn, button.action-btn').forEach((el) => {
585
+ if (el.classList.contains('actions-version')) return;
586
+ const label = (el.querySelector('.action-label') || {}).textContent;
587
+ if (!label || !label.trim()) return;
588
+ const name = label.replace(/\s+/g, ' ').trim();
589
+ const hint = (el.getAttribute('title') || '').replace(/\s+/g, ' ').trim();
590
+ el.dataset.tipLabel = name;
591
+ el.dataset.tipGroup = ((el.closest('.actions-group') || {}).dataset || {}).group || '';
592
+ if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', name);
593
+ if (hint && hint !== name) {
594
+ el.dataset.tipHint = hint;
595
+ el.setAttribute('aria-description', hint);
596
+ }
597
+ el.removeAttribute('title');
598
+ });
599
+
600
+ let tip = null;
601
+ let shownFor = null;
602
+ const hide = () => { if (tip) tip.hidden = true; shownFor = null; };
603
+ const show = (el) => {
604
+ if (window.innerWidth < 960 || !el.dataset.tipLabel) return;
605
+ const collapsed = document.documentElement.classList.contains('actions-collapsed');
606
+ const head = collapsed ? el.dataset.tipLabel : '';
607
+ const hint = el.dataset.tipHint || '';
608
+ if (!head && !hint) return;
609
+ if (!tip) {
610
+ tip = document.createElement('div');
611
+ tip.id = 'rail-tip';
612
+ tip.setAttribute('role', 'tooltip');
613
+ document.body.appendChild(tip);
614
+ }
615
+ tip.textContent = '';
616
+ if (head) { const b = document.createElement('strong'); b.textContent = head; tip.appendChild(b); }
617
+ if (hint) { const s = document.createElement('span'); s.textContent = hint; tip.appendChild(s); }
618
+ tip.hidden = false;
619
+ const r = el.getBoundingClientRect();
620
+ const t = tip.getBoundingClientRect();
621
+ tip.style.right = `${Math.round(window.innerWidth - r.left + 10)}px`;
622
+ tip.style.top = `${Math.round(Math.max(8, Math.min(window.innerHeight - t.height - 8, r.top + r.height / 2 - t.height / 2)))}px`;
623
+ shownFor = el;
624
+ };
625
+ const tile = (e) => e.target.closest && e.target.closest('#actions-rail a.action-btn, #actions-rail button.action-btn');
626
+ rail.addEventListener('mouseover', (e) => { const el = tile(e); if (el && el !== shownFor) show(el); });
627
+ rail.addEventListener('mouseout', (e) => { if (tile(e)) hide(); });
628
+ rail.addEventListener('focusin', (e) => { const el = tile(e); if (el) show(el); });
629
+ rail.addEventListener('focusout', hide);
630
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
631
+ rail.addEventListener('scroll', hide, { passive: true });
632
+
633
+ // Whether there is more rail below the fold, so Go and Help are not simply cut off: a fade at the foot
634
+ // while there is, and none once the end is in view.
635
+ const fade = () => {
636
+ const more = rail.scrollHeight - rail.clientHeight - rail.scrollTop > 4;
637
+ rail.classList.toggle('can-scroll', more);
638
+ };
639
+ rail.addEventListener('scroll', fade, { passive: true });
640
+ window.addEventListener('resize', fade);
641
+ document.addEventListener('click', (e) => { if (e.target.closest('.actions-toggle')) setTimeout(fade, 250); });
642
+ fade();
643
+ })();
644
+
645
+ // Tabbed modals (tab-modal.js renders the markup). A tab is a button that loads its fragment into the
646
+ // pane; this keeps the one you are on marked, moves between tabs with the arrow keys, filters a long
647
+ // list, keeps #<modal>/<tab> in the address for a modal that asks for it, and sends the links inside a
648
+ // pane back into the pane rather than away from the page.
649
+ (function tabModals() {
650
+ const hashOf = (tm) => tm.hasAttribute('data-hash') ? tm.dataset.tabmodal : '';
651
+ const select = (tm, tab) => {
652
+ tm.querySelectorAll('.tabmodal-tab').forEach((t) => {
653
+ const on = t === tab;
654
+ t.classList.toggle('is-selected', on);
655
+ t.setAttribute('aria-selected', on ? 'true' : 'false');
656
+ t.tabIndex = on ? 0 : -1;
657
+ });
658
+ const head = tm.querySelector('.tabmodal-heading');
659
+ const sub = tm.querySelector('.tabmodal-sub');
660
+ if (head) head.textContent = tab.dataset.label || '';
661
+ if (sub) sub.textContent = tab.dataset.hint || '';
662
+ const pane = tm.querySelector('.tabmodal-pane');
663
+ if (pane) pane.setAttribute('aria-labelledby', tab.id);
664
+ const h = hashOf(tm);
665
+ if (h) { try { history.replaceState(null, '', `#${h}/${encodeURIComponent(tab.dataset.tab)}`); } catch (_) { /* a sandboxed frame */ } }
666
+ };
667
+
668
+ document.addEventListener('click', (e) => {
669
+ const tab = e.target.closest && e.target.closest('.tabmodal-tab');
670
+ if (tab) { select(tab.closest('.tabmodal'), tab); return; }
671
+
672
+ // A link in a pane that points at another page of the same section loads into the pane.
673
+ const a = e.target.closest && e.target.closest('.tabmodal-pane a[href^="/settings"]');
674
+ if (a && !e.defaultPrevented && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0
675
+ && a.target !== '_blank' && window.htmx) {
676
+ const tm = a.closest('.tabmodal');
677
+ e.preventDefault();
678
+ const u = new URL(a.getAttribute('href'), location.origin);
679
+ u.searchParams.set('fragment', '1');
680
+ window.htmx.ajax('GET', u.pathname + u.search, { target: a.closest('.tabmodal-pane'), swap: 'innerHTML' });
681
+ const seg = decodeURIComponent(u.pathname.replace(/^\/settings\/?/, ''));
682
+ const want = seg === '' ? 'overview' : seg;
683
+ const tab2 = [...tm.querySelectorAll('.tabmodal-tab')].find((t) => t.dataset.tab === want);
684
+ if (tab2) select(tm, tab2);
685
+ return;
686
+ }
687
+
688
+ // The rail's tile for a place that also exists as a modal: open it over the page you are on.
689
+ const opener = e.target.closest && e.target.closest('[data-tabmodal-open]');
690
+ if (opener && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0 && window.htmx) {
691
+ e.preventDefault();
692
+ window.htmx.ajax('GET', opener.dataset.tabmodalOpen, { target: '#editor-modal-body', swap: 'innerHTML' });
693
+ }
694
+ });
695
+
696
+ document.addEventListener('keydown', (e) => {
697
+ const tab = e.target.closest && e.target.closest('.tabmodal-tab');
698
+ if (!tab || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
699
+ const tabs = [...tab.closest('.tabmodal').querySelectorAll('.tabmodal-tab')].filter((t) => !t.hidden && t.offsetParent);
700
+ const i = tabs.indexOf(tab);
701
+ const next = e.key === 'Home' ? tabs[0] : e.key === 'End' ? tabs[tabs.length - 1]
702
+ : tabs[Math.min(tabs.length - 1, Math.max(0, i + (e.key === 'ArrowDown' ? 1 : -1)))];
703
+ if (next) { e.preventDefault(); next.focus(); }
704
+ });
705
+
706
+ document.addEventListener('input', (e) => {
707
+ const box = e.target.closest && e.target.closest('.tabmodal-filter');
708
+ if (!box) return;
709
+ const q = box.value.trim().toLowerCase();
710
+ const tm = box.closest('.tabmodal');
711
+ tm.querySelectorAll('.tabmodal-tab').forEach((t) => {
712
+ t.hidden = !!q && !(t.dataset.label || '').toLowerCase().includes(q);
713
+ });
714
+ tm.querySelectorAll('.tabmodal-group').forEach((g) => {
715
+ g.hidden = ![...g.querySelectorAll('.tabmodal-tab')].some((t) => !t.hidden);
716
+ });
717
+ });
718
+
719
+ // A link to a place: #settings/<tab> opens the modal on that tab, from any page.
720
+ // The tab you are on may be far down a long list: bring it into view when the modal opens.
721
+ document.body.addEventListener('htmx:after:swap', (e) => {
722
+ const t = e.target && e.target.id === 'editor-modal-body' && e.target.querySelector('.tabmodal-tab.is-selected');
723
+ if (t) t.scrollIntoView({ block: 'nearest' });
724
+ });
725
+
726
+ const openFromHash = () => {
727
+ const m = location.hash.match(/^#(settings)\/([^/]+)$/);
728
+ if (m && window.htmx) {
729
+ window.htmx.ajax('GET', `/fragments/settings/modal?tab=${m[2]}`, { target: '#editor-modal-body', swap: 'innerHTML' });
730
+ }
731
+ };
732
+ window.addEventListener('hashchange', openFromHash);
733
+ window.addEventListener('load', openFromHash);
734
+ document.addEventListener('hidden', (e) => {
735
+ if (e.target && e.target.id === 'editor-modal' && /^#settings\//.test(location.hash)) {
736
+ try { history.replaceState(null, '', location.pathname + location.search); } catch (_) { /* nothing to clear */ }
737
+ }
738
+ }, true);
739
+ })();
740
+
741
+ // A list's quick filter: typing narrows the cards already on screen at once (each card says what it is
742
+ // in data-find), while the debounced request asks the server for the wider answer. The input names
743
+ // the list it filters in data-list-find (a selector); the server's reply then replaces the cards, so
744
+ // nothing the page did not have is ever hidden by this. Plugins' lists (Jira's workitems) use it too.
745
+ document.addEventListener('input', (e) => {
746
+ const box = e.target.closest && e.target.closest('input[data-list-find]');
747
+ if (!box) return;
748
+ const words = box.value.toLowerCase().split(/\s+/).filter(Boolean);
749
+ let host = null;
750
+ try { host = document.querySelector(box.dataset.listFind || ''); } catch (_) { /* not a selector */ }
751
+ if (!host) return;
752
+ const cards = [...host.querySelectorAll('[data-find]')];
753
+ let shown = 0;
754
+ cards.forEach((c) => {
755
+ const hay = c.dataset.find || '';
756
+ const on = words.every((w) => hay.includes(w));
757
+ c.hidden = !on;
758
+ if (on) shown += 1;
759
+ });
760
+ let note = host.querySelector('.list-find-note');
761
+ if (!note) {
762
+ note = document.createElement('p');
763
+ note.className = 'list-find-note uk-text-meta';
764
+ host.prepend(note);
765
+ }
766
+ note.textContent = words.length && cards.length ? `${shown} of ${cards.length} shown here, asking for more…` : '';
767
+ note.hidden = !note.textContent;
768
+ });
769
+
770
+ // A project's row menus (DDEV, GIT, NODE, COMPOSER, UPSUN and the rest) can hold thirty or seventy entries.
771
+ // A long one folds its groups: the first group and "This project" stay open, the rest open on their
772
+ // heading; a heading that is a sentence stays a note. Every entry is one line, its full text the tooltip.
773
+ function foldDdevMenus(root) {
774
+ (root || document).querySelectorAll('.act-menu .uk-dropdown:not([data-folded])').forEach((drop) => {
775
+ const links = drop.querySelectorAll('li > a').length;
776
+ if (!links) return; // filled later by its own request, folded then
777
+ drop.dataset.folded = '1';
778
+ drop.querySelectorAll('li > a').forEach((a) => { if (!a.title) a.title = a.textContent.replace(/\s+/g, ' ').trim(); });
779
+ const rows = drop.querySelectorAll('li:not(.uk-nav-divider)').length;
780
+ if (links < 8 && rows < 12) return;
781
+ const heads = [...drop.querySelectorAll('.uk-dropdown-nav > .uk-nav-header')].filter((h) => h.textContent.trim().length <= 40);
782
+ let folds = 0;
783
+ heads.forEach((h) => {
784
+ const rows = [];
785
+ // A group ends at the next heading or divider; a short one is not worth a fold.
786
+ for (let n = h.nextElementSibling; n && !n.classList.contains('uk-nav-header') && !n.classList.contains('uk-nav-divider'); n = n.nextElementSibling) rows.push(n);
787
+ if (rows.length < 3) return;
788
+ const open = (folds === 0 && rows.length <= 6) || /this project/i.test(h.textContent);
789
+ folds += 1;
790
+ h.setAttribute('role', 'button');
791
+ h.tabIndex = 0;
792
+ h.classList.add('ddev-fold');
793
+ const set = (on) => { h.setAttribute('aria-expanded', on ? 'true' : 'false'); rows.forEach((r) => { r.hidden = !on; }); };
794
+ set(open);
795
+ h.addEventListener('click', (e) => { e.stopPropagation(); set(h.getAttribute('aria-expanded') !== 'true'); });
796
+ h.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); h.click(); } });
797
+ });
798
+ });
799
+ }
800
+ document.addEventListener('DOMContentLoaded', () => foldDdevMenus());
801
+ document.body.addEventListener('htmx:after:swap', (e) => foldDdevMenus(swappedEl(e)));
802
+ // A menu that opens upwards near the top would sit under the top bar; the page moves down to show it.
803
+ document.addEventListener('shown', (e) => {
804
+ const d = e.target;
805
+ if (!d || !d.classList || !d.classList.contains('uk-dropdown') || !d.closest('.project-row')) return;
806
+ foldDdevMenus(d.closest('.act-menu') || d.parentElement);
807
+ const bar = document.querySelector('.toolbar, header');
808
+ const limit = (bar ? bar.getBoundingClientRect().bottom : 64) + 8;
809
+ const top = d.getBoundingClientRect().top;
810
+ if (top < limit) window.scrollBy({ top: top - limit, behavior: 'instant' });
811
+ }, true);
812
+
813
+ // A list's search box sits inside the region its own results replace, so every search threw the box
814
+ // away: focus fell to the page and whatever was typed while the request ran was lost. The field being
815
+ // typed in is remembered before a swap and given back after it, with its value and caret; if more was
816
+ // typed than was searched, the search runs again.
817
+ (function keepTypingThroughSwaps() {
818
+ let kept = null;
819
+ document.body.addEventListener('htmx:before:swap', (e) => {
820
+ const a = document.activeElement;
821
+ const ctx = (e.detail && e.detail.ctx) || {};
822
+ const target = ctx.target || (e.detail && e.detail.target) || e.target;
823
+ const into = target && target.nodeType === 1 ? target : null;
824
+ if (!a || !into || !into.contains(a) || !a.matches('input[name], textarea[name]')) { kept = null; return; }
825
+ kept = { name: a.name, value: a.value, start: a.selectionStart, end: a.selectionEnd, target: into };
826
+ });
827
+ document.body.addEventListener('htmx:after:swap', () => {
828
+ if (!kept) return;
829
+ const k = kept; kept = null;
830
+ const field = k.target.querySelector(`[name="${CSS.escape(k.name)}"]`);
831
+ if (!field) return;
832
+ const searched = field.value;
833
+ if (field.value !== k.value) field.value = k.value;
834
+ field.focus({ preventScroll: true });
835
+ try { field.setSelectionRange(k.start, k.end); } catch (_) { /* not a text field */ }
836
+ if (searched !== k.value) field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Unidentified' }));
837
+ });
838
+ })();
839
+
840
+ // A settings file: find a key, see a switch say what it is, and know when there is something unsaved.
841
+ document.addEventListener('input', (e) => {
842
+ const box = e.target.closest && e.target.closest('.settings-find');
843
+ if (!box) return;
844
+ const card = box.closest('.settings-card');
845
+ const words = box.value.toLowerCase().split(/\s+/).filter(Boolean);
846
+ let shown = 0;
847
+ card.querySelectorAll('.settings-form .settings-row').forEach((row) => {
848
+ const on = words.every((w) => row.textContent.toLowerCase().includes(w));
849
+ row.hidden = !on;
850
+ if (on) shown += 1;
851
+ });
852
+ card.querySelectorAll('.settings-form .settings-group').forEach((g) => {
853
+ g.hidden = !!words.length && ![...g.querySelectorAll('.settings-row')].some((r) => !r.hidden);
854
+ });
855
+ const none = card.querySelector('.settings-find-none');
856
+ if (none) none.hidden = shown > 0 || !words.length;
857
+ });
858
+ document.addEventListener('change', (e) => {
859
+ const sw = e.target.closest && e.target.closest('.settings-switch');
860
+ if (sw) { const st = sw.parentElement.querySelector('.settings-switch-state'); if (st) st.textContent = sw.checked ? 'On' : 'Off'; }
861
+ });
862
+ ['input', 'change'].forEach((t) => document.addEventListener(t, (e) => {
863
+ const form = e.target.closest && e.target.closest('.settings-form');
864
+ // The list editor writes as it goes, so its fields are never unsaved settings.
865
+ if (!form || e.target.closest('.settings-find, [id^="list-editor"]')) return;
866
+ form.dataset.dirty = '1';
867
+ const d = form.querySelector('.settings-dirty');
868
+ if (d) d.hidden = false;
869
+ }));
870
+ document.body.addEventListener('htmx:after:request', (e) => {
871
+ const form = e.target && e.target.closest && e.target.closest('.settings-form');
872
+ if (!form || e.target.closest('[id^="list-editor"]')) return;
873
+ const ok = e.detail && e.detail.ctx && e.detail.ctx.response ? e.detail.ctx.response.status < 400 : true;
874
+ if (ok) { delete form.dataset.dirty; const d = form.querySelector('.settings-dirty'); if (d) d.hidden = true; }
875
+ });
876
+ window.addEventListener('beforeunload', (e) => {
877
+ if (document.querySelector('.settings-form[data-dirty]')) { e.preventDefault(); e.returnValue = ''; }
878
+ });
879
+
880
+ // The guide: search keeps the sections that hold every word and marks the hits; the contents follow
881
+ // the section you are reading.
882
+ (function helpPage() {
883
+ const page = document.querySelector('.help-layout');
884
+ if (!page) return;
885
+ const sections = [...page.querySelectorAll('.help-section')];
886
+ const clearMarks = () => page.querySelectorAll('mark.help-hit').forEach((m) => m.replaceWith(document.createTextNode(m.textContent)));
887
+ const mark = (root, word) => {
888
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
889
+ const hits = [];
890
+ for (let n = walk.nextNode(); n; n = walk.nextNode()) {
891
+ const i = n.nodeValue.toLowerCase().indexOf(word);
892
+ if (i >= 0 && !n.parentElement.closest('pre, code, mark')) hits.push([n, i]);
893
+ }
894
+ hits.forEach(([n, i]) => {
895
+ const r = document.createRange(); r.setStart(n, i); r.setEnd(n, i + word.length);
896
+ const m = document.createElement('mark'); m.className = 'help-hit'; r.surroundContents(m);
897
+ });
898
+ };
899
+ page.querySelector('.help-find').addEventListener('input', (e) => {
900
+ clearMarks();
901
+ page.querySelectorAll('.help-section').forEach((s) => s.normalize());
902
+ const words = e.target.value.toLowerCase().split(/\s+/).filter((w) => w.length > 1);
903
+ let shown = 0;
904
+ sections.forEach((s) => {
905
+ const on = words.every((w) => s.textContent.toLowerCase().includes(w));
906
+ s.hidden = !on;
907
+ const link = page.querySelector(`[data-help-toc="${CSS.escape(s.dataset.helpSection)}"]`);
908
+ if (link) link.parentElement.hidden = !on;
909
+ if (on) { shown += 1; if (words.length) words.forEach((w) => mark(s, w)); }
910
+ });
911
+ page.querySelector('.help-none').hidden = shown > 0;
912
+ });
913
+ if ('IntersectionObserver' in window) {
914
+ const io = new IntersectionObserver((entries) => {
915
+ entries.filter((x) => x.isIntersecting).forEach((x) => {
916
+ page.querySelectorAll('[data-help-toc]').forEach((a) => a.classList.toggle('is-current', a.dataset.helpToc === x.target.dataset.helpSection));
917
+ });
918
+ }, { rootMargin: '-20% 0px -70% 0px' });
919
+ sections.forEach((s) => io.observe(s));
920
+ }
921
+ })();
922
+
923
+ // Help for the page you are on: a "?" beside the page heading opens the guide at the part about it.
924
+ (function pageHelpLink() {
925
+ const where = document.documentElement.dataset.help;
926
+ const head = document.querySelector('.page-heading');
927
+ if (!where || !head || head.querySelector('.page-help')) return;
928
+ const a = document.createElement('a');
929
+ a.className = 'page-help';
930
+ a.href = `/help#${where}`;
931
+ a.title = 'How this page works, in the guide';
932
+ a.setAttribute('aria-label', 'Help for this page');
933
+ a.textContent = '?';
934
+ head.appendChild(a);
935
+ })();
936
+
937
+ // The guide's code blocks are what people come to copy, so each one has a Copy button.
938
+ document.querySelectorAll('.help-body pre').forEach((pre) => {
939
+ const b = document.createElement('button');
940
+ b.type = 'button';
941
+ b.className = 'help-copy';
942
+ b.textContent = 'Copy';
943
+ b.addEventListener('click', async () => {
944
+ try { await navigator.clipboard.writeText(pre.innerText.replace(/\nCopy$/, '')); b.textContent = 'Copied'; }
945
+ catch (_) { b.textContent = 'Select and copy'; }
946
+ setTimeout(() => { b.textContent = 'Copy'; }, 1600);
947
+ });
948
+ pre.classList.add('has-copy');
949
+ pre.appendChild(b);
950
+ });
951
+
952
+ // Starting a new conversation redraws the panel: deep-chat holds the transcript it was given, and
953
+ // emptying it is not something the component exposes — the page it is on is what carries it.
954
+ document.body.addEventListener('chat-cleared', () => { window.location.reload(); });
955
+
956
+ /* ---------------- how much room a page's content gets ------------------- */
957
+
958
+ // The pages were capped at UIKit's small container whatever room they had, which was right when the
959
+ // assistant was a fixed column and the rail was another: 900px was what was left. Neither is fixed any
960
+ // more, so the cap is a choice — and it is the content that most wants the width (a command catalog, a
961
+ // settings file, a terminal) that was capped hardest.
962
+ //
963
+ // Applied by the pre-paint script, like the assistant's view: this is the width of the main column, and
964
+ // changing it after the page is drawn moves everything on it.
965
+ document.addEventListener('click', (e) => {
966
+ const pick = e.target.closest('.page-width-pick');
967
+ if (!pick) return;
968
+ e.preventDefault();
969
+ const width = pick.dataset.width;
970
+ if (!['comfortable', 'wide', 'full'].includes(width)) return;
971
+ document.documentElement.dataset.pageWidth = width;
972
+ wsPrefSet('ws-page-width', width);
973
+ markCurrentViews();
974
+ });
975
+
976
+ // Comfortable or compact project rows. The same buttons; compact folds the caption under each one away, and
977
+ // shows it on hover instead. Applied before first paint, like the width.
978
+ document.addEventListener('click', (e) => {
979
+ const pick = e.target.closest('.density-pick');
980
+ if (!pick) return;
981
+ e.preventDefault();
982
+ const density = pick.dataset.density === 'compact' ? 'compact' : 'comfortable';
983
+ document.documentElement.dataset.density = density;
984
+ wsPrefSet('ws-density', density);
985
+ markCurrentViews();
986
+ });
987
+
988
+ document.addEventListener('click', (e) => {
989
+ const pick = e.target.closest('.tool-labels-pick');
990
+ if (!pick) return;
991
+ e.preventDefault();
992
+ const mode = pick.dataset.toolLabels === 'words' ? 'words' : 'icons';
993
+ document.documentElement.dataset.toolLabels = mode;
994
+ wsPrefSet('ws-tool-labels', mode);
995
+ markCurrentViews();
996
+ });
997
+
998
+ // The caption of a button is also its tooltip, so folding it away loses nothing for a pointer.
999
+ function syncRowCaptions(root) {
1000
+ for (const wrap of (root || document).querySelectorAll('.act-with-status')) {
1001
+ const cap = wrap.querySelector('.act-status');
1002
+ const btn = wrap.querySelector('button');
1003
+ if (!cap || !btn) continue;
1004
+ const text = cap.textContent.trim();
1005
+ if (text && !(btn.title || '').includes(text)) btn.title = btn.title ? `${btn.title} · ${text}` : text;
1006
+ }
1007
+ }
1008
+ document.addEventListener('DOMContentLoaded', () => syncRowCaptions());
1009
+ document.body.addEventListener('htmx:after:swap', (e) => syncRowCaptions(swappedEl(e)));
1010
+
1011
+ // The tab says when a session has stopped to ask something, so it is visible from any other tab (Vercel does
1012
+ // the same for a build). Only "waiting" goes in the title: a count of detached sessions would be noise, and the
1013
+ // page's own title is kept as it was.
1014
+ const BASE_TITLE = document.title;
1015
+ function syncTitleState() {
1016
+ const w = document.querySelector('.screens-count .screens-waiting');
1017
+ if (!w) { document.title = BASE_TITLE; return; }
1018
+ const n = parseInt(w.textContent.replace(/[^0-9]/g, ''), 10) || 1;
1019
+ document.title = `✋ ${n} waiting · ${BASE_TITLE}`;
1020
+ }
1021
+ document.body.addEventListener('htmx:after:swap', (e) => {
1022
+ const t = swappedEl(e);
1023
+ if (t && t.classList && (t.classList.contains('screens-count') || (t.closest && t.closest('.screens-count')))) syncTitleState();
1024
+ });
1025
+ // Watching the badge itself is sturdier than waiting for htmx's own event: it does not matter who rewrote it.
1026
+ const screensCountEl = document.querySelector('.screens-count');
1027
+ if (screensCountEl) new MutationObserver(syncTitleState).observe(screensCountEl, { childList: true, subtree: true, characterData: true });
1028
+ // The first count usually arrives before this script has run, so look once now.
1029
+ syncTitleState();
1030
+
1031
+ /* ---------------- the rail, arranged --------------------------------------- */
1032
+
1033
+ // Each entry in Commands → Rail posts "<group>:<id>", and dragging it moves the element without
1034
+ // touching that value — so a save said every entry was still where it was rendered, and hiding
1035
+ // something did nothing at all. Every form that sends the arrangement calls this first, from its own
1036
+ // hx-on::before:request: the value is rewritten from the list the entry is IN at that moment.
1037
+ window.navSyncGroups = function navSyncGroups() {
1038
+ const form = document.getElementById('nav-form');
1039
+ if (!form) return;
1040
+ for (const entry of form.querySelectorAll('.nav-entry')) {
1041
+ const list = entry.closest('[data-group]');
1042
+ const input = entry.querySelector('input[name="item"]');
1043
+ if (list && input) input.value = `${list.dataset.group}:${entry.dataset.id}`;
1044
+ }
1045
+ };
1046
+
1047
+ // Run it on every drop rather than when the form is sent: htmx collects a form's values when the
1048
+ // request is TRIGGERED, before htmx:before:request fires, so rewriting them there is already too late
1049
+ // — the save kept arriving with every entry still in the group it was rendered in. UIkit's sortable
1050
+ // says when something moved; that is the moment the values are stale.
1051
+ for (const ev of ['moved', 'added', 'removed', 'stop']) {
1052
+ document.addEventListener(ev, (e) => {
1053
+ if (e.target && e.target.closest && e.target.closest('#nav-form')) window.navSyncGroups();
1054
+ }, true);
1055
+ }
1056
+
1057
+ /* ---------------- grid or list -------------------------------------------- */
1058
+
1059
+ // Which layout the workspace cards and the project rows use. The state is on <html> and applied by the
1060
+ // pre-paint script; this only changes it, marks the chosen button, and remembers the choice through the
1061
+ // shared cookie so it is the same layout on every workspace.
1062
+ const VIEW_PREFS = {
1063
+ 'ws-home-view': { attr: 'homeView', allowed: ['list', 'grid-1', 'grid-2', 'grid-3', 'grid-4', 'grid-5', 'grid-6'] },
1064
+ 'ws-rows-view': { attr: 'rowsView', allowed: ['list', 'grid-1', 'grid-2', 'grid-3'] },
1065
+ // A folder of files: a list of names, or a grid of up to six across for a folder of pictures.
1066
+ 'ws-files-view': { attr: 'filesView', allowed: ['list', 'grid-1', 'grid-2', 'grid-3', 'grid-4', 'grid-5', 'grid-6'] },
1067
+ };
1068
+
1069
+ function markCurrentViews() {
1070
+ for (const btn of document.querySelectorAll('.view-pick')) {
1071
+ const spec = VIEW_PREFS[btn.dataset.pref];
1072
+ if (!spec) continue;
1073
+ const current = document.documentElement.dataset[spec.attr] === btn.dataset.view;
1074
+ btn.classList.toggle('is-current', current);
1075
+ btn.setAttribute('aria-pressed', current ? 'true' : 'false');
1076
+ }
1077
+ for (const btn of document.querySelectorAll('.density-pick')) {
1078
+ const current = document.documentElement.dataset.density === btn.dataset.density;
1079
+ btn.classList.toggle('is-current', current);
1080
+ btn.setAttribute('aria-pressed', current ? 'true' : 'false');
1081
+ }
1082
+ // Words or icons: the browser's own choice, else what the style says (its --pcard-tool-label).
1083
+ const styleSays = (parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--pcard-tool-label')) || 0) > 0 ? 'words' : 'icons';
1084
+ for (const btn of document.querySelectorAll('.tool-labels-pick')) {
1085
+ const current = (document.documentElement.dataset.toolLabels || styleSays) === btn.dataset.toolLabels;
1086
+ btn.classList.toggle('is-current', current);
1087
+ btn.setAttribute('aria-pressed', current ? 'true' : 'false');
1088
+ }
1089
+ // The page width sits in the same group and is marked the same way.
1090
+ for (const btn of document.querySelectorAll('.page-width-pick')) {
1091
+ const current = document.documentElement.dataset.pageWidth === btn.dataset.width;
1092
+ btn.classList.toggle('is-current', current);
1093
+ btn.setAttribute('aria-pressed', current ? 'true' : 'false');
1094
+ }
1095
+ }
1096
+
1097
+ document.addEventListener('click', (e) => {
1098
+ const btn = e.target.closest('.view-pick');
1099
+ if (!btn) return;
1100
+ e.preventDefault();
1101
+ const spec = VIEW_PREFS[btn.dataset.pref];
1102
+ if (!spec || !spec.allowed.includes(btn.dataset.view)) return;
1103
+ document.documentElement.dataset[spec.attr] = btn.dataset.view;
1104
+ wsPrefSet(btn.dataset.pref, btn.dataset.view);
1105
+ markCurrentViews();
1106
+ });
1107
+
1108
+ // The rows and the cards arrive in fragments, so the marking is redone after every swap.
1109
+ markCurrentViews();
1110
+ document.addEventListener('htmx:after:settle', markCurrentViews);
1111
+
1112
+ /* ---------------- how the assistant is shown ---------------------------- */
1113
+
1114
+ // Four views, one switch, after the pattern Drupal's own AI assistant uses: a toggle in the top
1115
+ // toolbar, and a menu in the panel's header holding the rest. Sidebar is the column it has always
1116
+ // been, Wide is that column doubled for a long answer, Floating is a launcher with the panel over
1117
+ // the page, and Full page is /assistant.
1118
+ //
1119
+ // The state lives on <html> (data-assistant-view, .assistant-closed) and is applied by the
1120
+ // pre-paint script in the head, not from here: the panel is 25rem of the layout, so deciding it in
1121
+ // this deferred file would draw the page one way and reflow it a frame later. This file only
1122
+ // CHANGES it. localStorage, like the theme and the rail — and with the same known limit, that it
1123
+ // does not cross the workspace subdomains.
1124
+ const ASSISTANT_VIEWS = ['sidebar', 'wide', 'float', 'full'];
1125
+
1126
+ function assistantView() {
1127
+ const v = document.documentElement.dataset.assistantView;
1128
+ return ASSISTANT_VIEWS.includes(v) ? v : 'sidebar';
1129
+ }
1130
+
1131
+ function setAssistantView(view) {
1132
+ if (!ASSISTANT_VIEWS.includes(view)) return;
1133
+ // Full page is a place, not a layout: switching to it goes there, and the panel on the page you
1134
+ // came from would be a second copy of the same conversation.
1135
+ if (view === 'full') {
1136
+ wsPrefSet('ws-assistant-view', view);
1137
+ window.location.href = '/assistant';
1138
+ return;
1139
+ }
1140
+ document.documentElement.dataset.assistantView = view;
1141
+ setAssistantOpen(true);
1142
+ document.dispatchEvent(new CustomEvent('assistant-view-changed'));
1143
+ wsPrefSet('ws-assistant-view', view);
1144
+ // Leaving the full-page view has to leave the page too: /assistant has no panel to become a
1145
+ // sidebar. Back where you came from, or home when that was somewhere else.
1146
+ if (window.location.pathname === '/assistant') {
1147
+ const from = document.referrer && new URL(document.referrer, window.location.origin);
1148
+ window.location.href = from && from.origin === window.location.origin && from.pathname !== '/assistant'
1149
+ ? from.href : '/';
1150
+ }
1151
+ }
1152
+
1153
+ function setAssistantOpen(open) {
1154
+ document.documentElement.classList.toggle('assistant-closed', !open);
1155
+ const row = document.querySelector('.actions-assistant');
1156
+ if (row) row.setAttribute('aria-expanded', open ? 'true' : 'false');
1157
+ wsPrefSet('ws-assistant-open', open ? '1' : '0');
1158
+ }
1159
+
1160
+ document.addEventListener('click', (e) => {
1161
+ // Show it. In the full-page view there is no panel to show — the assistant IS a page there — so this
1162
+ // is the way to it; on that page it is already in front of you and there is nothing to do.
1163
+ if (e.target.closest('.assistant-open-pick')) {
1164
+ e.preventDefault();
1165
+ if (window.location.pathname === '/assistant') return;
1166
+ if (assistantView() === 'full') { window.location.href = '/assistant'; return; }
1167
+ setAssistantOpen(true);
1168
+ return;
1169
+ }
1170
+ // Wider, and back again — one button, because it is one question: how much room does this answer
1171
+ // need? Floating has its own size, so there it is the sidebar it widens back into.
1172
+ if (e.target.closest('.assistant-wider')) {
1173
+ e.preventDefault();
1174
+ setAssistantView(assistantView() === 'wide' ? 'sidebar' : 'wide');
1175
+ return;
1176
+ }
1177
+ const pick = e.target.closest('.assistant-view-pick');
1178
+ if (pick) { e.preventDefault(); setAssistantView(pick.dataset.view); return; }
1179
+ if (e.target.closest('.assistant-hide, .assistant-close-pick')) { e.preventDefault(); setAssistantOpen(false); return; }
1180
+ // The launcher in the corner: the way back in, in every view. In the full-page view there is no
1181
+ // panel to show, so it leads to the page instead.
1182
+ if (e.target.closest('.assistant-launcher')) {
1183
+ e.preventDefault();
1184
+ if (assistantView() === 'full') { window.location.href = '/assistant'; return; }
1185
+ setAssistantOpen(true);
1186
+ }
1187
+ }, true);
1188
+
1189
+ document.addEventListener('click', (e) => {
1190
+ // A pick closes the menu it was made in — a dropdown left open over the panel it just changed reads
1191
+ // as a control that did not take.
1192
+ if (e.target.closest('.assistant-dropdown a') && window.UIkit) {
1193
+ const dd = e.target.closest('.uk-dropdown');
1194
+ if (dd) window.UIkit.dropdown(dd).hide(false);
1195
+ }
1196
+ });
1197
+
1198
+ // Floating means floating: the panel can be dragged anywhere on the screen, because where it should sit
1199
+ // depends on what is under it — a project table, a form, a terminal — and that changes page by page.
1200
+ //
1201
+ // The header is the handle (a window's title bar is), minus its buttons: a drag that started on the
1202
+ // close button would close the panel on release. The position is per browser, clamped into the viewport
1203
+ // on every apply, so a panel dragged to the edge of a wide screen is still reachable on a narrow one.
1204
+ (function draggableAssistant() {
1205
+ const KEY = 'ws-assistant-pos';
1206
+ const panel = () => document.querySelector('.assistant-side');
1207
+
1208
+ const clamp = (x, y, el) => {
1209
+ const r = el.getBoundingClientRect();
1210
+ // A strip of the panel always has to stay on screen, and the header has to stay reachable.
1211
+ const maxX = Math.max(0, window.innerWidth - r.width);
1212
+ const maxY = Math.max(0, window.innerHeight - Math.min(r.height, 64));
1213
+ return [Math.min(Math.max(0, x), maxX), Math.min(Math.max(0, y), maxY)];
1214
+ };
1215
+
1216
+ const place = (x, y) => {
1217
+ const el = panel();
1218
+ if (!el) return;
1219
+ const [cx, cy] = clamp(x, y, el);
1220
+ el.style.left = `${cx}px`;
1221
+ el.style.top = `${cy}px`;
1222
+ el.style.bottom = 'auto';
1223
+ el.style.right = 'auto';
1224
+ };
1225
+
1226
+ const stored = () => {
1227
+ try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch (_) { return null; }
1228
+ };
1229
+
1230
+ function applyStored() {
1231
+ const el = panel();
1232
+ if (!el) return;
1233
+ if (document.documentElement.dataset.assistantView !== 'float') {
1234
+ // Every other view is laid out by the stylesheet; leftover inline positions would fight it.
1235
+ el.style.left = el.style.top = el.style.bottom = el.style.right = '';
1236
+ return;
1237
+ }
1238
+ const p = stored();
1239
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) place(p.x, p.y);
1240
+ }
1241
+
1242
+ document.addEventListener('pointerdown', (e) => {
1243
+ if (document.documentElement.dataset.assistantView !== 'float') return;
1244
+ const head = e.target.closest('.assistant-side .assistant-head');
1245
+ if (!head || e.target.closest('button, a, input, select, .assistant-dropdown')) return;
1246
+ const el = panel();
1247
+ const r = el.getBoundingClientRect();
1248
+ const dx = e.clientX - r.left;
1249
+ const dy = e.clientY - r.top;
1250
+ let moved = false;
1251
+ head.setPointerCapture(e.pointerId);
1252
+ el.classList.add('is-dragging');
1253
+
1254
+ const move = (ev) => { moved = true; place(ev.clientX - dx, ev.clientY - dy); };
1255
+ const up = () => {
1256
+ head.removeEventListener('pointermove', move);
1257
+ head.removeEventListener('pointerup', up);
1258
+ el.classList.remove('is-dragging');
1259
+ if (!moved) return;
1260
+ const r2 = el.getBoundingClientRect();
1261
+ try { localStorage.setItem(KEY, JSON.stringify({ x: Math.round(r2.left), y: Math.round(r2.top) })); } catch (_) { /* ignore */ }
1262
+ };
1263
+ head.addEventListener('pointermove', move);
1264
+ head.addEventListener('pointerup', up);
1265
+ });
1266
+
1267
+ // A window that got smaller must not leave the panel off-screen, and a view change re-lays it out.
1268
+ window.addEventListener('resize', applyStored);
1269
+ document.addEventListener('assistant-view-changed', applyStored);
1270
+ applyStored();
1271
+ })();
1272
+
1273
+ /* ---------------- the AI assistant, as a deep-chat component ------------ */
1274
+
1275
+ // deep-chat is configured with properties rather than attributes: anything that is not a string
1276
+ // (the connect object, the style maps, the interceptor) cannot survive an attribute.
1277
+ (function initDeepChat() {
1278
+ const el = document.getElementById('ws-deep-chat');
1279
+ if (!el || el.dataset.wsReady) return;
1280
+ el.dataset.wsReady = '1';
1281
+
1282
+ const context = el.dataset.context || 'home';
1283
+ // What the settings file says about this panel. A missing or unreadable attribute falls back to
1284
+ // the same defaults the resolver uses, so the panel still works if the settings never arrive.
1285
+ let cfg = { voiceInput: true, submitAfterSilence: 2500, voiceOutput: true, speechRate: 1, placeholder: '' };
1286
+ try { cfg = { ...cfg, ...JSON.parse(el.dataset.settings || '{}') }; } catch (_) { /* keep the defaults */ }
1287
+
1288
+ el.connect = {
1289
+ url: '/actions/deep-chat',
1290
+ method: 'POST',
1291
+ additionalBodyProps: { context },
1292
+ };
1293
+
1294
+ // The conversation carries across pages. Every page draws a new deep-chat, so the transcript has
1295
+ // to be handed back to it: the server keeps the thread (the id is a cookie on the hub domain, the
1296
+ // only thing that crosses <workspace>.<hub> subdomains) and returns it in deep-chat's own history
1297
+ // shape. Fetched rather than rendered into the page because the panel is built in pageShell, which
1298
+ // does not see the request — and it is one small same-origin GET either way.
1299
+ //
1300
+ // The agent's own memory is the other half, and it is the server's: it resumes the claude session
1301
+ // this thread started. A transcript in front of an agent that remembers none of it reads as memory
1302
+ // and is not.
1303
+ // Suggested prompts: shown only when the saved conversation is empty.
1304
+ const suggestions = Array.isArray(cfg.suggestions)
1305
+ ? cfg.suggestions.filter((s) => typeof s === 'string' && s.trim()) : [];
1306
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => (
1307
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
1308
+
1309
+ // A click fills the composer and never sends (the assistant has a shell).
1310
+ const fillComposer = (text) => {
1311
+ const input = el.shadowRoot && el.shadowRoot.querySelector('#text-input');
1312
+ if (!input) return;
1313
+ const existing = (input.innerText || '').trim();
1314
+ input.innerText = existing ? `${existing} ${text}` : text;
1315
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1316
+ input.focus();
1317
+ const range = document.createRange();
1318
+ range.selectNodeContents(input);
1319
+ range.collapse(false);
1320
+ const sel = window.getSelection();
1321
+ sel.removeAllRanges();
1322
+ sel.addRange(range);
1323
+ };
1324
+
1325
+ // deep-chat draws inside a shadow root, so the look and the click are attached by class here.
1326
+ el.htmlClassUtilities = {
1327
+ ...(el.htmlClassUtilities || {}),
1328
+ 'ws-suggestion': {
1329
+ events: { click: (event) => fillComposer((event.currentTarget || event.target).textContent.trim()) },
1330
+ styles: {
1331
+ default: {
1332
+ display: 'block', width: '100%', textAlign: 'left', cursor: 'pointer',
1333
+ margin: '0 0 0.35rem', padding: '0.45rem 0.7rem', borderRadius: '0.6rem',
1334
+ border: '1px solid var(--line)', background: 'var(--surface-input)', color: 'var(--text-default)',
1335
+ fontFamily: 'var(--font-primary)', fontSize: '0.88rem', lineHeight: '1.4',
1336
+ },
1337
+ hover: { borderColor: 'var(--brand-primary)', color: 'var(--brand-primary)' },
1338
+ },
1339
+ },
1340
+ };
1341
+
1342
+ // deep-chat-temporary-message: removed when the next message arrives, never saved to the thread.
1343
+ let suggestTries = 0;
1344
+ let conversationEmpty = true;
1345
+ const suggestOn = () => (window.wsPref ? window.wsPref('ws-suggest', null) : null) !== '0';
1346
+ const showSuggestions = () => {
1347
+ if (!suggestions.length || el.dataset.wsSuggested || !suggestOn()) return;
1348
+ el.dataset.wsSuggested = '1';
1349
+ const html = `<div class="deep-chat-temporary-message ws-suggestions">${
1350
+ suggestions.map((s) => `<button type="button" class="ws-suggestion">${escapeHtml(s)}</button>`).join('')}</div>`;
1351
+ try { el.addMessage({ role: 'ai', html }); }
1352
+ catch (_) {
1353
+ // The component may not be ready yet.
1354
+ delete el.dataset.wsSuggested;
1355
+ if ((suggestTries += 1) < 8) setTimeout(showSuggestions, 400);
1356
+ }
1357
+ };
1358
+
1359
+ const hideSuggestions = () => {
1360
+ const box = el.shadowRoot && el.shadowRoot.querySelector('.ws-suggestions');
1361
+ const outer = box && box.closest('[class*="outer-message-container"]');
1362
+ if (outer) outer.remove();
1363
+ delete el.dataset.wsSuggested;
1364
+ };
1365
+
1366
+ fetch('/fragments/chat/history', { headers: { Accept: 'application/json' } })
1367
+ .then((r) => (r.ok ? r.json() : null))
1368
+ .then((data) => {
1369
+ if (data && Array.isArray(data.messages) && data.messages.length) { conversationEmpty = false; el.history = data.messages; }
1370
+ else showSuggestions();
1371
+ })
1372
+ .catch(() => { showSuggestions(); });
1373
+
1374
+ // Voice summary: the checkbox in the assistant options. The choice is kept per browser; settings.yml
1375
+ // only sets where it starts.
1376
+ // The narration's voice settings, kept by reference: the avatar's settings change its volume live.
1377
+ const narrationParams = { ...(cfg.narration || {}) };
1378
+ // settings.yml never makes an ElevenLabs voice the default: that spends credits, so it is picked in the avatar's settings.
1379
+ if (/^elevenlabs:/i.test(String(narrationParams.voiceName || ''))) delete narrationParams.voiceName;
1380
+ // What someone chose in the avatar's settings: volume, speed, pitch and the voice. An empty cookie means
1381
+ // not chosen, never 0, which would silence the voice.
1382
+ const VOICE_PREFS = { volume: ['ws-voice-volume', 0, 1], rate: ['ws-voice-rate', 0.5, 2], pitch: ['ws-voice-pitch', 0, 2] };
1383
+ for (const [key, [name, min, max]] of Object.entries(VOICE_PREFS)) {
1384
+ try { const raw = window.wsPref ? window.wsPref(name, '') : ''; const v = Number(raw); if (raw !== '' && raw !== null && Number.isFinite(v) && v >= min && v <= max) narrationParams[key] = v; } catch (_) { /* default */ }
1385
+ }
1386
+ try { const vn = window.wsPref ? window.wsPref('ws-voice-name', '') : ''; if (vn) narrationParams.voiceName = vn; } catch (_) { /* best free voice */ }
1387
+ const narrator = window.wsNarration.createNarrator({
1388
+ params: narrationParams,
1389
+ defaultOn: cfg.voiceOutput !== false,
1390
+ getPref: (name) => (window.wsPref ? window.wsPref(name, null) : null),
1391
+ setPref: wsPrefSet,
1392
+ synth: window.speechSynthesis,
1393
+ Utterance: window.SpeechSynthesisUtterance,
1394
+ onSpeech: (detail) => document.dispatchEvent(new CustomEvent('ws-assistant-speech', { detail })),
1395
+ // An ElevenLabs voice: the server speaks (its key never comes here) and this plays the audio, at the
1396
+ // chosen volume and speed. The words are spread over the clip so the avatar's mouth keeps moving.
1397
+ // When the server cannot, the same words are spoken in the browser's voice instead.
1398
+ remote: (text, voiceId, params, onSpeech, fallback) => {
1399
+ let stopped = false; let audio = null; let url = '';
1400
+ const ctl = new AbortController();
1401
+ const words = text.split(/\s+/);
1402
+ const done = () => { if (url) URL.revokeObjectURL(url); url = ''; if (onSpeech) onSpeech({ type: 'end' }); };
1403
+ fetch('/actions/assistant/speak', { method: 'POST', signal: ctl.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice: voiceId, text }) })
1404
+ .then((r) => (r.ok ? r.blob() : Promise.reject(new Error(String(r.status)))))
1405
+ .then((blob) => {
1406
+ if (stopped) return;
1407
+ url = URL.createObjectURL(blob);
1408
+ audio = new Audio(url);
1409
+ audio.volume = Number.isFinite(params.volume) ? params.volume : 1;
1410
+ audio.playbackRate = Number.isFinite(params.rate) ? params.rate : 1;
1411
+ let said = -1;
1412
+ audio.onplay = () => { if (onSpeech) onSpeech({ type: 'start' }); };
1413
+ audio.ontimeupdate = () => {
1414
+ const i = Math.floor((audio.currentTime / (audio.duration || 1)) * words.length);
1415
+ if (i !== said && i < words.length) { said = i; if (onSpeech) onSpeech({ type: 'word', word: words[i] }); }
1416
+ };
1417
+ audio.onended = done;
1418
+ audio.onerror = done;
1419
+ return audio.play();
1420
+ })
1421
+ .catch(() => { if (!stopped) fallback(); });
1422
+ return () => { stopped = true; ctl.abort(); if (audio) { audio.pause(); audio.src = ''; } if (url) { URL.revokeObjectURL(url); url = ''; } };
1423
+ },
1424
+ });
1425
+ window.addEventListener('pagehide', narrator.cancel);
1426
+ // The assistant's avatar (assistant-avatar-ui.js) mutes and unmutes this voice, and hides it.
1427
+ window.wsAssistantVoice = {
1428
+ isOn: narrator.isOn, setOn: narrator.setOn, cancel: narrator.cancel, speak: narrator.speak,
1429
+ volume: () => (Number.isFinite(narrationParams.volume) ? narrationParams.volume : 1),
1430
+ setVolume: (v) => { narrationParams.volume = Math.max(0, Math.min(1, Number(v))); try { wsPrefSet('ws-voice-volume', String(narrationParams.volume)); } catch (_) { /* not kept */ } },
1431
+ get: (key) => narrationParams[key],
1432
+ set: (key, value) => {
1433
+ if (key === 'voiceName') { narrationParams.voiceName = String(value || ''); try { wsPrefSet('ws-voice-name', narrationParams.voiceName); } catch (_) { /* not kept */ } return; }
1434
+ if (!VOICE_PREFS[key]) return;
1435
+ const [name, min, max] = VOICE_PREFS[key];
1436
+ narrationParams[key] = Math.max(min, Math.min(max, Number(value)));
1437
+ try { wsPrefSet(name, String(narrationParams[key])); } catch (_) { /* not kept */ }
1438
+ },
1439
+ };
1440
+ el.requestInterceptor = (details) => {
1441
+ details.body = { ...details.body, voice: narrator.isOn(), avatar: Boolean(window.wsAssistantAvatarOn && window.wsAssistantAvatarOn()), avatarName: window.wsAvatarName ? window.wsAvatarName() : '', avatarId: window.wsAvatarId ? window.wsAvatarId() : '', page: window.wsAvatarContext ? window.wsAvatarContext() : null };
1442
+ return details;
1443
+ };
1444
+ const playSendAnimation = () => {
1445
+ const btn = el.shadowRoot && el.shadowRoot.querySelector('.input-button.inside-end');
1446
+ if (!btn) return;
1447
+ btn.classList.remove('ws-sent');
1448
+ void btn.offsetWidth;
1449
+ btn.classList.add('ws-sent');
1450
+ setTimeout(() => btn.classList.remove('ws-sent'), 700);
1451
+ };
1452
+ // Working: from a message being sent until the answer, or an error, comes back.
1453
+ let workingTimer = null;
1454
+ const setWorking = (on, failed = false) => {
1455
+ clearTimeout(workingTimer);
1456
+ el.toggleAttribute('data-assistant-working', on);
1457
+ document.dispatchEvent(new CustomEvent('ws-assistant-working', { detail: { on, failed } }));
1458
+ document.querySelectorAll('.assistant-panel').forEach((p) => p.toggleAttribute('data-working', on));
1459
+ if (on) workingTimer = setTimeout(() => setWorking(false), 16 * 60 * 1000);
1460
+ };
1461
+ el.onMessage = ({ message, isHistory }) => {
1462
+ if (isHistory || !message) return;
1463
+ if (message.role === 'user') { conversationEmpty = false; narrator.cancel(); playSendAnimation(); setWorking(true); }
1464
+ else setWorking(false);
1465
+ };
1466
+ el.onError = () => setWorking(false, true);
1467
+
1468
+ // The reply arrives as rendered markdown plus the directives the assistant ended with. Acting on
1469
+ // them here keeps the interface steering that the HTMX pane had: the agent can start a project
1470
+ // and then take you to it.
1471
+ el.responseInterceptor = (response) => {
1472
+ const d = response && response.directive;
1473
+ if (d) {
1474
+ if (d.refresh) document.body.dispatchEvent(new CustomEvent('refresh-projects'));
1475
+ if (d.open) window.open(d.open, '_blank', 'noopener');
1476
+ if (d.navigate) setTimeout(() => { window.location.href = d.navigate; }, 600);
1477
+ }
1478
+ setWorking(false);
1479
+ if (response && response.html) narrator.narrate({ spoken: d && d.spoken, html: response.html });
1480
+ document.dispatchEvent(new CustomEvent('ws-assistant-reply', { detail: { directive: d || null, voice: narrator.isOn() } }));
1481
+ return response;
1482
+ };
1483
+
1484
+ // Voice in and out, exactly as speech.js resolved them.
1485
+ //
1486
+ // The page does not build these objects any more. Which keys may be absent is the substance of
1487
+ // that config — an empty `commands` phrase arms a command rather than disabling one, and
1488
+ // `substrings` defaults to true so "submit the form" would fire mid-sentence — and those rules
1489
+ // belong somewhere a test can require, not in a browser file that can only be checked by reading
1490
+ // it. `false` here means the microphone is removed rather than left as a button that does nothing.
1491
+ if (cfg.speechToText !== undefined) el.speechToText = cfg.speechToText;
1492
+ el.textToSpeech = false; // narration is the voice summary above; deep-chat's own never speaks an HTML reply
1493
+
1494
+ // The prompt is the main control of this panel, so it is sized like one rather than like a
1495
+ // single-line search box: room for a few lines of a real instruction before it scrolls.
1496
+ el.textInput = {
1497
+ // Whatever `assistant: placeholder:` says, and by default nothing visible: an empty field with
1498
+ // a cursor in it already says "type here", and a prompt that suggests what to ask narrows what
1499
+ // gets asked.
1500
+ //
1501
+ // "Nothing" cannot be spelled as an empty string. deep-chat reads this as
1502
+ // `placeholder.text || "Ask me anything!"`, so a blank does not remove its placeholder, it
1503
+ // restores deep-chat's — which is how "Ask me anything!" was showing on a panel configured to
1504
+ // say nothing. The text it IS given becomes the composer's aria-label as well, so with nothing
1505
+ // to display this carries the accessible name a textbox needs and `hidePlaceholder` below
1506
+ // blanks what is drawn. Configure one and it is both the visible text and the name.
1507
+ placeholder: { text: cfg.placeholder || `Message the ${cfg.name || 'AI Assistant'}` },
1508
+ styles: {
1509
+ container: {
1510
+ minHeight: '4.5rem',
1511
+ maxHeight: '11rem',
1512
+ borderRadius: '0.85rem',
1513
+ padding: '0.65rem 0.9rem',
1514
+ boxShadow: '0 1px 2px rgba(0,0,0,.06)',
1515
+ // Tokens, not values. deep-chat writes these as inline styles inside its shadow root,
1516
+ // and a custom property inherits THROUGH a shadow boundary from the host — so var()
1517
+ // here resolves against :root and follows the theme, both halves, with no second copy
1518
+ // of this object for dark and no re-render when the toggle is pressed.
1519
+ backgroundColor: 'var(--surface-input)',
1520
+ border: '1px solid var(--line)',
1521
+ color: 'var(--text-default)',
1522
+ },
1523
+ text: { fontSize: '1rem', lineHeight: '1.45', fontFamily: 'var(--font-primary)' },
1524
+ },
1525
+ };
1526
+
1527
+ // deep-chat's own send and microphone are role="button" with an SVG inside and no accessible name,
1528
+ // so a screen reader announces two unnamed buttons at the end of the composer — axe's
1529
+ // aria-command-name, on every page the panel is on. They live in the component's shadow root, which
1530
+ // is reachable but not ours to edit, so the names are set from here rather than by forking the
1531
+ // vendored bundle.
1532
+ const nameChatButtons = () => {
1533
+ const root = el.shadowRoot;
1534
+ if (!root) return;
1535
+ const names = [
1536
+ ['#microphone-button', 'Dictate the message'],
1537
+ ['.input-button.inside-end', 'Send the message'],
1538
+ ['#submit-icon', 'Send the message'],
1539
+ ];
1540
+ for (const [sel, label] of names) {
1541
+ for (const node of root.querySelectorAll(sel)) {
1542
+ if (!node.getAttribute('aria-label')) node.setAttribute('aria-label', label);
1543
+ }
1544
+ }
1545
+ };
1546
+ // The two things deep-chat insists on drawing, turned off from inside its shadow root.
1547
+ //
1548
+ // Both are there because the component treats "nothing" as "not configured" and substitutes its
1549
+ // own: an empty `placeholder.text` restores "Ask me anything!", and an unset `introMessage`
1550
+ // restores the library placeholder about connect and the demo property. So both are SET, to the
1551
+ // emptiest thing that still suppresses the default, and what is left to draw is hidden here.
1552
+ //
1553
+ // - The placeholder is `content: attr(deep-chat-placeholder-text)` on the empty input, coloured
1554
+ // by a rule of deep-chat's own, so neither an empty string nor a colour on the input turns it
1555
+ // off. Blanking that one pseudo-element does, and it leaves the attribute — and the aria-label
1556
+ // built from it — in place: silent on screen, named to a screen reader. A configured
1557
+ // placeholder skips this and is drawn normally.
1558
+ // - The intro message is an empty <span>, and an empty span in a message bubble is still a
1559
+ // bubble: a small blank pill at the top of every transcript, which reads as a message that
1560
+ // failed to load.
1561
+ const chatChrome = () => {
1562
+ const root = el.shadowRoot;
1563
+ if (!root || root.getElementById('ws-chat-chrome')) return;
1564
+ const st = document.createElement('style');
1565
+ st.id = 'ws-chat-chrome';
1566
+ st.textContent = '.deep-chat-intro { display: none !important; }'
1567
+ + (cfg.placeholder ? '' : '\n#text-input:empty::before { content: "" !important; }')
1568
+ + '\n:host([data-assistant-mic="off"]) #microphone-button { display: none !important; }';
1569
+ root.appendChild(st);
1570
+ };
1571
+ // Watched, not timed: the component builds and rebuilds those buttons whenever the composer changes,
1572
+ // so a handful of setTimeouts named them on one page load and missed them on the next.
1573
+ nameChatButtons();
1574
+ chatChrome();
1575
+ if (el.shadowRoot && window.MutationObserver) {
1576
+ new MutationObserver(() => { nameChatButtons(); chatChrome(); })
1577
+ .observe(el.shadowRoot, { childList: true, subtree: true });
1578
+ } else {
1579
+ setTimeout(() => { nameChatButtons(); chatChrome(); }, 500);
1580
+ }
1581
+
1582
+ // No opening message, and none of deep-chat's own either. Left unset, the component renders its
1583
+ // library placeholder — the one about connect, directConnection and the demo property — which is
1584
+ // documentation for whoever is building a chat, not for whoever is using this one. An empty
1585
+ // introMessage is what keeps that out without putting anything of ours in its place; the bubble
1586
+ // it still draws around that emptiness is hidden by `chatChrome` above.
1587
+ el.introMessage = { html: '<span></span>' };
1588
+
1589
+
1590
+ // A build is minutes of work, so the component must not give up on the request.
1591
+ //
1592
+ // The look, in the Webship design system rather than deep-chat's stock preset: the user's turn
1593
+ // carries the same brand gradient as the rest of the interface, and the assistant's sits on a
1594
+ // plain card surface so long technical replies stay readable. Every colour here is a token —
1595
+ // the component shipped a fixed purple gradient and a fixed near-white bubble, which meant the
1596
+ // panel was a different product from the page around it and stayed light after dark.
1597
+ el.messageStyles = {
1598
+ default: {
1599
+ shared: {
1600
+ bubble: {
1601
+ maxWidth: '92%',
1602
+ borderRadius: '0.85rem',
1603
+ padding: '0.6rem 0.85rem',
1604
+ marginTop: '0.35rem',
1605
+ marginBottom: '0.35rem',
1606
+ fontSize: '0.95rem',
1607
+ lineHeight: '1.5',
1608
+ fontFamily: 'var(--font-primary)',
1609
+ },
1610
+ },
1611
+ user: {
1612
+ bubble: {
1613
+ background: 'var(--brand-grad)',
1614
+ color: 'var(--text-inverse)',
1615
+ },
1616
+ },
1617
+ ai: {
1618
+ bubble: { backgroundColor: 'var(--surface-muted)', color: 'var(--text-default)' },
1619
+ },
1620
+ },
1621
+ // Since 2.1.0 the loading bubble is styled through its own shape, not a plain bubble object.
1622
+ loading: { message: { styles: { bubble: { backgroundColor: 'var(--surface-muted)' } } } },
1623
+ error: { bubble: { backgroundColor: 'var(--danger-soft)', color: 'var(--danger-ink)', fontSize: '0.9rem' } },
1624
+ };
1625
+
1626
+ // Injected into the component's shadow root, which is the only way to reach what deep-chat
1627
+ // renders inside itself: the typeface, and the typography of the markdown we return.
1628
+ //
1629
+ // deep-chat's own default is Inter. The dashboard is Figtree, per the brand tokens, so the
1630
+ // one panel a person actually reads sentences in was the one panel not in the brand face. The
1631
+ // wildcard is deliberate: the component builds its own buttons, toolbar and placeholder, and
1632
+ // none of them inherit from the page across the shadow boundary.
1633
+ el.auxiliaryStyle = `
1634
+ * { font-family: var(--font-primary); }
1635
+ /* deep-chat's own chrome, which its style properties do not reach: the panel it draws behind
1636
+ the transcript, the input shell, the two round buttons and the tooltip. Left alone they are
1637
+ a white panel and a lilac microphone — the component's brand, not ours, and both stayed
1638
+ light after dark. */
1639
+ #chat-view, #input { background-color: transparent !important; }
1640
+ #text-input-container { background-color: var(--surface-input) !important; border: 1px solid var(--line) !important; }
1641
+ #text-input { color: var(--text-default) !important; }
1642
+ #text-input[textinputplaceholderstyle] { color: var(--text-subtle) !important; }
1643
+ .input-button-svg { background-color: var(--surface-muted) !important; }
1644
+ #microphone-button { background: var(--brand-grad) !important; }
1645
+ #microphone-button.active { background: var(--danger) !important; }
1646
+ .tooltip { background-color: var(--code-bg) !important; color: var(--code-ink) !important; }
1647
+ .dc-reply p { margin: 0 0 .5rem; }
1648
+ .dc-reply p:last-child { margin-bottom: 0; }
1649
+ .dc-reply pre { background: var(--code-bg); color: var(--code-ink); padding: .5rem .65rem; border-radius: .5rem; overflow-x: auto; }
1650
+ .dc-reply code, .dc-reply pre, .dc-reply pre * { font-family: var(--font-code); font-size: .88em; }
1651
+ .dc-reply a { color: var(--link); }
1652
+ .dc-reply table { border-collapse: collapse; width: 100%; font-size: .9em; }
1653
+ .dc-reply th, .dc-reply td { border: 1px solid var(--line); padding: .3rem .45rem; text-align: left; }
1654
+ .input-button.inside-end { transition: transform .08s; }
1655
+ .input-button.inside-end:active { transform: scale(.92); }
1656
+ .input-button.inside-end.ws-sent svg { animation: ws-send-fly .6s cubic-bezier(.4, 0, .2, 1); }
1657
+ @keyframes ws-send-fly {
1658
+ 0% { translate: 0 0; scale: 1; opacity: 1; }
1659
+ 40% { translate: 10px -10px; scale: .8; opacity: 0; }
1660
+ 41% { translate: -10px 10px; scale: .8; opacity: 0; }
1661
+ 100% { translate: 0 0; scale: 1; opacity: 1; }
1662
+ }
1663
+ @media (prefers-reduced-motion: reduce) { .input-button.inside-end.ws-sent svg { animation: none; } }
1664
+ .input-button.inside-end svg { transition: opacity .15s; }
1665
+ :host([data-assistant-working]) .input-button.inside-end { position: relative !important; }
1666
+ :host([data-assistant-working]) .input-button.inside-end.disabled-button { opacity: 1; }
1667
+ :host([data-assistant-working]) .input-button.inside-end svg { opacity: 0; }
1668
+ :host([data-assistant-working]) .loading-submit-button { display: none !important; }
1669
+ :host([data-assistant-working]) .input-button.inside-end::after {
1670
+ content: ''; position: absolute; inset: 0; pointer-events: none;
1671
+ background-image: radial-gradient(circle, var(--text-inverse) 2.4px, transparent 2.9px), radial-gradient(circle, var(--text-inverse) 2.4px, transparent 2.9px), radial-gradient(circle, var(--text-inverse) 2.4px, transparent 2.9px);
1672
+ background-size: 8px 8px; background-repeat: no-repeat;
1673
+ background-position: calc(50% - 9px) 55%, 50% 55%, calc(50% + 9px) 55%;
1674
+ animation: ws-dots 1.2s ease-in-out infinite;
1675
+ }
1676
+ @keyframes ws-dots {
1677
+ 0%, 60%, 100% { background-position: calc(50% - 9px) 55%, 50% 55%, calc(50% + 9px) 55%; }
1678
+ 15% { background-position: calc(50% - 9px) 30%, 50% 55%, calc(50% + 9px) 55%; }
1679
+ 30% { background-position: calc(50% - 9px) 55%, 50% 30%, calc(50% + 9px) 55%; }
1680
+ 45% { background-position: calc(50% - 9px) 55%, 50% 55%, calc(50% + 9px) 30%; }
1681
+ }
1682
+ @media (prefers-reduced-motion: reduce) { :host([data-assistant-working]) .input-button.inside-end::after { animation: none; } }
1683
+ `;
1684
+
1685
+ el.requestBodyLimits = { maxMessages: 1 };
1686
+
1687
+ // Prompt mode lives in deep-chat's own input toolbar, beside the send button, the way
1688
+ // ai_agent_modes puts its compact selector in the message box toolbar. deep-chat builds that
1689
+ // toolbar itself, so the select is rendered in the page and moved in once it exists.
1690
+ const picker = document.querySelector('.chat-tools .command-picker');
1691
+
1692
+ // The check buttons in the assistant options. Each is kept per browser; the settings only say where it starts.
1693
+ const savedSwitch = (name, start) => {
1694
+ const v = window.wsPref ? window.wsPref(name, null) : null;
1695
+ return v === null || v === undefined ? start : v === '1';
1696
+ };
1697
+ const prefSwitch = (name, start, apply) => ({
1698
+ get: () => savedSwitch(name, start),
1699
+ set: (on) => { wsPrefSet(name, on ? '1' : '0'); apply(on); },
1700
+ init: () => apply(savedSwitch(name, start)),
1701
+ });
1702
+ const switches = {
1703
+ voice: {
1704
+ show: narrator.supported, get: narrator.isOn,
1705
+ set: (on) => { narrator.setOn(on); if (on) narrator.speak('Voice summary on.'); },
1706
+ },
1707
+ mic: {
1708
+ show: cfg.speechToText !== false,
1709
+ ...prefSwitch('ws-mic', cfg.voiceInput !== false, (on) => (on ? el.removeAttribute('data-assistant-mic') : el.setAttribute('data-assistant-mic', 'off'))),
1710
+ },
1711
+ mode: {
1712
+ show: !!picker,
1713
+ ...prefSwitch('ws-mode', true, (on) => { if (picker) picker.style.display = on ? '' : 'none'; }),
1714
+ },
1715
+ suggest: {
1716
+ show: suggestions.length > 0,
1717
+ ...prefSwitch('ws-suggest', true, (on) => { if (on) { if (conversationEmpty) showSuggestions(); } else hideSuggestions(); }),
1718
+ init: null, // showSuggestions already reads the saved choice once the history has loaded
1719
+ },
1720
+ };
1721
+ document.querySelectorAll('.assistant-switch').forEach((box) => {
1722
+ const sw = switches[box.dataset.switch];
1723
+ if (!sw || !sw.show) { box.closest('li').hidden = true; return; }
1724
+ box.checked = sw.get();
1725
+ if (sw.init) sw.init();
1726
+ box.addEventListener('change', () => {
1727
+ sw.set(box.checked);
1728
+ document.querySelectorAll(`.assistant-switch[data-switch="${box.dataset.switch}"]`).forEach((b) => { b.checked = box.checked; });
1729
+ });
1730
+ });
1731
+ if (picker) {
1732
+ picker.addEventListener('change', () => {
1733
+ if (!picker.value) return;
1734
+ const [workspace, file] = picker.value.split('/');
1735
+ const input = el.shadowRoot && el.shadowRoot.querySelector('#text-input');
1736
+ picker.value = '';
1737
+ if (!input) return;
1738
+ const existing = (input.innerText || '').trim();
1739
+ const phrase = `Run ${file} in ${workspace}: `;
1740
+ input.innerText = existing ? `${existing} ${phrase}` : phrase;
1741
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1742
+ input.focus();
1743
+ const range = document.createRange();
1744
+ range.selectNodeContents(input);
1745
+ range.collapse(false);
1746
+ const sel = window.getSelection();
1747
+ sel.removeAllRanges();
1748
+ sel.addRange(range);
1749
+ });
1750
+
1751
+ // Styles do not cross a shadow boundary, so the select takes its own with it.
1752
+ const shadowCss = `
1753
+ /* The prompt is a card: the text on its own line, and a toolbar under it holding the mode
1754
+ selector and the send button — the shape ai_agent_modes uses. deep-chat lays its input out
1755
+ in one row, so the row is allowed to wrap and the text box is given the whole first line. */
1756
+ #input {
1757
+ box-sizing: border-box;
1758
+ max-width: 100%;
1759
+ flex-wrap: wrap;
1760
+ align-items: center;
1761
+ row-gap: 8px;
1762
+ border: 1px solid var(--line);
1763
+ border-radius: 12px;
1764
+ padding: 8px 10px;
1765
+ background-color: var(--surface-input);
1766
+ }
1767
+ #text-input-container { flex: 1 1 100%; }
1768
+ #text-input {
1769
+ border: none !important;
1770
+ box-shadow: none !important;
1771
+ background: transparent !important;
1772
+ padding: 4px 2px !important;
1773
+ }
1774
+ /* deep-chat anchors its buttons absolutely inside zero-size containers, which floats them
1775
+ over the text area. In a toolbar they have to take part in the row, so the containers get
1776
+ a size and the buttons stop being positioned. */
1777
+ .input-button-container {
1778
+ order: 2;
1779
+ position: static;
1780
+ width: auto;
1781
+ height: auto;
1782
+ display: flex;
1783
+ align-items: center;
1784
+ }
1785
+ .input-button-container > * { position: static !important; }
1786
+ .input-button { position: static !important; margin: 0 !important; }
1787
+ /* The icons are laid out by deep-chat with their own offsets — the microphone sat 14px down
1788
+ and 2px outside its button. Centring them means clearing that, not just centring the box
1789
+ that holds them. */
1790
+ .input-button svg {
1791
+ position: static !important;
1792
+ margin: 0 !important;
1793
+ inset: auto !important;
1794
+ transform: none !important;
1795
+ display: block !important;
1796
+ flex: none !important;
1797
+ }
1798
+
1799
+ /* The send button is the assistant's own colour, the brand gradient its header and your messages
1800
+ carry, in a square sized to the selector beside it so the row reads as one control. Every colour in
1801
+ this block is a token: a custom property inherits into the shadow root, so both halves follow the
1802
+ theme. It used to be UIKit blue, resting on a fixed pale grey that stayed light after dark. */
1803
+ .input-button.inside-end {
1804
+ width: 34px;
1805
+ height: 34px;
1806
+ box-sizing: border-box;
1807
+ padding: 0 !important;
1808
+ border-radius: 8px;
1809
+ background: var(--brand-grad) !important;
1810
+ display: flex;
1811
+ align-items: center;
1812
+ justify-content: center;
1813
+ }
1814
+ /* deep-chat tints its icons with an inline filter, so the icon colour has to be forced the
1815
+ same way the background was. On the blue square the arrow is white. */
1816
+ .input-button.inside-end svg { width: 17px !important; height: 17px !important; filter: brightness(0) invert(1) !important; }
1817
+ .input-button.inside-end:hover { filter: brightness(1.08); }
1818
+ /* Disabled is the resting state — there is nothing to send until something is typed — so it reads
1819
+ as the same button, not yet ready: the gradient at half strength, the arrow still white. */
1820
+ .input-button.inside-end.disabled-button { opacity: .5; }
1821
+ .input-button.inside-end.disabled-button:hover { filter: none; }
1822
+
1823
+ /* The microphone is a button too, so it gets the same square as the send button rather than
1824
+ sitting next to it as a bare icon. */
1825
+ .input-button.outside-end {
1826
+ width: 34px;
1827
+ height: 34px;
1828
+ box-sizing: border-box;
1829
+ padding: 0 !important;
1830
+ margin-left: 6px !important;
1831
+ display: flex;
1832
+ align-items: center;
1833
+ justify-content: center;
1834
+ border-radius: 8px;
1835
+ background: var(--brand-grad) !important;
1836
+ }
1837
+ /* The microphone is a filled icon, so it is coloured rather than filtered — the same ink the
1838
+ rest of the interface uses, not deep-chat's green tint. */
1839
+ .input-button.outside-end svg { width: 17px !important; height: 17px !important; filter: none !important; }
1840
+ .input-button.outside-end svg, .input-button.outside-end svg * { fill: var(--text-inverse) !important; }
1841
+ .input-button.outside-end:hover { filter: brightness(1.08); }
1842
+ /* While it is listening it is the active control in the row, so it says so. */
1843
+ .input-button.outside-end.active-button { background: var(--danger) !important; }
1844
+
1845
+ .command-picker {
1846
+ order: 2;
1847
+ box-sizing: border-box;
1848
+ /* A select is as wide as its longest option unless it is told otherwise — 563px of one
1849
+ here, in a 358px sidebar. Zero basis with min-width:0 makes it take the space that is
1850
+ left instead of the space it wants. */
1851
+ flex: 1 1 0;
1852
+ width: 0;
1853
+ min-width: 0;
1854
+ height: 34px;
1855
+ font-size: 14px;
1856
+ line-height: 1;
1857
+ padding: 0 28px 0 10px;
1858
+ margin: 0 8px 0 0;
1859
+ border: 1px solid var(--line);
1860
+ border-radius: 8px;
1861
+ background-color: var(--surface-input);
1862
+ color: var(--text-default);
1863
+ cursor: pointer;
1864
+ appearance: none;
1865
+ background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23555' d='M4 6l4 4 4-4z'/%3E%3C/svg%3E");
1866
+ background-repeat: no-repeat;
1867
+ background-position: right 8px center;
1868
+ background-size: 14px;
1869
+ }
1870
+ .command-picker:focus { outline: none; border-color: var(--link); box-shadow: 0 0 0 2px var(--focus-ring); }
1871
+ `;
1872
+
1873
+ // The toolbar is built after the component upgrades, so wait for the send button rather than
1874
+ // assuming it is there; give up quietly and leave the select where it is if it never appears.
1875
+ let tries = 0;
1876
+ const place = () => {
1877
+ const sr = el.shadowRoot;
1878
+ const submit = sr && sr.querySelector('.input-button.inside-end, .input-button.outside-end');
1879
+ if (!submit) {
1880
+ // No give-up: deep-chat can take longer than any fixed budget on a busy
1881
+ // machine, and a silent stop is exactly "sometimes the prompt mode is
1882
+ // missing". Poll fast for the first six seconds, then slowly forever —
1883
+ // the picker stays visible in its own row until the move happens.
1884
+ tries += 1;
1885
+ return setTimeout(place, tries < 60 ? 100 : 1000);
1886
+ }
1887
+ const style = document.createElement('style');
1888
+ style.textContent = shadowCss;
1889
+ sr.appendChild(style);
1890
+ // Into the input row itself, not the small container the button sits in: the selector is
1891
+ // sized against the row's free space, and a button container has none to give.
1892
+ const buttonBox = submit.closest('.input-button-container') || submit.parentElement;
1893
+ buttonBox.parentElement.insertBefore(picker, buttonBox);
1894
+ const row = document.querySelector('.chat-tools');
1895
+ if (row) row.remove();
1896
+
1897
+ // "@" and "/" in the composer, scoped to the page's own workspace.
1898
+ //
1899
+ // deep-chat's input is a contenteditable/textarea inside this shadow root, so the
1900
+ // suggester has to be handed the element directly — there is no `data-suggest`
1901
+ // attribute to find from the document, and querySelectorAll does not cross a
1902
+ // shadow boundary. The endpoint carries the workspace (and project, where the page
1903
+ // has one) so a project's own .claude/ items outrank the global ones, exactly as a
1904
+ // session started in that folder would resolve them.
1905
+ const suggestInput = sr.querySelector('#text-input, textarea, [contenteditable="true"]');
1906
+ if (suggestInput && window.wsSuggest) {
1907
+ const scope = document.body.dataset.suggestScope || '';
1908
+ window.wsSuggest.attachSuggest(suggestInput, `/fragments/ai-suggest${scope}`);
1909
+ }
1910
+
1911
+ // deep-chat re-renders its shadow DOM after applying async config, which
1912
+ // silently deletes foreign nodes — the picker survived only when that
1913
+ // re-render happened to run first. The JS reference outlives the detach,
1914
+ // so a watchdog puts the same node (and its style) back whenever it is
1915
+ // dropped, forever — a re-render can happen again on theme changes.
1916
+ setInterval(() => {
1917
+ if (picker.isConnected) return;
1918
+ const sub2 = sr.querySelector('.input-button.inside-end, .input-button.outside-end');
1919
+ if (!sub2) return;
1920
+ if (!sr.contains(style)) sr.appendChild(style);
1921
+ const box2 = sub2.closest('.input-button-container') || sub2.parentElement;
1922
+ box2.parentElement.insertBefore(picker, box2);
1923
+ }, 1000);
1924
+ // No dark copy of these styles, and nothing mirroring the theme in: every colour above is a token, and a
1925
+ // custom property set on <html> inherits into the shadow root, so the toggle reaches it on its own.
1926
+ };
1927
+ place();
1928
+ }
1929
+ el.errorMessages = { displayServiceErrorMessages: true };
1930
+ })();
1931
+
1932
+ // The actions rail is handled once, near the top of this file, under the `actions-collapsed`
1933
+ // key that the no-flash script in the page head also reads. A SECOND implementation used to sit
1934
+ // here — same class, its own `ws-actions-collapsed` key, its own listener bound straight to the
1935
+ // button — and the two cancelled each other out: the delegated handler toggled the class and wrote
1936
+ // its key, then this one read the already-toggled class and toggled it back. The rail looked
1937
+ // broken because every click was undone a millisecond later by the other half of the same feature.
1938
+
1939
+ // Reordering the workspaces list by dragging. The buttons stay — a drag is quick for a big move
1940
+ // and awkward for a single step, and neither is a substitute for the other. The order is posted
1941
+ // once, on drop, rather than as a run of swaps.
1942
+ document.addEventListener('dragstart', (e) => {
1943
+ const item = e.target.closest && e.target.closest('.ws-item');
1944
+ if (!item) return;
1945
+ item.classList.add('is-dragging');
1946
+ e.dataTransfer.effectAllowed = 'move';
1947
+ e.dataTransfer.setData('text/plain', item.dataset.ws || '');
1948
+ });
1949
+
1950
+ document.addEventListener('dragover', (e) => {
1951
+ const list = e.target.closest && e.target.closest('.ws-list');
1952
+ if (!list) return;
1953
+ e.preventDefault();
1954
+ const dragging = list.querySelector('.ws-item.is-dragging');
1955
+ const over = e.target.closest('.ws-item');
1956
+ if (!dragging || !over || over === dragging) return;
1957
+ const box = over.getBoundingClientRect();
1958
+ const after = e.clientY > box.top + box.height / 2;
1959
+ list.insertBefore(dragging, after ? over.nextSibling : over);
1960
+ });
1961
+
1962
+ document.addEventListener('dragend', (e) => {
1963
+ const item = e.target.closest && e.target.closest('.ws-item');
1964
+ if (!item) return;
1965
+ item.classList.remove('is-dragging');
1966
+ const editor = item.closest('#list-editor');
1967
+ const list = item.closest('.ws-list');
1968
+ if (!editor || !list) return;
1969
+ const order = [...list.querySelectorAll('.ws-item')].map((li) => li.dataset.ws).join(',');
1970
+ htmx.ajax('POST', '/actions/list-order', {
1971
+ target: '#list-editor',
1972
+ swap: 'outerHTML',
1973
+ values: { file: editor.dataset.file, key: editor.dataset.key, order },
1974
+ });
1975
+ });
1976
+
1977
+ // Interface directives from the Workspace AI Assistant (HX-Trigger events):
1978
+ // after the agent creates/starts/launches something, it can steer the page —
1979
+ // open the new site in a tab and/or navigate to the relevant workspace page.
1980
+ document.body.addEventListener('assistant-directive', (e) => {
1981
+ const d = e.detail || {};
1982
+ if (d.open) window.open(d.open, '_blank');
1983
+ if (d.navigate) setTimeout(() => { location.href = d.navigate; }, 900);
1984
+ });
1985
+
1986
+ // Global "system is working" indicator: count in-flight HTMX requests and
1987
+ // toggle html.htmx-busy, which shows the top progress bar + Working… pill.
1988
+ let activeRequests = 0;
1989
+ const isJobPoll = (e) => e.detail?.elt?.id?.startsWith('job-');
1990
+ document.addEventListener('htmx:before:request', (e) => {
1991
+ if (isJobPoll(e)) return; // 1s job polls would make the bar flicker forever
1992
+ activeRequests += 1;
1993
+ document.documentElement.classList.add('htmx-busy');
1994
+ });
1995
+ const requestDone = (e) => {
1996
+ if (isJobPoll(e)) return;
1997
+ activeRequests = Math.max(0, activeRequests - 1);
1998
+ if (activeRequests === 0) document.documentElement.classList.remove('htmx-busy');
1999
+ };
2000
+ document.addEventListener('htmx:after:request', requestDone);
2001
+ document.addEventListener('htmx:finally:request', requestDone);
2002
+ document.addEventListener('htmx:error', requestDone);
2003
+ document.addEventListener('htmx:response:error', requestDone);
2004
+
2005
+ // A counter can only ever be as honest as the events that feed it, and a pill that says the
2006
+ // system is working when it is not is worse than no pill: it is the one piece of chrome you
2007
+ // cannot dismiss. htmx marks the element it is requesting for with .htmx-request, so that is
2008
+ // the truth to fall back on — when nothing on the page is in flight, the count is zero,
2009
+ // whatever the events did or did not say.
2010
+ setInterval(() => {
2011
+ if (!document.documentElement.classList.contains('htmx-busy')) return;
2012
+ if (document.querySelector('.htmx-request')) return;
2013
+ activeRequests = 0;
2014
+ document.documentElement.classList.remove('htmx-busy');
2015
+ }, 2000);
2016
+
2017
+ // Keep live terminal boxes scrolled to the newest output line.
2018
+ document.addEventListener('htmx:after:swap', (e) => {
2019
+ const el = swappedEl(e);
2020
+ const pres = [];
2021
+ if (el.classList?.contains('terminal')) pres.push(...el.querySelectorAll('.terminal-pre'));
2022
+ el.querySelectorAll?.('.terminal .terminal-pre').forEach((p) => pres.push(p));
2023
+ pres.forEach((p) => { p.scrollTop = p.scrollHeight; });
2024
+ });
2025
+
2026
+ // Keep the chat log scrolled to the latest message after every HTMX swap.
2027
+ document.addEventListener('htmx:after:swap', (e) => {
2028
+ const el = swappedEl(e);
2029
+ const log = document.getElementById('chat-log');
2030
+ if (log && (el === log || log.contains(el))) {
2031
+ log.scrollTop = log.scrollHeight;
2032
+ }
2033
+ });
2034
+
2035
+ // Dark mode toggle (light is the default; choice persists in localStorage).
2036
+ document.addEventListener('click', (e) => {
2037
+ if (!e.target.closest('.theme-toggle')) return;
2038
+ const dark = document.documentElement.classList.toggle('dark');
2039
+ wsPrefSet('ws-theme', dark ? 'dark' : 'light');
2040
+ });
2041
+
2042
+ // The command picker inside the prompt: choosing an entry writes a runnable
2043
+ // phrase into the box and hands focus back, so the prompt stays the one place
2044
+ // you type. It never runs anything by itself.
2045
+ document.addEventListener('change', (e) => {
2046
+ const picker = e.target.closest('.command-picker');
2047
+ if (!picker || !picker.value) return;
2048
+ const form = picker.closest('form');
2049
+ const input = form && form.querySelector('input[name="message"]');
2050
+ if (!input) return;
2051
+ const [workspace, file] = picker.value.split('/');
2052
+ const phrase = `Run ${file} in ${workspace}: `;
2053
+ input.value = input.value ? `${input.value.trim()} ${phrase}` : phrase;
2054
+ picker.value = '';
2055
+ input.focus();
2056
+ input.setSelectionRange(input.value.length, input.value.length);
2057
+ });
2058
+
2059
+ // The Auto-mode confirm card. "Run it" sends the agreement back as the next turn of the
2060
+ // conversation (so the agent executes with its own safeguards), "Keep prompting" hands the
2061
+ // panel back to you.
2062
+ //
2063
+ // Two things about WHERE this card lives, both of which silently broke it when the assistant
2064
+ // became a deep-chat component:
2065
+ //
2066
+ // - The card is drawn INSIDE deep-chat's shadow DOM, so a click on it arrives at the
2067
+ // document retargeted to <deep-chat> itself: `e.target.closest('.plan-run')` is null and
2068
+ // the handler never ran. `composedPath()` is the path the click actually took, shadow
2069
+ // boundary included, and is the only way to see the button from out here.
2070
+ // - There is no `.chat-input` form any more — the panel owns its own input — so the turn is
2071
+ // sent with deep-chat's own `submitUserMessage`, which puts it in the transcript and posts
2072
+ // it exactly as typing it would.
2073
+ document.addEventListener('click', (e) => {
2074
+ const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
2075
+ const hit = (cls) => path.find((n) => n && n.classList && n.classList.contains(cls));
2076
+ const run = hit('plan-run');
2077
+ const keep = hit('plan-keep');
2078
+ if (!run && !keep) return;
2079
+ const chat = document.getElementById('ws-deep-chat');
2080
+ if (!chat) return;
2081
+ if (keep) {
2082
+ const input = chat.shadowRoot && chat.shadowRoot.querySelector('#text-input');
2083
+ if (input) input.focus();
2084
+ return;
2085
+ }
2086
+ const card = path.find((n) => n && n.classList && n.classList.contains('plan-card'));
2087
+ if (card) card.classList.add('plan-card-done');
2088
+ // Both spellings: deep-chat has taken a plain string and an object across versions, and a
2089
+ // card that quietly does nothing is exactly the failure this is fixing.
2090
+ try { chat.submitUserMessage({ text: 'Yes — run it.' }); }
2091
+ catch (_) { try { chat.submitUserMessage('Yes — run it.'); } catch (__) { /* nothing to do */ } }
2092
+ });
2093
+
2094
+ // The image picker in settings. Clicking a tile writes the filename into its field and
2095
+ // refreshes the preview beside it — the value has to match a file in public/ exactly, so
2096
+ // picking beats typing, which is why these fields have no microphone.
2097
+ document.body.addEventListener('click', (e) => {
2098
+ const close = e.target.closest('.image-choices-close');
2099
+ if (close) { close.closest('.image-picker').innerHTML = ''; return; }
2100
+ const tile = e.target.closest('[data-pick-image]');
2101
+ if (!tile) return;
2102
+ const field = document.getElementById(tile.dataset.pickTarget);
2103
+ if (!field) return;
2104
+ field.value = tile.dataset.pickImage;
2105
+ field.dispatchEvent(new Event('input', { bubbles: true }));
2106
+ const preview = field.closest('.image-field')?.querySelector('.image-preview');
2107
+ if (preview) preview.innerHTML = `<img src="/${tile.dataset.pickImage}" alt="">`;
2108
+ tile.closest('.image-picker').innerHTML = '';
2109
+ field.focus();
2110
+ });
2111
+
2112
+ // Running commands live in a toast stack.
2113
+ //
2114
+ // A job fragment arrives in whatever page area launched it, and polls by replacing ITSELF —
2115
+ // so moving it into the stack once is enough: every later poll lands wherever the element now
2116
+ // is. That is why this hooks the swap rather than changing the hx-target of the ~40 buttons
2117
+ // that start jobs.
2118
+ //
2119
+ // Which jobs are collapsed is kept on the STACK, not in a JS variable: the stack survives every
2120
+ // poll (only its children are replaced), so the state cannot be lost to a re-render or to this
2121
+ // file being evaluated twice.
2122
+ function collapsedSet() {
2123
+ const stack = document.getElementById('job-toasts');
2124
+ return new Set((stack?.dataset.collapsed || '').split(' ').filter(Boolean));
2125
+ }
2126
+
2127
+ function saveCollapsed(set) {
2128
+ const stack = document.getElementById('job-toasts');
2129
+ if (stack) stack.dataset.collapsed = [...set].join(' ');
2130
+ }
2131
+
2132
+ // A dragged size, like the collapsed state, lives on the stack rather than on the toast: the
2133
+ // toast element is replaced by its own poll every second, so anything stored on it is gone a
2134
+ // second later — which is why a resized job used to snap back to its default width.
2135
+ function toastSizes() {
2136
+ const stack = document.getElementById('job-toasts');
2137
+ if (!stack) return {};
2138
+ try { return JSON.parse(stack.dataset.sizes || '{}'); } catch (_) { return {}; }
2139
+ }
2140
+ function setToastSize(id, w, h) {
2141
+ const stack = document.getElementById('job-toasts');
2142
+ if (!stack) return;
2143
+ const all = toastSizes();
2144
+ all[id] = { w: Math.round(w), h: Math.round(h) };
2145
+ stack.dataset.sizes = JSON.stringify(all);
2146
+ }
2147
+
2148
+ // CSS `resize` only ever gives ONE handle, at the bottom right. These are the eight grips a
2149
+ // resizable panel is expected to have — four corners and four edges — added by hand for that
2150
+ // reason. Which edge stays put is fixed by the layout: the stack is bottom-anchored and
2151
+ // right-aligned, so a toast always grows leftwards and upwards. Dragging outward from any grip
2152
+ // makes it bigger, dragging inward makes it smaller.
2153
+ const TOAST_GRIPS = {
2154
+ tl: { x: -1, y: -1, cursor: 'nwse-resize', label: 'top-left corner' },
2155
+ t: { x: 0, y: -1, cursor: 'ns-resize', label: 'top edge' },
2156
+ tr: { x: 1, y: -1, cursor: 'nesw-resize', label: 'top-right corner' },
2157
+ r: { x: 1, y: 0, cursor: 'ew-resize', label: 'right edge' },
2158
+ br: { x: 1, y: 1, cursor: 'nwse-resize', label: 'bottom-right corner' },
2159
+ b: { x: 0, y: 1, cursor: 'ns-resize', label: 'bottom edge' },
2160
+ bl: { x: -1, y: 1, cursor: 'nesw-resize', label: 'bottom-left corner' },
2161
+ l: { x: -1, y: 0, cursor: 'ew-resize', label: 'left edge' },
2162
+ };
2163
+ function addToastGrips(toast) {
2164
+ if (toast.querySelector('.toast-grip')) return;
2165
+ Object.keys(TOAST_GRIPS).forEach((corner) => {
2166
+ const g = document.createElement('span');
2167
+ g.className = `toast-grip toast-grip-${corner}`;
2168
+ g.dataset.grip = corner;
2169
+ g.title = `Resize from the ${TOAST_GRIPS[corner].label}`;
2170
+ toast.appendChild(g);
2171
+ });
2172
+ }
2173
+
2174
+ // The grip lives inside the card, which is a link: a click that lands on it must not navigate.
2175
+ document.addEventListener('click', (e) => {
2176
+ const grip = e.target.closest('.ws-card-handle');
2177
+ if (!grip) return;
2178
+ e.preventDefault();
2179
+ e.stopPropagation();
2180
+ }, true);
2181
+
2182
+ document.addEventListener('pointerdown', (e) => {
2183
+ const grip = e.target.closest('.toast-grip');
2184
+ if (!grip) return;
2185
+ const toast = grip.closest('.job-toast');
2186
+ const dir = TOAST_GRIPS[grip.dataset.grip];
2187
+ if (!toast || !dir) return;
2188
+ e.preventDefault();
2189
+ const box = toast.getBoundingClientRect();
2190
+ const startX = e.clientX;
2191
+ const startY = e.clientY;
2192
+ const min = { w: 16 * 16, h: 5 * 16 }; // the CSS min-width / min-height, in px
2193
+ const max = { w: toast.parentElement.clientWidth, h: window.innerHeight - 96 };
2194
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2195
+ // The toast replaces ITSELF every second while the job runs, so the element under the
2196
+ // pointer is gone within a second of grabbing it. Everything below works from the id and
2197
+ // re-queries the live element, and the size is written to the store on every move — which is
2198
+ // what makes the drag survive a poll landing in the middle of it.
2199
+ const id = toast.id;
2200
+ const move = (ev) => {
2201
+ // An outward drag is positive on every grip: the sign per axis comes from which side of the
2202
+ // toast the grip is on, so the left grips read a leftward drag as growth and the right ones
2203
+ // read a rightward drag the same way. A `0` axis is an edge grip — that dimension is fixed.
2204
+ const w = dir.x ? clamp(box.width + dir.x * (ev.clientX - startX), min.w, max.w) : box.width;
2205
+ const h = dir.y ? clamp(box.height + dir.y * (ev.clientY - startY), min.h, max.h) : box.height;
2206
+ setToastSize(id, w, h);
2207
+ const live = document.getElementById(id);
2208
+ if (live) { live.style.width = `${w}px`; live.style.height = `${h}px`; }
2209
+ };
2210
+ const up = () => {
2211
+ document.removeEventListener('pointermove', move);
2212
+ document.removeEventListener('pointerup', up);
2213
+ document.body.classList.remove('toast-resizing');
2214
+ };
2215
+ document.body.classList.add('toast-resizing');
2216
+ document.addEventListener('pointermove', move);
2217
+ document.addEventListener('pointerup', up);
2218
+ });
2219
+
2220
+ function adoptJobToasts() {
2221
+ const stack = document.getElementById('job-toasts');
2222
+ if (!stack) return;
2223
+ const collapsed = collapsedSet();
2224
+ const sizes = toastSizes();
2225
+ document.querySelectorAll('.job-toast').forEach((toast) => {
2226
+ // An inline job (a clone running in its modal) stays where it was swapped in.
2227
+ if (toast.classList.contains('job-inline')) return;
2228
+ if (toast.parentElement !== stack) stack.appendChild(toast);
2229
+ addToastGrips(toast);
2230
+ // Re-apply a size the user dragged, for the same reason the collapsed state is re-applied.
2231
+ const size = sizes[toast.id];
2232
+ if (size) { toast.style.width = `${size.w}px`; toast.style.height = `${size.h}px`; }
2233
+ // The server does not know what the user collapsed, and each poll rebuilds the element,
2234
+ // so the choice is re-applied here from the stack that remembers it.
2235
+ const isCollapsed = collapsed.has(toast.id);
2236
+ toast.classList.toggle('is-collapsed', isCollapsed);
2237
+ const btn = toast.querySelector('.job-toast-collapse');
2238
+ if (btn) btn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
2239
+ // Follow the tail while it runs, unless the user has scrolled up to read something.
2240
+ const pre = toast.querySelector('.terminal-pre');
2241
+ if (pre && !isCollapsed && toast.classList.contains('is-running') && !toast.dataset.userScrolled) {
2242
+ pre.scrollTop = pre.scrollHeight;
2243
+ }
2244
+ });
2245
+ }
2246
+
2247
+
2248
+ document.body.addEventListener('htmx:before:swap', (e) => {
2249
+ const el = swappedEl(e);
2250
+ if (el && el.id === 'editor-modal-body') {
2251
+ el.querySelectorAll('video, audio').forEach((m) => { try { m.pause(); } catch (_) { /* gone */ } });
2252
+ }
2253
+ });
2254
+
2255
+ document.addEventListener('DOMContentLoaded', adoptJobToasts);
2256
+ // Both events: afterSwap puts the element in place, afterSettle runs once htmx has finished
2257
+ // with it — re-applying at both points is what makes a collapsed job stay collapsed through
2258
+ // a poll.
2259
+ document.body.addEventListener('htmx:after:swap', adoptJobToasts);
2260
+ document.body.addEventListener('htmx:after:swap', adoptJobToasts);
2261
+
2262
+ document.addEventListener('click', (e) => {
2263
+ const collapse = e.target.closest('.job-toast-collapse');
2264
+ if (collapse) {
2265
+ const toast = collapse.closest('.job-toast');
2266
+ const set = collapsedSet();
2267
+ if (set.has(toast.id)) set.delete(toast.id);
2268
+ else set.add(toast.id);
2269
+ saveCollapsed(set);
2270
+ adoptJobToasts();
2271
+ return;
2272
+ }
2273
+ const close = e.target.closest('.job-toast-close');
2274
+ if (close) {
2275
+ const toast = close.closest('.job-toast');
2276
+ const set = collapsedSet();
2277
+ set.delete(toast.id);
2278
+ saveCollapsed(set);
2279
+ // Removing the element stops its polling; the job itself carries on server-side.
2280
+ toast.remove();
2281
+ }
2282
+ });
2283
+
2284
+ // Reading the output of a long job means scrolling up; do not yank it back to the tail.
2285
+ document.addEventListener('scroll', (e) => {
2286
+ const pre = e.target.closest && e.target.closest('.job-toast .terminal-pre');
2287
+ if (!pre) return;
2288
+ const toast = pre.closest('.job-toast');
2289
+ const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;
2290
+ if (atBottom) delete toast.dataset.userScrolled;
2291
+ else toast.dataset.userScrolled = '1';
2292
+ }, true);
2293
+
2294
+ // Short results become a toast at the top.
2295
+ //
2296
+ // A one-line "saved" or "deleted" used to render wherever the form was, which on the settings
2297
+ // page is below a 60-field form and off-screen — the save looked like it had done nothing. A job
2298
+ // keeps its own stack at the bottom: that is something you watch, this is something you read once
2299
+ // and forget, so these fade on their own.
2300
+ const FLASH_MS = { assistant: 6000, error: 12000 }; // an error is left long enough to read
2301
+
2302
+ function flashResults(root) {
2303
+ const stack = document.getElementById('flash-toasts');
2304
+ if (!stack) return;
2305
+ const scope = root && root.querySelectorAll ? root : document;
2306
+ scope.querySelectorAll('.msg:not(.terminal):not(.flash-toast)').forEach((msg) => {
2307
+ // Leave the chat log and anything inside the modal where they are: those are content, not
2308
+ // a transient result.
2309
+ //
2310
+ // Except a result that ARRIVED in the modal. A form in there (Remove, Clone, Restore, the
2311
+ // item forms) posts its own target, so the answer replaces the form — leaving the modal
2312
+ // open on a sentence about work already running, with nothing to do but close it by hand.
2313
+ // The server marks those `flash-now`; they come out and the modal goes with them.
2314
+ if (msg.closest('.chat-log') || msg.closest('#flash-toasts')) return;
2315
+ const inModal = msg.closest('#editor-modal');
2316
+ if (inModal && !msg.classList.contains('flash-now')) return;
2317
+ if (inModal) {
2318
+ try { window.UIkit.modal('#editor-modal').hide(); } catch (_) { /* modal already gone */ }
2319
+ }
2320
+ if (!msg.textContent.trim()) return;
2321
+ msg.classList.add('flash-toast');
2322
+ const isError = msg.classList.contains('error');
2323
+ const close = document.createElement('button');
2324
+ close.type = 'button';
2325
+ close.className = 'flash-toast-close';
2326
+ close.title = 'Dismiss';
2327
+ close.setAttribute('aria-label', 'Dismiss');
2328
+ close.innerHTML = '<span uk-icon="icon: close; ratio: .7"></span>';
2329
+ msg.appendChild(close);
2330
+ stack.appendChild(msg);
2331
+ const ms = isError ? FLASH_MS.error : FLASH_MS.assistant;
2332
+ // The countdown bar is drawn from this, so the animation and the timer are the same number.
2333
+ msg.style.setProperty('--flash-ms', `${ms}ms`);
2334
+ // Work that runs in a detached session changes what the page lists — a built project
2335
+ // gains a row, a removed one loses it — and the page that started it never hears about
2336
+ // it. So the toast that announced the start also asks for a reload as it leaves: by
2337
+ // then the row exists, and the reader has had the six seconds to read the message.
2338
+ const done = msg.dataset.reloadOnClose ? () => { msg.remove(); reloadForChanges(); } : () => msg.remove();
2339
+ let timer = setTimeout(done, ms);
2340
+ // Reading a long message must not race the timer.
2341
+ msg.addEventListener('mouseenter', () => { clearTimeout(timer); msg.classList.add('is-held'); });
2342
+ msg.addEventListener('mouseleave', () => {
2343
+ msg.classList.remove('is-held');
2344
+ timer = setTimeout(done, 2500);
2345
+ });
2346
+ });
2347
+ }
2348
+
2349
+ // A reload is not free: it discards whatever is half-typed and any menu that is open. So it
2350
+ // waits for a moment when it costs nothing — nobody typing, no modal, nothing in flight — and
2351
+ // gives up rather than fighting the user for the page. Whatever it skips, the next navigation
2352
+ // shows anyway.
2353
+ function reloadForChanges() {
2354
+ const held = () => {
2355
+ const el = document.activeElement;
2356
+ if (el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) && el.type !== 'submit') return 'a field has focus';
2357
+ if (document.querySelector('#editor-modal.uk-open')) return 'the modal is open';
2358
+ if (document.documentElement.classList.contains('htmx-busy')) return 'a request is in flight';
2359
+ if (document.hidden) return 'the tab is in the background';
2360
+ return '';
2361
+ };
2362
+ let tries = 0;
2363
+ const attempt = () => {
2364
+ if (!held()) { location.reload(); return; }
2365
+ // Ten seconds of patience, then leave the page alone.
2366
+ if (++tries > 10) return;
2367
+ setTimeout(attempt, 1000);
2368
+ };
2369
+ attempt();
2370
+ }
2371
+
2372
+ document.body.addEventListener('htmx:after:swap', (e) => flashResults(swappedEl(e)));
2373
+ // An outerHTML swap replaces the element the event names, so the new message can land OUTSIDE
2374
+ // e.target and never reach the toast stack — which is why a saved card order sometimes showed no
2375
+ // confirmation at all. A document-wide pass once htmx has settled catches it; flashResults skips
2376
+ // anything already turned into a toast, so this cannot double up.
2377
+ document.body.addEventListener('htmx:after:swap', () => flashResults(document));
2378
+
2379
+ document.addEventListener('click', (e) => {
2380
+ const close = e.target.closest('.flash-toast-close');
2381
+ if (!close) return;
2382
+ const toast = close.closest('.flash-toast');
2383
+ const reload = !!toast.dataset.reloadOnClose;
2384
+ toast.remove();
2385
+ // Dismissing early is still the toast closing, so the page still catches up. Someone who
2386
+ // clicks the × after starting a build wants it gone, not a stale list.
2387
+ if (reload) reloadForChanges();
2388
+ });
2389
+
2390
+ // Commands → Buttons: a button carries the row it sits in. Dragged to another row, or moved with its
2391
+ // select, its hidden row field follows — so what is saved is what is on the screen.
2392
+ function syncRowButtons() {
2393
+ document.querySelectorAll('.row-buttons[data-row]').forEach((list) => {
2394
+ list.querySelectorAll(':scope > .row-button').forEach((li) => {
2395
+ const input = li.querySelector(':scope > .row-of');
2396
+ const select = li.querySelector(':scope > .row-move');
2397
+ if (input) input.value = list.dataset.row;
2398
+ if (select) select.value = list.dataset.row;
2399
+ });
2400
+ });
2401
+ // …and a group dragged to another column of its menu carries that column.
2402
+ document.querySelectorAll('.row-groups[data-col]').forEach((list) => {
2403
+ list.querySelectorAll(':scope > .row-group > .gcol-of').forEach((input) => {
2404
+ input.value = `${input.dataset.key}\t${list.dataset.col}`;
2405
+ });
2406
+ });
2407
+ }
2408
+ ['moved', 'added'].forEach((name) => document.body.addEventListener(name, (e) => {
2409
+ if (e.target && e.target.closest && e.target.closest('.row-buttons, .row-groups')) syncRowButtons();
2410
+ }));
2411
+ // Commands → Buttons keeps your place. Every change on the Rows tab answers with the editor re-rendered,
2412
+ // which used to close every card and jump to the top: the open cards and the scroll position are carried over.
2413
+ // htmx 4 names: before:swap comes from the element that made the request (a form or button inside the
2414
+ // editor), after:swap names what was swapped — swappedEl() — so the place is put back only when that was the
2415
+ // editor itself, and forgotten after any smaller swap inside it (Definition's Check report).
2416
+ let rowEditorPlace = null;
2417
+ document.body.addEventListener('htmx:before:swap', (e) => {
2418
+ const from = e.target && e.target.closest ? e.target.closest('#row-actions-editor') : null;
2419
+ if (!from && swappedEl(e).id !== 'row-actions-editor') return;
2420
+ rowEditorPlace = {
2421
+ buttons: [...document.querySelectorAll('.row-button > details.row-button-body[open]')].map((d) => d.parentElement.dataset.button),
2422
+ y: window.scrollY,
2423
+ };
2424
+ });
2425
+ document.body.addEventListener('htmx:after:swap', (e) => {
2426
+ if (!rowEditorPlace) return;
2427
+ if (swappedEl(e).id !== 'row-actions-editor') { rowEditorPlace = null; return; }
2428
+ const place = rowEditorPlace;
2429
+ rowEditorPlace = null;
2430
+ place.buttons.forEach((id) => {
2431
+ const d = document.querySelector(`.row-button[data-button="${CSS.escape(id)}"] > details.row-button-body`);
2432
+ if (d) d.open = true;
2433
+ });
2434
+ window.scrollTo(0, place.y);
2435
+ });
2436
+
2437
+ // …marks the Rows form as changed, so its save bar can say so and the page can ask before losing it. The
2438
+ // add-an-action and edit fields are left out: they have their own buttons, and saving the rows ignores them.
2439
+ function markRowsChanged(el) {
2440
+ const form = el && el.closest && el.closest('#row-arrange-form');
2441
+ if (!form) return;
2442
+ if (el.name && /^(add|edit)_/.test(el.name)) return;
2443
+ form.dataset.dirty = '1';
2444
+ }
2445
+ ['input', 'change'].forEach((name) => document.addEventListener(name, (e) => markRowsChanged(e.target)));
2446
+ ['moved', 'added'].forEach((name) => document.body.addEventListener(name, (e) => markRowsChanged(e.target)));
2447
+ document.addEventListener('click', (e) => {
2448
+ if (e.target.closest && e.target.closest('#row-arrange-form .ws-move button')) markRowsChanged(e.target);
2449
+ });
2450
+ window.addEventListener('beforeunload', (e) => {
2451
+ if (!document.querySelector('#row-arrange-form[data-dirty="1"]')) return;
2452
+ e.preventDefault();
2453
+ e.returnValue = '';
2454
+ });
2455
+
2456
+ document.addEventListener('change', (e) => {
2457
+ const select = e.target && e.target.closest && e.target.closest('.row-move');
2458
+ if (!select) return;
2459
+ const li = select.closest('.row-button');
2460
+ const target = document.querySelector(`.row-buttons[data-row="${CSS.escape(select.value)}"]`);
2461
+ if (li && target && li.parentNode !== target) target.appendChild(li);
2462
+ syncRowButtons();
2463
+ });
2464
+
2465
+ /* ---------------- the New screen form ------------------------------------- */
2466
+
2467
+ // Which fieldsets belong to which kind of session. The kind is a radio now rather than a select: three
2468
+ // named choices with a line each read better than three words in a dropdown, and the thing being chosen
2469
+ // changes what the rest of the form asks.
2470
+ window.screenKindChanged = function screenKindChanged(form) {
2471
+ if (!form) return;
2472
+ const kind = (form.querySelector('input[name="kind"]:checked') || {}).value || 'shell';
2473
+ for (const el of form.querySelectorAll('[data-claude-only]')) el.hidden = kind !== 'claude';
2474
+ for (const el of form.querySelectorAll('[data-not-claude]')) el.hidden = kind === 'claude';
2475
+ for (const label of form.querySelectorAll('.screen-kind')) {
2476
+ label.classList.toggle('is-on', (label.querySelector('input') || {}).checked === true);
2477
+ }
2478
+ };
2479
+
2480
+ // The session name follows where it runs, until somebody types their own. It is the first thing
2481
+ // `screen -ls` shows and what `screen -r` takes, so the answer is almost always the place — but a name
2482
+ // that overwrote what was typed would be worse than no default at all.
2483
+ window.screenNameFollowsTarget = function screenNameFollowsTarget(form) {
2484
+ if (!form) return;
2485
+ const field = form.querySelector('input[name="session"]');
2486
+ const target = form.querySelector('select[name="target"]');
2487
+ if (!field || !target) return;
2488
+ // The same shape the server fills in: where it runs, then a short id so a second session in the same
2489
+ // place is not refused as a name that already exists. The id is kept while only the place changes, so
2490
+ // watching the name settle does not look like a slot machine.
2491
+ const tail = field.dataset.tail || (field.dataset.tail = Math.random().toString(16).slice(2, 6));
2492
+ const value = String(target.value || '');
2493
+ const derived = (value === 'root'
2494
+ ? `workspace-${tail}`
2495
+ : `${value.replace(/^p:([a-z0-9_-]+):(.+)$/, '$1--$2').replace(/^ws:/, '')}--${tail}`)
2496
+ .toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-{3,}/g, '--').slice(0, 60);
2497
+ const path = (target.selectedOptions[0] || {}).dataset;
2498
+ const line = form.querySelector('.screen-where-path code');
2499
+ if (line && path && path.path) line.textContent = path.path;
2500
+ if (field.value && field.value !== field.dataset.derived) return; // typed by hand: leave it alone
2501
+ field.value = derived;
2502
+ field.dataset.derived = derived;
2503
+ };
2504
+
2505
+ // A prompt template fills the prompt and brings its own flags along, so the session runs the command
2506
+ // that prompt file documents. The flags land on the controls that own them rather than in a text box.
2507
+ window.screenPromptTemplate = function screenPromptTemplate(select) {
2508
+ const form = select.form;
2509
+ if (!select.value || !form) return;
2510
+ const flags = (select.selectedOptions[0].dataset.flags || '').trim();
2511
+ const tokens = flags.split(/\s+/).filter(Boolean);
2512
+ for (let i = 0; i < tokens.length; i += 1) {
2513
+ if (!tokens[i].startsWith('--')) continue;
2514
+ const field = form.querySelector(`[name="arg_${tokens[i].replace(/^--/, '').replace(/[^A-Za-z0-9]+/g, '_')}"]`);
2515
+ if (!field) continue;
2516
+ const next = tokens[i + 1];
2517
+ if (field.type === 'checkbox') { field.checked = true; continue; }
2518
+ if (next && !next.startsWith('--')) { field.value = next; i += 1; }
2519
+ }
2520
+ fetch(`/fragments/prompt-text/${encodeURIComponent(select.value)}`)
2521
+ .then((r) => r.text())
2522
+ .then((t) => {
2523
+ const text = t.trim();
2524
+ const field = form.querySelector('textarea[name="command"]');
2525
+ if (!field) return;
2526
+ field.value = text;
2527
+ if (field._aceEditor) { field._aceEditor.session.setValue(text); field._aceEditor.focus(); } else field.focus();
2528
+ });
2529
+ };
2530
+
2531
+ // The form arrives in the editor modal, and its "where it runs" half is swapped again whenever the
2532
+ // workspace changes — so both the kind toggle and the name default are settled here rather than from an
2533
+ // inline handler on an element the swap may have replaced.
2534
+ document.body.addEventListener('htmx:after:settle', (e) => {
2535
+ const el = e.target;
2536
+ if (!el || !el.querySelector) return;
2537
+ const kinds = el.querySelector('form .screen-kinds') || (el.closest && el.closest('form') && el.matches('.screen-kinds') ? el : null);
2538
+ if (kinds) window.screenKindChanged(kinds.closest('form'));
2539
+ const where = el.id === 'screen-where' ? el : el.querySelector('#screen-where');
2540
+ if (where && where.closest('form')) window.screenNameFollowsTarget(where.closest('form'));
2541
+ });
2542
+
2543
+ // The Screens page: how many sessions across.
2544
+ //
2545
+ // One under another by DEFAULT, and that is the point of the default rather than an accident of CSS: a
2546
+ // session row carries its name, its state, its log path and five verbs, and two of those side by side
2547
+ // on a 1400px screen leaves each narrower than the buttons it holds. Two and three across are for a
2548
+ // machine with a dozen sessions, where the question is "which of these is waiting" rather than "what
2549
+ // does this one say".
2550
+ //
2551
+ // Re-applied after every swap because the list refreshes itself every 20 seconds — a class set once
2552
+ // would last exactly one poll. Kept per browser, like the theme and the page width.
2553
+ (function screensColumns() {
2554
+ const KEY = 'ws-screens-cols';
2555
+ const read = () => {
2556
+ try { return ['1', '2', '3'].includes(localStorage.getItem(KEY)) ? localStorage.getItem(KEY) : '1'; }
2557
+ catch (_) { return '1'; }
2558
+ };
2559
+ function apply() {
2560
+ const list = document.querySelector('.screens-list');
2561
+ // The modal only wears the count while it is actually holding the list: it is the one modal in the
2562
+ // dashboard, and an editor opened after the screens would otherwise inherit a width chosen for
2563
+ // three columns of session cards.
2564
+ const modal = document.getElementById('editor-modal');
2565
+ if (modal) {
2566
+ if (modal.contains(list)) modal.dataset.screensCols = read();
2567
+ else delete modal.dataset.screensCols;
2568
+ }
2569
+ if (!list) return;
2570
+ const cols = read();
2571
+ list.dataset.cols = cols;
2572
+ // The buttons only: the modal itself carries data-screens-cols for its width, and as a match it was
2573
+ // being given the primary button's class, which painted the whole overlay brand blue.
2574
+ document.querySelectorAll('button[data-screens-cols]').forEach((b) => {
2575
+ const on = b.dataset.screensCols === cols;
2576
+ b.classList.toggle('uk-button-primary', on);
2577
+ b.classList.toggle('uk-button-default', !on);
2578
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
2579
+ });
2580
+ }
2581
+ document.addEventListener('click', (e) => {
2582
+ const btn = e.target.closest('[data-screens-cols]');
2583
+ if (!btn) return;
2584
+ e.preventDefault();
2585
+ try { localStorage.setItem(KEY, btn.dataset.screensCols); } catch (_) { /* private window */ }
2586
+ apply();
2587
+ });
2588
+ document.addEventListener('DOMContentLoaded', apply);
2589
+ document.body.addEventListener('htmx:after:swap', apply);
2590
+ apply();
2591
+ })();
2592
+
2593
+ /* ---------------- the name the Build form proposes ---------------- */
2594
+ //
2595
+ // Choosing a builder fills the name field with something derived from what it builds, numbered past
2596
+ // what is already in that folder — `byte1`, then `byte2` — because a form that says only
2597
+ // `new-project-name` is how a tree ends up holding `my-drupal-site`, `my-drupal-site-pulse` and
2598
+ // `my-drupal-site-pulse2`.
2599
+ //
2600
+ // The one rule: a name somebody TYPED is never overwritten. The field is filled only while it is
2601
+ // empty, or while it still holds the exact suggestion this code last put there — so changing the
2602
+ // dropdown re-proposes, and changing it after typing leaves the typing alone.
2603
+ (function () {
2604
+ let lastSuggested = '';
2605
+ document.body.addEventListener('suggest-project-name', (e) => {
2606
+ const name = typeof e.detail === 'string' ? e.detail : (e.detail && e.detail.value);
2607
+ if (!name) return;
2608
+ document.querySelectorAll('input[data-suggest-name]').forEach((input) => {
2609
+ const now = input.value.trim();
2610
+ if (now !== '' && now !== lastSuggested) return; // theirs, not ours
2611
+ input.value = name;
2612
+ });
2613
+ lastSuggested = name;
2614
+ });
2615
+ })();
2616
+
2617
+ // Who is signed in, when the dashboard asks for Google sign-in: the name and a way out. With sign-in off
2618
+ // /auth/me says so and nothing is drawn.
2619
+ (function () {
2620
+ var slot = document.getElementById('auth-user');
2621
+ if (!slot || !window.fetch) return;
2622
+ fetch('/auth/me', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (u) {
2623
+ if (!u || !u.enabled || !u.email) return;
2624
+ slot.textContent = '';
2625
+ var who = document.createElement('span');
2626
+ who.textContent = u.name || u.email;
2627
+ who.title = u.email;
2628
+ var out = document.createElement('a');
2629
+ out.href = '/auth/signout';
2630
+ out.textContent = 'Sign out';
2631
+ out.className = 'uk-margin-small-left';
2632
+ slot.appendChild(who);
2633
+ slot.appendChild(out);
2634
+ slot.hidden = false;
2635
+ }).catch(function () {});
2636
+ })();
2637
+
2638
+ // The image viewer: fit or actual size, and the arrow keys step through the list it was opened from.
2639
+ document.addEventListener('click', (e) => {
2640
+ const btn = e.target && e.target.closest ? e.target.closest('[data-img-zoom]') : null;
2641
+ if (!btn) return;
2642
+ const stage = btn.closest('[data-img-viewer]') && btn.closest('[data-img-viewer]').querySelector('[data-img-stage]');
2643
+ if (!stage) return;
2644
+ const fit = stage.classList.toggle('is-fit');
2645
+ btn.textContent = fit ? 'Actual size' : 'Fit';
2646
+ });
2647
+ document.addEventListener('keydown', (e) => {
2648
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
2649
+ const viewer = document.querySelector('#editor-modal.uk-open [data-img-viewer]');
2650
+ if (!viewer || e.target.closest('input, textarea, select, [contenteditable]')) return;
2651
+ const btn = viewer.querySelector(e.key === 'ArrowLeft' ? '[data-img-prev]' : '[data-img-next]');
2652
+ if (!btn) return;
2653
+ e.preventDefault();
2654
+ btn.click();
2655
+ });
2656
+
2657
+ // Upload images: each file is its own request with its bytes as the body, then the gallery refreshes.
2658
+ async function uploadImages(box, files) {
2659
+ const log = box.querySelector('[data-img-upload-log]');
2660
+ const folder = (box.querySelector('[data-img-upload-folder]') || {}).value || '';
2661
+ for (const f of files) {
2662
+ const li = document.createElement('li');
2663
+ li.textContent = `${f.name}…`;
2664
+ log.appendChild(li);
2665
+ try {
2666
+ const r = await fetch(`/actions/image-upload?path=${encodeURIComponent(folder)}&name=${encodeURIComponent(f.name)}`,
2667
+ { method: 'POST', body: f, headers: { 'Content-Type': f.type || 'application/octet-stream' } });
2668
+ li.innerHTML = await r.text();
2669
+ } catch (err) {
2670
+ li.textContent = `${f.name}: ${err.message}`;
2671
+ }
2672
+ }
2673
+ if (window.htmx) htmx.trigger(document.body, 'refresh-projects');
2674
+ }
2675
+ document.addEventListener('change', (e) => {
2676
+ const input = e.target && e.target.matches && e.target.matches('[data-img-upload-input]') ? e.target : null;
2677
+ if (input && input.files.length) uploadImages(input.closest('[data-img-upload]'), [...input.files]);
2678
+ });
2679
+ ['dragover', 'dragleave', 'drop'].forEach((type) => document.addEventListener(type, (e) => {
2680
+ const zone = e.target && e.target.closest ? e.target.closest('[data-img-drop]') : null;
2681
+ if (!zone) return;
2682
+ e.preventDefault();
2683
+ zone.classList.toggle('is-over', type === 'dragover');
2684
+ if (type === 'drop' && e.dataTransfer.files.length) uploadImages(zone.closest('[data-img-upload]'), [...e.dataTransfer.files]);
2685
+ }));
2686
+
2687
+ // Choosing a DDEV quickstart fills the fields beside it; each can still be changed.
2688
+ document.addEventListener('change', (e) => {
2689
+ const sel = e.target && e.target.closest ? e.target.closest('select[data-ddev-preset]') : null;
2690
+ if (!sel) return;
2691
+ const o = sel.selectedOptions[0];
2692
+ const box = sel.closest('.ddev-options');
2693
+ if (!o || !o.value || !box) return;
2694
+ ['project_type', 'php_version', 'database', 'webserver_type'].forEach((k) => {
2695
+ const f = box.querySelector(`[name="${k}"]`);
2696
+ const v = o.dataset[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())];
2697
+ if (f && v !== undefined) f.value = v;
2698
+ });
2699
+ const root = box.querySelector('[name="docroot"]');
2700
+ if (root) root.value = o.dataset.docroot === '__default' ? '' : (o.dataset.docroot || '');
2701
+ const omit = box.querySelector('[name="omit_db"]');
2702
+ if (omit) omit.checked = o.dataset.omitDb === '1';
2703
+ const py = box.querySelector('[name="python"]');
2704
+ if (py) py.checked = o.dataset.python === '1';
2705
+ });
2706
+
2707
+ // Deleting a workspace: Enter in a step's field answers that step instead of submitting the settings form around it.
2708
+ document.addEventListener('keydown', (e) => {
2709
+ const field = e.key === 'Enter' && e.target.closest && e.target.closest('.ws-delete-step input');
2710
+ if (!field) return;
2711
+ e.preventDefault();
2712
+ const go = field.closest('.ws-delete-step').querySelector('.ws-delete-go');
2713
+ if (go) go.click();
2714
+ });
2715
+ // Focus the step's field after the swap, and again once the confirm dialog has closed, which hands focus to the body.
2716
+ const focusWsDeleteStep = () => {
2717
+ const field = document.querySelector('.ws-delete-step input');
2718
+ if (field && document.activeElement !== field) field.focus();
2719
+ };
2720
+ document.body.addEventListener('htmx:after:swap', focusWsDeleteStep);
2721
+ document.addEventListener('hidden', (e) => { if (e.target && e.target.id === 'confirm-modal') setTimeout(focusWsDeleteStep, 0); }, true);