@bitseek/hermes-webui 0.1.0-beta.0

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 (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. package/vendor/agent-frontend-shell/static/workspace.js +680 -0
@@ -0,0 +1,1990 @@
1
+ (function(){
2
+ // Clear stale stop-server flag on successful page load (server is reachable)
3
+ try{localStorage.removeItem('hermes-webui-server-stopped');}catch(_){}
4
+ // Listen for shutdown broadcast from other tabs
5
+ try {
6
+ var _stopChan = new BroadcastChannel('hermes-webui-shutdown');
7
+ _stopChan.onmessage = function() { _showServerStopped(); };
8
+ } catch(_) {}
9
+ })();
10
+
11
+ async function cancelStream(){
12
+ const streamId = S.activeStreamId;
13
+ if(!streamId) return;
14
+ try{
15
+ await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
16
+ }catch(e){/* cancel request failed - cleanup below still runs */}
17
+ // Clear status unconditionally after the cancel request completes.
18
+ // The SSE cancel event may also fire, but if the connection is already
19
+ // closed it won't arrive — so we handle cleanup here as the guaranteed path.
20
+ S.activeStreamId=null;
21
+ setBusy(false);
22
+ if(typeof setComposerStatus==='function') setComposerStatus('');
23
+ else setStatus('');
24
+ }
25
+
26
+ async function cancelSessionStream(session){
27
+ const streamId = session&&session.active_stream_id;
28
+ const sid = session&&session.session_id;
29
+ if(!streamId||!sid) return;
30
+ try{
31
+ await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
32
+ }catch(e){/* close local stream; keep UI state honest below */}
33
+ if(typeof closeLiveStream==='function') closeLiveStream(sid, streamId);
34
+ session.active_stream_id=null;
35
+ delete INFLIGHT[sid];
36
+ clearInflightState(sid);
37
+ if(S.session&&S.session.session_id===sid){
38
+ S.activeStreamId=null;
39
+ if(S.session) S.session.active_stream_id=null;
40
+ clearInflight();
41
+ setBusy(false);
42
+ if(typeof setComposerStatus==='function') setComposerStatus('');
43
+ else setStatus('');
44
+ }
45
+ if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){
46
+ stopApprovalPolling();
47
+ hideApprovalCard(true);
48
+ }
49
+ if(typeof _clarifySessionId!=='undefined' && _clarifySessionId===sid){
50
+ stopClarifyPolling();
51
+ hideClarifyCard(true, 'cancelled');
52
+ }
53
+ if(typeof renderSessionList==='function') renderSessionList();
54
+ }
55
+
56
+ async function _savedSessionShouldStaySidebarOnly(sid){
57
+ if(!sid) return false;
58
+ try{
59
+ const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
60
+ const session = data&&data.session;
61
+ return !!(session&&(session.active_stream_id||session.pending_user_message));
62
+ }catch(e){
63
+ return false;
64
+ }
65
+ }
66
+
67
+ // ── Mobile navigation ──────────────────────────────────────────────────────
68
+ let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'
69
+
70
+ function _isCompactWorkspaceViewport(){
71
+ return window.matchMedia('(max-width: 900px)').matches;
72
+ }
73
+
74
+ function _syncWorkspacePanelInlineWidth(){
75
+ const {panel}= _workspacePanelEls();
76
+ if(!panel) return;
77
+
78
+ const isCompact = _isCompactWorkspaceViewport();
79
+ if(isCompact){
80
+ if(panel.style.width) panel.style.removeProperty('width');
81
+ return;
82
+ }
83
+
84
+ const saved = localStorage.getItem('hermes-panel-w');
85
+ if(!saved) return;
86
+ const parsed = parseInt(saved, 10);
87
+ if(Number.isNaN(parsed) || parsed <= 0) return;
88
+ panel.style.width = `${parsed}px`;
89
+ }
90
+
91
+ function _workspacePanelEls(){
92
+ return {
93
+ layout: document.querySelector('.layout'),
94
+ panel: document.querySelector('.rightpanel'),
95
+ toggleBtn: $('btnWorkspacePanelToggle'),
96
+ edgeToggleBtn: $('btnWorkspacePanelEdgeToggle'),
97
+ collapseBtn: $('btnCollapseWorkspacePanel'),
98
+ };
99
+ }
100
+
101
+ function _hasWorkspacePreviewVisible(){
102
+ const preview=$('previewArea');
103
+ return !!(preview&&preview.classList.contains('visible'));
104
+ }
105
+
106
+ function _setWorkspacePanelMode(mode){
107
+ const {layout,panel}= _workspacePanelEls();
108
+ if(!layout||!panel)return;
109
+ _workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
110
+ const open=_workspacePanelMode!=='closed';
111
+ document.documentElement.dataset.workspacePanel=open?'open':'closed';
112
+ // Persist open/closed across refreshes (browse/preview → open; closed → closed)
113
+ // Do NOT overwrite the user's "keep open" preference — only track runtime state
114
+ // so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting.
115
+ try{localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');}catch(_){}
116
+ layout.classList.toggle('workspace-panel-collapsed',!open);
117
+ if(_isCompactWorkspaceViewport()){
118
+ panel.classList.toggle('mobile-open',open);
119
+ }else{
120
+ panel.classList.remove('mobile-open');
121
+ }
122
+ syncWorkspacePanelUI();
123
+ }
124
+
125
+ function syncWorkspacePanelState(){
126
+ const hasPreview=_hasWorkspacePreviewVisible();
127
+ if(hasPreview){
128
+ if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
129
+ else syncWorkspacePanelUI();
130
+ return;
131
+ }
132
+ if(!S.session){
133
+ // No active session — if the panel was explicitly opened (browse mode), keep it
134
+ // open so the workspace pane doesn't vanish on a fresh-page or empty-session boot.
135
+ // The file tree will show the "no workspace" placeholder naturally via renderFileTree().
136
+ // Only force-close if the mode is 'preview' (file preview without a session is invalid).
137
+ if(_workspacePanelMode==='preview') _setWorkspacePanelMode('closed');
138
+ else syncWorkspacePanelUI();
139
+ return;
140
+ }
141
+ _setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode);
142
+ }
143
+
144
+ function openWorkspacePanel(mode='browse'){
145
+ if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible()&&!S._profileDefaultWorkspace)return;
146
+ if(mode==='preview'&&_workspacePanelMode==='browse'){
147
+ syncWorkspacePanelUI();
148
+ return;
149
+ }
150
+ _setWorkspacePanelMode(mode);
151
+ }
152
+
153
+ function closeWorkspacePanel(){
154
+ _setWorkspacePanelMode('closed');
155
+ }
156
+
157
+ function ensureWorkspacePreviewVisible(){
158
+ if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
159
+ else syncWorkspacePanelUI();
160
+ }
161
+
162
+ function handleWorkspaceClose(){
163
+ if(_hasWorkspacePreviewVisible()){
164
+ clearPreview();
165
+ return;
166
+ }
167
+ closeWorkspacePanel();
168
+ }
169
+
170
+ /**
171
+ * Set a tooltip on a button, preferring the custom CSS tooltip (`data-tooltip`)
172
+ * when the element opts in via the `has-tooltip` class. Falls back to the
173
+ * native `title` attribute for elements that haven't opted in.
174
+ *
175
+ * Critical: when the element DOES have data-tooltip, this MUST also clear any
176
+ * existing native `title` attribute, otherwise the slow ~1.5s native browser
177
+ * tooltip co-fires alongside the fast custom CSS tooltip — exactly the bug
178
+ * #1775 reports. Always pair `data-tooltip` with `removeAttribute('title')`.
179
+ */
180
+ function _setButtonTooltip(btn, text){
181
+ if(!btn) return;
182
+ if(btn.hasAttribute('data-tooltip')){
183
+ btn.setAttribute('data-tooltip', text);
184
+ if(btn.hasAttribute('title')) btn.removeAttribute('title');
185
+ } else {
186
+ btn.title = text;
187
+ }
188
+ }
189
+
190
+ function syncWorkspacePanelUI(){
191
+ const {layout,panel,toggleBtn,edgeToggleBtn,collapseBtn}= _workspacePanelEls();
192
+ if(!layout||!panel)return;
193
+ const desktopOpen=_workspacePanelMode!=='closed';
194
+ const mobileOpen=panel.classList.contains('mobile-open');
195
+ const isCompact=_isCompactWorkspaceViewport();
196
+ const isOpen=isCompact?mobileOpen:desktopOpen;
197
+ const canBrowse=!!S.session||_hasWorkspacePreviewVisible()||!!(S._profileDefaultWorkspace);
198
+ const hasPreview=_hasWorkspacePreviewVisible();
199
+ if(toggleBtn){
200
+ toggleBtn.classList.toggle('active',isOpen);
201
+ toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
202
+ _setButtonTooltip(toggleBtn, isOpen?'Hide workspace panel':'Show workspace panel');
203
+ toggleBtn.disabled=!canBrowse;
204
+ }
205
+ if(edgeToggleBtn){
206
+ edgeToggleBtn.classList.toggle('active',isOpen);
207
+ edgeToggleBtn.setAttribute('aria-expanded',isOpen?'true':'false');
208
+ _setButtonTooltip(edgeToggleBtn, isOpen?'Hide workspace panel':'Show workspace panel');
209
+ edgeToggleBtn.disabled=!canBrowse;
210
+ }
211
+ if(collapseBtn){
212
+ _setButtonTooltip(collapseBtn, isCompact?'Close workspace panel':'Hide workspace panel');
213
+ }
214
+ const hasSession=!!S.session;
215
+ ['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{
216
+ const el=$(id);
217
+ if(el)el.disabled=!hasSession;
218
+ });
219
+ const clearBtn=$('btnClearPreview');
220
+ if(clearBtn){
221
+ clearBtn.disabled=!isOpen;
222
+ _setButtonTooltip(clearBtn, hasPreview?'Close preview':'Close');
223
+ if(!isCompact) clearBtn.style.display='';
224
+ }
225
+ }
226
+
227
+ function toggleMobileSidebar(){
228
+ const sidebar=document.querySelector('.sidebar');
229
+ const overlay=$('mobileOverlay');
230
+ if(!sidebar)return;
231
+ const isOpen=sidebar.classList.contains('mobile-open');
232
+ if(isOpen){closeMobileSidebar();}
233
+ else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');}
234
+ }
235
+ function closeMobileSidebar(){
236
+ const sidebar=document.querySelector('.sidebar');
237
+ const overlay=$('mobileOverlay');
238
+ if(sidebar)sidebar.classList.remove('mobile-open');
239
+ if(overlay)overlay.classList.remove('visible');
240
+ }
241
+
242
+ const _PWA_SIDEBAR_SWIPE_EDGE=28;
243
+ const _PWA_SIDEBAR_SWIPE_TRIGGER=72;
244
+ const _PWA_SIDEBAR_SWIPE_MAX_VERTICAL=48;
245
+ let _pwaSidebarSwipe=null;
246
+
247
+ function _isPwaStandalone(){
248
+ try{
249
+ return document.documentElement.classList.contains('pwa-standalone')
250
+ || window.matchMedia('(display-mode: standalone)').matches
251
+ || window.navigator.standalone===true;
252
+ }catch(_){return false;}
253
+ }
254
+
255
+ function _isInteractiveSwipeTarget(target){
256
+ try{return !!(target&&target.closest&&target.closest('input,textarea,select,button,a,[contenteditable="true"],.topbar-chips,.composer-left,.sidebar,.rightpanel'));}
257
+ catch(_){return false;}
258
+ }
259
+
260
+ function _openMobileSidebarFromGesture(){
261
+ if(_isDesktopWidth())return;
262
+ const sidebar=document.querySelector('.sidebar');
263
+ const overlay=$('mobileOverlay');
264
+ if(!sidebar)return;
265
+ const layout=document.querySelector('.layout');
266
+ if(layout)layout.classList.remove('sidebar-collapsed');
267
+ sidebar.classList.remove('sidebar-collapsed');
268
+ try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
269
+ sidebar.classList.add('mobile-open');
270
+ if(overlay)overlay.classList.add('visible');
271
+ }
272
+
273
+ function _onPwaSidebarSwipeStart(e){
274
+ if(!_isPwaStandalone()||_isDesktopWidth())return;
275
+ if(e.pointerType==='mouse'||(e.pointerType&&e.pointerType!=='touch'&&e.pointerType!=='pen'))return;
276
+ if(document.querySelector('.sidebar')?.classList.contains('mobile-open'))return;
277
+ const clientX=Number(e.clientX)||0;
278
+ if(clientX>_PWA_SIDEBAR_SWIPE_EDGE)return;
279
+ if(_isInteractiveSwipeTarget(e.target))return;
280
+ _pwaSidebarSwipe={startX:clientX,startY:Number(e.clientY)||0,active:true,opened:false};
281
+ }
282
+
283
+ function _onPwaSidebarSwipeMove(e){
284
+ const swipe=_pwaSidebarSwipe;
285
+ if(!swipe||!swipe.active||swipe.opened)return;
286
+ const dx=(Number(e.clientX)||0)-swipe.startX;
287
+ const dy=(Number(e.clientY)||0)-swipe.startY;
288
+ if(dx<0||Math.abs(dy)>_PWA_SIDEBAR_SWIPE_MAX_VERTICAL*1.5){_pwaSidebarSwipe=null;return;}
289
+ if(dx>=_PWA_SIDEBAR_SWIPE_TRIGGER&&Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL&&dx>Math.abs(dy)*1.5){
290
+ if(e.cancelable)e.preventDefault();
291
+ swipe.opened=true;
292
+ _openMobileSidebarFromGesture();
293
+ }
294
+ }
295
+
296
+ function _onPwaSidebarSwipeEnd(){_pwaSidebarSwipe=null;}
297
+ function _onPwaSidebarSwipeCancel(){_pwaSidebarSwipe=null;}
298
+
299
+ function _installPwaSidebarSwipeGesture(){
300
+ window.addEventListener('pointerdown', _onPwaSidebarSwipeStart, {passive:true});
301
+ window.addEventListener('pointermove', _onPwaSidebarSwipeMove, {passive:false});
302
+ window.addEventListener('pointerup', _onPwaSidebarSwipeEnd, {passive:true});
303
+ window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel, {passive:true});
304
+ }
305
+ _installPwaSidebarSwipeGesture();
306
+
307
+ // ── Desktop sidebar collapse toggle ────────────────────────────────────────
308
+ // Two discoverability paths into the same state:
309
+ // (1) Click the already-active rail icon → collapse / expand the sidebar.
310
+ // (2) Cmd/Ctrl+B keyboard shortcut (VS Code convention).
311
+ // Mobile is unaffected: the sidebar is an overlay there, and every collapse
312
+ // code path is gated on `_isDesktopWidth()` (min-width:641px).
313
+ // State is persisted via localStorage and survives reloads + bfcache.
314
+ const _SIDEBAR_COLLAPSED_KEY='hermes-webui-sidebar-collapsed';
315
+
316
+ function _isDesktopWidth(){
317
+ try{return window.matchMedia('(min-width:641px)').matches;}catch(_){return true;}
318
+ }
319
+
320
+ function _isSidebarCollapsed(){
321
+ return document.querySelector('.layout')?.classList.contains('sidebar-collapsed')||false;
322
+ }
323
+
324
+ function _syncSidebarAria(){
325
+ // Mirror the open/collapsed state on the active rail button via aria-expanded
326
+ // so screen readers announce the toggle. Open=true, collapsed=false.
327
+ const active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]');
328
+ if(active)active.setAttribute('aria-expanded',!_isSidebarCollapsed());
329
+ }
330
+
331
+ function toggleSidebar(forceState){
332
+ if(!_isDesktopWidth())return; // mobile uses an overlay; never collapse there
333
+ const layout=document.querySelector('.layout');
334
+ if(!layout)return;
335
+ const next=typeof forceState==='boolean'?forceState:!_isSidebarCollapsed();
336
+ layout.classList.toggle('sidebar-collapsed',next);
337
+ // Clear the flash-prevention root-level marker once JS owns the state.
338
+ try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
339
+ try{localStorage.setItem(_SIDEBAR_COLLAPSED_KEY,next?'1':'0');}catch(_){}
340
+ _syncSidebarAria();
341
+ }
342
+
343
+ function expandSidebar(){
344
+ if(_isSidebarCollapsed())toggleSidebar(false);
345
+ }
346
+
347
+ // Boot-time restore. The inline flash-prevention script in index.html already
348
+ // set data-sidebar-collapsed='1' on <html> before the stylesheet so the page
349
+ // renders collapsed without paint flash. This IIFE promotes that pre-paint
350
+ // state into the .layout class system where both JS and CSS can read it.
351
+ (function _restoreSidebarState(){
352
+ try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
353
+ if(!_isDesktopWidth())return;
354
+ try{
355
+ if(localStorage.getItem(_SIDEBAR_COLLAPSED_KEY)==='1'){
356
+ const layout=document.querySelector('.layout');
357
+ if(layout)layout.classList.add('sidebar-collapsed');
358
+ }
359
+ }catch(_){}
360
+ _syncSidebarAria();
361
+ })();
362
+ // ── Boot-time tab visibility ────────────────────────────────────────────────
363
+ // Apply hidden tabs from localStorage. The primary flash-prevention is an
364
+ // inline <script> in index.html (after sidebar-nav) that runs synchronously
365
+ // before first paint. This IIFE is a secondary fallback: it ensures consistency
366
+ // after panels.js is loaded and handles the active-tab switch. No-op if
367
+ // panels.js hasn't loaded yet (typeof guard).
368
+ (function _restoreTabVisibility(){
369
+ try{
370
+ if(typeof _applyTabVisibility==='function'&&typeof _getHiddenTabs==='function'){
371
+ _applyTabVisibility(_getHiddenTabs());
372
+ }
373
+ var active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]')
374
+ ||document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');
375
+ if(active&&active.classList.contains('nav-tab-hidden')){
376
+ var chatBtn=document.querySelector('.rail .rail-btn.nav-tab[data-panel="chat"]');
377
+ if(chatBtn)chatBtn.classList.add('active');
378
+ if(active)active.classList.remove('active');
379
+ }
380
+ }catch(_){}
381
+ })();
382
+ function toggleMobileFiles(){
383
+ toggleWorkspacePanel();
384
+ }
385
+ function closeMobileWorkspacePanelFromChat(e){
386
+ if(!_isCompactWorkspaceViewport()||_workspacePanelMode==='closed') return;
387
+ const panel=document.querySelector('.rightpanel');
388
+ if(panel&&panel.contains(e.target)) return;
389
+ closeWorkspacePanel();
390
+ }
391
+ function toggleWorkspacePanel(force){
392
+ const {panel}= _workspacePanelEls();
393
+ if(!panel)return;
394
+ const currentlyOpen=_workspacePanelMode!=='closed';
395
+ const nextOpen=typeof force==='boolean'?force:!currentlyOpen;
396
+ if(!nextOpen){
397
+ closeWorkspacePanel();
398
+ return;
399
+ }
400
+ const nextMode=_hasWorkspacePreviewVisible()?'preview':'browse';
401
+ openWorkspacePanel(nextMode);
402
+ }
403
+ function mobileSwitchPanel(name){
404
+ switchPanel(name);
405
+ if(name==='chat'){
406
+ closeMobileSidebar();
407
+ } else {
408
+ const sidebar=document.querySelector('.sidebar');
409
+ const overlay=$('mobileOverlay');
410
+ if(sidebar){
411
+ sidebar.classList.add('mobile-open');
412
+ if(overlay)overlay.classList.add('visible');
413
+ }
414
+ }
415
+ }
416
+
417
+ $('btnSend').onclick=()=>{
418
+ if(typeof handleComposerPrimaryAction==='function') return handleComposerPrimaryAction();
419
+ if(window._micActive){
420
+ window._micPendingSend=true;
421
+ _stopMic();
422
+ return;
423
+ }
424
+ // Turn-based voice mode: let the voice mode system handle the send flow
425
+ if(typeof window._voiceModeActive==='function'&&window._voiceModeActive()){
426
+ // Immediately send whatever is in the textarea
427
+ if(typeof window._voiceModeImmediateSend==='function') window._voiceModeImmediateSend();
428
+ return;
429
+ }
430
+ send();
431
+ };
432
+ $('mainChat')?.addEventListener('pointerdown', closeMobileWorkspacePanelFromChat);
433
+ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInput').value='';$('fileInput').click();};
434
+
435
+ // ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
436
+ (function(){
437
+ const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
438
+ const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
439
+ if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
440
+
441
+ // Persist SR failure across reloads (e.g. Tailscale/network error)
442
+ const _micForceMediaRecorderKey='mic_force_mediarecorder';
443
+ let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
444
+
445
+ // Raw audio mode preference: send audio file instead of transcribing
446
+ let _rawAudioMode = localStorage.getItem('hermes-raw-audio-mode') === 'true';
447
+ // Capture backend pinned at recording start ('speech' | 'media' | null) so
448
+ // _stopMic / onstop act on the backend that actually started, even if the
449
+ // raw-audio toggle changes mid-recording (#3169 Codex review).
450
+ let _activeCaptureMode = null;
451
+
452
+ const btn=$('btnMic');
453
+ const status=$('micStatus');
454
+ const ta=$('msg');
455
+ const statusText=status?status.querySelector('.status-text'):null;
456
+ btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
457
+
458
+ let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
459
+ let mediaRecorder=null;
460
+ let mediaStream=null;
461
+ let audioChunks=[];
462
+ let _finalText='';
463
+ let _prefix='';
464
+ let _isRecording=false;
465
+
466
+ function _setButtonTooltipAndKey(btn, key){
467
+ const text = t(key);
468
+ btn.setAttribute('data-i18n-title', key);
469
+ if(btn.hasAttribute('data-tooltip')){
470
+ btn.setAttribute('data-tooltip', text);
471
+ if(btn.hasAttribute('title')) btn.removeAttribute('title');
472
+ } else {
473
+ btn.title = text;
474
+ }
475
+ }
476
+
477
+ function _setRecording(on){
478
+ window._micActive=on;
479
+ btn.classList.toggle('recording',on);
480
+ // Active-state title flips so the tooltip is honest about what
481
+ // pressing the button will do (#1488).
482
+ _setButtonTooltipAndKey(btn, on ? (_rawAudioMode ? 'voice_recording_active' : 'voice_dictate_active') : (_rawAudioMode ? 'voice_send_raw' : 'voice_dictate'));
483
+ status.style.display=on?'':'none';
484
+ if(statusText) statusText.textContent=on?'Listening':'Listening';
485
+ if(!on){ _finalText=''; _prefix=''; }
486
+ }
487
+
488
+ function _updateMicTooltip(){
489
+ if(!window._micActive){
490
+ _setButtonTooltipAndKey(btn, _rawAudioMode ? 'voice_send_raw' : 'voice_dictate');
491
+ }
492
+ }
493
+
494
+ async function _sendRawAudio(blob){
495
+ const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
496
+ const file=new File([blob],`voice-input-${Date.now()}.${ext}`,{type:blob.type||`audio/${ext}`});
497
+ S.pendingFiles.push(file);
498
+ renderTray();
499
+ // An explicit Send-button click while recording sets _micPendingSend — that
500
+ // is an unambiguous send intent, so honor it even when the composer already
501
+ // has text (mirrors the transcribe path). Otherwise (manual mic-stop): send
502
+ // immediately only if the composer is empty, else just attach + toast so the
503
+ // user can keep composing.
504
+ if(window._micPendingSend){
505
+ window._micPendingSend=false;
506
+ send();
507
+ }else if(!ta.value.trim()){
508
+ send();
509
+ }else{
510
+ showToast(t('voice_raw_attached'));
511
+ }
512
+ }
513
+
514
+ function _commitTranscript(text){
515
+ const clean=(text||'').trim();
516
+ const committed=clean
517
+ ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
518
+ ? _prefix+' '+clean.trimStart()
519
+ : _prefix+clean)
520
+ : ta.value;
521
+ ta.value=committed;
522
+ autoResize();
523
+ if(window._micPendingSend){
524
+ window._micPendingSend=false;
525
+ send();
526
+ }
527
+ }
528
+
529
+ async function _transcribeBlob(blob){
530
+ const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
531
+ const form=new FormData();
532
+ form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
533
+ setComposerStatus('Transcribing…');
534
+ try{
535
+ const res=await fetch('api/transcribe',{method:'POST',body:form});
536
+ const data=await res.json().catch(()=>({}));
537
+ if(!res.ok) throw new Error(data.error||'Transcription failed');
538
+ _commitTranscript(data.transcript||'');
539
+ }catch(err){
540
+ window._micPendingSend=false;
541
+ showToast(err.message||t('mic_network'));
542
+ }finally{
543
+ setComposerStatus('');
544
+ }
545
+ }
546
+
547
+ function _stopTracks(){
548
+ if(mediaStream){
549
+ mediaStream.getTracks().forEach(track=>track.stop());
550
+ mediaStream=null;
551
+ }
552
+ }
553
+
554
+ function _stopMic(){
555
+ if(!window._micActive) return;
556
+ // Stop the backend that was ACTIVE WHEN RECORDING STARTED — not whatever
557
+ // _rawAudioMode says now. The user can toggle Settings → Sound mid-recording,
558
+ // which would otherwise make us stop the wrong backend and orphan the other
559
+ // (#3169 Codex review). _activeCaptureMode is pinned at start.
560
+ if(recognition && _activeCaptureMode==='speech'){
561
+ recognition.stop();
562
+ return;
563
+ }
564
+ if(mediaRecorder&&mediaRecorder.state!=='inactive'){
565
+ mediaRecorder.stop();
566
+ return;
567
+ }
568
+ _setRecording(false);
569
+ _stopTracks();
570
+ }
571
+ window._stopMic=_stopMic; // expose for send-guard above
572
+
573
+ if(recognition && !_forceMediaRecorder){
574
+ recognition.continuous=false;
575
+ recognition.interimResults=true;
576
+ recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
577
+
578
+ recognition.onstart=()=>{ _finalText=''; };
579
+
580
+ recognition.onresult=(event)=>{
581
+ let interim='';
582
+ let final=_finalText;
583
+ for(let i=event.resultIndex;i<event.results.length;i++){
584
+ const t=event.results[i][0].transcript;
585
+ if(event.results[i].isFinal){ final+=t; _finalText=final; }
586
+ else{ interim+=t; }
587
+ }
588
+ ta.value=_prefix+(final||interim);
589
+ autoResize();
590
+ };
591
+
592
+ recognition.onend=()=>{
593
+ const committed=_finalText
594
+ ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
595
+ ? _prefix+' '+_finalText.trimStart()
596
+ : _prefix+_finalText)
597
+ : ta.value;
598
+ _setRecording(false);
599
+ ta.value=committed;
600
+ autoResize();
601
+ if(window._micPendingSend){
602
+ window._micPendingSend=false;
603
+ send();
604
+ }
605
+ };
606
+
607
+ recognition.onerror=(event)=>{
608
+ _setRecording(false);
609
+ window._micPendingSend=false;
610
+ _isRecording=false;
611
+ if(event.error==='network'||event.error==='not-allowed'){
612
+ // Persist SR failure: next reload will skip SpeechRecognition
613
+ localStorage.setItem(_micForceMediaRecorderKey,'1');
614
+ _forceMediaRecorder=true;
615
+ recognition=null;
616
+ }
617
+ const msgs={
618
+ 'not-allowed':t('mic_denied'),
619
+ 'no-speech':t('mic_no_speech'),
620
+ 'network':t('mic_network'),
621
+ };
622
+ showToast(msgs[event.error]||t('mic_error')+event.error);
623
+ };
624
+ }
625
+
626
+ btn.onclick=async()=>{
627
+ // Race-condition guard: ignore rapid double-clicks
628
+ if(_isRecording){
629
+ _stopMic();
630
+ _isRecording=false;
631
+ return;
632
+ }
633
+ if(window._micActive){
634
+ _stopMic();
635
+ return;
636
+ }
637
+ _isRecording=true;
638
+ _finalText='';
639
+ _prefix=ta.value;
640
+ if(recognition && !_forceMediaRecorder && !_rawAudioMode){
641
+ _activeCaptureMode='speech';
642
+ recognition.start();
643
+ _setRecording(true);
644
+ return;
645
+ }
646
+ if(!_canRecordAudio){
647
+ _isRecording=false;
648
+ showToast(t('mic_network'));
649
+ return;
650
+ }
651
+ try{
652
+ mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
653
+ const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
654
+ const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
655
+ mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
656
+ audioChunks=[];
657
+ mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
658
+ mediaRecorder.onerror=()=>{
659
+ _isRecording=false;
660
+ _setRecording(false);
661
+ window._micPendingSend=false;
662
+ _stopTracks();
663
+ showToast(t('mic_network'));
664
+ };
665
+ mediaRecorder.onstop=async()=>{
666
+ _isRecording=false;
667
+ const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
668
+ _setRecording(false);
669
+ _stopTracks();
670
+ if(blob.size){
671
+ if(_activeCaptureMode==='media-raw'){
672
+ await _sendRawAudio(blob);
673
+ }else{
674
+ await _transcribeBlob(blob);
675
+ }
676
+ }
677
+ else if(window._micPendingSend){
678
+ window._micPendingSend=false;
679
+ }
680
+ };
681
+ _activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe';
682
+ mediaRecorder.start();
683
+ _setRecording(true);
684
+ }catch(err){
685
+ _isRecording=false;
686
+ window._micPendingSend=false;
687
+ _stopTracks();
688
+ showToast(t('mic_denied'));
689
+ }
690
+ };
691
+
692
+ // Wire up the settings checkbox
693
+ const rawAudioCheckbox = document.getElementById('settingsRawAudio');
694
+ if(rawAudioCheckbox){
695
+ rawAudioCheckbox.checked = _rawAudioMode;
696
+ rawAudioCheckbox.addEventListener('change', function(){
697
+ _rawAudioMode = this.checked;
698
+ localStorage.setItem('hermes-raw-audio-mode', _rawAudioMode ? 'true' : 'false');
699
+ _updateMicTooltip();
700
+ });
701
+ }
702
+ _updateMicTooltip();
703
+ })();
704
+ window._micActive=window._micActive||false;
705
+ window._micPendingSend=window._micPendingSend||false;
706
+
707
+ // ── Turn-based voice mode (#1333) ────────────────────────────────────────
708
+ // Chained flow: listen → send → (agent processes) → TTS response → listen again
709
+ (function(){
710
+ const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
711
+ const hasSTT=!(!SpeechRecognition);
712
+ const hasTTS=!!('speechSynthesis' in window);
713
+
714
+ // Need both STT and TTS for turn-based voice mode
715
+ if(!hasSTT||!hasTTS) return;
716
+
717
+ const modeBtn=$('btnVoiceMode');
718
+ const bar=$('voiceModeBar');
719
+ const indicator=$('voiceModeIndicator');
720
+ const label=$('voiceModeLabel');
721
+ const micBtn=$('btnMic');
722
+ const ta=$('msg');
723
+
724
+ if(!modeBtn||!bar||!indicator||!label) return;
725
+
726
+ // Voice-mode button is gated behind a Preferences toggle (#1488).
727
+ // Default off — keeps the composer footer uncluttered for users who
728
+ // only need plain dictation. The hands-free conversation feature is
729
+ // a power-user surface; explicit opt-in avoids the visual confusion
730
+ // of two near-identical mic icons.
731
+ function _voiceModePrefEnabled(){
732
+ try{ return localStorage.getItem('hermes-voice-mode-button')==='true'; }
733
+ catch(_){ return false; }
734
+ }
735
+ let _voiceModeActive=false;
736
+
737
+ function _applyVoiceModePref(){
738
+ const enabled = _voiceModePrefEnabled();
739
+ modeBtn.style.display = enabled ? '' : 'none';
740
+ if(!enabled && _voiceModeActive) _deactivate();
741
+ }
742
+ _applyVoiceModePref();
743
+ // Expose so the settings pane can re-apply immediately on toggle.
744
+ window._applyVoiceModePref = _applyVoiceModePref;
745
+
746
+ let _voiceModeState='idle'; // idle | listening | thinking | speaking
747
+ let _recognition=null;
748
+ let _silenceTimer=null;
749
+ // Capture the session id at thinking-time so the TTS callback won't read
750
+ // a different session's last assistant reply if the user navigated away
751
+ // between send and stream completion. (Opus pre-release advisor.)
752
+ let _voiceModeThinkingSid=null;
753
+ const SILENCE_MS=1800; // auto-send after 1.8s silence
754
+
755
+ function _setState(state){
756
+ _voiceModeState=state;
757
+ indicator.className='voice-mode-indicator '+state;
758
+ label.textContent=state==='listening'?t('voice_listening')
759
+ :state==='speaking'?t('voice_speaking')
760
+ :state==='thinking'?t('voice_thinking')
761
+ :'';
762
+ bar.style.display=_voiceModeActive?(state==='idle'?'none':''):'none';
763
+ }
764
+
765
+ function _startListening(){
766
+ if(!_voiceModeActive) return;
767
+ _setState('listening');
768
+
769
+ _recognition=new SpeechRecognition();
770
+ _recognition.continuous=false;
771
+ _recognition.interimResults=true;
772
+ _recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
773
+
774
+ let _finalText='';
775
+
776
+ _recognition.onstart=()=>{ _finalText=''; };
777
+
778
+ _recognition.onresult=(event)=>{
779
+ // Reset silence timer on any result
780
+ clearTimeout(_silenceTimer);
781
+ let interim='';
782
+ let final=_finalText;
783
+ for(let i=event.resultIndex;i<event.results.length;i++){
784
+ const txt=event.results[i][0].transcript;
785
+ if(event.results[i].isFinal){ final+=txt; _finalText=final; }
786
+ else{ interim+=txt; }
787
+ }
788
+ ta.value=final||interim;
789
+ autoResize();
790
+
791
+ // Auto-send on silence after final result
792
+ if(_finalText){
793
+ _silenceTimer=setTimeout(()=>{
794
+ _voiceModeSend();
795
+ },SILENCE_MS);
796
+ }
797
+ };
798
+
799
+ _recognition.onend=()=>{
800
+ clearTimeout(_silenceTimer);
801
+ // If we have text and haven't sent yet, send it
802
+ if(_finalText&&_voiceModeActive&&_voiceModeState==='listening'){
803
+ _voiceModeSend();
804
+ } else if(_voiceModeActive&&_voiceModeState==='listening'){
805
+ // No speech detected — restart listening
806
+ setTimeout(()=>{ if(_voiceModeActive) _startListening(); },500);
807
+ }
808
+ };
809
+
810
+ _recognition.onerror=(event)=>{
811
+ clearTimeout(_silenceTimer);
812
+ if(event.error==='no-speech'||event.error==='aborted'){
813
+ // Restart if still active
814
+ if(_voiceModeActive){
815
+ setTimeout(()=>{ if(_voiceModeActive) _startListening(); },800);
816
+ }
817
+ return;
818
+ }
819
+ if(event.error==='not-allowed'||event.error==='service-not-allowed'||event.error==='audio-capture'){
820
+ _deactivate();
821
+ showToast(t('mic_denied'));
822
+ return;
823
+ }
824
+ // Other errors — try to restart
825
+ if(_voiceModeActive){
826
+ setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1500);
827
+ }
828
+ };
829
+
830
+ try{ _recognition.start(); }catch(e){
831
+ // Already started or other error — retry shortly
832
+ setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1000);
833
+ }
834
+ }
835
+
836
+ function _voiceModeSend(){
837
+ if(!_voiceModeActive) return;
838
+ const text=(ta.value||'').trim();
839
+ if(!text){
840
+ ta.value='';
841
+ setTimeout(()=>{ if(_voiceModeActive) _startListening(); },300);
842
+ return;
843
+ }
844
+ _setState('thinking');
845
+ // Pin the active session id so the TTS callback won't speak a different
846
+ // session's reply if the user navigates away mid-stream.
847
+ _voiceModeThinkingSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
848
+ try{ if(_recognition) _recognition.abort(); }catch(_){}
849
+ _recognition=null;
850
+ // send() is global from boot.js
851
+ if(typeof send==='function') send();
852
+ }
853
+
854
+ function _speakResponse(){
855
+ if(!_voiceModeActive) return;
856
+ // Bail out if the user navigated to a different session between send and
857
+ // stream completion. The patched autoReadLastAssistant fires globally;
858
+ // without this guard it would TTS-read the wrong session's last assistant
859
+ // message. Drop back to listening on the new session instead.
860
+ const currentSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
861
+ if(_voiceModeThinkingSid && currentSid && currentSid!==_voiceModeThinkingSid){
862
+ _voiceModeThinkingSid=null;
863
+ _startListening();
864
+ return;
865
+ }
866
+ _voiceModeThinkingSid=null;
867
+ _setState('speaking');
868
+
869
+ // Find last assistant message
870
+ const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
871
+ if(!rows.length){ _startListening(); return; }
872
+ const last=rows[rows.length-1];
873
+ const rawText=last.dataset.rawText||'';
874
+ if(!rawText.trim()){ _startListening(); return; }
875
+
876
+ // Strip for TTS (reuse existing helper if available)
877
+ let clean=rawText;
878
+ if(typeof _stripForTTS==='function') clean=_stripForTTS(rawText);
879
+ else{
880
+ // Basic strip: remove code blocks, images, links
881
+ clean=clean.replace(/```[\s\S]*?```/g,' code block ')
882
+ .replace(/`([^`]*)`/g,'$1')
883
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g,'$1')
884
+ .replace(/\[([^\]]*)\]\([^)]*\)/g,'$1')
885
+ .replace(/#{1,6}\s/g,'')
886
+ .replace(/[*_~]+/g,'')
887
+ .replace(/\n{2,}/g,'. ')
888
+ .replace(/\n/g,' ')
889
+ .trim();
890
+ }
891
+ if(!clean){ _startListening(); return; }
892
+
893
+ const utter=new SpeechSynthesisUtterance(clean);
894
+
895
+ // Apply saved voice preferences
896
+ const savedVoice=localStorage.getItem('hermes-tts-voice');
897
+ const voices=speechSynthesis.getVoices();
898
+ if(savedVoice&&voices.length){
899
+ const match=voices.find(v=>v.name===savedVoice);
900
+ if(match) utter.voice=match;
901
+ }
902
+ const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
903
+ if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
904
+ const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
905
+ if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
906
+
907
+ utter.onend=()=>{
908
+ // After speaking, go back to listening
909
+ if(_voiceModeActive) setTimeout(()=>_startListening(),500);
910
+ };
911
+ utter.onerror=()=>{
912
+ if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
913
+ };
914
+
915
+ speechSynthesis.speak(utter);
916
+ }
917
+
918
+ // Hook into response completion — observe when the agent finishes
919
+ // We patch setComposerStatus to detect when a response completes
920
+ const _origSetComposerStatus=(typeof setComposerStatus==='function')?setComposerStatus.bind(window):null;
921
+
922
+ window._voiceModeOnResponseComplete=function(){
923
+ if(_voiceModeActive&&_voiceModeState==='thinking'){
924
+ // Small delay to let DOM render the final message
925
+ setTimeout(()=>{
926
+ if(_voiceModeActive&&_voiceModeState==='thinking'){
927
+ _speakResponse();
928
+ }
929
+ },400);
930
+ }
931
+ };
932
+
933
+ // Observe S.busy changes to detect response completion
934
+ // The existing code calls setBusy(false) when response completes
935
+ const _origSetBusy=(typeof setBusy==='function')?setBusy.bind(window):null;
936
+ if(_origSetBusy){
937
+ // We use a MutationObserver-style approach via polling S.busy
938
+ // Actually, we'll use a simpler approach: hook into the message stream completion
939
+ }
940
+
941
+ // Most reliable hook: use the existing autoReadLastAssistant call site.
942
+ // We override autoReadLastAssistant so that if voice mode is active, we use our
943
+ // own speak-and-resume flow instead of the default auto-read.
944
+ const _origAutoRead=(typeof autoReadLastAssistant==='function')?autoReadLastAssistant:null;
945
+ window.autoReadLastAssistant=function(){
946
+ if(_voiceModeActive&&_voiceModeState==='thinking'){
947
+ _speakResponse();
948
+ return;
949
+ }
950
+ if(_origAutoRead) _origAutoRead.apply(this,arguments);
951
+ };
952
+
953
+ function _activate(){
954
+ _voiceModeActive=true;
955
+ modeBtn.classList.add('active');
956
+ _setButtonTooltip(modeBtn, t('voice_mode_toggle_active'));
957
+ showToast(t('voice_mode_active'),1500);
958
+ // If the agent is busy, wait — state will be 'thinking' and we'll detect completion
959
+ if(typeof S!=='undefined'&&S.busy){
960
+ _setState('thinking');
961
+ return;
962
+ }
963
+ // Cancel any existing TTS
964
+ if(typeof stopTTS==='function') stopTTS();
965
+ _startListening();
966
+ }
967
+
968
+ function _deactivate(){
969
+ _voiceModeActive=false;
970
+ _voiceModeState='idle';
971
+ _voiceModeThinkingSid=null;
972
+ modeBtn.classList.remove('active');
973
+ _setButtonTooltip(modeBtn, t('voice_mode_toggle'));
974
+ bar.style.display='none';
975
+ clearTimeout(_silenceTimer);
976
+ try{ if(_recognition) _recognition.abort(); }catch(_){}
977
+ _recognition=null;
978
+ if(typeof stopTTS==='function') stopTTS();
979
+ // Restore original autoReadLastAssistant
980
+ if(_origAutoRead) window.autoReadLastAssistant=_origAutoRead;
981
+ // Clear textarea if it was only voice input
982
+ ta.value='';
983
+ autoResize();
984
+ }
985
+
986
+ modeBtn.onclick=()=>{
987
+ if(_voiceModeActive){
988
+ _deactivate();
989
+ showToast(t('voice_mode_off'),1500);
990
+ }else{
991
+ _activate();
992
+ }
993
+ };
994
+
995
+ // Expose for external use
996
+ window._voiceModeActive=()=>_voiceModeActive;
997
+ window._voiceModeDeactivate=_deactivate;
998
+ window._voiceModeImmediateSend=_voiceModeSend;
999
+ })();
1000
+ $('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
1001
+ $('btnNewChat').onclick=async()=>{
1002
+ // If the current session has no messages AND nothing is in flight, just focus
1003
+ // the composer rather than creating another empty session that will clutter the
1004
+ // sidebar list (#1171).
1005
+ //
1006
+ // The "nothing in flight" half is critical (#1432): if the user clicks + while
1007
+ // their first message is still streaming (or queued), `message_count` is still 0
1008
+ // server-side because the user turn hasn't been merged yet. The old guard treated
1009
+ // that as "empty" and made + a no-op for the entire stream duration, so users
1010
+ // couldn't actually start a parallel chat. Use the same in-flight signal as
1011
+ // `_restoreSettledSession()` in messages.js: an active stream id or a queued
1012
+ // pending user message means the session is real, not empty.
1013
+ if(S.session
1014
+ && (S.session.message_count||0)===0
1015
+ && !S.busy
1016
+ && !S.session.active_stream_id
1017
+ && !S.session.pending_user_message){
1018
+ $('msg').focus();closeMobileSidebar();return;
1019
+ }
1020
+ await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
1021
+ };
1022
+ $('btnDownload').onclick=()=>{
1023
+ if(!S.session)return;
1024
+ const blob=new Blob([transcript()],{type:'text/markdown'});
1025
+ const a=document.createElement('a');a.href=URL.createObjectURL(blob);
1026
+ a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href);
1027
+ };
1028
+ $('btnExportJSON').onclick=()=>{
1029
+ if(!S.session)return;
1030
+ const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`;
1031
+ const a=document.createElement('a');a.href=url;
1032
+ a.download=`hermes-${S.session.session_id}.json`;a.click();
1033
+ };
1034
+ $('btnImportJSON').onclick=()=>$('importFileInput').click();
1035
+ $('importFileInput').onchange=async(e)=>{
1036
+ const file=e.target.files[0];
1037
+ if(!file)return;
1038
+ e.target.value='';
1039
+ try{
1040
+ const text=await file.text();
1041
+ const data=JSON.parse(text);
1042
+ const res=await api('/api/session/import',{method:'POST',body:JSON.stringify(data)});
1043
+ if(res.ok&&res.session){
1044
+ await loadSession(res.session.session_id);
1045
+ await renderSessionList();
1046
+ if(_currentPanel==='settings') switchPanel('chat');
1047
+ showToast(t('session_imported'));
1048
+ }
1049
+ }catch(err){
1050
+ showToast(t('import_failed')+(err.message||t('import_invalid_json')));
1051
+ }
1052
+ };
1053
+ // btnRefreshFiles is now panel-icon-btn in header (see HTML)
1054
+ function clearPreview(opts={}){
1055
+ const keepPanelOpen=!!(opts&&opts.keepPanelOpen);
1056
+ // Restore directory breadcrumb after closing file preview
1057
+ if(typeof renderBreadcrumb==='function') renderBreadcrumb();
1058
+ const closePanelAfter=_workspacePanelMode==='preview'&&!keepPanelOpen;
1059
+ const pa=$('previewArea');if(pa)pa.classList.remove('visible');
1060
+ const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
1061
+ const pdf=$('previewPdfFrame');if(pdf)pdf.src='';
1062
+ const html=$('previewHtmlIframe');if(html)html.src='';
1063
+ const pm=$('previewMd');if(pm)pm.innerHTML='';
1064
+ const pc=$('previewCode');if(pc)pc.textContent='';
1065
+ const pp=$('previewPathText');if(pp)pp.textContent='';
1066
+ const ft=$('fileTree');if(ft)ft.style.display='';
1067
+ _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
1068
+ if(closePanelAfter)closeWorkspacePanel();
1069
+ else if(keepPanelOpen&&_workspacePanelMode==='preview')openWorkspacePanel('browse');
1070
+ else syncWorkspacePanelUI();
1071
+ }
1072
+ $('btnClearPreview').onclick=handleWorkspaceClose;
1073
+ // workspacePath click handler removed -- use topbar workspace chip dropdown instead
1074
+ function _applySessionContextMetadataUpdate(data){
1075
+ if(!S.session||!data||!data.session)return;
1076
+ S.session.context_length=data.session.context_length||0;
1077
+ S.session.threshold_tokens=data.session.threshold_tokens||0;
1078
+ S.session.last_prompt_tokens=data.session.last_prompt_tokens||0;
1079
+ if(typeof _syncCtxIndicator==='function'){
1080
+ const u=S.lastUsage||{};
1081
+ const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
1082
+ _syncCtxIndicator({
1083
+ input_tokens:_pick(u.input_tokens,S.session.input_tokens),
1084
+ output_tokens:_pick(u.output_tokens,S.session.output_tokens),
1085
+ estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost),
1086
+ context_length:S.session.context_length||0,
1087
+ last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens),
1088
+ threshold_tokens:S.session.threshold_tokens||0,
1089
+ });
1090
+ }
1091
+ }
1092
+
1093
+ $('modelSelect').onchange=async()=>{
1094
+ const selectedModel=$('modelSelect').value;
1095
+ const modelState=(typeof _modelStateForSelect==='function')
1096
+ ? _modelStateForSelect($('modelSelect'),selectedModel)
1097
+ : {model:selectedModel,model_provider:null};
1098
+ if(typeof closeModelDropdown==='function') closeModelDropdown();
1099
+ if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
1100
+ else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
1101
+ if(!S.session){
1102
+ if(typeof syncModelChip==='function') syncModelChip();
1103
+ if(typeof syncReasoningChip==='function') syncReasoningChip();
1104
+ return;
1105
+ }
1106
+ if(typeof _rememberPendingSessionModel==='function') _rememberPendingSessionModel(S.session.session_id,modelState.model,modelState.model_provider);
1107
+ S.session.model=modelState.model;
1108
+ S.session.model_provider=modelState.model_provider||null;
1109
+ if(typeof syncModelChip==='function') syncModelChip();
1110
+ if(typeof syncReasoningChip==='function') syncReasoningChip();
1111
+ syncTopbar();
1112
+ // Clarify scope: composer model changes are session-local, not the global default.
1113
+ if(typeof showToast==='function'){
1114
+ showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
1115
+ }
1116
+ const data=await api('/api/session/update',{method:'POST',body:JSON.stringify({
1117
+ session_id:S.session.session_id,
1118
+ workspace:S.session.workspace,
1119
+ model:modelState.model,
1120
+ model_provider:modelState.model_provider||null,
1121
+ })});
1122
+ if(typeof _readPendingSessionModel==='function'&&typeof _clearPendingSessionModel==='function'){
1123
+ const pending=_readPendingSessionModel(S.session.session_id);
1124
+ if(!pending||(pending.model===modelState.model&&String(pending.model_provider||'')===String(modelState.model_provider||''))){
1125
+ _clearPendingSessionModel(S.session.session_id);
1126
+ }
1127
+ }
1128
+ _applySessionContextMetadataUpdate(data);
1129
+ // Warn if selected model belongs to a different provider than what Hermes is configured for
1130
+ if(typeof _checkProviderMismatch==='function'){
1131
+ const warn=_checkProviderMismatch(selectedModel);
1132
+ if(warn&&typeof showToast==='function') showToast(warn,4000);
1133
+ }
1134
+ };
1135
+ $('msg').addEventListener('input',()=>{
1136
+ autoResize();
1137
+ updateSendBtn();
1138
+ // Persist composer draft to server (debounced in _saveComposerDraft).
1139
+ const sid = S && S.session && S.session.session_id;
1140
+ if (sid && typeof _saveComposerDraft === 'function') {
1141
+ _saveComposerDraft(sid, $('msg').value, S.pendingFiles ? [...S.pendingFiles] : []);
1142
+ }
1143
+ const text=$('msg').value;
1144
+ if(text.startsWith('/')&&text.indexOf('\n')===-1){
1145
+ if(typeof getSlashAutocompleteMatches==='function'){
1146
+ getSlashAutocompleteMatches(text).then(matches=>{
1147
+ if(($('msg').value||'')!==text) return;
1148
+ if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
1149
+ });
1150
+ }else{
1151
+ const prefix=text.slice(1);
1152
+ const matches=getMatchingCommands(prefix);
1153
+ if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
1154
+ }
1155
+ if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
1156
+ } else {
1157
+ hideCmdDropdown();
1158
+ }
1159
+ });
1160
+ // Track IME composition for East Asian input. Safari fires the committing
1161
+ // keydown AFTER compositionend with isComposing=false, so we also keep a
1162
+ // manual flag and reset it on the next tick to swallow that trailing Enter.
1163
+ // Also reset on blur so the flag can never get stuck in a true state if
1164
+ // compositionend never fires (focus loss with some IME implementations).
1165
+ //
1166
+ // The `_imeComposing` flag is bound to the chat composer (`#msg`); other
1167
+ // inputs (session/project rename, app dialog, message edit, workspace rename)
1168
+ // rely on the state-free `e.isComposing || e.keyCode === 229` part of
1169
+ // `_isImeEnter`, which is sufficient for the Safari race because keyCode 229
1170
+ // is the canonical "still composing" signal regardless of which field is
1171
+ // focused. Promote `_isImeEnter` to `window` so other modules can reuse it
1172
+ // without duplicating the full IIFE per input (issue #1443).
1173
+ let _imeComposing=false;
1174
+ (()=>{const _c=$('msg');if(!_c)return;
1175
+ _c.addEventListener('compositionstart',()=>{_imeComposing=true;});
1176
+ _c.addEventListener('compositionend',()=>{setTimeout(()=>{_imeComposing=false;},0);});
1177
+ _c.addEventListener('blur',()=>{_imeComposing=false;});
1178
+ })();
1179
+ function _isImeEnter(e){return e.isComposing||e.keyCode===229||_imeComposing;}
1180
+ window._isImeEnter=_isImeEnter;
1181
+ function _isVirtualKeyboardLikelyOpen(){
1182
+ const vv=window.visualViewport;
1183
+ if(!vv||!window.innerHeight)return true;
1184
+ return window.innerHeight-vv.height>120;
1185
+ }
1186
+ // #3076: a touch-primary device (`pointer:coarse`) can still have a
1187
+ // physical keyboard attached (Android tablet + Bluetooth keyboard,
1188
+ // detachable Surface in tablet mode, iPad + Magic Keyboard). When that
1189
+ // happens we should NOT force the mobile newline-on-Enter override
1190
+ // because Shift+Enter / Ctrl+Enter come from real keys and the user
1191
+ // expects desktop semantics. `matchMedia('(any-pointer:fine)')` is true
1192
+ // whenever ANY available pointing device is fine-grained — which is the
1193
+ // strongest signal browsers expose for "there is a real keyboard /
1194
+ // trackpad in the picture too". Skip the mobile default in that case.
1195
+ function _hasFinePointerCoexisting(){
1196
+ try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; }
1197
+ }
1198
+ function _isNumpadEnter(e){
1199
+ return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD);
1200
+ }
1201
+ $('msg').addEventListener('keydown',e=>{
1202
+ // Autocomplete navigation when dropdown is open
1203
+ const dd=$('cmdDropdown');
1204
+ const dropdownOpen=dd&&dd.classList.contains('open');
1205
+ if(dropdownOpen){
1206
+ if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;}
1207
+ if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;}
1208
+ if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;}
1209
+ if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;}
1210
+ if(e.key==='Enter'&&!e.shiftKey){
1211
+ if(_isImeEnter(e)){return;}
1212
+ e.preventDefault();
1213
+ selectCmdDropdownItem();
1214
+ return;
1215
+ }
1216
+ }
1217
+ // Send key: respect user preference.
1218
+ // On touch-primary devices with the software keyboard open, default to
1219
+ // Enter = newline since there's no physical Shift key. Hardware keyboards on
1220
+ // tablets keep desktop behavior when the viewport is not keyboard-shrunk.
1221
+ // The 'ctrl+enter' setting also uses this behavior (Enter = newline).
1222
+ // Users can override in Settings by explicitly choosing 'enter' mode.
1223
+ if(e.key==='Enter'){
1224
+ if(_isImeEnter(e)){return;}
1225
+ const isNumpadEnter=_isNumpadEnter(e);
1226
+ const _mobileDefault=matchMedia('(pointer:coarse)').matches
1227
+ &&!_hasFinePointerCoexisting()
1228
+ &&window._sendKey==='enter'
1229
+ &&_isVirtualKeyboardLikelyOpen();
1230
+ if(window._sendKey==='ctrl+enter'||_mobileDefault){
1231
+ if(isNumpadEnter||e.ctrlKey||e.metaKey){e.preventDefault();send();}
1232
+ } else {
1233
+ if(!e.shiftKey){e.preventDefault();send();}
1234
+ }
1235
+ }
1236
+ });
1237
+ // B14: Cmd/Ctrl+K creates a new chat from anywhere
1238
+ document.addEventListener('keydown',async e=>{
1239
+ // Cmd/Ctrl+B toggles desktop sidebar collapse (VS Code convention).
1240
+ // Skip when typing in an input/textarea/contenteditable so text-edit
1241
+ // shortcuts (e.g. bold in some embedded editors) are never stolen.
1242
+ if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&!e.altKey&&(e.key==='b'||e.key==='B')){
1243
+ const t=e.target;
1244
+ const isText=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable);
1245
+ if(!isText&&typeof toggleSidebar==='function'&&_isDesktopWidth()){
1246
+ e.preventDefault();
1247
+ toggleSidebar();
1248
+ return;
1249
+ }
1250
+ }
1251
+ // Enter on approval card = Allow once (when a button inside the card is focused or
1252
+ // card is visible and focus is not on an input/textarea/select)
1253
+ if(e.key==='Enter'&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey){
1254
+ const card=$('approvalCard');
1255
+ const tag=(document.activeElement||{}).tagName||'';
1256
+ if(card&&card.classList.contains('visible')&&tag!=='TEXTAREA'&&tag!=='INPUT'&&tag!=='SELECT'){
1257
+ e.preventDefault();
1258
+ if(typeof respondApproval==='function') respondApproval('once');
1259
+ return;
1260
+ }
1261
+ }
1262
+ if((e.metaKey||e.ctrlKey)&&e.key==='k'){
1263
+ e.preventDefault();
1264
+ // If the current session has no messages AND nothing is in flight, just focus
1265
+ // the composer rather than creating another empty session that will clutter
1266
+ // the sidebar list (#1171). See the matching guard in $('btnNewChat').onclick
1267
+ // and bug #1432 for why the in-flight check is needed.
1268
+ if(S.session
1269
+ && (S.session.message_count||0)===0
1270
+ && !S.busy
1271
+ && !S.session.active_stream_id
1272
+ && !S.session.pending_user_message){
1273
+ $('msg').focus();return;
1274
+ }
1275
+ // Cmd/Ctrl+K should always create a new conversation, even while the current
1276
+ // one is still streaming. The old !S.busy guard meant users had to wait for
1277
+ // a long generation to finish before they could start something new — exactly
1278
+ // the moment they want to switch context. newSession() leaves the in-flight
1279
+ // stream running on its own session; the user just gets a fresh blank one.
1280
+ await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
1281
+ }
1282
+ if(e.key==='Escape'){
1283
+ // Close onboarding overlay if open (skip/dismiss the wizard)
1284
+ const onboardingOverlay=$('onboardingOverlay');
1285
+ if(onboardingOverlay&&onboardingOverlay.style.display!=='none'){
1286
+ if(typeof skipOnboarding==='function') skipOnboarding();
1287
+ return;
1288
+ }
1289
+ // Close settings panel if active
1290
+ if(_currentPanel==='settings'){_closeSettingsPanel();return;}
1291
+ // Close workspace dropdown
1292
+ closeWsDropdown();
1293
+ // Clear session search
1294
+ const ss=$('sessionSearch');
1295
+ if(ss&&ss.value){
1296
+ if(typeof clearSessionSearch==='function') clearSessionSearch(false);
1297
+ else { ss.value=''; filterSessions(); }
1298
+ }
1299
+ // Cancel any active message edit
1300
+ const editArea=document.querySelector('.msg-edit-area');
1301
+ if(editArea){
1302
+ const bar=editArea.closest('.msg-row')&&editArea.closest('.msg-row').querySelector('.msg-edit-bar');
1303
+ if(bar){const cancel=bar.querySelector('.msg-edit-cancel');if(cancel)cancel.click();}
1304
+ }
1305
+ }
1306
+ });
1307
+ $('msg').addEventListener('paste',e=>{
1308
+ const items=Array.from(e.clipboardData?.items||[]);
1309
+ // When the clipboard carries BOTH text and an image (common from Notes,
1310
+ // Word, browsers, Slack — the OS attaches a rendered preview alongside
1311
+ // the plain text), prefer the text and let the browser paste normally.
1312
+ // Only intercept when the clipboard is image-only (true screenshot paste).
1313
+ // Tighten the image filter to kind==='file' so string items advertising an
1314
+ // image MIME (e.g. text/html with an embedded data URI) are not misclassified.
1315
+ const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html'));
1316
+ const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/'));
1317
+ if(!imageItems.length||hasText)return;
1318
+ e.preventDefault();
1319
+ const pasteTs=Date.now();
1320
+ const files=imageItems.map((i,idx)=>{
1321
+ const blob=i.getAsFile();
1322
+ const ext=i.type.split('/')[1]||'png';
1323
+ const suffix=imageItems.length>1?`-${idx+1}`:'';
1324
+ return new File([blob],`screenshot-${pasteTs}${suffix}.${ext}`,{type:i.type});
1325
+ });
1326
+ addFiles(files);
1327
+ setStatus(t('image_pasted')+files.map(f=>f.name).join(', '));
1328
+ });
1329
+ document.querySelectorAll('.suggestion').forEach(btn=>{
1330
+ btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
1331
+ });
1332
+
1333
+ function applyEmptyStateSuggestionPref(){
1334
+ if(!$('emptyState')) return;
1335
+ $('emptyState').classList.toggle('no-suggestions',window._hideEmptyStateSuggestions===true);
1336
+ }
1337
+
1338
+ window.addEventListener('resize',()=>{
1339
+ _syncWorkspacePanelInlineWidth();
1340
+ syncWorkspacePanelState();
1341
+ });
1342
+
1343
+ // Boot: restore last session or start fresh
1344
+ // ── Resizable panels ──────────────────────────────────────────────────────
1345
+ (function(){
1346
+ const SIDEBAR_MIN=180, SIDEBAR_MAX=420;
1347
+ const PANEL_MIN=180, PANEL_MAX=1200;
1348
+
1349
+ function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
1350
+ const handle = $(handleId);
1351
+ if(!handle || !targetEl) return;
1352
+
1353
+ // Restore saved width
1354
+ if(storageKey === 'hermes-panel-w'){
1355
+ _syncWorkspacePanelInlineWidth();
1356
+ }else{
1357
+ const saved = localStorage.getItem(storageKey);
1358
+ if(saved) targetEl.style.width = saved + 'px';
1359
+ }
1360
+
1361
+ let startX=0, startW=0;
1362
+
1363
+ handle.addEventListener('mousedown', e=>{
1364
+ e.preventDefault();
1365
+ startX = e.clientX;
1366
+ startW = targetEl.getBoundingClientRect().width;
1367
+ handle.classList.add('dragging');
1368
+ document.body.classList.add('resizing');
1369
+
1370
+ const onMove = ev=>{
1371
+ const delta = edge==='right' ? ev.clientX - startX : startX - ev.clientX;
1372
+ const newW = Math.min(maxW, Math.max(minW, startW + delta));
1373
+ targetEl.style.width = newW + 'px';
1374
+ };
1375
+ const onUp = ()=>{
1376
+ handle.classList.remove('dragging');
1377
+ document.body.classList.remove('resizing');
1378
+ localStorage.setItem(storageKey, parseInt(targetEl.style.width));
1379
+ document.removeEventListener('mousemove', onMove);
1380
+ document.removeEventListener('mouseup', onUp);
1381
+ };
1382
+ document.addEventListener('mousemove', onMove);
1383
+ document.addEventListener('mouseup', onUp);
1384
+ });
1385
+ }
1386
+
1387
+ // Run after DOM ready (called from boot)
1388
+ window._initResizePanels = function(){
1389
+ const sidebar = document.querySelector('.sidebar');
1390
+ const rightpanel = document.querySelector('.rightpanel');
1391
+ initResize('sidebarResize', sidebar, 'right', SIDEBAR_MIN, SIDEBAR_MAX, 'hermes-sidebar-w');
1392
+ initResize('rightpanelResize', rightpanel, 'left', PANEL_MIN, PANEL_MAX, 'hermes-panel-w');
1393
+ };
1394
+ })();
1395
+
1396
+ // ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
1397
+ const _THEMES=[
1398
+ {name:'Light', value:'light', colors:['#FEFCF7','#FAF7F0','#B8860B']},
1399
+ {name:'Dark', value:'dark', colors:['#0D0D1A','#141425','#FFD700']},
1400
+ {name:'System', value:'system', colors:['#FEFCF7','#0D0D1A','#B8860B']},
1401
+ ];
1402
+ const _SKINS=[
1403
+ {name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
1404
+ {name:'Ares', colors:['#FF4444','#CC3333','#992222']},
1405
+ {name:'Mono', colors:['#CCCCCC','#999999','#666666']},
1406
+ {name:'Slate', colors:['#334155','#475569','#64748b']},
1407
+ {name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
1408
+ {name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
1409
+ {name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
1410
+ {name:'Sienna', colors:['#D97757','#C06A49','#9A523A']},
1411
+ {name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']},
1412
+ {name:'Hepburn', colors:['#c6246a','#ec5597','#f2abca']},
1413
+ {name:'Nous', colors:['#4682B4','#3A6E9A','#2C5F88']},
1414
+ {name:'Neon', colors:['#B347FF','#C76BFF','#00DDFF']},
1415
+ {name:'Geist Contrast', value:'geist-contrast', colors:['#000000','#ffffff','#FFF175']},
1416
+ ];
1417
+ const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
1418
+ const _VALID_SKINS=new Set((_SKINS||[]).map(s=>(s.value||s.name).toLowerCase()));
1419
+ const _LEGACY_THEME_MAP={
1420
+ slate:{theme:'dark',skin:'slate'},
1421
+ solarized:{theme:'dark',skin:'poseidon'},
1422
+ monokai:{theme:'dark',skin:'sisyphus'},
1423
+ nord:{theme:'dark',skin:'slate'},
1424
+ oled:{theme:'dark',skin:'default'},
1425
+ };
1426
+ let _systemThemeMq=null;
1427
+ let _onSystemThemeChange=null;
1428
+
1429
+ function _normalizeAppearance(theme,skin){
1430
+ const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
1431
+ const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
1432
+ const legacy=_LEGACY_THEME_MAP[rawTheme];
1433
+ const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
1434
+ const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
1435
+ return {theme:nextTheme,skin:nextSkin};
1436
+ }
1437
+
1438
+ // Sync <meta name="theme-color"> with the active theme's app chrome color.
1439
+ // This surfaces the WebUI's exact theme background to:
1440
+ // 1. Mobile Safari status bar (the prefers-color-scheme media variants in index.html
1441
+ // cover the pre-load case; this updater handles user-toggled changes mid-session).
1442
+ // 2. iOS PWA / Add to Home Screen status bar.
1443
+ // 3. Native WKWebView wrappers (e.g. hermes-swift-mac) that read this attribute as
1444
+ // the source of truth for AppKit chrome (tab bar, title bar, traffic-light area)
1445
+ // instead of pixel-sampling — overlay-resistant and IPC-free.
1446
+ // Reading getComputedStyle(html).getPropertyValue('--sidebar') picks up the active skin
1447
+ // (Default, Sienna, Sisyphus, Charizard, etc.) so each skin's distinct paint reaches
1448
+ // the meta tag.
1449
+ function _syncThemeColorMeta(){
1450
+ try{
1451
+ const bg=getComputedStyle(document.documentElement).getPropertyValue('--sidebar').trim();
1452
+ if(!bg) return;
1453
+ const known=document.getElementById('hermes-theme-color');
1454
+ if(known){
1455
+ known.setAttribute('content',bg);
1456
+ known.removeAttribute('media');
1457
+ }
1458
+ document.querySelectorAll('meta[name="theme-color"]').forEach(meta=>{
1459
+ meta.setAttribute('content',bg);
1460
+ meta.removeAttribute('media');
1461
+ });
1462
+ }catch(e){}
1463
+ }
1464
+
1465
+ function _setResolvedTheme(isDark){
1466
+ document.documentElement.classList.toggle('dark',!!isDark);
1467
+ const link=document.getElementById('prism-theme');
1468
+ if(!link){ _syncThemeColorMeta(); return; }
1469
+ const want=isDark
1470
+ ?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
1471
+ :'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
1472
+ // No SRI integrity on theme CSS — jsdelivr edge nodes serve different
1473
+ // digests for the same pinned version, causing intermittent blocking (#1100).
1474
+ if(link.href!==want){ link.integrity=''; link.href=want; }
1475
+ _syncThemeColorMeta();
1476
+ }
1477
+
1478
+ function _applyTheme(name){
1479
+ const normalized=_normalizeAppearance(name,'default');
1480
+ delete document.documentElement.dataset.theme;
1481
+ if(_systemThemeMq&&_onSystemThemeChange){
1482
+ _systemThemeMq.removeEventListener('change',_onSystemThemeChange);
1483
+ _systemThemeMq=null;
1484
+ _onSystemThemeChange=null;
1485
+ }
1486
+ if(normalized.theme==='system'){
1487
+ _systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
1488
+ _onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
1489
+ _setResolvedTheme(_systemThemeMq.matches);
1490
+ _systemThemeMq.addEventListener('change',_onSystemThemeChange);
1491
+ return;
1492
+ }
1493
+ _setResolvedTheme(normalized.theme==='dark');
1494
+ }
1495
+
1496
+ function _applySkin(name){
1497
+ const key=(name||'default').toLowerCase();
1498
+ if(key==='default') delete document.documentElement.dataset.skin;
1499
+ else document.documentElement.dataset.skin=key;
1500
+ _syncThemeColorMeta();
1501
+ }
1502
+
1503
+ function _pickTheme(name){
1504
+ const currentSkin=localStorage.getItem('hermes-skin');
1505
+ const appearance=_normalizeAppearance(name,currentSkin);
1506
+ localStorage.setItem('hermes-theme',appearance.theme);
1507
+ localStorage.setItem('hermes-skin',appearance.skin);
1508
+ _applyTheme(appearance.theme);
1509
+ _applySkin(appearance.skin);
1510
+ _syncThemePicker(appearance.theme);
1511
+ _syncSkinPicker(appearance.skin);
1512
+ const hidden=$('settingsTheme');
1513
+ if(hidden) hidden.value=appearance.theme;
1514
+ const skinHidden=$('settingsSkin');
1515
+ if(skinHidden) skinHidden.value=appearance.skin;
1516
+ if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
1517
+ }
1518
+
1519
+ function _pickSkin(name){
1520
+ const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
1521
+ localStorage.setItem('hermes-theme',appearance.theme);
1522
+ localStorage.setItem('hermes-skin',appearance.skin);
1523
+ _applyTheme(appearance.theme);
1524
+ _applySkin(appearance.skin);
1525
+ _syncThemePicker(appearance.theme);
1526
+ _syncSkinPicker(appearance.skin);
1527
+ const hidden=$('settingsSkin');
1528
+ if(hidden) hidden.value=appearance.skin;
1529
+ const themeHidden=$('settingsTheme');
1530
+ if(themeHidden) themeHidden.value=appearance.theme;
1531
+ if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
1532
+ }
1533
+
1534
+ function _syncThemePicker(active){
1535
+ document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
1536
+ btn.classList.toggle('active',btn.dataset.themeVal===active);
1537
+ btn.style.borderColor='';
1538
+ btn.style.boxShadow='';
1539
+ });
1540
+ }
1541
+
1542
+ function _syncSkinPicker(active){
1543
+ document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
1544
+ btn.classList.toggle('active',btn.dataset.skinVal===active);
1545
+ btn.style.borderColor='';
1546
+ btn.style.boxShadow='';
1547
+ });
1548
+ }
1549
+
1550
+ function _applyFontSize(size){
1551
+ if(size&&size!=='default'){
1552
+ document.documentElement.dataset.fontSize=size;
1553
+ } else {
1554
+ delete document.documentElement.dataset.fontSize;
1555
+ }
1556
+ }
1557
+
1558
+ function _pickFontSize(size){
1559
+ localStorage.setItem('hermes-font-size',size);
1560
+ _applyFontSize(size);
1561
+ _syncFontSizePicker(size);
1562
+ const hidden=$('settingsFontSize');
1563
+ if(hidden) hidden.value=size;
1564
+ if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
1565
+ }
1566
+
1567
+ function _syncFontSizePicker(active){
1568
+ document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
1569
+ btn.classList.toggle('active',btn.dataset.fontSizeVal===(active||'default'));
1570
+ btn.style.borderColor='';
1571
+ btn.style.boxShadow='';
1572
+ });
1573
+ }
1574
+
1575
+ function _buildSkinPicker(activeSkin){
1576
+ const grid=$('skinPickerGrid');
1577
+ if(!grid) return;
1578
+ grid.innerHTML='';
1579
+ for(const skin of _SKINS){
1580
+ const key=(skin.value||skin.name).toLowerCase();
1581
+ const btn=document.createElement('button');
1582
+ btn.type='button';
1583
+ btn.className='skin-pick-btn';
1584
+ btn.dataset.skinVal=key;
1585
+ btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
1586
+ btn.onclick=()=>_pickSkin(key);
1587
+ const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
1588
+ const label=skin.label||skin.name;
1589
+ btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${label}</span>`;
1590
+ grid.appendChild(btn);
1591
+ }
1592
+ _syncSkinPicker((activeSkin||'default').toLowerCase());
1593
+ }
1594
+
1595
+ function applyBotName(){
1596
+ // The saved assistant name applies to the default profile only.
1597
+ // Non-default profiles use their own profile names.
1598
+ const name=assistantDisplayName();
1599
+ document.title=name;
1600
+ const sidebarH1=document.querySelector('.sidebar-header h1');
1601
+ if(sidebarH1) sidebarH1.textContent=name;
1602
+ const logo=document.querySelector('.sidebar-header .logo');
1603
+ if(logo) logo.textContent=name.charAt(0).toUpperCase();
1604
+ const topbarTitle=$('topbarTitle');
1605
+ if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
1606
+ const msg=$('msg');
1607
+ if(msg) msg.placeholder='Message '+name+'\u2026';
1608
+ }
1609
+
1610
+ (async()=>{
1611
+ // Load send key preference
1612
+ let _bootSettings={};
1613
+ try{
1614
+ const s=await api('/api/settings');
1615
+ _bootSettings=s;
1616
+ window._sendKey=s.send_key||'enter';
1617
+ window._showTokenUsage=!!s.show_token_usage;
1618
+ window._showQuotaChip=s.show_quota_chip===true;
1619
+ window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true;
1620
+ applyEmptyStateSuggestionPref();
1621
+ window._showTps=!!s.show_tps;
1622
+ window._fadeTextEffect=!!s.fade_text_effect;
1623
+ window._showCliSessions=!!s.show_cli_sessions;
1624
+ window._showPreviousMessagingSessions=!!s.show_previous_messaging_sessions;
1625
+ window._soundEnabled=!!s.sound_enabled;
1626
+ window._notificationsEnabled=!!s.notifications_enabled;
1627
+ // Persist default workspace so the blank new-chat page can show it
1628
+ // and workspace actions (New file/folder) work before the first session (#804).
1629
+ if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
1630
+ window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled;
1631
+ window._showThinking=s.show_thinking!==false;
1632
+ window._simplifiedToolCalling=s.simplified_tool_calling!==false;
1633
+ window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
1634
+ window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3;
1635
+ window._inflightStateLimits={
1636
+ maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8,
1637
+ messages:parseInt(s.inflight_state_max_messages||24,10)||24,
1638
+ toolCalls:parseInt(s.inflight_state_max_tool_calls||48,10)||48,
1639
+ stringChars:parseInt(s.inflight_state_max_string_chars||60000,10)||60000,
1640
+ jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000,
1641
+ };
1642
+ window._busyInputMode=(s.busy_input_mode||'queue');
1643
+ window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
1644
+ window._botName=s.bot_name||'Hermes';
1645
+ if(s.default_model_provider) window._activeProvider=s.default_model_provider;
1646
+ if(s.default_model){
1647
+ window._defaultModel=s.default_model;
1648
+ const sel=$('modelSelect');
1649
+ if(sel&&typeof _applyModelToDropdown==='function'){
1650
+ // Fresh page boot must prefer the profile/server default over stale
1651
+ // browser-persisted model state. A restored session can still apply its
1652
+ // own persisted model later through loadSession(). Preserve the browser
1653
+ // keys for legacy/no-default fallback paths instead of deleting them.
1654
+ const existingDefaultOpt=Array.from(sel.options).find(o=>o.value===s.default_model);
1655
+ if(existingDefaultOpt&&window._activeProvider&&!existingDefaultOpt.dataset.provider){
1656
+ existingDefaultOpt.dataset.provider=window._activeProvider;
1657
+ }
1658
+ if(!existingDefaultOpt){
1659
+ const opt=document.createElement('option');
1660
+ opt.value=s.default_model;
1661
+ opt.textContent=typeof getModelLabel==='function'?getModelLabel(s.default_model):s.default_model;
1662
+ opt.dataset.custom='1';
1663
+ opt.dataset.provider=window._activeProvider||'';
1664
+ sel.querySelectorAll('option[data-custom]').forEach(o=>o.remove());
1665
+ sel.appendChild(opt);
1666
+ }
1667
+ _applyModelToDropdown(s.default_model,sel,window._activeProvider||null);
1668
+ }
1669
+ }
1670
+ window._sessionJumpButtonsEnabled=!!s.session_jump_buttons;
1671
+ // Reconcile appearance: prefer localStorage (what the user last saw) over
1672
+ // the server. If they diverge (e.g. a previous autosave POST failed),
1673
+ // push the localStorage values back to the server so settings.json stays
1674
+ // in sync without ever clobbering the user's chosen theme/skin.
1675
+ //
1676
+ // Caveat: the pre-paint inline script in index.html normalises empty
1677
+ // localStorage into 'dark'/'default' BEFORE this code runs, so a truly
1678
+ // empty (new-browser) state is indistinguishable from a user who chose
1679
+ // the defaults. To avoid blocking server→client sync on first visit we
1680
+ // only let localStorage override the server when it carries an explicit
1681
+ // user-selectable theme value or a NON-DEFAULT skin. That keeps the
1682
+ // server in charge for empty first-visit state while preserving explicit
1683
+ // light/dark/system choices after a failed autosave.
1684
+ const srvAppearance=_normalizeAppearance(s.theme,s.skin);
1685
+ const lsTheme=(localStorage.getItem('hermes-theme')||'').trim().toLowerCase();
1686
+ const lsSkin=(localStorage.getItem('hermes-skin')||'').trim().toLowerCase();
1687
+ const lsAppearance=_normalizeAppearance(lsTheme||null,lsSkin||null);
1688
+ const lsHasExplicitSkin=lsSkin&&lsSkin!=='default';
1689
+ const lsHasExplicitTheme=lsTheme&&['system','light','dark'].includes(lsTheme);
1690
+ const theme=lsHasExplicitTheme?lsAppearance.theme:srvAppearance.theme;
1691
+ const skin=lsHasExplicitSkin?lsAppearance.skin:srvAppearance.skin;
1692
+ localStorage.setItem('hermes-theme',theme);
1693
+ _applyTheme(theme);
1694
+ localStorage.setItem('hermes-skin',skin);
1695
+ _applySkin(skin);
1696
+ // Reconcile: if localStorage and server disagree, push localStorage
1697
+ // values to the server so the next refresh won't revert.
1698
+ if((lsHasExplicitTheme||lsHasExplicitSkin)&&(theme!==srvAppearance.theme||skin!==srvAppearance.skin)){
1699
+ try{
1700
+ api('/api/settings',{method:'POST',body:JSON.stringify({theme,skin})});
1701
+ }catch(_){}
1702
+ }
1703
+ const fontSize=(s.font_size||localStorage.getItem('hermes-font-size')||'default');
1704
+ localStorage.setItem('hermes-font-size',fontSize);
1705
+ _applyFontSize(fontSize);
1706
+ if(typeof setLocale==='function'){
1707
+ const _lang=typeof resolvePreferredLocale==='function'
1708
+ ? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
1709
+ : (s.language || localStorage.getItem('hermes-lang') || 'en');
1710
+ setLocale(_lang);
1711
+ if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
1712
+ }
1713
+ // TTS: apply enabled state on boot so buttons show/hide correctly (#499)
1714
+ if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
1715
+ }catch(e){
1716
+ window._sendKey='enter';
1717
+ window._showTokenUsage=false;
1718
+ window._showQuotaChip=false;
1719
+ window._hideEmptyStateSuggestions=false;
1720
+ applyEmptyStateSuggestionPref();
1721
+ window._showTps=false;
1722
+ window._fadeTextEffect=false;
1723
+ window._showCliSessions=false;
1724
+ window._soundEnabled=false;
1725
+ window._notificationsEnabled=false;
1726
+ window._whatsNewSummaryEnabled=false;
1727
+ window._showThinking=true;
1728
+ window._simplifiedToolCalling=true;
1729
+ window._sessionJumpButtonsEnabled=false;
1730
+ window._sidebarDensity='compact';
1731
+ window._pinnedSessionsLimit=3;
1732
+ window._busyInputMode='queue';
1733
+ window._sessionEndlessScrollEnabled=false;
1734
+ window._botName='Hermes';
1735
+ _bootSettings={check_for_updates:false};
1736
+ if(typeof setLocale==='function'){
1737
+ const _lang=typeof resolvePreferredLocale==='function'
1738
+ ? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
1739
+ : (localStorage.getItem('hermes-lang') || 'en');
1740
+ setLocale(_lang);
1741
+ if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
1742
+ }
1743
+ if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
1744
+ }
1745
+ // Non-blocking update check (fire-and-forget, once per tab session)
1746
+ // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
1747
+ const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
1748
+ if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
1749
+ const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
1750
+ api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
1751
+ }
1752
+ // Fetch active profile
1753
+ try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
1754
+ applyBotName();
1755
+ // Update profile chip label immediately
1756
+ const profileLabel=$('profileChipLabel');
1757
+ if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
1758
+ // Fetch available models without blocking session restore. The static HTML
1759
+ // options are enough for first paint; the dynamic provider list can settle
1760
+ // after the saved session is visible.
1761
+ const _hydrateBootModelDropdown=()=>populateModelDropdown({preferProfileDefaultOnFreshBoot:true}).then(()=>{
1762
+ const sessionModelState=S.session&&S.session.model
1763
+ ? {model:S.session.model,model_provider:S.session.model_provider||null}
1764
+ : null;
1765
+ const savedState=(typeof _readPersistedModelState==='function')
1766
+ ? _readPersistedModelState()
1767
+ : (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null);
1768
+ // Active sessions are authoritative. On fresh boot without a restored
1769
+ // session, keep the profile/server default ahead of stale browser model
1770
+ // state when a default exists.
1771
+ const stateToApply=sessionModelState||(!window._defaultModel?savedState:null);
1772
+ const savedModel=stateToApply&&stateToApply.model;
1773
+ if(savedModel && $('modelSelect')){
1774
+ const applied=(typeof _applyModelToDropdown==='function')
1775
+ ? (sessionModelState
1776
+ ? _applyModelToDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)
1777
+ : _applyModelToDropdown(savedState.model,$('modelSelect'),savedState.model_provider||null))
1778
+ : null;
1779
+ if(!applied) $('modelSelect').value=stateToApply.model;
1780
+ // If the value didn't take (model not in list), clear the bad pref only
1781
+ // for persisted browser preferences. Active sessions remain authoritative.
1782
+ if(!applied&&sessionModelState&&typeof _ensureModelOptionInDropdown==='function'){
1783
+ _ensureModelOptionInDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null);
1784
+ }
1785
+ else if(!applied&&!sessionModelState&&$('modelSelect').value!==stateToApply.model){
1786
+ if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
1787
+ else {
1788
+ localStorage.removeItem('hermes-webui-model');
1789
+ localStorage.removeItem('hermes-webui-model-state');
1790
+ }
1791
+ }
1792
+ else if(typeof syncModelChip==='function') syncModelChip();
1793
+ }
1794
+ if(S.session) syncTopbar();
1795
+ }).catch(e=>{
1796
+ window._modelDropdownReady=null;
1797
+ throw e;
1798
+ });
1799
+ const _startBootModelDropdown=()=>{
1800
+ const ready=window._modelDropdownReady;
1801
+ if(ready&&typeof ready.then==='function') return ready;
1802
+ const next=_hydrateBootModelDropdown();
1803
+ window._modelDropdownReady=next;
1804
+ return next;
1805
+ };
1806
+ window._modelDropdownReady=null;
1807
+ window._ensureModelDropdownReady=_startBootModelDropdown;
1808
+ setTimeout(()=>{
1809
+ try{Promise.resolve(_startBootModelDropdown()).catch(()=>{});}catch(_){}
1810
+ },0);
1811
+ // Start independent boot fetches without holding the conversation list behind
1812
+ // them. The sidebar can render from /api/sessions while workspace/onboarding
1813
+ // metadata settles in parallel.
1814
+ const _workspaceListReady=loadWorkspaceList();
1815
+ const _onboardingReady=_bootSettings.onboarding_completed?Promise.resolve(false):loadOnboardingWizard();
1816
+ // Render the session list before restoring the saved conversation so a stale
1817
+ // saved-session/client-side boot error cannot leave the sidebar empty forever.
1818
+ await renderSessionList();
1819
+ await _workspaceListReady;
1820
+ await _onboardingReady;
1821
+ _initResizePanels();
1822
+ // Workspace panel restore happens AFTER loadSession so we know if
1823
+ // the session has a workspace — prevents the snap-open-then-closed flash (#576).
1824
+ // Fix #822: clear any browser-restored value before first render. This
1825
+ // covers fresh page loads and reloads. The bfcache restore case is handled
1826
+ // separately below by a `pageshow` listener — the async IIFE here does NOT
1827
+ // re-run when the browser restores the page from bfcache.
1828
+ const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
1829
+ if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear();
1830
+ // Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load)
1831
+ if(typeof fetchReasoningChip==='function') fetchReasoningChip();
1832
+ if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
1833
+ const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
1834
+ const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function')
1835
+ ? window.HermesPWA.launchAction()
1836
+ : null;
1837
+ if(pwaLaunchAction==='new-chat'){
1838
+ try{
1839
+ await newSession(true);
1840
+ if(S.session) await _startBootModelDropdown();
1841
+ S._bootReady=true;
1842
+ syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return;
1843
+ }catch(e){console.warn('[pwa] new-chat launch action failed', e);}
1844
+ }
1845
+ const savedLocal=localStorage.getItem('hermes-webui-session');
1846
+ const saved=urlSession||savedLocal;
1847
+ if(saved){
1848
+ try{
1849
+ if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal)){
1850
+ S.session=null; S.messages=[]; S.activeStreamId=null; S.busy=false;
1851
+ S._bootReady=true;
1852
+ syncTopbar();syncWorkspacePanelState();
1853
+ $('emptyState').style.display='';
1854
+ await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
1855
+ return;
1856
+ }
1857
+ await loadSession(saved);
1858
+ // Hard refresh starts from the static HTML model list. Hydrate the live
1859
+ // catalog after the saved session is known, then re-apply that session's
1860
+ // model before S._bootReady lets syncModelChip reveal the composer label.
1861
+ // Otherwise the chip can display the static default (e.g. GPT-5.4 Mini)
1862
+ // even though S.session already points at the Codex/current model.
1863
+ if(S.session) await _startBootModelDropdown();
1864
+ // If the restored session has no messages it is an ephemeral scratch pad —
1865
+ // treat the page as a fresh start rather than resuming a blank conversation.
1866
+ // loadSession() already ran, so loadDir() has populated the workspace file tree.
1867
+ // Do NOT remove the session ID from localStorage — keeping it means every
1868
+ // subsequent refresh will also run loadSession() → loadDir() → files stay visible.
1869
+ // Removing it here caused the file tree to go blank on the second refresh
1870
+ // because the "no saved session" path never calls loadDir (#workspace-files).
1871
+ const _restoredInFlight = S.session && (
1872
+ S.session.active_stream_id ||
1873
+ S.session.pending_user_message
1874
+ );
1875
+ if(S.session && (S.session.message_count||0) === 0 && !_restoredInFlight){
1876
+ S.session=null; S.messages=[];
1877
+ S._bootReady=true;
1878
+ // Restore panel pref before syncing so the workspace panel stays visible
1879
+ // even though there is no active session (#workspace-persist).
1880
+ const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
1881
+ || localStorage.getItem('hermes-webui-workspace-panel')==='open';
1882
+ if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';
1883
+ syncTopbar();syncWorkspacePanelState();
1884
+ $('emptyState').style.display='';
1885
+ await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
1886
+ return;
1887
+ }
1888
+ // Restore the panel from localStorage when the session has a workspace.
1889
+ // Preference key takes priority over runtime state so that closing
1890
+ // the panel via toolbar X doesn't suppress the "keep open" setting.
1891
+ const panelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
1892
+ || localStorage.getItem('hermes-webui-workspace-panel')==='open';
1893
+ if(S.session&&S.session.workspace&&panelPref&&!_isCompactWorkspaceViewport()){
1894
+ _workspacePanelMode='browse';
1895
+ }
1896
+ S._bootReady=true;
1897
+ syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
1898
+ catch(e){localStorage.removeItem('hermes-webui-session');}
1899
+ }
1900
+ // no saved session - show empty state, wait for user to hit +
1901
+ S._bootReady=true;
1902
+ syncTopbar();
1903
+ // Restore panel pref so the workspace panel stays visible on a fresh load if the
1904
+ // user had it open during their last session (#workspace-persist).
1905
+ const _freshPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
1906
+ || localStorage.getItem('hermes-webui-workspace-panel')==='open';
1907
+ if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';
1908
+ syncWorkspacePanelState();
1909
+ $('emptyState').style.display='';
1910
+ await renderSessionList();
1911
+ // Start real-time gateway session sync if setting is enabled
1912
+ if(typeof startGatewaySSE==='function') startGatewaySSE();
1913
+ })().catch(e=>{
1914
+ console.error('[hermes] boot failed', e);
1915
+ try{S._bootReady=true;}catch(_){}
1916
+ try{syncTopbar();}catch(_){}
1917
+ try{syncWorkspacePanelState();}catch(_){}
1918
+ try{$('emptyState').style.display='';}catch(_){}
1919
+ try{if(typeof renderSessionList==='function') void renderSessionList();}catch(_){}
1920
+ });
1921
+
1922
+ // Fix #822 (bfcache path): when the browser restores the page from the
1923
+ // back-forward cache, the async boot IIFE above does NOT re-run, but the
1924
+ // DOM — including any stale value in #sessionSearch — IS restored. A
1925
+ // prior search string would silently hide all sessions via the filter in
1926
+ // renderSessionListFromCache(). Clear the field and re-run the full layout
1927
+ // sync whenever the page is restored from cache (`event.persisted === true`).
1928
+ // Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
1929
+ // chrome aren't left in the stale bfcache snapshot.
1930
+ window.addEventListener('pageshow', async (event) => {
1931
+ if (!event.persisted) return; // fresh loads are handled by the IIFE above
1932
+ const _srch = document.getElementById('sessionSearch');
1933
+ if (_srch) _srch.value = '';
1934
+ if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear();
1935
+ // Close any dropdowns/popovers that were open when the user navigated away.
1936
+ // bfcache freezes DOM state, so a dropdown left open remains open on restore.
1937
+ if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
1938
+ if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
1939
+ if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
1940
+ if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
1941
+ // BFCache restores the frozen DOM without rerunning boot. Refresh the active
1942
+ // session through the normal load path so in-flight sessions with
1943
+ // active_stream_id / pending_user_message can reattach like a reload restore.
1944
+ if (S.session && S.session.session_id && typeof loadSession === 'function') {
1945
+ try {
1946
+ await loadSession(S.session.session_id);
1947
+ if (S.session && S.session.session_id && typeof checkInflightOnBoot === 'function') {
1948
+ try { await checkInflightOnBoot(S.session.session_id); } catch (_) {}
1949
+ }
1950
+ } catch (_) {}
1951
+ }
1952
+ // Re-synchronise layout chrome that the boot IIFE sets up but bfcache
1953
+ // doesn't re-run. Each call is guarded so missing helpers degrade silently.
1954
+ if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
1955
+ if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
1956
+ if (typeof renderSessionListFromCache === 'function') {
1957
+ try { renderSessionListFromCache(); } catch (_) {}
1958
+ }
1959
+ // Restart the gateway SSE watcher — the persisted connection is dead after bfcache
1960
+ if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
1961
+ // Re-sync sidebar collapse state from localStorage. bfcache restored the
1962
+ // frozen DOM but another tab may have toggled the sidebar in the meantime.
1963
+ if (typeof _isSidebarCollapsed === 'function' && typeof toggleSidebar === 'function') {
1964
+ try {
1965
+ const _want = localStorage.getItem('hermes-webui-sidebar-collapsed') === '1';
1966
+ const _have = _isSidebarCollapsed();
1967
+ if (_want !== _have) toggleSidebar(_want);
1968
+ if (typeof _syncSidebarAria === 'function') _syncSidebarAria();
1969
+ } catch (_) {}
1970
+ }
1971
+ });
1972
+
1973
+ async function shutdownServer() {
1974
+ const ok = await showConfirmDialog({
1975
+ title: (typeof t === 'function' ? t('settings_shutdown_confirm_title') : 'Stop Hermes WebUI'),
1976
+ message: (typeof t === 'function' ? t('settings_shutdown_confirm_message') : 'Stop the Hermes WebUI server?'),
1977
+ confirmLabel: (typeof t === 'function' ? t('settings_shutdown_confirm_btn') : 'Stop'),
1978
+ danger: true,
1979
+ });
1980
+ if (!ok) return;
1981
+ localStorage.setItem('hermes-webui-server-stopped', '1');
1982
+ try { var bc = new BroadcastChannel('hermes-webui-shutdown'); bc.postMessage('stop'); bc.close(); } catch(_) {}
1983
+ _showServerStopped();
1984
+ try { await api('/api/shutdown', { method: 'POST' }); } catch (_) {}
1985
+ }
1986
+
1987
+ function _showServerStopped() {
1988
+ var stoppedMsg = (typeof t === 'function' ? t('settings_shutdown_stopped_message') : 'Server stopped. You can close this tab.');
1989
+ document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:var(--muted);font-family:system-ui,ui-sans-serif;font-size:14px"><p>' + stoppedMsg + '</p></div>';
1990
+ }