synabun 2.0.0 → 2.0.5

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 (334) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/THIRD-PARTY-LICENSES.md +46 -0
  5. package/benchmarks/fixtures/browser/README.md +1 -1
  6. package/benchmarks/fixtures/desktop/README.md +1 -1
  7. package/docs/whatsapp.md +2 -2
  8. package/hooks/claude-code/pre-compact.mjs +11 -0
  9. package/hooks/claude-code/prompt-submit.mjs +60 -184
  10. package/hooks/claude-code/session-start.mjs +73 -16
  11. package/hooks/claude-code/shared.mjs +20 -0
  12. package/hooks/claude-code/stop.mjs +5 -5
  13. package/lib/start-launcher.js +88 -1
  14. package/mcp-server/package-lock.json +2 -2
  15. package/mcp-server/package.json +1 -3
  16. package/neural-interface/i18n/en.json +1500 -153
  17. package/neural-interface/i18n/pt-BR.json +1498 -152
  18. package/neural-interface/lib/assistant-api.js +62 -6
  19. package/neural-interface/lib/assistant-brains/claude.js +4 -0
  20. package/neural-interface/lib/assistant-catalog.js +179 -27
  21. package/neural-interface/lib/assistant-config.js +31 -2
  22. package/neural-interface/lib/assistant-dispatch.js +62 -13
  23. package/neural-interface/lib/assistant-hidden-models.js +393 -66
  24. package/neural-interface/lib/assistant-memory.js +24 -10
  25. package/neural-interface/lib/assistant-persona.js +6 -2
  26. package/neural-interface/lib/assistant-pricing.js +21 -6
  27. package/neural-interface/lib/assistant-runtime.js +181 -12
  28. package/neural-interface/lib/assistant-task-prompt.js +13 -3
  29. package/neural-interface/lib/assistant-test-density.js +142 -0
  30. package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
  31. package/neural-interface/lib/assistant-user-context.js +908 -0
  32. package/neural-interface/lib/assistant-user-knowledge.js +328 -0
  33. package/neural-interface/lib/assistant-user-learn.js +404 -0
  34. package/neural-interface/lib/assistant-user-threads.js +226 -0
  35. package/neural-interface/lib/assistant-user-writer.js +181 -0
  36. package/neural-interface/lib/browser-background-tab.js +134 -0
  37. package/neural-interface/lib/browser-screenshot.js +678 -61
  38. package/neural-interface/lib/claude-agent-bridge.js +33 -6
  39. package/neural-interface/lib/claude-model-catalog.js +30 -2
  40. package/neural-interface/lib/claude-temporary.js +46 -7
  41. package/neural-interface/lib/cloudflared/install.js +533 -0
  42. package/neural-interface/lib/cloudflared/manager.js +545 -0
  43. package/neural-interface/lib/cloudflared/pins.js +115 -0
  44. package/neural-interface/lib/cloudflared/pins.json +63 -0
  45. package/neural-interface/lib/cloudflared/tunnel.js +265 -0
  46. package/neural-interface/lib/editor-completion-endpoint.js +264 -0
  47. package/neural-interface/lib/editor-completion-opencode.js +436 -0
  48. package/neural-interface/lib/editor-completion.js +714 -0
  49. package/neural-interface/lib/editor-config.js +149 -0
  50. package/neural-interface/lib/editor-files-api.js +268 -0
  51. package/neural-interface/lib/editor-git-api.js +134 -0
  52. package/neural-interface/lib/file-explorer-api.js +1039 -0
  53. package/neural-interface/lib/file-explorer-system.js +355 -0
  54. package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
  55. package/neural-interface/lib/file-explorer-watch.js +239 -0
  56. package/neural-interface/lib/git-diff-api.js +103 -0
  57. package/neural-interface/lib/git-read.js +202 -0
  58. package/neural-interface/lib/greeting/api.js +89 -0
  59. package/neural-interface/lib/greeting/budgets.js +90 -0
  60. package/neural-interface/lib/greeting/engine.js +461 -0
  61. package/neural-interface/lib/greeting/facts.js +320 -0
  62. package/neural-interface/lib/greeting/sources.js +252 -0
  63. package/neural-interface/lib/greeting/writer.js +139 -0
  64. package/neural-interface/lib/http-guards.js +189 -13
  65. package/neural-interface/lib/machine-names.js +88 -0
  66. package/neural-interface/lib/memory-api.js +31 -7
  67. package/neural-interface/lib/native-loop-providers.js +10 -5
  68. package/neural-interface/lib/plugin-installer.js +4 -2
  69. package/neural-interface/lib/project-storage.js +5 -7
  70. package/neural-interface/lib/request-gate.js +589 -0
  71. package/neural-interface/lib/ui-blob-api.js +108 -0
  72. package/neural-interface/lib/ui-blob-store.js +68 -0
  73. package/neural-interface/package-lock.json +701 -2
  74. package/neural-interface/package.json +13 -2
  75. package/neural-interface/public/install-app.html +9 -6
  76. package/neural-interface/public/offline.html +3 -2
  77. package/neural-interface/public/onboarding.html +12 -1
  78. package/neural-interface/public/shared/agent-runtime-options.js +22 -6
  79. package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
  80. package/neural-interface/public/shared/assistant/asst-brain-picker.js +312 -62
  81. package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
  82. package/neural-interface/public/shared/assistant/asst-mascot.js +18 -58
  83. package/neural-interface/public/shared/assistant/asst-menu.js +12 -8
  84. package/neural-interface/public/shared/assistant/asst-panel.js +150 -28
  85. package/neural-interface/public/shared/assistant/asst-render.js +162 -19
  86. package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
  87. package/neural-interface/public/shared/assistant/asst-route.js +23 -329
  88. package/neural-interface/public/shared/assistant/asst-routes-editor.js +495 -0
  89. package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
  90. package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
  91. package/neural-interface/public/shared/assistant/asst-state.js +185 -1
  92. package/neural-interface/public/shared/assistant/asst-styles.js +158 -4
  93. package/neural-interface/public/shared/blob-store.js +111 -22
  94. package/neural-interface/public/shared/cdx/cdx-panel.js +32 -11
  95. package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
  96. package/neural-interface/public/shared/cdx/cdx-styles.js +36 -0
  97. package/neural-interface/public/shared/cdx/cdx-tabs.js +87 -4
  98. package/neural-interface/public/shared/constants.js +6 -1
  99. package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
  100. package/neural-interface/public/shared/cp/cp-styles.js +15 -0
  101. package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
  102. package/neural-interface/public/shared/editor/editor-ai.js +619 -0
  103. package/neural-interface/public/shared/editor/editor-commands.js +212 -0
  104. package/neural-interface/public/shared/editor/editor-compare.js +103 -0
  105. package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
  106. package/neural-interface/public/shared/editor/editor-doc.js +257 -0
  107. package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
  108. package/neural-interface/public/shared/editor/editor-git.js +95 -0
  109. package/neural-interface/public/shared/editor/editor-languages.js +156 -0
  110. package/neural-interface/public/shared/editor/editor-lint.js +229 -0
  111. package/neural-interface/public/shared/editor/editor-loader.js +51 -0
  112. package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
  113. package/neural-interface/public/shared/editor/editor-menu.js +233 -0
  114. package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
  115. package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
  116. package/neural-interface/public/shared/editor/editor-palette.js +125 -0
  117. package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
  118. package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
  119. package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
  120. package/neural-interface/public/shared/editor/editor-safety.js +500 -0
  121. package/neural-interface/public/shared/editor/editor-search.js +352 -0
  122. package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
  123. package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
  124. package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
  125. package/neural-interface/public/shared/editor/editor-theme.js +244 -0
  126. package/neural-interface/public/shared/editor/editor-view.js +332 -0
  127. package/neural-interface/public/shared/explorer/fx-api.js +216 -0
  128. package/neural-interface/public/shared/explorer/fx-links.js +204 -0
  129. package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
  130. package/neural-interface/public/shared/explorer/fx-main.js +865 -0
  131. package/neural-interface/public/shared/explorer/fx-names.js +87 -0
  132. package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
  133. package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
  134. package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
  135. package/neural-interface/public/shared/file-links.js +339 -0
  136. package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
  137. package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
  138. package/neural-interface/public/shared/html-shell.js +160 -165
  139. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
  140. package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
  141. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +99 -17
  142. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
  143. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +5 -0
  144. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +40 -0
  145. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
  146. package/neural-interface/public/shared/pwa-install-page.js +29 -1
  147. package/neural-interface/public/shared/pwa-install.css +3 -3
  148. package/neural-interface/public/shared/pwa-install.js +100 -1
  149. package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
  150. package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
  151. package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
  152. package/neural-interface/public/shared/settings/settings-ia.js +18 -4
  153. package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
  154. package/neural-interface/public/shared/start-bridge.js +238 -19
  155. package/neural-interface/public/shared/styles.css +1414 -721
  156. package/neural-interface/public/shared/synabun-mascot.js +20 -56
  157. package/neural-interface/public/shared/tunnel-view.js +167 -0
  158. package/neural-interface/public/shared/ui-claude-panel.js +157 -16
  159. package/neural-interface/public/shared/ui-detail.js +15 -0
  160. package/neural-interface/public/shared/ui-explorer.js +22 -0
  161. package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
  162. package/neural-interface/public/shared/ui-file-links.js +103 -0
  163. package/neural-interface/public/shared/ui-help.js +26 -0
  164. package/neural-interface/public/shared/ui-invite.js +126 -116
  165. package/neural-interface/public/shared/ui-keybinds.js +4 -0
  166. package/neural-interface/public/shared/ui-loading.js +159 -15
  167. package/neural-interface/public/shared/ui-menubar.js +12 -30
  168. package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
  169. package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
  170. package/neural-interface/public/shared/ui-navbar.js +45 -21
  171. package/neural-interface/public/shared/ui-settings.js +455 -488
  172. package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
  173. package/neural-interface/public/shared/ui-sync.js +16 -0
  174. package/neural-interface/public/shared/ui-terminal.js +23 -5
  175. package/neural-interface/public/shared/ui-tunnel.js +130 -0
  176. package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
  177. package/neural-interface/public/shared/ui-whiteboard.js +12 -0
  178. package/neural-interface/public/shared/ui-worker-end.js +245 -0
  179. package/neural-interface/public/shared/ui-workspaces.js +41 -27
  180. package/neural-interface/public/shared/worker-end.js +251 -0
  181. package/neural-interface/public/sw.js +2 -2
  182. package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
  183. package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
  184. package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
  185. package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
  186. package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
  187. package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
  188. package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
  189. package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
  190. package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
  191. package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
  192. package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
  193. package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
  194. package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
  195. package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
  196. package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
  197. package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
  198. package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
  199. package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
  200. package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
  201. package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
  202. package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
  203. package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
  204. package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
  205. package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
  206. package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
  207. package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
  208. package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
  209. package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
  210. package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
  211. package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
  212. package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
  213. package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
  214. package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
  215. package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
  216. package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
  217. package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
  218. package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
  219. package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
  220. package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
  221. package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
  222. package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
  223. package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
  224. package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
  225. package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
  226. package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
  227. package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
  228. package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
  229. package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
  230. package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
  231. package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
  232. package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
  233. package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
  234. package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
  235. package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
  236. package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
  237. package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
  238. package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
  239. package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
  240. package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
  241. package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
  242. package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
  243. package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
  244. package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
  245. package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
  246. package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
  247. package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
  248. package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
  249. package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
  250. package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
  251. package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
  252. package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
  253. package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
  254. package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
  255. package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
  256. package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
  257. package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
  258. package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
  259. package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
  260. package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
  261. package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
  262. package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
  263. package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
  264. package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
  265. package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
  266. package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
  267. package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
  268. package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
  269. package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
  270. package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
  271. package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
  272. package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
  273. package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
  274. package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
  275. package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
  276. package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
  277. package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
  278. package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
  279. package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
  280. package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
  281. package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
  282. package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
  283. package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
  284. package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
  285. package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
  286. package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
  287. package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
  288. package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
  289. package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
  290. package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
  291. package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
  292. package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
  293. package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
  294. package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
  295. package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
  296. package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
  297. package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
  298. package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
  299. package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
  300. package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
  301. package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
  302. package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
  303. package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
  304. package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
  305. package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
  306. package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
  307. package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
  308. package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
  309. package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
  310. package/neural-interface/scripts/build-vendor.mjs +78 -2
  311. package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
  312. package/neural-interface/server.js +873 -1028
  313. package/neural-interface/templates/rulesets/README.md +1 -1
  314. package/package.json +2 -8
  315. package/setup.js +44 -7
  316. package/benchmarks/browser-live-results.json +0 -79
  317. package/benchmarks/browser-v2-results.json +0 -258
  318. package/neural-interface/lib/codex-capabilities.d.ts +0 -41
  319. package/neural-interface/public/category-logos/claude.svg +0 -1
  320. package/neural-interface/public/category-logos/gemini-color.svg +0 -1
  321. package/neural-interface/public/category-logos/openai.svg +0 -1
  322. package/neural-interface/public/category-logos/synabun.png +0 -0
  323. package/neural-interface/public/claude.svg +0 -1
  324. package/neural-interface/public/gallery.svg +0 -6
  325. package/neural-interface/public/leonardoai.svg +0 -1
  326. package/neural-interface/public/mcp.svg +0 -4
  327. package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
  328. package/neural-interface/public/pin-svgrepo-com.svg +0 -25
  329. package/neural-interface/public/sessionicon.svg +0 -8
  330. package/neural-interface/public/toolbar-album.svg +0 -8
  331. package/neural-interface/public/toolbar-question.svg +0 -6
  332. package/neural-interface/public/toolbar-sessions.svg +0 -6
  333. package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
  334. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,495 @@
1
+ // Model routes: draft-only editor. All model data comes through asst-route-models.
2
+ // The global default and saved preferences are written together only on Save.
3
+ import { clampEffortId, getEffortLevelsForModel, modelEffortIds } from '../agent-runtime-options.js';
4
+ import { getProviderMeta } from '../provider-icons.js';
5
+ import { getRouting, putRouting } from './asst-api.js';
6
+ import { catalogModelRow, hiddenModelFor, ICON_CARET, openModelMenu } from './asst-brain-picker.js';
7
+ import { closeMenu, currentMenu } from './asst-menu.js';
8
+ import { loadRouteModels, routeModelLabel, routeModelsStatus, watchRouteModels } from './asst-route-models.js';
9
+ import { designRouteSight, openModelsManager, routeCatalogRow, routeModeLabel } from './asst-route.js';
10
+ import { DEFAULT_ROUTE_MODE, normalizeRouteMode, ROUTE_MODES } from './asst-state.js';
11
+
12
+ const ICON_ROUTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h15l-4-4M20 17H5l4 4"/></svg>';
13
+ const ICON_X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18"/></svg>';
14
+ const ICON_RESET = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 10a8 8 0 1 1 1 8M4 4v6h6"/></svg>';
15
+ const ICON_CHOICE = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 8 3 3 7-7"/></svg>';
16
+ const ICON_AUTO = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" aria-hidden="true"><circle cx="8" cy="8" r="3"/></svg>';
17
+ const ICON_ISSUE = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 4v4m0 3v.1"/></svg>';
18
+ let editor = null;
19
+ let uid = 0;
20
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
21
+ const rowsOf = catalog => Object.values(catalog || {}).flatMap(v => Array.isArray(v) ? v : []);
22
+ // The task classes the translation files name; a test keeps this list equal to them. A class the
23
+ // server knows and the strings do not keeps the server's own label, instead of a key that i18n
24
+ // would report as missing.
25
+ export const TRANSLATED_ROUTE_CLASSES = Object.freeze(['automation', 'browser', 'chat', 'code', 'complex', 'computer', 'design', 'image_gen', 'quick', 'research', 'review', 'video_gen']);
26
+ function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
27
+ function translator(t) {
28
+ return (key, fallback, params) => {
29
+ const text = t?.(key, params);
30
+ return typeof text === 'string' && text !== key ? text : String(fallback).replace(/\{(\w+)\}/g, (_, k) => params?.[k] ?? `{${k}}`);
31
+ };
32
+ }
33
+ function choiceKey(p) { return p ? JSON.stringify([p.kind || 'direct', p.provider || '', p.model || '', p.effort || '']) : ''; }
34
+
35
+ /** Only actual choices change the draft. Catalog label/by updates are not user edits. */
36
+ export function routeEditorPatch(mode, initialMode, prefs, edits) {
37
+ const patch = {};
38
+ if (mode !== initialMode) patch.defaultMode = mode;
39
+ const preferences = Object.fromEntries(Object.entries(edits).filter(([key, p]) => choiceKey(p) !== choiceKey(prefs[key])));
40
+ if (Object.keys(preferences).length) patch.preferences = preferences;
41
+ return patch;
42
+ }
43
+
44
+ /** Same rule as the router's preferenceTarget: direct on another provider is a worker. */
45
+ export function routeExecutionKind(pref, row, brain) {
46
+ if (!pref) return row.dispatchOnly ? 'dispatch' : null;
47
+ return row.dispatchOnly || pref.kind !== 'direct' || pref.provider !== brain?.provider ? 'dispatch' : 'direct';
48
+ }
49
+
50
+ /** Availability is an explanation, never a rewrite of the stored preference. */
51
+ export function routePreferenceIssue(pref, task, catalog, { hidden = hiddenModelFor, partial = false } = {}) {
52
+ if (!pref) return null;
53
+ if (hidden(pref.provider, pref.model)) return 'disabled';
54
+ if (!catalog || partial) return 'unverified';
55
+ const row = routeCatalogRow(pref, catalog);
56
+ // Design's alias resolution and confirmed sight rule are identical to the existing editor.
57
+ const sight = task.visionOnly ? designRouteSight(pref, catalog) : null;
58
+ if (task.visionOnly && sight !== 'ok') return sight === 'unavailable' ? 'unavailable' : sight === 'blind' ? 'blind' : 'unknownVision';
59
+ if (!row) return 'missing';
60
+ if (row.status === 'unavailable') return 'unavailable';
61
+ if (task.requires && row.outputs?.[task.requires] !== true) return 'incapable';
62
+ if ((task.id === 'computer' || task.id === 'vision') && row.vision === false) return 'blind';
63
+ if (pref.effort && !getEffortLevelsForModel(pref.provider, row).some(e => e.id === pref.effort)) return 'effort';
64
+ return null;
65
+ }
66
+
67
+ export async function openRoutesEditor(hooks = {}) {
68
+ if (editor) { editor.focus(); return editor; }
69
+ const tr = translator(hooks.t);
70
+ const id = `asst-routes-${++uid}`;
71
+ const returnTo = document.activeElement;
72
+ const overlay = el('div', 'asst-modal-overlay asst-routes-overlay');
73
+ overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-labelledby', `${id}-title`);
74
+ const modal = el('div', 'asst-modal asst-routes-modal');
75
+ const head = el('div', 'asst-modal-head');
76
+ const icon = el('span', 'asst-icon'); icon.innerHTML = ICON_ROUTE; icon.setAttribute('aria-hidden', 'true');
77
+ const title = el('span', '', tr('assistant.routes.editorTitle', 'Model routes')); title.id = `${id}-title`;
78
+ const closeBtn = button('asst-iconbtn asst-modal-close', tr('common.close', 'Close'), () => close('close'));
79
+ closeBtn.innerHTML = ICON_X;
80
+ head.append(icon, title, closeBtn);
81
+ const body = el('div', 'asst-modal-body');
82
+ const cue = button('asst-routes-scroll-cue', '', () => body.scrollBy({ top: body.clientHeight * 0.7, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' }));
83
+ cue.hidden = true;
84
+ const confirmation = el('div', 'asst-routes-confirm'); confirmation.hidden = true;
85
+ const foot = el('div', 'asst-modal-foot');
86
+ const resetAllBtn = button('asst-btn asst-btn-secondary asst-routes-reset-all', tr('assistant.routes.resetAll', 'Reset all'), () => confirm('reset', 'reset-all'));
87
+ const draft = el('span', 'asst-routes-draft'); draft.setAttribute('role', 'status');
88
+ const cancelBtn = button('asst-btn asst-btn-secondary asst-routes-cancel', tr('common.cancel', 'Cancel'), () => close('cancel'));
89
+ const saveBtn = button('asst-btn asst-btn-primary asst-routes-save', tr('common.save', 'Save'), save);
90
+ closeBtn.dataset.focusKey = 'close'; resetAllBtn.dataset.focusKey = 'reset-all';
91
+ cancelBtn.dataset.focusKey = 'cancel'; saveBtn.dataset.focusKey = 'save';
92
+ foot.append(resetAllBtn, draft, cancelBtn, saveBtn);
93
+ modal.append(head, body, cue, confirmation, foot); overlay.append(modal);
94
+
95
+ const state = { data: null, catalog: null, version: null, mode: DEFAULT_ROUTE_MODE, initialMode: DEFAULT_ROUTE_MODE, prefs: {}, patch: {}, loading: true, modelsLoading: true, modelError: false, error: '', conflict: false, saving: false, confirm: null, suspended: false, offline: navigator.onLine === false, menuFocus: '', notice: '' };
96
+ const effectivePref = key => own(state.patch, key) ? state.patch[key] : state.prefs[key] || null;
97
+ const changes = () => routeEditorPatch(state.mode, state.initialMode, state.prefs, state.patch);
98
+ const dirty = () => Object.keys(changes()).length > 0;
99
+ const countChoices = () => new Set([...Object.keys(state.prefs), ...Object.keys(state.patch)].filter(k => effectivePref(k))).size;
100
+ const brain = () => hooks.getBrain?.() || {};
101
+ const threshold = () => Math.round((state.data?.routing?.askBelow ?? hooks.getRouting?.()?.askBelow ?? 0.75) * 100);
102
+ const modelsWatch = watchRouteModels(catalog => {
103
+ if (!overlay.isConnected || !state.data) return;
104
+ const focusKey = document.activeElement?.dataset?.focusKey || state.menuFocus;
105
+ closeMenu(); state.catalog = catalog; state.modelError = routeModelsStatus().partial; state.modelsLoading = false;
106
+ state.notice = tr('assistant.routes.editorState.modelsUpdated', 'Model list updated. Your unsaved changes are kept.');
107
+ render(focusKey);
108
+ });
109
+
110
+ function button(cls, label, action) {
111
+ const b = el('button', cls, label); b.type = 'button'; b.setAttribute('aria-label', label); if (action) b.addEventListener('click', action); return b;
112
+ }
113
+ function fieldButton(cls, task, field, label, action) {
114
+ const b = button(`asst-dd ${cls}`, '', action);
115
+ b.dataset.focusKey = `${task.id}-${field}`;
116
+ b.setAttribute('aria-label', tr('assistant.routes.fieldLabel', '{task}: {field}, {value}', { task: task.label, field, value: label }));
117
+ b.append(el('span', 'asst-dd-label', label));
118
+ return b;
119
+ }
120
+ function caret(b) { const c = el('span', 'asst-dd-caret'); c.innerHTML = ICON_CARET; c.setAttribute('aria-hidden', 'true'); b.append(c); b.setAttribute('aria-haspopup', 'menu'); b.setAttribute('aria-expanded', 'false'); }
121
+ function focus(key) {
122
+ const target = key && [...modal.querySelectorAll('[data-focus-key]')].find(n => n.dataset.focusKey === key && !n.disabled);
123
+ (target || closeBtn).focus({ preventScroll: true });
124
+ }
125
+ function finishClose() {
126
+ if (!overlay.isConnected) return;
127
+ closeMenu(); modelsWatch.stop(); resizeObserver?.disconnect();
128
+ window.removeEventListener('keydown', onKey, true); document.removeEventListener('focusin', onFocus, true);
129
+ window.removeEventListener('online', network); window.removeEventListener('offline', network);
130
+ overlay.remove(); editor = null;
131
+ try { hooks.onClose?.(); } catch { /* host callback */ }
132
+ if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });
133
+ }
134
+ function close(initiator) {
135
+ if (state.saving || state.suspended) return;
136
+ if (dirty()) { confirm('discard', typeof initiator === 'string' ? initiator : undefined); return; }
137
+ finishClose();
138
+ }
139
+ function confirmationOff() {
140
+ state.confirm = null; confirmation.hidden = true; body.inert = false; foot.hidden = false; closeBtn.disabled = state.saving; updateFoot(); focus(state.confirmFocus);
141
+ }
142
+ // `initiator` is the focus key of the button that asked: WebKit on macOS does not focus
143
+ // a button on click, so the active element cannot say which control to return to.
144
+ function confirm(kind, initiator) {
145
+ if (state.saving || !state.data || (kind === 'reset' && !countChoices())) return;
146
+ closeMenu(); state.confirmFocus = initiator || document.activeElement?.dataset?.focusKey; state.confirm = kind;
147
+ body.inert = true; cue.hidden = true; foot.hidden = true; closeBtn.disabled = true; confirmation.hidden = false; confirmation.innerHTML = '';
148
+ confirmation.setAttribute('role', 'group'); confirmation.setAttribute('aria-labelledby', `${id}-confirm`);
149
+ const copy = el('div', 'asst-routes-confirm-copy');
150
+ const label = el('strong', '', kind === 'discard' ? tr('assistant.routes.editorState.discardTitle', 'Discard unsaved changes?') : tr('assistant.routes.editorState.resetTitle', 'Reset {count} saved choices?', { count: countChoices() })); label.id = `${id}-confirm`;
151
+ copy.append(label, el('span', '', kind === 'discard' ? tr('assistant.routes.editorState.discardDesc', 'Your saved settings will stay as they are.') : tr('assistant.routes.editorState.resetDesc', 'The assistant will choose for these tasks. Nothing changes until you save.')));
152
+ const keep = button('asst-btn asst-btn-secondary asst-routes-keep', tr('assistant.routes.editorState.keepEditing', 'Keep editing'), confirmationOff);
153
+ const proceed = button('asst-btn asst-btn-secondary asst-routes-confirm-action', kind === 'discard' ? tr('assistant.routes.editorState.discard', 'Discard changes') : tr('assistant.routes.editorState.resetConfirm', 'Reset choices'), () => {
154
+ if (kind === 'discard') { finishClose(); return; }
155
+ for (const key of new Set([...Object.keys(state.prefs), ...Object.keys(state.patch)])) state.patch[key] = null;
156
+ confirmationOff(); render(); resetAllBtn.focus();
157
+ });
158
+ confirmation.append(copy, keep, proceed); keep.focus();
159
+ }
160
+ function tabbables() {
161
+ return [...modal.querySelectorAll('button, input, [tabindex="0"]')].filter(n => !n.disabled && !n.closest('[hidden], [inert]') && n.getClientRects().length && (n.type !== 'radio' || n.checked));
162
+ }
163
+ function onKey(e) {
164
+ if (state.suspended) return;
165
+ const menu = currentMenu();
166
+ if (e.key === 'Escape') {
167
+ e.preventDefault(); e.stopImmediatePropagation();
168
+ if (menu) closeMenu({ restoreFocus: true });
169
+ else if (state.confirm) confirmationOff();
170
+ else close();
171
+ return;
172
+ }
173
+ if (e.key === 'Tab') {
174
+ const nodes = tabbables(); if (!nodes.length) { e.preventDefault(); return; }
175
+ if (menu) {
176
+ closeMenu({ restoreFocus: true });
177
+ const i = nodes.indexOf(document.activeElement);
178
+ e.preventDefault(); e.stopImmediatePropagation(); nodes[(i + (e.shiftKey ? -1 : 1) + nodes.length) % nodes.length].focus();
179
+ } else if ((!e.shiftKey && document.activeElement === nodes.at(-1)) || (e.shiftKey && document.activeElement === nodes[0]) || !modal.contains(document.activeElement)) {
180
+ e.preventDefault(); e.stopImmediatePropagation(); (e.shiftKey ? nodes.at(-1) : nodes[0]).focus();
181
+ }
182
+ }
183
+ }
184
+ function onFocus(e) {
185
+ if (!state.suspended && !overlay.contains(e.target) && !currentMenu()?.contains(e.target)) (tabbables()[0] || closeBtn).focus();
186
+ }
187
+ function network() { state.offline = navigator.onLine === false; render(); }
188
+ overlay.addEventListener('keydown', e => e.stopPropagation());
189
+ overlay.addEventListener('mousedown', e => { if (e.target === overlay) close(); });
190
+ body.addEventListener('scroll', updateCue, { passive: true });
191
+ // Own Escape before the workspace's document capture shortcuts (including
192
+ // the whiteboard). The listener is removed with this editor and suspended
193
+ // while the Models manager is open.
194
+ window.addEventListener('keydown', onKey, true); document.addEventListener('focusin', onFocus, true);
195
+ window.addEventListener('online', network); window.addEventListener('offline', network);
196
+ document.body.append(overlay);
197
+ // On the next frame: showing or hiding the cue resizes the body it is measured from, and a
198
+ // write inside the observer's own callback makes WebKit report a ResizeObserver loop error.
199
+ const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(() => requestAnimationFrame(updateCue)) : null; resizeObserver?.observe(body);
200
+ editor = { el: overlay, close, focus: () => focus() };
201
+
202
+ function updateCue() {
203
+ if (!overlay.isConnected || state.confirm || state.suspended) return;
204
+ const more = state.mode === 'never' && body.scrollHeight - body.clientHeight - body.scrollTop > 4;
205
+ cue.hidden = !more;
206
+ cue.textContent = tr('assistant.routes.editorState.moreBelow', 'More task routes below ↓'); cue.setAttribute('aria-label', cue.textContent);
207
+ }
208
+ function issueText(issue, task) {
209
+ switch (issue) {
210
+ case 'disabled': return tr('assistant.routes.issue.disabled', 'Hidden in Models. Show it there or choose another model.');
211
+ case 'missing': return tr('assistant.routes.issue.missing', 'Not in the current list. Reload models, choose a listed model or reset this choice.');
212
+ case 'unavailable': return tr('assistant.routes.issue.unavailable', 'Model unavailable. Choose an available model or reset this choice.');
213
+ case 'blind': return tr('assistant.routes.issue.blind', 'This task needs vision. Choose a model that can see images.');
214
+ case 'unknownVision': return tr('assistant.routes.issue.unknownVision', 'Vision is unverified. Choose a model known to see images or reset this choice.');
215
+ case 'incapable': return tr('assistant.routes.issue.incapable', 'This model cannot generate {medium}. Choose one that can.', { medium: tr(`assistant.routes.medium.${task.requires === 'video' ? 'videos' : 'images'}`, task.requires === 'video' ? 'videos' : 'images') });
216
+ case 'effort': return tr('assistant.routes.issue.effort', 'Saved effort is unsupported. Choose a supported level or use the default.');
217
+ default: return tr('assistant.routes.issue.unverified', 'Could not verify this model. Reload models before relying on this choice.');
218
+ }
219
+ }
220
+ function tasks() {
221
+ const list = (state.data?.taskClasses || []).filter(c => c?.id).map(c => ({ ...c, visionOnly: c.vision === true, label: TRANSLATED_ROUTE_CLASSES.includes(c.id) ? tr(`assistant.routes.classes.${c.id}.label`, c.label || c.id) : (c.label || c.id), description: TRANSLATED_ROUTE_CLASSES.includes(c.id) ? tr(`assistant.routes.classes.${c.id}.desc`, c.description || '') : (c.description || '') }));
222
+ // Actionable tasks first; Chat is retained for compatibility but never routed.
223
+ const chat = list.findIndex(task => task.id === 'chat'); if (chat >= 0) list.push(...list.splice(chat, 1));
224
+ if (!list.some(t => t.id === 'vision')) list.push({ id: 'vision', label: tr('assistant.routes.visionRow', 'Screenshots & images'), description: tr('assistant.routes.visionDesc', 'Fallback when a task needs a model that can see.'), defaultKind: 'direct', visionOnly: false });
225
+ return list;
226
+ }
227
+ function setChoice(key, pref, focusKey) { state.patch[key] = pref; state.notice = ''; render(focusKey); }
228
+ function effortName(value) { return tr(`assistant.routes.efforts.${value}`, value); }
229
+ function defaultEffort(row) { return row?.defaultEffort ? tr('assistant.routes.effortAutoDefault', 'Default · {effort}', { effort: effortName(row.defaultEffort) }) : tr('assistant.routes.effortDefaultUnknown', 'Default · unknown'); }
230
+ function updateFoot() {
231
+ const changed = dirty(); const count = Object.keys(changes().preferences || {}).length + (own(changes(), 'defaultMode') ? 1 : 0);
232
+ draft.textContent = state.saving ? tr('assistant.routes.editorState.saving', 'Saving…') : changed ? count === 1 ? tr('assistant.routes.editorState.unsavedOne', '1 unsaved change') : tr('assistant.routes.editorState.unsaved', '{count} unsaved changes', { count }) : tr('assistant.routes.editorState.noChanges', 'No unsaved changes');
233
+ draft.dataset.dirty = changed ? 'true' : 'false';
234
+ const invalidEdit = tasks().some(t => {
235
+ const p = changes().preferences?.[t.id]; if (!p) return false;
236
+ const issue = routePreferenceIssue(p, t, state.catalog, { partial: state.modelError });
237
+ return (t.visionOnly || t.requires) && issue && issue !== 'effort';
238
+ });
239
+ saveBtn.disabled = !state.data || !changed || state.loading || state.saving || state.conflict || state.offline || invalidEdit;
240
+ saveBtn.title = invalidEdit ? tr('assistant.routes.editorState.fixChoices', 'Fix the highlighted choices or reset them before saving.') : '';
241
+ resetAllBtn.disabled = !state.data || !countChoices() || state.saving;
242
+ resetAllBtn.hidden = state.mode !== 'never';
243
+ cancelBtn.disabled = state.saving; closeBtn.disabled = state.saving || !!state.confirm;
244
+ body.inert = state.saving || !!state.confirm;
245
+ modal.dataset.dirty = changed ? 'true' : 'false';
246
+ }
247
+ function banner(text, actionLabel, action, tone = 'status') {
248
+ const b = el('div', `asst-routes-error${tone === 'status' ? ' asst-routes-notice' : ''}`); b.setAttribute('role', tone === 'error' ? 'alert' : 'status'); b.append(el('span', '', text));
249
+ if (action) b.append(button('asst-btn asst-btn-secondary', actionLabel, action)); body.append(b);
250
+ }
251
+ function render(focusKey) {
252
+ const scroll = body.scrollTop;
253
+ const active = focusKey || document.activeElement?.dataset?.focusKey;
254
+ body.innerHTML = ''; body.setAttribute('aria-busy', state.loading ? 'true' : 'false');
255
+ if (state.error) banner(state.error, tr('assistant.routes.reload', 'Reload'), () => load(true), 'error');
256
+ if (state.offline) banner(tr('assistant.routes.editorState.offline', 'Offline. Your edits are kept. Reconnect to save or reload models.'));
257
+ if (state.notice) banner(state.notice);
258
+ if (state.loading && !state.data) body.append(el('div', 'asst-modal-note', tr('assistant.routes.editorState.loadingRoutes', 'Loading routing settings and models…')));
259
+ if (!state.data) { updateFoot(); return; }
260
+ const section = el('section', 'asst-routes-section');
261
+ const heading = el('h2', 'asst-routes-section-title', tr('assistant.routes.whenToAsk', 'When to ask')); heading.id = `${id}-modes`;
262
+ section.append(heading);
263
+ const modes = el('div', 'asst-routes-modes'); modes.setAttribute('role', 'radiogroup'); modes.setAttribute('aria-labelledby', heading.id);
264
+ const modeCopy = {
265
+ 'always-ask': 'Asks before every task. Routes saved in Never ask are offered first on the route card.',
266
+ 'ask-unsure': 'Asks below {pct}% confidence or if a model cannot run the task. Routes saved in Never ask are offered first on the route card.',
267
+ never: 'Runs valid saved routes immediately; the Assistant chooses for other tasks. Plain Chat stays on the current brain.',
268
+ };
269
+ for (const mode of ROUTE_MODES) {
270
+ const label = el('label', 'asst-routes-mode'); label.dataset.mode = mode;
271
+ const input = document.createElement('input'); input.type = 'radio'; input.name = `${id}-mode`; input.value = mode; input.checked = mode === state.mode; input.dataset.focusKey = `mode-${mode}`;
272
+ input.setAttribute('aria-labelledby', `${id}-${mode}-label`); input.setAttribute('aria-describedby', `${id}-mode-desc`);
273
+ const name = el('span', 'asst-routes-mode-label', routeModeLabel(mode, hooks.t)); name.id = `${id}-${mode}-label`;
274
+ label.append(input, name);
275
+ input.addEventListener('change', () => { if (input.checked) { closeMenu(); state.mode = mode; state.notice = ''; body.scrollTop = 0; render(`mode-${mode}`); } }); modes.append(label);
276
+ }
277
+ const desc = el('p', 'asst-routes-mode-desc', tr(`assistant.routes.modeSummary.${state.mode}`, modeCopy[state.mode], { pct: threshold() })); desc.id = `${id}-mode-desc`;
278
+ section.append(modes, desc);
279
+ const current = hooks.getRouting?.();
280
+ if (current?.mode != null) {
281
+ const note = el('p', 'asst-routes-session-note', tr('assistant.routes.editorSessionOverride', 'This conversation overrides the default: {mode}.', { mode: routeModeLabel(current.effectiveMode || current.mode, hooks.t) }));
282
+ note.title = note.textContent; section.append(note);
283
+ }
284
+ body.append(section);
285
+ // The draft is independent of the rendered table: mode switches never clear
286
+ // route edits, and updateFoot counts them while the table is absent.
287
+ if (state.mode !== 'never') {
288
+ updateFoot();
289
+ if (active && !state.confirm && !state.suspended) focus(active);
290
+ requestAnimationFrame(updateCue);
291
+ return;
292
+ }
293
+
294
+ const table = el('section', 'asst-routes-section asst-routes-tasks');
295
+ const top = el('div', 'asst-routes-section-head');
296
+ const taskTitle = el('div', 'asst-routes-task-title');
297
+ const taskHeading = el('h2', 'asst-routes-section-title', tr('assistant.routes.taskRoutes', 'Task routes'));
298
+ const brainLabel = hooks.brainName?.() || routeModelLabel(brain(), state.catalog) || tr('assistant.routes.theBrain', 'The assistant');
299
+ taskHeading.title = tr('assistant.routes.brainLabel', 'Routing brain: {model}', { model: brainLabel }); taskTitle.append(taskHeading);
300
+ top.append(taskTitle);
301
+ const actions = el('div', 'asst-routes-list-actions');
302
+ const reload = button('asst-btn asst-btn-secondary asst-routes-reload-models', state.modelsLoading ? tr('assistant.routes.editorState.loadingModels', 'Loading models…') : tr('assistant.routes.reloadModels', 'Reload models'), reloadModels); reload.disabled = state.modelsLoading || state.saving || state.offline; reload.dataset.focusKey = 'reload-models';
303
+ const manage = button('asst-btn asst-btn-secondary asst-routes-manage-models', tr('assistant.models.manage', 'Manage models…'), () => manageModels('manage-models')); manage.dataset.focusKey = 'manage-models';
304
+ actions.append(manage, reload); top.append(actions); table.append(top);
305
+ if (state.modelsLoading) table.append(el('div', 'asst-modal-note', tr('assistant.routes.editorState.loadingModels', 'Loading models…')));
306
+ else if (state.modelError) {
307
+ const note = el('div', 'asst-routes-catalog-note'); note.setAttribute('role', 'status'); note.append(el('span', '', tr('assistant.routes.editorState.modelsFailed', 'Could not refresh the model list. Showing provider lists where available. Reload models to verify your choices.'))); table.append(note);
308
+ } else if (!rowsOf(state.catalog).length) table.append(el('div', 'asst-routes-catalog-note', tr('assistant.routes.editorState.modelsEmpty', 'No models are available. Check Models or connect a provider, then reload.')));
309
+ const columns = el('div', 'asst-routes-columns'); columns.setAttribute('aria-hidden', 'true');
310
+ for (const key of ['task', 'model', 'effort', 'run', 'reset']) columns.append(el('span', '', tr(`assistant.routes.columns.${key}`, key)));
311
+ table.append(columns);
312
+ const list = el('div', 'asst-routes-list');
313
+ const taskList = tasks();
314
+ for (const task of taskList) list.append(taskRow(task));
315
+ if (!taskList.length) list.append(el('div', 'asst-modal-note', tr('assistant.routes.editorState.tasksEmpty', 'No task classes are available on this server.')));
316
+ table.append(list); body.append(table);
317
+ updateFoot(); body.scrollTop = scroll;
318
+ if (active && !state.confirm && !state.suspended) focus(active);
319
+ requestAnimationFrame(updateCue);
320
+ }
321
+ function taskRow(task) {
322
+ const pref = effectivePref(task.id);
323
+ const model = pref ? routeCatalogRow(pref, state.catalog) : null;
324
+ const issue = routePreferenceIssue(pref, task, state.catalog, { partial: state.modelError });
325
+ const node = el('div', 'asst-routes-row'); node.dataset.taskClass = task.id;
326
+ const nameId = `${id}-task-${task.id}`;
327
+ node.setAttribute('role', 'group'); node.setAttribute('aria-labelledby', nameId);
328
+ const main = el('div', 'asst-routes-row-main');
329
+ const name = el('div', 'asst-routes-row-label', task.label); name.id = nameId; main.append(name);
330
+ name.title = task.label;
331
+ const description = el('div', 'asst-routes-row-desc', task.description); description.title = task.description;
332
+ main.append(description);
333
+ if (task.dispatchOnly || task.id === 'computer' || task.id === 'vision') {
334
+ const requirements = [];
335
+ if (task.dispatchOnly) requirements.push(tr('assistant.routes.workerOnly', 'Worker only'));
336
+ if (task.visionOnly || task.id === 'computer' || task.id === 'vision') requirements.push(tr('assistant.routes.needsVision', 'Needs vision'));
337
+ if (task.requires) requirements.push(tr(`assistant.routes.needs.${task.requires}`, task.requires));
338
+ main.title = [task.description, requirements.join(' · ')].filter(Boolean).join(' — ');
339
+ }
340
+ let capabilityNote = null;
341
+ const capable = rowsOf(state.catalog).some(m => task.requires ? m.outputs?.[task.requires] === true && m.status !== 'unavailable' : m.vision === true && m.status !== 'unavailable');
342
+ if ((task.requires || task.visionOnly) && !state.modelsLoading && !state.modelError && !capable) {
343
+ const empty = task.requires ? tr('assistant.routes.noCapableModel', 'No model in your catalog can make {medium} yet.', { medium: tr(`assistant.routes.medium.${task.requires === 'video' ? 'videos' : 'images'}`, task.requires) }) : tr('assistant.routes.noSeeingModel', 'No model in your catalog can see images yet.');
344
+ description.hidden = true;
345
+ capabilityNote = el('div', 'asst-routes-row-note', empty); capabilityNote.title = empty;
346
+ }
347
+ node.append(main);
348
+ const modelCell = el('div', 'asst-routes-cell asst-routes-model-cell');
349
+ modelCell.append(el('span', 'asst-routes-cell-label', tr('assistant.routes.columns.model', 'Model')));
350
+ const label = pref ? routeModelLabel(pref, state.catalog) : tr('assistant.routes.brainDecides', 'Assistant chooses');
351
+ const modelBtn = fieldButton('asst-routes-model', task, tr('assistant.routes.columns.model', 'Model'), label, async () => {
352
+ if (currentMenu()) { closeMenu({ restoreFocus: true }); return; }
353
+ state.menuFocus = modelBtn.dataset.focusKey;
354
+ const emptyLabel = task.requires ? tr('assistant.routes.noCapableModel', 'No model in your catalog can make {medium} yet.', { medium: tr(`assistant.routes.medium.${task.requires === 'video' ? 'videos' : 'images'}`, task.requires) }) : tr('assistant.routes.noSeeingModel', 'No model in your catalog can see images yet.');
355
+ const menu = await openModelMenu(modelBtn, {
356
+ brain: brain(), t: hooks.t, includeHere: true, hereLabel: tr('assistant.routes.brainDecides', 'Assistant chooses'), title: task.label,
357
+ current: pref ? { provider: pref.provider, model: pref.model } : null, onToast: hooks.onToast,
358
+ routingEditor: true, onManageModels: () => manageModels(modelBtn.dataset.focusKey),
359
+ ...(task.requires ? { accept: (_p, m) => m.outputs?.[task.requires] === true, allowCustom: false, emptyLabel } : {}),
360
+ ...(task.visionOnly ? { accept: (_p, m) => m.vision === true && m.status !== 'unavailable', allowCustom: false, emptyLabel } : {}),
361
+ onSelect: target => {
362
+ if (target?.here) setChoice(task.id, null, modelBtn.dataset.focusKey);
363
+ else if (target?.model) {
364
+ const levels = modelEffortIds(catalogModelRow(target.provider, target.model, state.catalog));
365
+ const effort = !pref?.effort ? null : levels ? clampEffortId(pref.effort, levels) : pref.provider === target.provider ? pref.effort : null;
366
+ setChoice(task.id, { kind: task.dispatchOnly ? 'dispatch' : pref?.kind || task.defaultKind || 'direct', provider: target.provider, model: target.model, effort, label: target.label || target.model }, modelBtn.dataset.focusKey);
367
+ }
368
+ },
369
+ });
370
+ if (menu && overlay.isConnected && !state.suspended) adoptMenu(menu, overlay);
371
+ });
372
+ // Keys are stable across translations; accessible names use the translated field.
373
+ modelBtn.dataset.focusKey = `${task.id}-model`; caret(modelBtn); modelBtn.setAttribute('aria-haspopup', 'dialog');
374
+ modelBtn.title = label;
375
+ if (pref?.provider) { const mark = el('span', 'asst-icon'); mark.setAttribute('aria-hidden', 'true'); mark.innerHTML = getProviderMeta(pref.provider).icon; mark.style.color = getProviderMeta(pref.provider).color; modelBtn.prepend(mark); }
376
+ modelCell.append(modelBtn);
377
+ const source = el('span', 'asst-routes-source');
378
+ let sourceText = pref ? tr('assistant.routes.source.yours', 'Your choice') : tr('assistant.routes.source.default', 'Assistant chooses');
379
+ source.dataset.source = issue === 'disabled' ? 'disabled' : issue ? 'incapable' : pref ? 'yours' : 'default';
380
+ if (issue) { sourceText = tr(`assistant.routes.issueLabel.${issue}`, tr('assistant.routes.issueLabel.unverified', 'Unverified')); source.setAttribute('data-tooltip', issueText(issue, task)); }
381
+ const edited = own(changes().preferences || {}, task.id); source.dataset.edited = String(edited);
382
+ if (edited) sourceText += tr('assistant.routes.editorState.edited', ' · edited');
383
+ source.id = `${nameId}-source`; source.setAttribute('role', 'img'); source.setAttribute('aria-label', sourceText); source.title = sourceText;
384
+ source.innerHTML = issue ? ICON_ISSUE : pref ? ICON_CHOICE : ICON_AUTO;
385
+ modelBtn.insertBefore(source, modelBtn.querySelector('.asst-dd-caret')); modelBtn.setAttribute('aria-describedby', source.id);
386
+ node.append(modelCell);
387
+
388
+ const efforts = pref ? getEffortLevelsForModel(pref.provider, model) : [];
389
+ const hasEffort = efforts.some(e => e.id !== 'off');
390
+ const effortText = !pref ? tr('assistant.routes.brainDecides', 'Assistant chooses') : pref.effort ? effortName(pref.effort) : hasEffort ? defaultEffort(model) : tr('assistant.routes.effortNotSupported', 'Not supported');
391
+ const effortBtn = fieldButton('asst-routes-effort', task, tr('assistant.routes.columns.effort', 'Effort'), effortText, () => {
392
+ state.menuFocus = effortBtn.dataset.focusKey;
393
+ const menu = openMenuFor(effortBtn, tr('assistant.brain.effort', 'Thinking effort'), [
394
+ { kind: 'radio', id: '', label: defaultEffort(model), desc: tr('assistant.routes.effortNote', 'Default effort uses the model default when the brain does not propose a level.'), selected: !pref.effort, onSelect: () => setChoice(task.id, { ...pref, effort: null }, effortBtn.dataset.focusKey) },
395
+ ...efforts.filter(e => e.id !== 'off').map(e => ({ kind: 'radio', id: e.id, label: effortName(e.id), selected: e.id === pref.effort, onSelect: () => setChoice(task.id, { ...pref, effort: e.id }, effortBtn.dataset.focusKey) })),
396
+ ]); return menu;
397
+ });
398
+ effortBtn.dataset.focusKey = `${task.id}-effort`; effortBtn.disabled = !pref || (!hasEffort && !pref.effort); if (!effortBtn.disabled) caret(effortBtn);
399
+ effortBtn.title = effortText;
400
+ // The model control already names who chooses an automatic route. Keep the
401
+ // other cells quiet, and retain the complete default effort in the name/menu.
402
+ const unusedValue = tr('assistant.routes.editorUnusedValue', '—');
403
+ const effortValue = effortBtn.disabled ? unusedValue : pref.effort ? effortText : tr('assistant.routes.editorEffortAuto', 'Auto');
404
+ effortBtn.querySelector('.asst-dd-label').textContent = effortValue;
405
+ if (effortValue !== unusedValue && effortValue !== effortText) effortBtn.setAttribute('aria-label', tr('assistant.routes.fieldLabel', '{task}: {field}, {value}', { task: task.label, field: tr('assistant.routes.columns.effort', 'Effort'), value: `${effortValue} · ${effortText}` }));
406
+ const effortCell = el('div', 'asst-routes-cell asst-routes-effort-cell'); effortCell.append(el('span', 'asst-routes-cell-label', tr('assistant.routes.columns.effort', 'Effort')), effortBtn); node.append(effortCell);
407
+ const kind = routeExecutionKind(pref, task, brain());
408
+ const runLabel = task.id === 'chat' ? tr('assistant.routes.inConversation', 'In conversation') : !pref ? task.dispatchOnly ? tr('assistant.routes.workerChooses', 'Worker · assistant chooses') : tr('assistant.routes.brainDecides', 'Assistant chooses') : kind === 'direct' ? tr('assistant.routes.inConversation', 'In conversation') : tr('assistant.routes.onWorker', '{provider} worker', { provider: getProviderMeta(pref.provider).label });
409
+ const run = fieldButton('asst-routes-run', task, tr('assistant.routes.columns.run', 'Runs in'), runLabel, () => openMenuFor(run, tr('assistant.routes.columns.run', 'Runs in'), [
410
+ { kind: 'radio', id: 'direct', label: tr('assistant.routes.inConversation', 'In conversation'), desc: tr('assistant.routes.conversationDesc', 'Continue this conversation on the chosen model for this task.'), selected: kind === 'direct', onSelect: () => setChoice(task.id, { ...pref, kind: 'direct' }, run.dataset.focusKey) },
411
+ { kind: 'radio', id: 'dispatch', label: tr('assistant.routes.onWorker', '{provider} worker', { provider: getProviderMeta(pref?.provider).label }), desc: tr('assistant.routes.workerDesc', 'Hand the task to a worker and show its result here.'), selected: kind === 'dispatch', onSelect: () => setChoice(task.id, { ...pref, kind: 'dispatch' }, run.dataset.focusKey) },
412
+ ]));
413
+ run.dataset.focusKey = `${task.id}-run`; run.disabled = !pref || task.dispatchOnly || pref.provider !== brain().provider || task.id === 'chat';
414
+ const inConversation = task.id === 'chat' || (pref && kind === 'direct');
415
+ const runValue = inConversation ? tr('assistant.routes.editorInConversation', 'This conversation') : pref ? getProviderMeta(pref.provider).label : unusedValue;
416
+ run.querySelector('.asst-dd-label').textContent = runValue;
417
+ if (inConversation) run.setAttribute('aria-label', tr('assistant.routes.fieldLabel', '{task}: {field}, {value}', { task: task.label, field: tr('assistant.routes.columns.run', 'Runs in'), value: runValue }));
418
+ if (!run.disabled) caret(run);
419
+ run.title = runLabel;
420
+ if (task.dispatchOnly) run.title = tr('assistant.routes.workerOnly', 'Worker only');
421
+ else if (pref && pref.provider !== brain().provider && task.id !== 'chat') run.title = tr('assistant.routes.otherProviderWorker', 'A model from another provider runs on a worker.');
422
+ const runCell = el('div', 'asst-routes-cell asst-routes-run-cell'); runCell.append(el('span', 'asst-routes-cell-label', tr('assistant.routes.columns.run', 'Runs in')), run); node.append(runCell);
423
+ const resetCell = el('div', 'asst-routes-reset-cell');
424
+ if (pref) { const reset = button('asst-iconbtn asst-routes-reset', tr('assistant.routes.reset', 'Reset {taskClass}', { taskClass: task.label }), () => setChoice(task.id, null, `${task.id}-model`)); reset.dataset.focusKey = `${task.id}-reset`; reset.title = reset.getAttribute('aria-label'); reset.innerHTML = ICON_RESET; resetCell.append(reset); }
425
+ node.append(resetCell);
426
+ if (capabilityNote) node.append(capabilityNote);
427
+ if (issue) { description.hidden = true; node.querySelector('.asst-routes-row-note')?.remove(); const note = el('div', 'asst-routes-row-warning', issueText(issue, task)); note.title = note.textContent; note.id = `${nameId}-issue`; modelBtn.setAttribute('aria-describedby', `${source.id} ${note.id}`); modelBtn.title = `${label} — ${note.textContent}`; node.append(note); }
428
+ if (task.id === 'chat' || task.id === 'vision') {
429
+ description.hidden = true;
430
+ const text = task.id === 'chat' ? tr('assistant.routes.chatInactive', 'Plain chat stays on the current brain. A stored Chat choice is not applied in any mode.') : tr('assistant.routes.visionFallback', 'Vision fallback, not a task class. Used when the chosen model cannot see image input.');
431
+ const context = el('div', 'asst-routes-row-context', text); context.title = text; context.id = `${nameId}-context`; node.append(context);
432
+ context.hidden = !!issue;
433
+ modelBtn.setAttribute('aria-describedby', `${modelBtn.getAttribute('aria-describedby')} ${context.id}`);
434
+ if (task.id === 'chat') { node.dataset.inactive = 'true'; const inactive = el('span', 'asst-routes-inactive', tr('assistant.routes.notApplied', 'Not applied')); inactive.title = text; name.append(inactive); }
435
+ }
436
+ return node;
437
+ }
438
+ async function manageModels(focusKey = 'reload-models') {
439
+ closeMenu(); state.suspended = true; overlay.hidden = true;
440
+ try {
441
+ await openModelsManager({ ...hooks, tab: 'archived', onClose: () => { state.suspended = false; overlay.hidden = false; focus(focusKey); reloadModels(); } });
442
+ } catch { state.suspended = false; overlay.hidden = false; focus(focusKey); }
443
+ }
444
+ async function reloadModels() {
445
+ if (state.modelsLoading || state.offline) return;
446
+ state.modelsLoading = true; closeMenu(); render();
447
+ try { state.catalog = await loadRouteModels(); state.modelError = routeModelsStatus().partial; modelsWatch.seed(state.catalog); }
448
+ catch { state.modelError = true; }
449
+ if (!overlay.isConnected) return;
450
+ state.modelsLoading = false; state.notice = state.modelError ? '' : tr('assistant.routes.editorState.modelsUpdated', 'Model list updated. Your unsaved changes are kept.'); render();
451
+ }
452
+ async function load(preserve = false) {
453
+ const pending = preserve ? changes() : {};
454
+ state.loading = true; state.error = ''; state.modelsLoading = true; render();
455
+ try {
456
+ const [data, result] = await Promise.all([getRouting(), loadRouteModels().then(catalog => ({ catalog })).catch(() => ({ catalog: state.catalog, error: true }))]);
457
+ if (!overlay.isConnected) return;
458
+ state.data = data; state.version = data.version ?? null; state.initialMode = normalizeRouteMode(data.routing?.defaultMode, DEFAULT_ROUTE_MODE);
459
+ state.mode = pending.defaultMode || state.initialMode; state.prefs = { ...(data.routing?.preferences || {}) }; state.patch = pending.preferences || {};
460
+ state.catalog = result.catalog; state.modelError = result.error || routeModelsStatus().partial; modelsWatch.seed(state.catalog); state.conflict = false;
461
+ if (preserve) state.notice = tr('assistant.routes.editorState.settingsReloaded', 'Latest settings loaded. Your edits are kept; review them before saving.');
462
+ } catch (err) {
463
+ state.error = err.status === 403 ? tr('assistant.routes.editorState.permission', 'You do not have permission to edit routing settings.') : err.status === 404 ? tr('assistant.routes.unavailable', 'Model routing is not available on this server yet.') : tr('assistant.routes.loadFailed', 'Could not load model routes');
464
+ }
465
+ state.loading = false; state.modelsLoading = false; render(); if (!preserve) focus();
466
+ }
467
+ async function save() {
468
+ if (saveBtn.disabled) return;
469
+ const patch = changes(); state.saving = true; state.error = ''; updateFoot(); closeMenu();
470
+ try {
471
+ const res = await putRouting(patch, state.version);
472
+ try { hooks.onSaved?.(res?.routing || null, res); } catch { /* host callback */ }
473
+ finishClose();
474
+ } catch (err) {
475
+ state.saving = false;
476
+ if (err.status === 409 || err.code === 'VERSION_CONFLICT') { state.conflict = true; state.error = tr('assistant.routes.conflict', 'Settings changed elsewhere. Reload to keep your edits and review the latest choices.'); }
477
+ else if (err.code === 'ROUTING_INVALID') state.error = tr('assistant.routes.invalidField', 'Invalid routing setting: {field}', { field: err.message || '' });
478
+ else state.error = err.status === 403 ? tr('assistant.routes.editorState.permission', 'You do not have permission to edit routing settings.') : tr('assistant.routes.saveFailed', 'Could not save. Your edits are kept; try again.');
479
+ render();
480
+ }
481
+ }
482
+ render(); closeBtn.focus(); await load(); return editor || { el: overlay, close, focus };
483
+ }
484
+
485
+ import { openMenu } from './asst-menu.js';
486
+ function openMenuFor(anchor, title, items) {
487
+ if (currentMenu()) { closeMenu({ restoreFocus: true }); return null; }
488
+ const menu = openMenu(anchor, { title, items, className: 'asst-route-field-menu', role: 'menu' });
489
+ const overlay = anchor.closest('.asst-routes-overlay'); if (overlay) adoptMenu(menu, overlay); return menu;
490
+ }
491
+ function adoptMenu(menu, overlay) {
492
+ const active = menu.el.contains(document.activeElement) ? document.activeElement : null;
493
+ for (const item of menu.el.querySelectorAll('.asst-dd-item')) if (!item.title) item.title = item.textContent.trim();
494
+ overlay.append(menu.el); active?.focus({ preventScroll: true });
495
+ }
@@ -8,7 +8,10 @@
8
8
  // <template> (nothing runs, nothing loads) and rebuilt node by node into
9
9
  // fresh elements, so nothing the parser saw survives unless it is listed
10
10
  // here: no on* or style attributes, no ids, no namespaced markup. No
11
- // dependency, nothing touches `document` at import time.
11
+ // dependency beyond file-links.js (pure), nothing touches `document` at
12
+ // import time.
13
+
14
+ import { localFileHref } from '../file-links.js';
12
15
 
13
16
  const HTML_NS = 'http://www.w3.org/1999/xhtml';
14
17
 
@@ -49,6 +52,10 @@ function copyAttributes(from, to, tag) {
49
52
  if (tag === 'a') {
50
53
  const href = safeHref(from.getAttribute('href'));
51
54
  if (href) to.setAttribute('href', href);
55
+ // A path or a file: URL does not survive as an href (it is resolved against the page, or dropped).
56
+ // Kept as written, as data: ui-file-links.js reads it on a click and opens the file in the editor.
57
+ const file = localFileHref(from.getAttribute('href'));
58
+ if (file) to.setAttribute('data-file-target', file);
52
59
  const title = from.getAttribute('title');
53
60
  if (title) to.setAttribute('title', title);
54
61
  to.setAttribute('rel', 'noopener noreferrer');