aicodeman 1.35.0 → 1.41.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 (316) hide show
  1. package/README.md +33 -7
  2. package/dist/attachment-registry.d.ts +6 -0
  3. package/dist/attachment-registry.d.ts.map +1 -1
  4. package/dist/attachment-registry.js +9 -3
  5. package/dist/attachment-registry.js.map +1 -1
  6. package/dist/cli-agent.d.ts +225 -0
  7. package/dist/cli-agent.d.ts.map +1 -0
  8. package/dist/cli-agent.js +767 -0
  9. package/dist/cli-agent.js.map +1 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +8 -32
  12. package/dist/cli.js.map +1 -1
  13. package/dist/codeman-credentials.d.ts +27 -0
  14. package/dist/codeman-credentials.d.ts.map +1 -0
  15. package/dist/codeman-credentials.js +66 -0
  16. package/dist/codeman-credentials.js.map +1 -0
  17. package/dist/config/cli-registry/patterns.d.ts +6 -0
  18. package/dist/config/cli-registry/patterns.d.ts.map +1 -1
  19. package/dist/config/cli-registry/patterns.js +13 -0
  20. package/dist/config/cli-registry/patterns.js.map +1 -1
  21. package/dist/config/cli-registry/schema.d.ts +15 -0
  22. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  23. package/dist/config/cli-registry/schema.js +63 -2
  24. package/dist/config/cli-registry/schema.js.map +1 -1
  25. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  26. package/dist/config/cli-registry/stock.js +180 -7
  27. package/dist/config/cli-registry/stock.js.map +1 -1
  28. package/dist/config/cli-registry/types.d.ts +105 -7
  29. package/dist/config/cli-registry/types.d.ts.map +1 -1
  30. package/dist/deepseek-route-config.d.ts +110 -0
  31. package/dist/deepseek-route-config.d.ts.map +1 -0
  32. package/dist/deepseek-route-config.js +477 -0
  33. package/dist/deepseek-route-config.js.map +1 -0
  34. package/dist/git-clone.d.ts.map +1 -1
  35. package/dist/git-clone.js +4 -0
  36. package/dist/git-clone.js.map +1 -1
  37. package/dist/git-workspace-status.d.ts +31 -5
  38. package/dist/git-workspace-status.d.ts.map +1 -1
  39. package/dist/git-workspace-status.js +49 -29
  40. package/dist/git-workspace-status.js.map +1 -1
  41. package/dist/image-watcher.d.ts.map +1 -1
  42. package/dist/image-watcher.js +6 -2
  43. package/dist/image-watcher.js.map +1 -1
  44. package/dist/mcp-sync-targets.d.ts +34 -0
  45. package/dist/mcp-sync-targets.d.ts.map +1 -0
  46. package/dist/mcp-sync-targets.js +55 -0
  47. package/dist/mcp-sync-targets.js.map +1 -0
  48. package/dist/mcp-sync.d.ts +2 -1
  49. package/dist/mcp-sync.d.ts.map +1 -1
  50. package/dist/mcp-sync.js +62 -1
  51. package/dist/mcp-sync.js.map +1 -1
  52. package/dist/model-config-resolvers.d.ts +29 -0
  53. package/dist/model-config-resolvers.d.ts.map +1 -0
  54. package/dist/model-config-resolvers.js +34 -0
  55. package/dist/model-config-resolvers.js.map +1 -0
  56. package/dist/session-display-model.d.ts +110 -0
  57. package/dist/session-display-model.d.ts.map +1 -0
  58. package/dist/session-display-model.js +154 -0
  59. package/dist/session-display-model.js.map +1 -0
  60. package/dist/session.d.ts +145 -8
  61. package/dist/session.d.ts.map +1 -1
  62. package/dist/session.js +290 -39
  63. package/dist/session.js.map +1 -1
  64. package/dist/subagent-watcher.d.ts.map +1 -1
  65. package/dist/subagent-watcher.js +29 -6
  66. package/dist/subagent-watcher.js.map +1 -1
  67. package/dist/templates/case-template.md +2 -0
  68. package/dist/tmux-manager.d.ts +5 -0
  69. package/dist/tmux-manager.d.ts.map +1 -1
  70. package/dist/tmux-manager.js +47 -22
  71. package/dist/tmux-manager.js.map +1 -1
  72. package/dist/tui/tui-client.d.ts +3 -17
  73. package/dist/tui/tui-client.d.ts.map +1 -1
  74. package/dist/tui/tui-client.js +3 -44
  75. package/dist/tui/tui-client.js.map +1 -1
  76. package/dist/types/session.d.ts +28 -1
  77. package/dist/types/session.d.ts.map +1 -1
  78. package/dist/types/session.js.map +1 -1
  79. package/dist/types/tools.d.ts +1 -1
  80. package/dist/types/tools.d.ts.map +1 -1
  81. package/dist/utils/deepseek-cli-resolver.d.ts +8 -0
  82. package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
  83. package/dist/utils/deepseek-cli-resolver.js +18 -2
  84. package/dist/utils/deepseek-cli-resolver.js.map +1 -1
  85. package/dist/utils/index.d.ts +1 -0
  86. package/dist/utils/index.d.ts.map +1 -1
  87. package/dist/utils/index.js +1 -0
  88. package/dist/utils/index.js.map +1 -1
  89. package/dist/utils/process-exit-wait.d.ts +40 -0
  90. package/dist/utils/process-exit-wait.d.ts.map +1 -0
  91. package/dist/utils/process-exit-wait.js +68 -0
  92. package/dist/utils/process-exit-wait.js.map +1 -0
  93. package/dist/web/launch-defaults.d.ts +22 -0
  94. package/dist/web/launch-defaults.d.ts.map +1 -0
  95. package/dist/web/launch-defaults.js +46 -0
  96. package/dist/web/launch-defaults.js.map +1 -0
  97. package/dist/web/paste-image-gc.d.ts +46 -6
  98. package/dist/web/paste-image-gc.d.ts.map +1 -1
  99. package/dist/web/paste-image-gc.js +106 -32
  100. package/dist/web/paste-image-gc.js.map +1 -1
  101. package/dist/web/public/admin-ui.js.gz +0 -0
  102. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  103. package/dist/web/public/app.6f8552e1.js +43 -0
  104. package/dist/web/public/app.6f8552e1.js.br +0 -0
  105. package/dist/web/public/app.6f8552e1.js.gz +0 -0
  106. package/dist/web/public/approvals-ui.js.gz +0 -0
  107. package/dist/web/public/{constants.d95fade9.js → constants.bc9c1f63.js} +1276 -127
  108. package/dist/web/public/constants.bc9c1f63.js.br +0 -0
  109. package/dist/web/public/constants.bc9c1f63.js.gz +0 -0
  110. package/dist/web/public/cron-ui.js.gz +0 -0
  111. package/dist/web/public/entrance-animations.js +491 -43
  112. package/dist/web/public/entrance-animations.js.br +0 -0
  113. package/dist/web/public/entrance-animations.js.gz +0 -0
  114. package/dist/web/public/git-status-ui.js +55 -9
  115. package/dist/web/public/git-status-ui.js.br +0 -0
  116. package/dist/web/public/git-status-ui.js.gz +0 -0
  117. package/dist/web/public/home-sessions.js +13 -9
  118. package/dist/web/public/home-sessions.js.br +0 -0
  119. package/dist/web/public/home-sessions.js.gz +0 -0
  120. package/dist/web/public/host-wake-ui.js.gz +0 -0
  121. package/dist/web/public/i18n.a9840ea2.js +2 -0
  122. package/dist/web/public/i18n.a9840ea2.js.br +0 -0
  123. package/dist/web/public/i18n.a9840ea2.js.gz +0 -0
  124. package/dist/web/public/{image-input.a216cf9e.js → image-input.e00471c6.js} +21 -9
  125. package/dist/web/public/image-input.e00471c6.js.br +0 -0
  126. package/dist/web/public/image-input.e00471c6.js.gz +0 -0
  127. package/dist/web/public/index.html +250 -59
  128. package/dist/web/public/index.html.br +0 -0
  129. package/dist/web/public/index.html.gz +0 -0
  130. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  131. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  132. package/dist/web/public/{mobile-handlers.23a85db1.js → mobile-handlers.0ef9c311.js} +20 -0
  133. package/dist/web/public/mobile-handlers.0ef9c311.js.br +0 -0
  134. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  135. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  136. package/dist/web/public/mobile-overview.js.gz +0 -0
  137. package/dist/web/public/mobile.111b187d.css +1 -0
  138. package/dist/web/public/mobile.111b187d.css.br +0 -0
  139. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  140. package/dist/web/public/{notification-manager.50bb87d8.js → notification-manager.feb82c04.js} +19 -2
  141. package/dist/web/public/notification-manager.feb82c04.js.br +0 -0
  142. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  143. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  144. package/dist/web/public/{panels-ui.6a5898d9.js → panels-ui.71846fcb.js} +4 -4
  145. package/dist/web/public/panels-ui.71846fcb.js.br +0 -0
  146. package/dist/web/public/panels-ui.71846fcb.js.gz +0 -0
  147. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  148. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  149. package/dist/web/public/readmymind-ui.js.gz +0 -0
  150. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  151. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  152. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  153. package/dist/web/public/session-lineage.js +229 -86
  154. package/dist/web/public/session-lineage.js.br +0 -0
  155. package/dist/web/public/session-lineage.js.gz +0 -0
  156. package/dist/web/public/session-ui.f731602c.js +100 -0
  157. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  158. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  159. package/dist/web/public/settings-ui.4e33390f.js +97 -0
  160. package/dist/web/public/settings-ui.4e33390f.js.br +0 -0
  161. package/dist/web/public/settings-ui.4e33390f.js.gz +0 -0
  162. package/dist/web/public/spreadsheet-preview-worker.js +290 -0
  163. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  164. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  165. package/dist/web/public/spreadsheet-preview.af82fa2f.js +555 -0
  166. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  167. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  168. package/dist/web/public/spreadsheet-xlsx-core.js +1006 -0
  169. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  170. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  171. package/dist/web/public/styles.81a72906.css +1 -0
  172. package/dist/web/public/styles.81a72906.css.br +0 -0
  173. package/dist/web/public/styles.81a72906.css.gz +0 -0
  174. package/dist/web/public/{subagent-windows.e6ca799f.js → subagent-windows.07277a58.js} +7 -5
  175. package/dist/web/public/subagent-windows.07277a58.js.br +0 -0
  176. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  177. package/dist/web/public/sw.js +2 -2
  178. package/dist/web/public/sw.js.br +0 -0
  179. package/dist/web/public/sw.js.gz +0 -0
  180. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  181. package/dist/web/public/{tab-rail-resize.94cd59f0.js → tab-rail-resize.a5867793.js} +1 -1
  182. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  183. package/dist/web/public/{tab-rail-resize.94cd59f0.js.gz → tab-rail-resize.a5867793.js.gz} +0 -0
  184. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js +1 -0
  185. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.br +0 -0
  186. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  187. package/dist/web/public/terminal-split.js +146 -649
  188. package/dist/web/public/terminal-split.js.br +0 -0
  189. package/dist/web/public/terminal-split.js.gz +0 -0
  190. package/dist/web/public/terminal-tile.js +1688 -0
  191. package/dist/web/public/terminal-tile.js.br +0 -0
  192. package/dist/web/public/terminal-tile.js.gz +0 -0
  193. package/dist/web/public/terminal-ui.b7267005.js +2 -0
  194. package/dist/web/public/terminal-ui.b7267005.js.br +0 -0
  195. package/dist/web/public/terminal-ui.b7267005.js.gz +0 -0
  196. package/dist/web/public/tile-grid.js +2895 -0
  197. package/dist/web/public/tile-grid.js.br +0 -0
  198. package/dist/web/public/tile-grid.js.gz +0 -0
  199. package/dist/web/public/ultracode-panel.js.gz +0 -0
  200. package/dist/web/public/ultracode-windows.js +9 -6
  201. package/dist/web/public/ultracode-windows.js.br +0 -0
  202. package/dist/web/public/ultracode-windows.js.gz +0 -0
  203. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  204. package/dist/web/public/vendor/exceljs.min.js +44 -0
  205. package/dist/web/public/vendor/exceljs.min.js.br +0 -0
  206. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  207. package/dist/web/public/vendor/fflate.min.js +1 -0
  208. package/dist/web/public/vendor/fflate.min.js.br +0 -0
  209. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  210. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  211. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  212. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  213. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  214. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  215. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  216. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +4 -0
  217. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
  218. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  219. package/dist/web/public/vendor/xterm.css.gz +0 -0
  220. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  221. package/dist/web/public/{voice-input.c4b51eb6.js → voice-input.2342c37b.js} +64 -10
  222. package/dist/web/public/voice-input.2342c37b.js.br +0 -0
  223. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  224. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  225. package/dist/web/public/webview-tabs.js +9 -1
  226. package/dist/web/public/webview-tabs.js.br +0 -0
  227. package/dist/web/public/webview-tabs.js.gz +0 -0
  228. package/dist/web/routes/cli-registry-routes.d.ts +23 -4
  229. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  230. package/dist/web/routes/cli-registry-routes.js +95 -8
  231. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  232. package/dist/web/routes/file-routes.d.ts +7 -0
  233. package/dist/web/routes/file-routes.d.ts.map +1 -1
  234. package/dist/web/routes/file-routes.js +34 -4
  235. package/dist/web/routes/file-routes.js.map +1 -1
  236. package/dist/web/routes/git-status-routes.d.ts.map +1 -1
  237. package/dist/web/routes/git-status-routes.js +33 -6
  238. package/dist/web/routes/git-status-routes.js.map +1 -1
  239. package/dist/web/routes/mcp-sync-routes.d.ts +7 -2
  240. package/dist/web/routes/mcp-sync-routes.d.ts.map +1 -1
  241. package/dist/web/routes/mcp-sync-routes.js +10 -3
  242. package/dist/web/routes/mcp-sync-routes.js.map +1 -1
  243. package/dist/web/routes/reboot-restore-routes.js +1 -1
  244. package/dist/web/routes/reboot-restore-routes.js.map +1 -1
  245. package/dist/web/routes/session-routes.d.ts +18 -0
  246. package/dist/web/routes/session-routes.d.ts.map +1 -1
  247. package/dist/web/routes/session-routes.js +154 -69
  248. package/dist/web/routes/session-routes.js.map +1 -1
  249. package/dist/web/routes/status-telemetry-routes.d.ts +3 -0
  250. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  251. package/dist/web/routes/status-telemetry-routes.js +12 -0
  252. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  253. package/dist/web/routes/system-routes.d.ts.map +1 -1
  254. package/dist/web/routes/system-routes.js +13 -0
  255. package/dist/web/routes/system-routes.js.map +1 -1
  256. package/dist/web/schemas.d.ts +30 -0
  257. package/dist/web/schemas.d.ts.map +1 -1
  258. package/dist/web/schemas.js +43 -0
  259. package/dist/web/schemas.js.map +1 -1
  260. package/dist/web/server.d.ts +6 -1
  261. package/dist/web/server.d.ts.map +1 -1
  262. package/dist/web/server.js +47 -21
  263. package/dist/web/server.js.map +1 -1
  264. package/dist/web/session-listener-wiring.d.ts +1 -0
  265. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  266. package/dist/web/session-listener-wiring.js +11 -0
  267. package/dist/web/session-listener-wiring.js.map +1 -1
  268. package/package.json +6 -4
  269. package/scripts/postinstall.js +16 -0
  270. package/skills/codeman/SKILL.md +6 -0
  271. package/README.zh-CN.md +0 -1139
  272. package/dist/web/public/app.b9516c7b.js +0 -43
  273. package/dist/web/public/app.b9516c7b.js.br +0 -0
  274. package/dist/web/public/app.b9516c7b.js.gz +0 -0
  275. package/dist/web/public/constants.d95fade9.js.br +0 -0
  276. package/dist/web/public/constants.d95fade9.js.gz +0 -0
  277. package/dist/web/public/i18n.52f5b7cc.js +0 -1
  278. package/dist/web/public/i18n.52f5b7cc.js.br +0 -0
  279. package/dist/web/public/i18n.52f5b7cc.js.gz +0 -0
  280. package/dist/web/public/image-input.a216cf9e.js.br +0 -0
  281. package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
  282. package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
  283. package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
  284. package/dist/web/public/mobile.1aa4952d.css +0 -1
  285. package/dist/web/public/mobile.1aa4952d.css.br +0 -0
  286. package/dist/web/public/mobile.1aa4952d.css.gz +0 -0
  287. package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
  288. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  289. package/dist/web/public/panels-ui.6a5898d9.js.br +0 -0
  290. package/dist/web/public/panels-ui.6a5898d9.js.gz +0 -0
  291. package/dist/web/public/session-ui.6bddf916.js +0 -89
  292. package/dist/web/public/session-ui.6bddf916.js.br +0 -0
  293. package/dist/web/public/session-ui.6bddf916.js.gz +0 -0
  294. package/dist/web/public/settings-ui.6e798b7b.js +0 -97
  295. package/dist/web/public/settings-ui.6e798b7b.js.br +0 -0
  296. package/dist/web/public/settings-ui.6e798b7b.js.gz +0 -0
  297. package/dist/web/public/styles.2d175c60.css +0 -1
  298. package/dist/web/public/styles.2d175c60.css.br +0 -0
  299. package/dist/web/public/styles.2d175c60.css.gz +0 -0
  300. package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
  301. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  302. package/dist/web/public/tab-rail-resize.94cd59f0.js.br +0 -0
  303. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js +0 -1
  304. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.br +0 -0
  305. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  306. package/dist/web/public/terminal-ui.a4535311.js +0 -2
  307. package/dist/web/public/terminal-ui.a4535311.js.br +0 -0
  308. package/dist/web/public/terminal-ui.a4535311.js.gz +0 -0
  309. package/dist/web/public/upload.html +0 -155
  310. package/dist/web/public/upload.html.br +0 -0
  311. package/dist/web/public/upload.html.gz +0 -0
  312. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js +0 -4
  313. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.br +0 -0
  314. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
  315. package/dist/web/public/voice-input.c4b51eb6.js.br +0 -0
  316. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
@@ -0,0 +1,2895 @@
1
+ // src/web/public/tile-grid.js
2
+
3
+ /**
4
+ * @fileoverview The tile grid: 1 to TILE_GRID_MAX (6) live sessions side by
5
+ * side in one window, each in its own TerminalTile (terminal-tile.js), laid
6
+ * out by count (window.CodemanTileGrid, constants.js). Desktop only; see
7
+ * docs/tile-grid-plan.md.
8
+ *
9
+ * While the grid is open the main terminal (terminal-ui.js) is PARKED: hidden,
10
+ * its socket closed, and every path that would write to it, fetch for it or
11
+ * reconnect it stands aside (`_tilesOwnTerminal()`). `activeSessionId` always
12
+ * names the FOCUSED tile's session, so everything keyed on it (files panel,
13
+ * respawn and Ralph panels, subagent windows, voice, image paste, the tab
14
+ * highlight) follows focus without knowing tiles exist.
15
+ *
16
+ * Every capture a tile fetches goes through the grid's one TileLoadQueue,
17
+ * because each is a synchronous tmux call on the server.
18
+ *
19
+ * @dependency terminal-tile.js (window.TerminalTile, window.TileLoadQueue)
20
+ * @dependency constants.js (window.CodemanTileGrid, SPLIT_PANE_MIN_WIDTH)
21
+ * @loadorder 7.6 of 16, loaded after terminal-split.js and before respawn-ui.js
22
+ */
23
+
24
+ // Per-device tile font size (a tile is a fraction of the screen).
25
+ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
26
+ // The grid this browser last had, as the user left it (sanitizeTileGridState,
27
+ // constants.js): `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`,
28
+ // `ids` being the cells in reading order with `null` for an empty one, `count`
29
+ // how many tiles the user's own last change left (a session that went away by
30
+ // itself does not lower it). Session ids and layout only, never content.
31
+ // Written on every change while the grid is open; `open: false` keeps it for
32
+ // the Tiles toggle, which brings it back exactly; a grid stored open is
33
+ // restored on reload inside handleInit. Never sent to the server.
34
+ const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
35
+ // The count last picked in the Tiles button's right-click menu (2, 4 or 6;
36
+ // owner decision 10), per device: what a click opens when there is no stored
37
+ // grid to bring back, and what a pick re-forms the grid to.
38
+ const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
39
+ // The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still
40
+ // copy of a closing grid goes even if animationend never comes (a hidden tab,
41
+ // a cancelled animation).
42
+ const TILE_GHOST_FALLBACK_MS = 450;
43
+ // The longest a closing grid's still copy waits for the single view's replay
44
+ // before it fades anyway, so a slow load never leaves a stale picture up.
45
+ const TILE_GHOST_HOLD_MAX_MS = 700;
46
+ // A tile's terminal shows once its first capture has landed (the load queue
47
+ // says so); this is the backstop should that never be reported.
48
+ const TILE_REVEAL_FALLBACK_MS = 15000;
49
+ // How long the pointer (or a keyboard focus) rests on the Tiles button before
50
+ // its hover card shows (owner feedback 1).
51
+ const TILE_HINT_DELAY_MS = 300;
52
+ // Trailing debounce for refitting tiles after the grid area changes size, so a
53
+ // window drag sends each tile's PTY one resize, not one per frame.
54
+ const TILE_GRID_REFIT_MS = 150;
55
+ // Width of the draggable column and row dividers (their own grid tracks), and
56
+ // the grid section's padding (styles.css .tile-grid), for the drag math.
57
+ const TILE_DIVIDER_PX = 6;
58
+ const TILE_GRID_PADDING_PX = 4;
59
+
60
+ /** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
61
+ function tileGridTracks(fr) {
62
+ return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `);
63
+ }
64
+ // Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each
65
+ // needs the grid open. The toggle applies wherever a grid could open. The arrow
66
+ // chords (`direction`, `move`) leave a text field its keys (tileShortcutFor).
67
+ const TILE_SHORTCUTS = {
68
+ 'toggle-tile-grid': { needsOpen: false },
69
+ 'focus-tile-left': { needsOpen: true, direction: 'left' },
70
+ 'focus-tile-right': { needsOpen: true, direction: 'right' },
71
+ 'focus-tile-up': { needsOpen: true, direction: 'up' },
72
+ 'focus-tile-down': { needsOpen: true, direction: 'down' },
73
+ 'move-tile-left': { needsOpen: true, move: 'left' },
74
+ 'move-tile-right': { needsOpen: true, move: 'right' },
75
+ 'move-tile-up': { needsOpen: true, move: 'up' },
76
+ 'move-tile-down': { needsOpen: true, move: 'down' },
77
+ 'remove-tile': { needsOpen: true },
78
+ 'zoom-tile': { needsOpen: true },
79
+ };
80
+
81
+ /**
82
+ * A text field other than a terminal's own input (xterm's helper textarea):
83
+ * the rename input, the file editor, a settings field. Shifted arrows select
84
+ * there (Ctrl+Shift by word; Option+Shift by word on macOS), so the arrow
85
+ * chords, focus and move, leave it alone (owner: best practice).
86
+ */
87
+ function isTextFieldTarget(target) {
88
+ if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false;
89
+ const tag = String(target.tagName || '').toUpperCase();
90
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true;
91
+ }
92
+
93
+ /**
94
+ * The count menu's picture of `n` tiles: the grid's own layout for that count
95
+ * (2x1, 2x2, 3x2) as small rounded cells.
96
+ */
97
+ function tileCountGlyph(n) {
98
+ const { cols, rows } = window.CodemanTileGrid.computeTileLayout({ count: n });
99
+ const NS = 'http://www.w3.org/2000/svg';
100
+ const svg = document.createElementNS(NS, 'svg');
101
+ svg.setAttribute('class', 'tile-count-glyph');
102
+ svg.setAttribute('viewBox', '0 0 30 20');
103
+ svg.setAttribute('aria-hidden', 'true');
104
+ const gap = 3;
105
+ const w = (30 - gap * (cols - 1)) / cols;
106
+ const h = (20 - gap * (rows - 1)) / rows;
107
+ for (let r = 0; r < rows; r++) {
108
+ for (let c = 0; c < cols; c++) {
109
+ const rect = document.createElementNS(NS, 'rect');
110
+ rect.setAttribute('x', String(c * (w + gap)));
111
+ rect.setAttribute('y', String(r * (h + gap)));
112
+ rect.setAttribute('width', String(w));
113
+ rect.setAttribute('height', String(h));
114
+ rect.setAttribute('rx', '1.5');
115
+ svg.appendChild(rect);
116
+ }
117
+ }
118
+ return svg;
119
+ }
120
+
121
+ /**
122
+ * The hover card's mouse: a rounded body with its left or right button filled.
123
+ */
124
+ function tileHintMouseGlyph(button) {
125
+ const NS = 'http://www.w3.org/2000/svg';
126
+ const svg = document.createElementNS(NS, 'svg');
127
+ svg.setAttribute('class', `tile-hint-glyph tile-hint-glyph--${button}`);
128
+ svg.setAttribute('viewBox', '0 0 10 14');
129
+ svg.setAttribute('aria-hidden', 'true');
130
+ const body = document.createElementNS(NS, 'rect');
131
+ for (const [k, v] of Object.entries({ x: '0.75', y: '0.75', width: '8.5', height: '12.5', rx: '4.25' })) {
132
+ body.setAttribute(k, v);
133
+ }
134
+ const press = document.createElementNS(NS, 'path');
135
+ press.setAttribute('class', 'tile-hint-press');
136
+ press.setAttribute(
137
+ 'd',
138
+ button === 'left'
139
+ ? 'M4.6 1.3 V6 H1.3 V5.2 A3.6 3.6 0 0 1 4.6 1.3 Z'
140
+ : 'M5.4 1.3 V6 H8.7 V5.2 A3.6 3.6 0 0 0 5.4 1.3 Z'
141
+ );
142
+ svg.append(body, press);
143
+ return svg;
144
+ }
145
+
146
+ /** The grid's state. `has(id)` answers only while it is open. */
147
+ class TileGridModel {
148
+ constructor() {
149
+ this.open = false;
150
+ // The grid's cells in reading order (row-major), cols x rows of them: a
151
+ // session id, or null for an empty cell. THE source of truth for where
152
+ // each tile is (owner: an empty cell can be any cell); `ids` derives from it.
153
+ this.cells = [];
154
+ // How many tiles the user's own last change left (open, add, remove, a
155
+ // count picked): a session that goes away by itself (deleted, popped out,
156
+ // refused) does not lower it, so the next time the grid opens the ranking
157
+ // fills that place. Stored with the grid (_persistTileGrid).
158
+ this.count = 0;
159
+ // id -> { tile: TerminalTile, el: HTMLElement }
160
+ this.tiles = new Map();
161
+ this.focusedId = null;
162
+ // The tile filling the grid (tmux zoom), or null. `autoZoom`: zoomed by the
163
+ // grid itself because the window cannot fit the tiles; it follows focus and
164
+ // lifts once the window fits again.
165
+ this.zoomedId = null;
166
+ this.autoZoom = false;
167
+ // Track fractions (grid-template fr values) set by the dividers; equal
168
+ // again whenever the column or row count changes.
169
+ this.colFr = [];
170
+ this.rowFr = [];
171
+ // 'col-<i>' / 'row-<i>' -> the divider element between track i and i+1.
172
+ this.dividers = new Map();
173
+ this.cols = 0;
174
+ this.rows = 0;
175
+ this.queue = null;
176
+ this.resizeObserver = null;
177
+ this.refitTimer = null;
178
+ }
179
+
180
+ has(id) {
181
+ return this.open && this.tiles.has(id);
182
+ }
183
+
184
+ /** The tiled sessions in reading order, holes skipped (a fresh array: change `cells`, never this). */
185
+ get ids() {
186
+ return this.cells.filter(Boolean);
187
+ }
188
+ }
189
+
190
+ Object.assign(CodemanApp.prototype, {
191
+ /**
192
+ * True while the grid owns the terminal area and the main terminal is parked.
193
+ * Every main-terminal path that would write, fetch, resize or reconnect checks
194
+ * this and stands aside.
195
+ */
196
+ _tilesOwnTerminal() {
197
+ return !!this._tileGrid?.open;
198
+ },
199
+
200
+ /** The open grid's TerminalTile for a session, or null. */
201
+ _tileFor(sessionId) {
202
+ return this._tileGrid?.open ? (this._tileGrid.tiles.get(sessionId)?.tile ?? null) : null;
203
+ },
204
+
205
+ /** Desktop only, never in a solo (popped-out) window; same gate as the split. */
206
+ canOpenTileGrid() {
207
+ return !this.isSoloWindow && window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
208
+ },
209
+
210
+ _tileGridFontSize() {
211
+ let saved = NaN;
212
+ try {
213
+ saved = parseInt(localStorage.getItem(TILE_GRID_FONT_KEY), 10);
214
+ } catch {
215
+ /* Storage unavailable: the default below. */
216
+ }
217
+ return saved >= 10 && saved <= 24 ? saved : window.CodemanTileGrid.TILE_FONT_SIZE_DEFAULT;
218
+ },
219
+
220
+ /** Ctrl +/- while the grid is open: every tile, then each tile's PTY (a font change is a size change, #464). */
221
+ setTileFontSize(size) {
222
+ try {
223
+ localStorage.setItem(TILE_GRID_FONT_KEY, String(size));
224
+ } catch {
225
+ /* Per-device convenience only. */
226
+ }
227
+ for (const { tile } of this._tileGrid?.tiles.values() || []) {
228
+ tile.fontSize = size;
229
+ if (!tile.terminal) continue;
230
+ tile.terminal.options.fontSize = size;
231
+ tile.fit();
232
+ }
233
+ },
234
+
235
+ _tileLoadQueue() {
236
+ const grid = this._tileGrid;
237
+ if (!grid.queue) {
238
+ grid.queue = new window.TileLoadQueue({
239
+ // The focused tile first, then reading order.
240
+ rank: (tile) => (tile.sessionId === grid.focusedId ? -1 : grid.ids.indexOf(tile.sessionId)),
241
+ // A quiet "loading" state on a tile until its capture has landed.
242
+ onChange: (tile, state) => {
243
+ const entry = grid.tiles.get(tile.sessionId);
244
+ if (entry?.tile !== tile) return;
245
+ // Rewritten on the way in, so a language switched since is picked up.
246
+ if (state !== 'idle') this._setTileLoadingLabel(entry.body);
247
+ entry.el.classList.toggle('tile--loading', state !== 'idle');
248
+ // The first capture has landed (or failed): the terminal fades in,
249
+ // whole, instead of showing its replay scroll by, or plays the
250
+ // terminal pane's entrance style (entrance-animations.js).
251
+ if (state === 'idle') {
252
+ entry.el.classList.remove('tile--revealing');
253
+ if (entry.screenOwed) {
254
+ entry.screenOwed = false;
255
+ this.playTileScreenEntrance?.(entry.body);
256
+ }
257
+ }
258
+ },
259
+ });
260
+ }
261
+ return grid.queue;
262
+ },
263
+
264
+ /**
265
+ * The "Loading…" label of a tile body. It is CSS generated content (styles.css,
266
+ * `content: attr(data-loading-label)`), which the i18n layer never reaches, so
267
+ * the text is written here in the UI language.
268
+ */
269
+ _setTileLoadingLabel(body) {
270
+ if (!body) return;
271
+ const t = window.codemanT;
272
+ body.dataset.loadingLabel = typeof t === 'function' ? t('Loading…') : 'Loading…';
273
+ },
274
+
275
+ _tileGridSection() {
276
+ let section = document.getElementById('tileGrid');
277
+ if (!section) {
278
+ section = document.createElement('section');
279
+ section.id = 'tileGrid';
280
+ section.className = 'tile-grid';
281
+ section.setAttribute('aria-label', 'Tiled sessions');
282
+ const wrap = document.querySelector('.terminal-wrap');
283
+ wrap?.parentElement?.insertBefore(section, wrap.nextSibling);
284
+ }
285
+ // Once per section (index.html ships it, so not only on create).
286
+ if (this._tileFileDropSection !== section) {
287
+ this._tileFileDropSection = section;
288
+ this._installTileFileDrop(section);
289
+ }
290
+ return section;
291
+ },
292
+
293
+ /**
294
+ * A file dragged over the grid. The single view's file drop (image-input.js)
295
+ * listens on #terminalContainer, hidden while tiles are open, so nothing
296
+ * cancelled a file drag here and the browser opened the file in place of
297
+ * Codeman. Anywhere over the grid (a tile, an empty cell, a divider, the
298
+ * padding) the drag is cancelled, so the page never navigates; dropped on a
299
+ * tile, its images upload to THAT tile's session and their paths are typed
300
+ * there, as the single view does for the active one. Bubble phase, files
301
+ * only: a tab or tile drag carries none, and its target stops it in the
302
+ * capture phase anyway (_acceptTabDrops).
303
+ */
304
+ _installTileFileDrop(section) {
305
+ const isFileDrag = (e) => {
306
+ const types = e.dataTransfer?.types;
307
+ return !!types && Array.from(types).includes('Files');
308
+ };
309
+ // The open grid's tile under `target`, or null (an empty cell, a divider, the padding).
310
+ const tileAt = (target) => {
311
+ const grid = this._tileGrid;
312
+ if (!grid?.open || !target) return null;
313
+ for (const [id, entry] of grid.tiles) if (entry.el.contains?.(target)) return id;
314
+ return null;
315
+ };
316
+ section.addEventListener('dragover', (e) => {
317
+ if (!isFileDrag(e)) return;
318
+ e.preventDefault();
319
+ if (e.dataTransfer && tileAt(e.target)) e.dataTransfer.dropEffect = 'copy';
320
+ });
321
+ section.addEventListener('drop', (e) => {
322
+ if (!isFileDrag(e)) return;
323
+ e.preventDefault();
324
+ const sessionId = tileAt(e.target);
325
+ const files = Array.from(e.dataTransfer?.files || []);
326
+ if (!sessionId || files.length === 0) return;
327
+ const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
328
+ if (images.length === 0) {
329
+ this.showToast?.('Only image files are supported', 'error');
330
+ return;
331
+ }
332
+ this._uploadAndInsertImages?.(images, { sessionId });
333
+ });
334
+ },
335
+
336
+ /**
337
+ * Opens the grid on `ids` (unknown, detached and duplicate ids are skipped;
338
+ * at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it
339
+ * adds what is missing and moves focus. `auto: false` makes the focus a human
340
+ * selection (it acknowledges that session's idle alert). An open split
341
+ * closes (the two are never open together); `mergeSplit` (default) makes its
342
+ * two sessions the first tiles, false opens exactly `ids` (a stored grid, a
343
+ * group, a Ctrl/Cmd+click: callers that size their own set).
344
+ *
345
+ * Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
346
+ * its snapshot of the session it shows is still right, and closes its socket.
347
+ *
348
+ * @returns {boolean} whether the grid is open afterwards
349
+ */
350
+ openTileGrid(ids, { focusedId = null, auto = true, mergeSplit = true } = {}) {
351
+ if (!this.canOpenTileGrid()) return false;
352
+ const grid = (this._tileGrid ||= new TileGridModel());
353
+ const max = window.CodemanTileGrid.TILE_GRID_MAX;
354
+ // The grid and the split are never open together. An open split becomes the
355
+ // grid's first two tiles (Pane A focused, Pane B beside it), so "split, then
356
+ // want more" is one step.
357
+ let requested = ids || [];
358
+ const splitOpen = !!this._splitPane;
359
+ if (splitOpen && mergeSplit) {
360
+ const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
361
+ requested = [...seed, ...requested];
362
+ if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
363
+ }
364
+ const wanted = [];
365
+ for (const id of requested) {
366
+ if (typeof id !== 'string' || wanted.includes(id)) continue;
367
+ if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue;
368
+ wanted.push(id);
369
+ if (wanted.length === max) break;
370
+ }
371
+ // No closing resize for Pane A only when it becomes a tile (its tile sizes
372
+ // the PTY). A Pane A left out of the set (a group, a stored grid: mergeSplit
373
+ // false) gets its full width back now, while the main terminal still shows
374
+ // it, or its PTY stays at the split's half width for as long as the grid
375
+ // is open.
376
+ if (splitOpen) this.closeSplitPane({ skipPrimaryResize: wanted.includes(this.activeSessionId) });
377
+ if (wanted.length === 0) return grid.open;
378
+ const focus = wanted.includes(focusedId) ? focusedId : wanted[0];
379
+
380
+ if (grid.open) {
381
+ for (const id of wanted) this.addTile(id);
382
+ if (grid.has(focus)) this._selectTiledSession(focus, { auto });
383
+ return true;
384
+ }
385
+
386
+ // No snapshot of a session that becomes a tile: closing the grid drops the
387
+ // main terminal's snapshot of every tiled id (stale by then), so taking one
388
+ // here (a 1000-line serialize and up to 256 KB of localStorage for a
389
+ // non-shell session) only ever produced a copy that was thrown away.
390
+ this._cleanupPreviousSession(focus, { skipSnapshot: wanted.includes(this.activeSessionId) });
391
+ // A grid closed a moment ago may still be fading out over the stage.
392
+ this._purgeTileGhosts();
393
+ grid.open = true;
394
+ grid.cells = [];
395
+ grid.cols = 0;
396
+ grid.rows = 0;
397
+ grid.focusedId = focus;
398
+ document.querySelector('.main')?.classList.add('tiles-active');
399
+ const section = this._tileGridSection();
400
+ this.hideWelcome();
401
+ // Mount every tile and lay the grid out BEFORE any tile connects, so each
402
+ // first fit measures its real cell. Each tile enters in
403
+ // reading order (opacity and transform only: the fit measures the final
404
+ // cell, so the animation adds no resize).
405
+ wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
406
+ // Packed from the first cell (_applyTileLayout pads the shape with empty cells).
407
+ grid.cells = wanted.filter((id) => grid.tiles.has(id));
408
+ // What the user opened is the grid they want (_openStoredTileGrid keeps a
409
+ // stored count instead).
410
+ grid.count = grid.ids.length;
411
+ this._applyTileLayout();
412
+ // The tiles' frames paint first; their terminals are built one per frame
413
+ // after it, the focused tile's first, so its capture is the one the queue
414
+ // starts with (_connectTilesPaced).
415
+ this._connectTilesPaced([focus, ...wanted.filter((id) => id !== focus)]);
416
+ if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') {
417
+ // The main terminal's observer watches a node that is now hidden; this one
418
+ // catches window resizes, sidebar toggles and rail drags for the grid.
419
+ grid.resizeObserver = new ResizeObserver(() => this._scheduleTileGridRefit());
420
+ grid.resizeObserver.observe(section);
421
+ }
422
+ this._installTileGridWidthGate();
423
+ this._selectTiledSession(focus, { auto });
424
+ this._updateConnectionIndicator?.();
425
+ this._updateSplitButtonForTiles();
426
+ this._updateTileGridButtonState();
427
+ return true;
428
+ },
429
+
430
+ /**
431
+ * Leaves the grid: every tile destroyed (sockets closed, xterms disposed,
432
+ * queued loads dropped), the main terminal unparked.
433
+ *
434
+ * `keepStored` (every caller) keeps the grid as it was, holes, count, sizes,
435
+ * focus and zoom, closed, for the Tiles toggle to bring back (a reload
436
+ * restores only a grid stored open); false would forget it. `reselect` shows the focused session in the single
437
+ * view through a forced reload; pass false when the caller selects
438
+ * something itself. `animate` (the Tiles toggle only, owner answer 4)
439
+ * leaves a still copy of the tiles over the stage until the single view has
440
+ * its content (_ghostTileGrid).
441
+ *
442
+ * The main terminal's cached content for EVERY tiled id is invalidated: it was
443
+ * written before the grid opened, possibly hours ago, and selectSession paints
444
+ * a snapshot as its first frame.
445
+ */
446
+ closeTileGrid({ keepStored = true, reselect = true, animate = false } = {}) {
447
+ const grid = this._tileGrid;
448
+ if (!grid?.open) return;
449
+ const focusedId = grid.focusedId;
450
+ const ids = grid.ids.slice();
451
+ // The close itself stays synchronous (every caller relies on the grid
452
+ // being gone on return); what the user sees leave is a still copy of the
453
+ // tiles, dimmed at once, held until the single view has replayed its
454
+ // session (no empty flash), then faded out over it.
455
+ const releaseGhosts = animate ? this._ghostTileGrid({ hold: reselect }) : null;
456
+ // Remembered (closed) for one-click return, or forgotten.
457
+ if (keepStored) this._persistTileGrid({ open: false });
458
+ else this._forgetStoredTileGrid();
459
+ // A divider drag or a header drag in progress ends with the grid.
460
+ this._tileDividerDragTeardown?.();
461
+ this._endTileMoveDrag();
462
+ // Closed BEFORE the tiles go: each destroy() updates the header's connection
463
+ // state, which must read the main terminal again, not half-destroyed tiles.
464
+ grid.open = false;
465
+ clearTimeout(grid.refitTimer);
466
+ grid.refitTimer = null;
467
+ grid.resizeObserver?.disconnect();
468
+ grid.resizeObserver = null;
469
+ for (const { tile, el } of grid.tiles.values()) {
470
+ this._destroyTerminalTile(tile);
471
+ el.remove();
472
+ }
473
+ grid.tiles.clear();
474
+ for (const el of grid.dividers.values()) el.remove();
475
+ grid.dividers.clear();
476
+ for (const slot of grid.slots || []) slot.remove();
477
+ grid.slots = [];
478
+ grid.colFr = [];
479
+ grid.rowFr = [];
480
+ grid.cells = [];
481
+ grid.count = 0;
482
+ grid.cols = 0;
483
+ grid.rows = 0;
484
+ grid.focusedId = null;
485
+ grid.zoomedId = null;
486
+ grid.autoZoom = false;
487
+ document.querySelector('.main')?.classList.remove('tiles-active');
488
+ const section = document.getElementById('tileGrid');
489
+ if (section) {
490
+ section.style.gridTemplateColumns = '';
491
+ section.style.gridTemplateRows = '';
492
+ section.classList.remove('tile-grid--zoomed');
493
+ }
494
+ // As _redock does: the tiles sized these PTYs, so the main terminal's
495
+ // record of the last size it sent no longer describes them.
496
+ this._lastResizeDims = null;
497
+ for (const id of ids) {
498
+ this._xtermSnapshots?.delete(id);
499
+ try {
500
+ localStorage.removeItem(`codeman-xs-${id}`);
501
+ } catch {
502
+ /* Nothing stored. */
503
+ }
504
+ this.terminalBufferCache?.delete(id);
505
+ }
506
+ this._updateConnectionIndicator?.();
507
+ this._updateSplitButtonForTiles();
508
+ this._updateTileGridButtonState();
509
+ // The tabs drop their .in-tiles marker.
510
+ this.renderSessionTabs?.();
511
+ if (reselect) {
512
+ const shown = this._selectAfterTileGrid(focusedId);
513
+ if (releaseGhosts) Promise.resolve(shown).finally(releaseGhosts);
514
+ } else {
515
+ releaseGhosts?.();
516
+ }
517
+ },
518
+
519
+ /** False under prefers-reduced-motion: the grid then opens and closes at once. */
520
+ _tileMotionAllowed() {
521
+ return !window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
522
+ },
523
+
524
+ /**
525
+ * A still copy of the open grid over the stage, so the tiles leave calmly
526
+ * while closeTileGrid (or a re-form) tears the real ones down at once.
527
+ * Clones only: no xterm, socket or listener comes along (the DOM renderer's
528
+ * rows and their style elements do, so the copy shows the text), and the
529
+ * layer is inert, hidden from assistive tech and takes no pointer. With
530
+ * `hold` it dims at once and waits for the returned release() (the single
531
+ * view has its content), at most TILE_GHOST_HOLD_MAX_MS; without, it fades
532
+ * out straight away. It removes itself when the fade ends (or after
533
+ * TILE_GHOST_FALLBACK_MS). Nothing under reduced motion (release is then a
534
+ * no-op).
535
+ *
536
+ * @returns {() => void} release
537
+ */
538
+ _ghostTileGrid({ hold = false } = {}) {
539
+ const grid = this._tileGrid;
540
+ const section = document.getElementById('tileGrid');
541
+ const main = section?.parentElement;
542
+ const none = () => {};
543
+ if (!grid?.open || !section || !main || !this._tileMotionAllowed()) return none;
544
+ this._purgeTileGhosts();
545
+ const s = section.getBoundingClientRect();
546
+ const m = main.getBoundingClientRect();
547
+ if (!(s.width > 0 && s.height > 0)) return none;
548
+ const layer = document.createElement('div');
549
+ layer.className = hold ? 'tile-grid-ghosts' : 'tile-grid-ghosts tile-grid-ghosts--now';
550
+ layer.setAttribute('aria-hidden', 'true');
551
+ layer.inert = true;
552
+ layer.style.left = `${s.left - m.left}px`;
553
+ layer.style.top = `${s.top - m.top}px`;
554
+ layer.style.width = `${s.width}px`;
555
+ layer.style.height = `${s.height}px`;
556
+ layer.style.gridTemplateColumns = section.style.gridTemplateColumns;
557
+ layer.style.gridTemplateRows = section.style.gridTemplateRows;
558
+ // Zoomed, only the zoomed tile is on screen (the others are display: none
559
+ // under .tile-grid--zoomed, a rule the ghost layer does not carry).
560
+ const zoomed = section.classList.contains('tile-grid--zoomed');
561
+ const copies = [];
562
+ for (const id of grid.ids) {
563
+ const el = grid.tiles.get(id)?.el;
564
+ if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
565
+ const ghost = el.cloneNode(true);
566
+ ghost.classList.remove(
567
+ 'tile--entering',
568
+ 'tile--enter-themed',
569
+ 'tile--enter-hold',
570
+ 'tile--needs',
571
+ 'tile--loading',
572
+ 'tile--drop-target',
573
+ 'tile--dragging'
574
+ );
575
+ ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
576
+ ghost.classList.add('tile--leaving');
577
+ layer.appendChild(ghost);
578
+ copies.push({ ghost, el, sessionId: id });
579
+ }
580
+ // The entrance style's own way out (entrance-animations.js): back into the
581
+ // tabs, a CRT switch-off. Placed while the real tiles are still measurable.
582
+ const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
583
+ main.appendChild(layer);
584
+ let fallback = null;
585
+ let holdCap = null;
586
+ const done = () => {
587
+ clearTimeout(fallback);
588
+ clearTimeout(holdCap);
589
+ layer.remove();
590
+ if (this._tileGhostLayer === layer) this._tileGhostLayer = null;
591
+ };
592
+ const release = () => {
593
+ if (fallback !== null || !layer.isConnected) return;
594
+ clearTimeout(holdCap);
595
+ layer.classList.add('tile-grid-ghosts--release');
596
+ fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
597
+ };
598
+ layer.addEventListener('animationend', (e) => {
599
+ if (e.pseudoElement || e.target !== layer.lastElementChild) return;
600
+ if (/^tile-leave/.test(e.animationName)) done();
601
+ });
602
+ if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
603
+ else release();
604
+ this._tileGhostLayer = layer;
605
+ return release;
606
+ },
607
+
608
+ /** Drops a still copy still showing (the grid reopened, or another close came). */
609
+ _purgeTileGhosts() {
610
+ this._tileGhostLayer?.remove();
611
+ this._tileGhostLayer = null;
612
+ },
613
+
614
+ /**
615
+ * The header Tiles button: shown when its per-device setting is on AND the
616
+ * window is desktop-wide (a JS check plus a live media listener, the same
617
+ * pair as the Split button; the CSS `@media (max-width: 1179px)` rule is the
618
+ * backstop that hides it even if this never runs).
619
+ */
620
+ _applyTileGridButtonVisibility(enabled) {
621
+ this._installTileGridHint();
622
+ this._tileGridButtonSettingEnabled = !!enabled;
623
+ const btn = document.querySelector('.btn-tile-grid');
624
+ const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
625
+ btn?.classList.toggle('btn-tile-grid--hidden', !enabled || !wide || !!this.isSoloWindow);
626
+ if (!this._tileGridButtonWidthListener && window.matchMedia) {
627
+ this._tileGridButtonWidthListener = true;
628
+ const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
629
+ mq.addEventListener('change', () => this._applyTileGridButtonVisibility(this._tileGridButtonSettingEnabled));
630
+ }
631
+ },
632
+
633
+ // Open grid: the button's click closes it, and says so.
634
+ _updateTileGridButtonState() {
635
+ const btn = document.querySelector('.btn-tile-grid');
636
+ if (!btn) return;
637
+ const open = this._tilesOwnTerminal();
638
+ btn.classList.toggle('tiles-open', open);
639
+ btn.setAttribute('aria-pressed', open ? 'true' : 'false');
640
+ // No native title: the hover card says it (two tooltips never stack), and
641
+ // aria-describedby gives screen readers the same text.
642
+ btn.setAttribute(
643
+ 'aria-label',
644
+ open
645
+ ? 'Tiles: back to a single session (right-click for how many tiles)'
646
+ : 'Tiles: show several sessions side by side (right-click for how many)'
647
+ );
648
+ this._renderTileHint();
649
+ },
650
+
651
+ /**
652
+ * The Tiles button's hover card (owner feedback 1: "give me the hover info
653
+ * to right click over the tile button to adjust it"): the title with the
654
+ * remembered count, what a click does (open or close the grid), that a
655
+ * right-click chooses 2, 4 or 6, what opens when the count does not fit the
656
+ * window, and, shown from the keyboard, Shift+F10. It replaces the button's
657
+ * native title. The card always exists (hidden) and is kept current, since
658
+ * the button's aria-describedby reads it for screen readers without a
659
+ * hover. Installed once, from the button's visibility setter; never in a
660
+ * solo window.
661
+ */
662
+ _installTileGridHint() {
663
+ if (this._tileHint || this.isSoloWindow) return;
664
+ const btn = document.querySelector('.btn-tile-grid');
665
+ if (!btn) return;
666
+ const card = document.createElement('div');
667
+ card.id = 'tileGridHint';
668
+ card.className = 'tile-hint';
669
+ card.setAttribute('role', 'tooltip');
670
+ card.hidden = true;
671
+ const title = document.createElement('div');
672
+ title.className = 'tile-hint-title';
673
+ const line = (glyph) => {
674
+ const row = document.createElement('div');
675
+ row.className = 'tile-hint-line';
676
+ const text = document.createElement('span');
677
+ if (glyph) row.appendChild(glyph);
678
+ row.appendChild(text);
679
+ return { row, text };
680
+ };
681
+ const click = line(tileHintMouseGlyph('left'));
682
+ const right = line(tileHintMouseGlyph('right'));
683
+ right.text.textContent = 'Right-click: choose 2, 4 or 6 tiles';
684
+ const fits = line(null);
685
+ fits.row.classList.add('tile-hint-note');
686
+ const keys = line(null);
687
+ keys.row.classList.add('tile-hint-keys');
688
+ keys.text.textContent = 'Shift+F10: the same menu from the keyboard';
689
+ card.append(title, click.row, right.row, fits.row, keys.row);
690
+ document.body.appendChild(card);
691
+ btn.removeAttribute('title');
692
+ btn.setAttribute('aria-describedby', card.id);
693
+ const hint = (this._tileHint = { btn, card, title, click: click.text, fits, keys, timer: null, suppressed: false });
694
+ const canHover = () => window.matchMedia?.('(hover: hover)')?.matches !== false;
695
+ btn.addEventListener('pointerenter', (e) => {
696
+ if (e?.pointerType === 'touch' || !canHover()) return;
697
+ this._scheduleTileHint({ keyboard: false });
698
+ });
699
+ btn.addEventListener('pointerleave', () => {
700
+ hint.suppressed = false;
701
+ this._hideTileHint();
702
+ });
703
+ btn.addEventListener('focus', () => {
704
+ // Focus put back by the count menu's Escape: the user was just there.
705
+ if (hint.skipFocus) {
706
+ hint.skipFocus = false;
707
+ return;
708
+ }
709
+ if (btn.matches?.(':focus-visible')) this._scheduleTileHint({ keyboard: true });
710
+ });
711
+ // Leaving the button ends a click's suppression too: a keyboard focus that
712
+ // comes back later is a new arrival, even with the pointer still on it.
713
+ btn.addEventListener('blur', () => {
714
+ hint.suppressed = false;
715
+ this._hideTileHint();
716
+ });
717
+ // Capture: these run before the button's own handlers, so the card is gone
718
+ // before a right-click opens the count menu or a click opens the grid; and
719
+ // it stays gone while the pointer rests there.
720
+ const dismiss = () => {
721
+ hint.suppressed = true;
722
+ this._hideTileHint();
723
+ };
724
+ for (const type of ['pointerdown', 'click', 'contextmenu']) btn.addEventListener(type, dismiss, true);
725
+ // While shown: Escape, a scroll anywhere, a resize.
726
+ hint.onKey = (e) => {
727
+ if (e.key === 'Escape') this._hideTileHint();
728
+ };
729
+ hint.onAway = () => this._hideTileHint();
730
+ this._renderTileHint();
731
+ },
732
+
733
+ /**
734
+ * The card's text, from the remembered count, the grid's state and what the
735
+ * window fits. With the grid closed and a stored grid to bring back, the
736
+ * click opens that grid as it was (never trimmed to the window), so the
737
+ * window line only says what fits. Compared with the last English text set,
738
+ * never the DOM (in zh-CN the DOM holds the translation; see
739
+ * _renderTileOverlay).
740
+ */
741
+ _renderTileHint() {
742
+ const hint = this._tileHint;
743
+ if (!hint) return;
744
+ const count = this._tileGridCount();
745
+ const open = this._tilesOwnTerminal();
746
+ const capacity = this._tileGridLimit().capacity;
747
+ const set = (el, key, text) => {
748
+ if (hint[key] === text) return;
749
+ hint[key] = text;
750
+ el.textContent = text;
751
+ };
752
+ set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
753
+ set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
754
+ const plural = capacity === 1 ? '' : 's';
755
+ const restoring = open ? null : this._storedTileGridSet();
756
+ const opens = restoring ? restoring.ids.length : count;
757
+ const fitsText =
758
+ opens <= capacity
759
+ ? ''
760
+ : open || restoring
761
+ ? `This window fits ${capacity} tile${plural}`
762
+ : `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
763
+ set(hint.fits.text, 'fitsText', fitsText);
764
+ hint.fits.row.hidden = !fitsText;
765
+ },
766
+
767
+ _scheduleTileHint({ keyboard }) {
768
+ const hint = this._tileHint;
769
+ if (!hint || hint.suppressed) return;
770
+ clearTimeout(hint.timer);
771
+ hint.timer = setTimeout(() => this._showTileHint({ keyboard }), TILE_HINT_DELAY_MS);
772
+ },
773
+
774
+ /** Under the button, right-aligned as the count menu; never with the menu open or the button hidden. */
775
+ _showTileHint({ keyboard }) {
776
+ const hint = this._tileHint;
777
+ hint.timer = null;
778
+ if (hint.suppressed || this._tileCountMenu || hint.btn.classList.contains('btn-tile-grid--hidden')) return;
779
+ this._renderTileHint();
780
+ hint.keys.row.hidden = !keyboard;
781
+ const rect = hint.btn.getBoundingClientRect?.();
782
+ if (rect) {
783
+ hint.card.style.top = `${rect.bottom + 6}px`;
784
+ hint.card.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
785
+ }
786
+ if (!hint.card.hidden) return;
787
+ hint.card.hidden = false;
788
+ document.addEventListener('keydown', hint.onKey, true);
789
+ window.addEventListener('scroll', hint.onAway, true);
790
+ window.addEventListener('resize', hint.onAway);
791
+ },
792
+
793
+ /** Idempotent: cancels a pending show and hides the card. */
794
+ _hideTileHint() {
795
+ const hint = this._tileHint;
796
+ if (!hint) return;
797
+ clearTimeout(hint.timer);
798
+ hint.timer = null;
799
+ if (hint.card.hidden) return;
800
+ hint.card.hidden = true;
801
+ // Hidden, the card is the button's description in full: a hidden line
802
+ // inside it would be left out of what a screen reader hears.
803
+ hint.keys.row.hidden = false;
804
+ document.removeEventListener('keydown', hint.onKey, true);
805
+ window.removeEventListener('scroll', hint.onAway, true);
806
+ window.removeEventListener('resize', hint.onAway);
807
+ },
808
+
809
+ /**
810
+ * How many tiles the grid takes here and now: what the terminal area fits
811
+ * (the grid section, or the single view it would replace), at most the cap
812
+ * (TILE_GRID_MAX), never less than one. `full` says which of the two binds,
813
+ * so a large monitor never reads "this window fits 6".
814
+ */
815
+ _tileGridLimit() {
816
+ const T = window.CodemanTileGrid;
817
+ const max = T.TILE_GRID_MAX;
818
+ const el = this._tilesOwnTerminal() ? this._tileGridSection() : document.querySelector('.terminal-wrap');
819
+ const rect = el?.getBoundingClientRect?.() || { width: 0, height: 0 };
820
+ const fits = T.tileGridCapacity({
821
+ width: rect.width || window.innerWidth,
822
+ height: rect.height || window.innerHeight,
823
+ });
824
+ const capacity = Math.max(1, Math.min(fits, max));
825
+ const byCap = fits >= max;
826
+ return {
827
+ capacity,
828
+ full: byCap
829
+ ? `The grid holds at most ${max} tiles`
830
+ : `The grid already holds what this window fits (${capacity})`,
831
+ };
832
+ },
833
+
834
+ /** The tile count a click on Tiles opens (the menu's last pick, per device), default 6. */
835
+ _tileGridCount() {
836
+ let raw = null;
837
+ try {
838
+ raw = localStorage.getItem(TILE_GRID_COUNT_KEY);
839
+ } catch {
840
+ /* Storage unavailable: the default. */
841
+ }
842
+ return window.CodemanTileGrid.sanitizeTileCount(raw);
843
+ },
844
+
845
+ _rememberTileGridCount(count) {
846
+ try {
847
+ localStorage.setItem(TILE_GRID_COUNT_KEY, String(window.CodemanTileGrid.sanitizeTileCount(count)));
848
+ } catch {
849
+ /* Per-device convenience only. */
850
+ }
851
+ // The hover card names the count.
852
+ this._renderTileHint();
853
+ },
854
+
855
+ /**
856
+ * Right-click on the Tiles button (or Shift+F10 / the Menu key on it, which
857
+ * fire the same contextmenu event; its click opens the grid at once, see
858
+ * toggleTileGrid): how many tiles, 2, 4 or 6 (owner decision 10), each with
859
+ * its shape, the remembered count checked. A count the window cannot fit is
860
+ * greyed out, and the menu says why; the keyboard then starts on the largest
861
+ * that fits (the count a click opens here). Picking one opens the grid with
862
+ * that many tiles, or re-forms the open grid to it, and remembers it for the
863
+ * click. Arrows move, Enter or Space picks, Escape closes and gives the
864
+ * keyboard back to the Tiles button, Tab closes.
865
+ */
866
+ openTileCountMenu(event) {
867
+ // The right-click: the browser's own context menu stays away.
868
+ event?.preventDefault?.();
869
+ // Never beside a hover card still showing (its own capture listener has
870
+ // usually hidden it already).
871
+ this._hideTileHint();
872
+ if (this._tileCountMenu) {
873
+ this.closeTileCountMenu();
874
+ return;
875
+ }
876
+ if (!this.canOpenTileGrid()) return;
877
+ const T = window.CodemanTileGrid;
878
+ const capacity = this._tileGridLimit().capacity;
879
+ const fitsText = `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`;
880
+ const current = this._tileGridCount();
881
+ const menu = document.createElement('div');
882
+ menu.id = 'tileCountMenu';
883
+ menu.className = 'tile-count-menu';
884
+ menu.setAttribute('role', 'menu');
885
+ menu.setAttribute('aria-label', 'How many tiles');
886
+ const heading = document.createElement('div');
887
+ heading.className = 'tile-count-heading';
888
+ heading.setAttribute('aria-hidden', 'true');
889
+ heading.textContent = 'Tiles';
890
+ menu.appendChild(heading);
891
+ const items = [];
892
+ for (const n of T.TILE_GRID_COUNTS) {
893
+ const item = document.createElement('button');
894
+ item.type = 'button';
895
+ item.className = 'tile-count-item';
896
+ item.setAttribute('role', 'menuitemradio');
897
+ item.setAttribute('aria-checked', n === current ? 'true' : 'false');
898
+ item.tabIndex = -1;
899
+ item.dataset.count = String(n);
900
+ if (n > capacity) {
901
+ item.disabled = true;
902
+ item.setAttribute('aria-disabled', 'true');
903
+ item.title = fitsText;
904
+ }
905
+ const label = document.createElement('span');
906
+ label.className = 'tile-count-label';
907
+ label.textContent = `${n} tiles`;
908
+ const check = document.createElement('span');
909
+ check.className = 'tile-count-check';
910
+ check.setAttribute('aria-hidden', 'true');
911
+ check.textContent = n === current ? '\u2713' : '';
912
+ item.append(tileCountGlyph(n), label, check);
913
+ item.addEventListener('click', (e) => {
914
+ e.stopPropagation?.();
915
+ if (!item.disabled) this._pickTileCount(n);
916
+ });
917
+ item.addEventListener('pointerenter', () => {
918
+ if (!item.disabled) item.focus?.();
919
+ });
920
+ menu.appendChild(item);
921
+ items.push(item);
922
+ }
923
+ if (capacity < T.TILE_GRID_MAX) {
924
+ const hint = document.createElement('div');
925
+ hint.className = 'tile-count-hint';
926
+ hint.textContent = fitsText;
927
+ menu.appendChild(hint);
928
+ }
929
+
930
+ document.body.appendChild(menu);
931
+ const btn = document.querySelector('.btn-tile-grid');
932
+ if (btn?.getBoundingClientRect) {
933
+ // Under the Tiles button (.tile-count-menu is position: fixed).
934
+ const rect = btn.getBoundingClientRect();
935
+ menu.style.top = `${rect.bottom + 6}px`;
936
+ menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
937
+ }
938
+ const enabled = () => items.filter((i) => !i.disabled);
939
+ const move = (delta) => {
940
+ const list = enabled();
941
+ if (list.length === 0) return;
942
+ const at = list.indexOf(document.activeElement);
943
+ const next = at === -1 ? (delta > 0 ? 0 : list.length - 1) : (at + delta + list.length) % list.length;
944
+ list[next].focus?.();
945
+ };
946
+ // A click elsewhere closes it. A click on the Tiles button needs no
947
+ // exception: its own handler (toggleTileGrid) closes the menu first.
948
+ const onOutside = (e) => {
949
+ if (menu.contains?.(e.target)) return;
950
+ this.closeTileCountMenu();
951
+ };
952
+ // The menu's keys, in the capture phase while it is open. Escape normally
953
+ // reaches the app's global handler first, which closes the menu alone and
954
+ // gives the keyboard back (app.js); this one covers the rest.
955
+ const onKey = (e) => {
956
+ if (e.key === 'Escape') {
957
+ e.preventDefault?.();
958
+ this.closeTileCountMenu({ refocus: true });
959
+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
960
+ e.preventDefault?.();
961
+ move(1);
962
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
963
+ e.preventDefault?.();
964
+ move(-1);
965
+ } else if (e.key === 'Home' || e.key === 'End') {
966
+ e.preventDefault?.();
967
+ const list = enabled();
968
+ list[e.key === 'Home' ? 0 : list.length - 1]?.focus?.();
969
+ } else if (e.key === 'Enter' || e.key === ' ') {
970
+ const item = items.find((i) => i === document.activeElement);
971
+ if (!item) return;
972
+ // Not the button's own activation too.
973
+ e.preventDefault?.();
974
+ if (!item.disabled) this._pickTileCount(Number(item.dataset.count));
975
+ } else if (e.key === 'Tab') {
976
+ this.closeTileCountMenu();
977
+ }
978
+ };
979
+ // The keyboard leaving the menu for something else closes it, as any menu:
980
+ // closing the grid starts a selection that focuses the single view's
981
+ // terminal when its replay lands, and a menu left open behind that would
982
+ // send the keys meant for it (arrows, Enter, Escape) into the terminal. A
983
+ // focus going nowhere (a click on a button in Safari, which does not focus
984
+ // it) does not count.
985
+ menu.addEventListener('focusout', (e) => {
986
+ const to = e.relatedTarget;
987
+ if (to && !menu.contains?.(to)) this.closeTileCountMenu({ refocus: false });
988
+ });
989
+ this._tileCountMenu = { menu, onOutside, onKey };
990
+ document.addEventListener('click', onOutside);
991
+ document.addEventListener('keydown', onKey, true);
992
+ // The checked count, or when the window cannot fit it, the largest that fits.
993
+ const start = items.find((i) => !i.disabled && i.getAttribute('aria-checked') === 'true') || enabled().at(-1);
994
+ (start || menu).focus?.();
995
+ },
996
+
997
+ /**
998
+ * Idempotent: the global Escape handler may call it whether or not the menu
999
+ * is open. The keyboard goes back to the Tiles button when it was in the
1000
+ * menu (`refocus` unset), or when asked (Escape); a click elsewhere has
1001
+ * already moved it, and a pick passes `refocus: false`.
1002
+ */
1003
+ closeTileCountMenu({ refocus } = {}) {
1004
+ const menu = this._tileCountMenu;
1005
+ if (!menu) return;
1006
+ if (refocus === undefined) refocus = !!menu.menu.contains?.(document.activeElement);
1007
+ this._tileCountMenu = null;
1008
+ document.removeEventListener('click', menu.onOutside);
1009
+ document.removeEventListener('keydown', menu.onKey, true);
1010
+ menu.menu.remove();
1011
+ if (refocus) {
1012
+ // The user was just in the menu: no hover card for this focus.
1013
+ if (this._tileHint) this._tileHint.skipFocus = true;
1014
+ document.querySelector('.btn-tile-grid')?.focus?.();
1015
+ if (this._tileHint) this._tileHint.skipFocus = false;
1016
+ }
1017
+ },
1018
+
1019
+ /**
1020
+ * A count picked in the menu: remembered for the click, then the grid opens
1021
+ * with that many tiles (a stored grid re-formed to it, its tiles first in
1022
+ * their cells), or the open grid is re-formed to it (_reformTileGrid).
1023
+ * Never more than the window fits; fewer open sessions than the count give
1024
+ * fewer tiles. Either way the grid's count is what the pick left.
1025
+ */
1026
+ _pickTileCount(count) {
1027
+ this.closeTileCountMenu({ refocus: false });
1028
+ this._rememberTileGridCount(count);
1029
+ const grid = this._tileGrid;
1030
+ if (!grid?.open) {
1031
+ this._activateTileGrid({ count });
1032
+ return;
1033
+ }
1034
+ const T = window.CodemanTileGrid;
1035
+ const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity);
1036
+ // The tiles that join are the ranking's best (_tileGridRanking).
1037
+ this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId));
1038
+ grid.count = grid.ids.length;
1039
+ this._persistTileGrid();
1040
+ },
1041
+
1042
+ /**
1043
+ * The open grid re-formed to the tiles `target` (a count picked in the
1044
+ * menu): a count change is a shape change, so the cell model's rule applies
1045
+ * (reformTileCells: the tiles that stay keep their row and column when all
1046
+ * fit, else they pack in reading order), and the tiles that join fill the
1047
+ * empty cells in reading order, holes first. The focused tile is always
1048
+ * among those that stay (tileGridSetForCount). Every tile that joins is
1049
+ * mounted and the grid laid out ONCE before any of them connects (as in
1050
+ * openTileGrid): a tile connected into an intermediate layout would fit that
1051
+ * cell first and resize its PTY twice. A zoom the user chose ends: the grid
1052
+ * they asked for is meant to be seen.
1053
+ */
1054
+ _reformTileGrid(target) {
1055
+ const grid = this._tileGrid;
1056
+ if (!grid?.open) return;
1057
+ const T = window.CodemanTileGrid;
1058
+ const leaving = grid.ids.filter((id) => !target.includes(id));
1059
+ const joining = target.filter((id) => !grid.tiles.has(id));
1060
+ if (leaving.length === 0 && joining.length === 0) return;
1061
+ // The old grid fades out over the new one as it forms.
1062
+ this._ghostTileGrid();
1063
+ // A divider or header drag in progress measured the old grid.
1064
+ this._tileDividerDragTeardown?.();
1065
+ this._endTileMoveDrag();
1066
+ for (const id of leaving) {
1067
+ const entry = grid.tiles.get(id);
1068
+ this._destroyTerminalTile(entry.tile);
1069
+ entry.el.remove();
1070
+ grid.tiles.delete(id);
1071
+ }
1072
+ if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
1073
+ const staying = grid.ids.filter((id) => grid.tiles.has(id));
1074
+ const mounted = joining.filter((id, k) => this._mountTile(id, { enterIndex: k }));
1075
+ const { cols, rows } = this._tileShapeFor(grid.tiles.size);
1076
+ grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows);
1077
+ grid.cols = cols;
1078
+ grid.rows = rows;
1079
+ this._applyTileLayout();
1080
+ for (const id of mounted) this._connectTile(id);
1081
+ this._scheduleTileGridRefit();
1082
+ this.renderSessionTabs?.();
1083
+ },
1084
+
1085
+ /**
1086
+ * A session THIS tab's Run just created (session-ui.js
1087
+ * _ensureCreatedSessionVisible, reached only from the Run paths): with the
1088
+ * grid open it joins the next free slot, and Run's own selectSession then
1089
+ * focuses it through the tile branch. Sessions created elsewhere (agents,
1090
+ * other devices, cron) arrive only by session:created and never join. A grid
1091
+ * already holding what the window fits does not take it; Run's selection
1092
+ * then shows it in the single view (decision 1), and a hint says why.
1093
+ */
1094
+ _joinTileGridFromRun(sessionId) {
1095
+ const grid = this._tileGrid;
1096
+ if (!grid?.open || grid.tiles.has(sessionId) || !this.sessions.has(sessionId)) return false;
1097
+ const limit = this._tileGridLimit();
1098
+ if (grid.ids.length >= limit.capacity) {
1099
+ this.showToast?.(`${limit.full}: the new session opens on its own`, 'info');
1100
+ return false;
1101
+ }
1102
+ // Run starts the session right after creating it: no Attach overlay
1103
+ // meanwhile for a pane that is about to exist.
1104
+ (this._tileAttachPending ||= new Map()).set(sessionId, Date.now());
1105
+ return this.addTile(sessionId);
1106
+ },
1107
+
1108
+ // The Split button cannot act while the grid is open (openSplitPicker and
1109
+ // openSplitPane refuse), so it says so: aria-disabled plus a title, the same
1110
+ // refusal the split already gives for web tabs and the welcome screen.
1111
+ _updateSplitButtonForTiles() {
1112
+ const btn = document.querySelector('.btn-split');
1113
+ if (!btn) return;
1114
+ const blocked = this._tilesOwnTerminal();
1115
+ btn.classList.toggle('btn-split--blocked', blocked);
1116
+ btn.setAttribute('aria-disabled', blocked ? 'true' : 'false');
1117
+ if (blocked) {
1118
+ btn.title = 'Split: unavailable while tiles are open';
1119
+ btn.setAttribute('aria-label', btn.title);
1120
+ } else {
1121
+ this._updateSplitButtonState?.(!!this._splitPane);
1122
+ }
1123
+ },
1124
+
1125
+ /**
1126
+ * The tile chord `e` asks for, if it applies right now, else null. The
1127
+ * toggle applies while the grid is open, or where one could open AND the
1128
+ * per-device `showTileGridButton` setting is on (the desktop default; OFF on
1129
+ * handhelds and touch-primary tablets): with it off the chord is inert and
1130
+ * reaches the terminal like any unbound key (owner decision 6 in
1131
+ * docs/tile-grid-plan.md). An absent key
1132
+ * resolves through the device defaults exactly as the header button does
1133
+ * (settings-ui.js), so the chord and the button can never disagree. The focus, move,
1134
+ * zoom and remove chords apply only while the grid is open, however it was
1135
+ * opened (a move chord also while a tile is zoomed, as a no-op, so its keys
1136
+ * never reach the CLI). The arrow chords never apply in a text field, whose
1137
+ * keys they are.
1138
+ * Registry-aware (rebinds and disables in App Settings, Shortcuts).
1139
+ * The capture handler (app.js) dispatches it; every xterm key handler returns
1140
+ * false for it, so a chord that applies never reaches a PTY.
1141
+ *
1142
+ * @returns {string|null} the registry id
1143
+ */
1144
+ tileShortcutFor(e) {
1145
+ if (!e || (!e.ctrlKey && !e.metaKey && !e.altKey)) return null;
1146
+ if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return null;
1147
+ const open = this._tilesOwnTerminal();
1148
+ for (const shortcut of this.getShortcutRegistry()) {
1149
+ const spec = TILE_SHORTCUTS[shortcut.id];
1150
+ if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
1151
+ if ((spec.direction || spec.move) && isTextFieldTarget(e.target)) continue;
1152
+ if (spec.needsOpen) return open ? shortcut.id : null;
1153
+ const stored = this.loadAppSettingsFromStorage?.()?.showTileGridButton;
1154
+ const enabled = (stored ?? this.getDefaultSettings?.()?.showTileGridButton ?? true) === true;
1155
+ return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null;
1156
+ }
1157
+ return null;
1158
+ },
1159
+
1160
+ /** Runs a chord tileShortcutFor() matched. */
1161
+ runTileShortcut(id) {
1162
+ const spec = TILE_SHORTCUTS[id];
1163
+ if (!spec) return;
1164
+ if (id === 'toggle-tile-grid') this.toggleTileGrid();
1165
+ else if (id === 'remove-tile') this.removeFocusedTile();
1166
+ else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId);
1167
+ else if (spec.direction) this.focusTileInDirection(spec.direction);
1168
+ else if (spec.move) this.moveTileInDirection(spec.move);
1169
+ },
1170
+
1171
+ /**
1172
+ * The Tiles button's click and Ctrl+Shift+G, one function so the two never
1173
+ * drift (owner decision 8): opens the grid at once, no menu in the way, or
1174
+ * closes it to the single view of the focused session. The grid this
1175
+ * browser last had comes back EXACTLY as the user left it (owner request:
1176
+ * "when I turn tiles off and on, always keep what the last setting was"):
1177
+ * its tiles in their cells, holes included, its count, divider sizes, focus
1178
+ * and zoom; a cell whose session no longer exists is filled from the
1179
+ * ranking (_openStoredTileGrid). With none of its sessions left (or nothing
1180
+ * stored), `tileGridOpenSet` (constants.js) takes an open split's two
1181
+ * sessions, else the open sessions as the ranking orders them
1182
+ * (_tileGridRanking: working, then needing input, then the most recent),
1183
+ * the active one always among them and focused, filled to the remembered
1184
+ * count (the right-click menu's last pick, default 6, at most what the
1185
+ * window fits; owner decision 10).
1186
+ */
1187
+ toggleTileGrid() {
1188
+ this.closeTileCountMenu();
1189
+ if (this._tilesOwnTerminal()) {
1190
+ this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
1191
+ return;
1192
+ }
1193
+ this._activateTileGrid();
1194
+ },
1195
+
1196
+ /**
1197
+ * Opens the grid as the toggle does (toggleTileGrid), or, with `count` (a
1198
+ * pick in the count menu while the grid is closed), with that many tiles: a
1199
+ * stored grid re-formed to it, its tiles first in their cells, the ones the
1200
+ * count adds filling its empty cells first, the rest from the ranking.
1201
+ *
1202
+ * @returns {boolean} whether the grid opened
1203
+ */
1204
+ _activateTileGrid({ count = null } = {}) {
1205
+ if (!this.canOpenTileGrid()) return false;
1206
+ const stored = this._readStoredTileGrid();
1207
+ const set = this._tileGridOpenSet(stored, count);
1208
+ if (!set) {
1209
+ this.showToast?.('No sessions to show as tiles', 'info');
1210
+ return false;
1211
+ }
1212
+ if (set.source === 'stored') return this._openStoredTileGrid(stored, set, { keepCount: count === null });
1213
+ return this.openTileGrid(set.ids, { focusedId: set.focusedId });
1214
+ },
1215
+
1216
+ /**
1217
+ * What the toggle would open now (see toggleTileGrid): a stored grid as it
1218
+ * was, or `count` tiles (the remembered count by default), at most what the
1219
+ * window fits. With an explicit `count`, a stored grid is trimmed or filled
1220
+ * to it too. `stored` saves a second read.
1221
+ */
1222
+ _tileGridOpenSet(stored = this._readStoredTileGrid(), count = null) {
1223
+ const T = window.CodemanTileGrid;
1224
+ const n = Math.max(1, Math.min(count ?? this._tileGridCount(), this._tileGridLimit().capacity));
1225
+ const ranked = this._tileGridRanking();
1226
+ const set = T.tileGridOpenSet({
1227
+ stored,
1228
+ split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
1229
+ ranked,
1230
+ sessions: this.sessions,
1231
+ sessionOrder: this.sessionOrder,
1232
+ detachedIds: this.detachedSessions,
1233
+ activeId: this.activeSessionId,
1234
+ limit: n,
1235
+ });
1236
+ if (!set) return null;
1237
+ // A stored grid comes back as it was: the count does not apply to it
1238
+ // unless one was asked for.
1239
+ if (set.source === 'stored' && count === null) return set;
1240
+ return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) };
1241
+ },
1242
+
1243
+ /**
1244
+ * The open sessions the grid takes when nobody said which (the Tiles button
1245
+ * with nothing stored, and every tile it fills on its own), best first:
1246
+ * working (the most recently started turn first), then the ones needing
1247
+ * input (the red and yellow tab alerts), then the rest by most recent
1248
+ * activity, tab order breaking ties (rankTileSessions, constants.js). The
1249
+ * states and stamps are the home screens' own (`_mobileOverviewState()`,
1250
+ * mobile-overview.js). Detached sessions are never in it. Guarded like
1251
+ * the sorted rail: without the classifier (a stale cached
1252
+ * mobile-overview.js) it is plain tab order.
1253
+ *
1254
+ * @returns {string[]}
1255
+ */
1256
+ _tileGridRanking() {
1257
+ const T = window.CodemanTileGrid;
1258
+ const open = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
1259
+ if (typeof this._mobileOverviewState !== 'function' || typeof T.rankTileSessions !== 'function') {
1260
+ return open.map((c) => c.id);
1261
+ }
1262
+ const rows = open.map(({ id }, orderIndex) => {
1263
+ const session = this.sessions.get(id);
1264
+ return {
1265
+ id,
1266
+ state: this._mobileOverviewState(session, this.pendingHooks?.get(id)),
1267
+ lastActivityAt: Number(session.lastActivityAt) || 0,
1268
+ lastSubmitAt: Number(session.lastSubmitAt) || 0,
1269
+ orderIndex,
1270
+ };
1271
+ });
1272
+ return T.rankTileSessions(rows);
1273
+ },
1274
+
1275
+ /** Alt+Shift+Arrows: a human selection of the tile in that direction. */
1276
+ focusTileInDirection(direction) {
1277
+ const grid = this._tileGrid;
1278
+ if (!grid?.open) return;
1279
+ // Over the cells: focus skips an empty one, never lands on it.
1280
+ const id = window.CodemanTileGrid.tileInDirection(grid.cells, grid.focusedId, direction, grid.cols);
1281
+ if (id) this.selectSession(id);
1282
+ },
1283
+
1284
+ /**
1285
+ * Ctrl+Tab / Alt+] (delta 1) and Alt+[ (delta -1) while the grid is open: a
1286
+ * human selection of the next tile in reading order, wrapping. Returns
1287
+ * whether the grid took the chord (it is open), so the tab walk is skipped.
1288
+ */
1289
+ _cycleTileFocus(delta) {
1290
+ const grid = this._tileGrid;
1291
+ if (!grid?.open) return false;
1292
+ const id = window.CodemanTileGrid.cycleTile(grid.ids, this.activeSessionId, delta);
1293
+ if (id) this.selectSession(id);
1294
+ return true;
1295
+ },
1296
+
1297
+ /** Removes the focused tile (the session keeps running); a neighbour takes focus. */
1298
+ removeFocusedTile() {
1299
+ const grid = this._tileGrid;
1300
+ if (!grid?.open || !grid.focusedId) return;
1301
+ this.removeTile(grid.focusedId);
1302
+ },
1303
+
1304
+ // The single view after the grid closes: the session the grid was focused on,
1305
+ // replayed fresh (forceReload drops the stale snapshot and nulls
1306
+ // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
1307
+ // or, if that session is gone, the same fallback as closing the active tab.
1308
+ // A popped-out session counts as gone in both: selectSession would only
1309
+ // raise its window and return, leaving the parked terminal's pre-grid
1310
+ // content on screen under its tab (and saved as its snapshot on the next
1311
+ // switch). Returns the selection's promise (it settles once the replay is
1312
+ // written), or undefined for the welcome screen.
1313
+ _selectAfterTileGrid(sessionId) {
1314
+ const usable = (id) => this.sessions.has(id) && !this.detachedSessions?.has(id);
1315
+ if (sessionId && usable(sessionId)) {
1316
+ return this.selectSession(sessionId, { forceReload: true, auto: true });
1317
+ }
1318
+ this.activeSessionId = null;
1319
+ try {
1320
+ localStorage.removeItem('codeman-active-session');
1321
+ } catch {
1322
+ /* Nothing stored. */
1323
+ }
1324
+ const next = this.sessionOrder.find(usable);
1325
+ if (next) return this.selectSession(next, { auto: true });
1326
+ this.terminal?.clear();
1327
+ this.showWelcome();
1328
+ return undefined;
1329
+ },
1330
+
1331
+ /**
1332
+ * Adds one session as a tile (open grid only), into `cell` when that cell is
1333
+ * empty (a tab dropped on it), else the first empty cell in reading order
1334
+ * (_placeTile). It enters as the `enterIndex`-th of a staggered group
1335
+ * (_mountTile). Returns whether it was added.
1336
+ */
1337
+ addTile(sessionId, { cell = -1, enterIndex = 0 } = {}) {
1338
+ const grid = this._tileGrid;
1339
+ if (!grid?.open || grid.tiles.has(sessionId)) return false;
1340
+ if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
1341
+ if (!this._mountTile(sessionId, { enterIndex })) return false;
1342
+ this._placeTile(sessionId, cell);
1343
+ grid.count = grid.ids.length;
1344
+ // A tile added while one is zoomed by hand is meant to be seen.
1345
+ if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
1346
+ this._applyTileLayout();
1347
+ this._connectTile(sessionId);
1348
+ this._scheduleTileGridRefit();
1349
+ this.renderSessionTabs?.();
1350
+ return true;
1351
+ },
1352
+
1353
+ /**
1354
+ * Puts a tile just mounted into a cell. The shape for one more tile comes
1355
+ * first (fitTileCells: the same shape keeps every cell; 2x2 growing to 3x2
1356
+ * keeps each tile where it is), then `cell` if it is empty there, else the
1357
+ * first empty cell in reading order.
1358
+ */
1359
+ _placeTile(sessionId, cell = -1) {
1360
+ const grid = this._tileGrid;
1361
+ const { cols, rows } = this._tileShapeFor(grid.ids.length + 1);
1362
+ grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows);
1363
+ grid.cols = cols;
1364
+ grid.rows = rows;
1365
+ const k = Number.isInteger(cell) && grid.cells[cell] === null ? cell : grid.cells.indexOf(null);
1366
+ // (No empty cell cannot happen: the shape for n tiles has at least n cells.
1367
+ // Appended, the next layout packs it in.)
1368
+ if (k === -1) grid.cells.push(sessionId);
1369
+ else grid.cells[k] = sessionId;
1370
+ },
1371
+
1372
+ /** Columns x rows (and whether they fit) for `count` tiles in the grid area as it is now. */
1373
+ _tileShapeFor(count) {
1374
+ const rect = this._tileGridSection().getBoundingClientRect?.() || { width: 0, height: 0 };
1375
+ return window.CodemanTileGrid.computeTileLayout({
1376
+ count,
1377
+ width: rect.width || window.innerWidth,
1378
+ height: rect.height || window.innerHeight,
1379
+ });
1380
+ },
1381
+
1382
+ /**
1383
+ * Removes one tile; the session keeps running. Its cell becomes empty where
1384
+ * it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus`
1385
+ * moves focus to the neighbouring tile (next in grid order, else previous),
1386
+ * as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
1387
+ * DOM focus where it is (an app-driven removal: a socket the server closed),
1388
+ * so keystrokes never land in the neighbour's PTY unasked. `gone`: the
1389
+ * session went away by itself (deleted, popped out, its socket refused), not
1390
+ * by the user's hand, so the grid's count stays and the next time it opens
1391
+ * the ranking fills that place. The last tile leaving closes the grid, kept
1392
+ * as it was: with `refocus` the single view then shows that session (or,
1393
+ * popped out, the next one: _selectAfterTileGrid), without it the caller
1394
+ * decides what comes next.
1395
+ */
1396
+ removeTile(sessionId, { refocus = true, focus = true, gone = false } = {}) {
1397
+ const grid = this._tileGrid;
1398
+ const entry = grid?.open ? grid.tiles.get(sessionId) : null;
1399
+ if (!entry) return false;
1400
+ if (grid.ids.length === 1) {
1401
+ this.closeTileGrid({ keepStored: true, reselect: refocus });
1402
+ return true;
1403
+ }
1404
+ const wasFocused = grid.focusedId === sessionId;
1405
+ const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId);
1406
+ // A divider drag in progress was measured against this tile; a header
1407
+ // drag of this tile has nothing left to drop.
1408
+ this._tileDividerDragTeardown?.();
1409
+ if (this._draggedTileId === sessionId) this._endTileMoveDrag();
1410
+ // The zoomed tile leaving restores the grid (an automatic zoom moves to
1411
+ // the neighbour with focus, below).
1412
+ if (grid.zoomedId === sessionId) grid.zoomedId = grid.autoZoom && refocus ? neighbor : null;
1413
+ this._destroyTerminalTile(entry.tile);
1414
+ entry.el.remove();
1415
+ grid.tiles.delete(sessionId);
1416
+ grid.cells[grid.cells.indexOf(sessionId)] = null;
1417
+ if (!gone) grid.count = grid.ids.length;
1418
+ // Before the layout, which may zoom the focused tile on a small window.
1419
+ if (wasFocused) grid.focusedId = null;
1420
+ this._applyTileLayout();
1421
+ this._scheduleTileGridRefit();
1422
+ this.renderSessionTabs?.();
1423
+ if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true, focus });
1424
+ return true;
1425
+ },
1426
+
1427
+ /**
1428
+ * Builds one tile (header, body, TerminalTile); where it goes is the
1429
+ * caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
1430
+ * group (_beginTileEntrance), and its terminal stays transparent until its
1431
+ * first capture lands (.tile--revealing, cleared by the load queue, with a
1432
+ * backstop timer). Opacity and transform only, never anything its fit reads.
1433
+ */
1434
+ _mountTile(sessionId, { enterIndex = 0 } = {}) {
1435
+ const grid = this._tileGrid;
1436
+ const session = this.sessions.get(sessionId);
1437
+ if (!session || this.detachedSessions?.has(sessionId)) return false;
1438
+ const el = document.createElement('div');
1439
+ el.className = 'tile tile--revealing';
1440
+ setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
1441
+ // Its screen plays the terminal pane's entrance once its first capture
1442
+ // lands, when its frame entered in a tile style (Tile Animations): never
1443
+ // with the default `settle`, nor on a reload's restore, which settles.
1444
+ const entered = this._beginTileEntrance(el, sessionId, enterIndex);
1445
+ const screenOwed = !!entered && entered !== 'settle';
1446
+ el.dataset.sessionId = sessionId;
1447
+ // Header and body are siblings: the chrome is refreshed in place
1448
+ // (_renderTileHeader), never by rewriting the tile, which would take the
1449
+ // xterm in the body with it.
1450
+ const header = this._buildTileHeader(sessionId);
1451
+ const body = document.createElement('div');
1452
+ body.className = 'tile-body';
1453
+ this._setTileLoadingLabel(body);
1454
+ el.append(header.el, body);
1455
+ // Pressing a tile is a human selection: it focuses the tile and
1456
+ // acknowledges its idle alert (the already-focused tile hits
1457
+ // selectSession's early return, which acknowledges too). In the body on
1458
+ // pointerdown, not click, so focus moves before the press reaches xterm,
1459
+ // and never preventDefault: xterm's own mousedown focuses its textarea and
1460
+ // starts selections. In the header on click instead (below): a press there
1461
+ // may become a drag, and a drag that is cancelled changes nothing, focus
1462
+ // included (owner: best practice). A drag never ends in a click.
1463
+ el.addEventListener('pointerdown', (e) => {
1464
+ if (header.el.contains?.(e?.target)) return;
1465
+ if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId);
1466
+ });
1467
+ header.el.addEventListener('click', () => {
1468
+ if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId);
1469
+ });
1470
+ // A tile takes any session but its own (_acceptTabDrops).
1471
+ this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, sessionId), {
1472
+ accepts: (id) => id !== sessionId,
1473
+ });
1474
+ this._tileGridSection().appendChild(el);
1475
+ const tile = this._newTerminalTile(sessionId, body);
1476
+ grid.tiles.set(sessionId, {
1477
+ tile,
1478
+ el,
1479
+ body,
1480
+ overlay: null,
1481
+ header: header.el,
1482
+ dot: header.dot,
1483
+ harness: header.harness,
1484
+ name: header.name,
1485
+ model: header.model,
1486
+ modelName: header.modelName,
1487
+ zoomBtn: header.zoomBtn,
1488
+ renaming: false,
1489
+ // The pid this tile last saw, so a pane that starts later is noticed.
1490
+ pid: this.sessions.get(sessionId)?.pid ?? null,
1491
+ // Its screen plays the terminal entrance once its first capture lands.
1492
+ screenOwed,
1493
+ });
1494
+ // Where it goes is the caller's (grid.cells).
1495
+ this._renderTileHeader(sessionId);
1496
+ return true;
1497
+ },
1498
+
1499
+ /**
1500
+ * A tile's frame enters as the `enterIndex`-th of a staggered group, in the
1501
+ * entrance style (entrance-animations.js, App Settings → Entrance
1502
+ * Animations). `settle` is the grid's own fade and settle (styles.css
1503
+ * .tile--entering), and what a reload restores with whatever the theme:
1504
+ * nothing animates on page load. Any other style is timed by the entrance
1505
+ * module a frame later, once the layout is final (_stageTileEntrance).
1506
+ * Opacity and transform only, never anything the fit reads.
1507
+ *
1508
+ * @returns {string|null} the style it enters with, or null when it does not animate
1509
+ */
1510
+ _beginTileEntrance(el, sessionId, enterIndex = 0) {
1511
+ const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
1512
+ if (!this._tileMotionAllowed() || style === 'off') return null;
1513
+ el.classList.add('tile--entering');
1514
+ el.style.setProperty('--tile-enter-index', String(enterIndex));
1515
+ let backstop = null;
1516
+ const finish = () => {
1517
+ clearTimeout(backstop);
1518
+ el.removeEventListener('animationend', onEnd);
1519
+ el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
1520
+ if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
1521
+ };
1522
+ // The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
1523
+ // never a child's animation or a wash on its ::before.
1524
+ const onEnd = (e) => {
1525
+ if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
1526
+ finish();
1527
+ };
1528
+ el.addEventListener('animationend', onEnd);
1529
+ el._tileEnterFinish = finish;
1530
+ if (style !== 'settle') {
1531
+ this._stageTileEntrance?.(el, sessionId, (ms) => {
1532
+ clearTimeout(backstop);
1533
+ backstop = setTimeout(finish, ms);
1534
+ });
1535
+ }
1536
+ return style;
1537
+ },
1538
+
1539
+ /** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
1540
+ _replayTileEntrance(el, sessionId, enterIndex = 0) {
1541
+ el._tileEnterFinish?.();
1542
+ void el.offsetWidth;
1543
+ return this._beginTileEntrance(el, sessionId, enterIndex);
1544
+ },
1545
+
1546
+ /**
1547
+ * Makes `el` a drop target for a session tab dragged from the strip (the
1548
+ * strip's own drag sets `draggedTabId`) and for a tile dragged by its header
1549
+ * (`_draggedTileId`, _installTileMoveDrag). Capture phase, with the event
1550
+ * stopped: a tab drag carries the session id as text, and xterm's helper
1551
+ * textarea would otherwise accept that drop and type the id into a PTY.
1552
+ * `accepts(id)` is the target's own rule (a tile takes any session but its
1553
+ * own; an empty cell takes any). A session it does not accept
1554
+ * is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
1555
+ * drop follows). Any other drag (a file) is left to the grid section's own
1556
+ * guard (_installTileFileDrop).
1557
+ */
1558
+ _acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
1559
+ const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
1560
+ el.addEventListener(
1561
+ 'dragover',
1562
+ (e) => {
1563
+ const id = dragged();
1564
+ if (!id) return;
1565
+ e.preventDefault?.();
1566
+ e.stopPropagation?.();
1567
+ const ok = accepts(id);
1568
+ if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none';
1569
+ if (ok) el.classList.add('tile--drop-target');
1570
+ },
1571
+ true
1572
+ );
1573
+ el.addEventListener('dragleave', (e) => {
1574
+ if (!el.contains?.(e.relatedTarget)) el.classList.remove('tile--drop-target');
1575
+ });
1576
+ el.addEventListener(
1577
+ 'drop',
1578
+ (e) => {
1579
+ el.classList.remove('tile--drop-target');
1580
+ const id = dragged();
1581
+ if (!id) return;
1582
+ e.preventDefault?.();
1583
+ e.stopPropagation?.();
1584
+ if (accepts(id)) onDrop(id);
1585
+ },
1586
+ true
1587
+ );
1588
+ },
1589
+
1590
+ /** Whether tiles can be moved now: more than one, and none zoomed (moving is off while one is). */
1591
+ _tilesMovable() {
1592
+ const grid = this._tileGrid;
1593
+ return !!grid?.open && !grid.zoomedId && grid.ids.length > 1;
1594
+ },
1595
+
1596
+ /**
1597
+ * Puts the tiles in new cells (the same sessions, the same shape): the one
1598
+ * path every move takes, a header drag, the tab of a tiled session dropped
1599
+ * on a tile or an empty cell, and the Move Tile chords. Nothing is
1600
+ * remounted, reconnected or reloaded, and no session joins or leaves.
1601
+ * Divider sizes belong to the cells, so a moved tile takes its new cell's
1602
+ * size: each tile whose cell size changed fits once (its xterm and one PTY
1603
+ * resize together, #464), every other tile is left alone. Refused while a
1604
+ * tile is zoomed (moving is off then).
1605
+ *
1606
+ * @param {(string|null)[]} cells - the new cells
1607
+ * @returns {boolean} false when refused, true otherwise (also when nothing moved)
1608
+ */
1609
+ _reorderTiles(cells) {
1610
+ const grid = this._tileGrid;
1611
+ if (!grid?.open || grid.zoomedId || cells.length !== grid.cells.length) return false;
1612
+ const tiles = cells.filter(Boolean);
1613
+ if (tiles.length !== grid.tiles.size || new Set(tiles).size !== tiles.length) return false;
1614
+ if (tiles.some((id) => !grid.tiles.has(id))) return false;
1615
+ if (cells.every((id, k) => (id || null) === grid.cells[k])) return true;
1616
+ // A divider drag in progress measured the tiles at their old places.
1617
+ this._tileDividerDragTeardown?.();
1618
+ const cell = (k) => `${grid.colFr[k % grid.cols]}x${grid.rowFr[Math.floor(k / grid.cols)]}`;
1619
+ const before = new Map();
1620
+ grid.cells.forEach((id, k) => id && before.set(id, cell(k)));
1621
+ grid.cells = cells.map((id) => id || null);
1622
+ this._applyTileLayout();
1623
+ // Synchronous: the fit's measurement forces the new placement's layout.
1624
+ grid.cells.forEach((id, k) => {
1625
+ if (id && before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit();
1626
+ });
1627
+ return true;
1628
+ },
1629
+
1630
+ /** Two tiles trade cells (_reorderTiles). */
1631
+ _swapTiles(a, b) {
1632
+ const cells = this._tileGrid.cells.slice();
1633
+ const i = cells.indexOf(a);
1634
+ const j = cells.indexOf(b);
1635
+ if (i === -1 || j === -1) return false;
1636
+ cells[i] = b;
1637
+ cells[j] = a;
1638
+ return this._reorderTiles(cells);
1639
+ },
1640
+
1641
+ /**
1642
+ * A tile moves into empty cell `k`, leaving its own cell empty: nothing else
1643
+ * moves (_reorderTiles). False when `k` is not an empty cell, or moving is off.
1644
+ */
1645
+ _moveTileToCell(sessionId, k) {
1646
+ const cells = this._tileGrid.cells.slice();
1647
+ const from = cells.indexOf(sessionId);
1648
+ if (from === -1 || !Number.isInteger(k) || cells[k] !== null) return false;
1649
+ cells[from] = null;
1650
+ cells[k] = sessionId;
1651
+ return this._reorderTiles(cells);
1652
+ },
1653
+
1654
+ /**
1655
+ * Move Tile Left/Right/Up/Down: the focused tile goes to the cell next to it
1656
+ * in that direction (tileCellInDirection, never jumping a cell): into it
1657
+ * when it is empty, trading places when a tile is there. Focus stays on the
1658
+ * moved tile. Nothing while a tile is zoomed, or at an edge.
1659
+ */
1660
+ moveTileInDirection(direction) {
1661
+ const grid = this._tileGrid;
1662
+ if (!grid?.open || grid.zoomedId || !grid.focusedId) return;
1663
+ const from = grid.cells.indexOf(grid.focusedId);
1664
+ const to = window.CodemanTileGrid.tileCellInDirection(from, direction, grid.cols, grid.cells.length);
1665
+ if (to === -1) return;
1666
+ if (grid.cells[to]) this._swapTiles(grid.focusedId, grid.cells[to]);
1667
+ else this._moveTileToCell(grid.focusedId, to);
1668
+ },
1669
+
1670
+ /**
1671
+ * A tab dropped on a tile, or a tile dragged there by its header: a session
1672
+ * not yet tiled REPLACES that tile (same place; the replaced session keeps
1673
+ * running); one already tiled swaps places with it (_swapTiles, refused while
1674
+ * a tile is zoomed). Either way the dropped session takes focus (a human
1675
+ * selection).
1676
+ */
1677
+ dropSessionOnTile(draggedId, targetId) {
1678
+ const grid = this._tileGrid;
1679
+ if (!grid?.open || draggedId === targetId || !grid.tiles.has(targetId)) return;
1680
+ if (!this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return;
1681
+ if (grid.tiles.has(draggedId)) {
1682
+ if (!this._swapTiles(draggedId, targetId)) return;
1683
+ } else {
1684
+ this._tileDividerDragTeardown?.();
1685
+ if (!this._mountTile(draggedId)) return;
1686
+ // It takes the replaced tile's cell.
1687
+ grid.cells[grid.cells.indexOf(targetId)] = draggedId;
1688
+ const old = grid.tiles.get(targetId);
1689
+ this._destroyTerminalTile(old.tile);
1690
+ old.el.remove();
1691
+ grid.tiles.delete(targetId);
1692
+ if (grid.zoomedId === targetId) grid.zoomedId = grid.autoZoom ? draggedId : null;
1693
+ if (grid.focusedId === targetId) grid.focusedId = null;
1694
+ this._applyTileLayout();
1695
+ this._connectTile(draggedId);
1696
+ this._scheduleTileGridRefit();
1697
+ this.renderSessionTabs?.();
1698
+ }
1699
+ this.selectSession(draggedId);
1700
+ },
1701
+
1702
+ /**
1703
+ * Something dropped on empty cell `cell`: a tab of a session not tiled yet
1704
+ * joins the grid in that cell; a tiled session (its tab, or the tile dragged
1705
+ * by its header) moves into it, leaving its own cell empty
1706
+ * (_moveTileToCell, refused while a tile is zoomed). Either way the dropped
1707
+ * session takes focus (a human selection).
1708
+ */
1709
+ dropSessionOnSlot(draggedId, cell) {
1710
+ const grid = this._tileGrid;
1711
+ if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return;
1712
+ if (grid.tiles.has(draggedId)) {
1713
+ if (!this._moveTileToCell(draggedId, cell)) return;
1714
+ } else if (!this.addTile(draggedId, { cell })) {
1715
+ return;
1716
+ }
1717
+ this.selectSession(draggedId);
1718
+ },
1719
+
1720
+ /**
1721
+ * Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
1722
+ * human selection: the user clicked its tab). With the grid closed it opens
1723
+ * what the Tiles toggle would, with this session among the tiles and
1724
+ * focused, never past the remembered count (owner answer to decision 10's
1725
+ * questions: N, not N+1): it joins while the grid holds fewer than the
1726
+ * count (a stored grid's first empty cell), else it takes the last tile's
1727
+ * place. A stored grid keeps its cells, sizes and holes around it.
1728
+ * Returns false when the grid cannot open here (narrow or solo window), so
1729
+ * the click is an ordinary one.
1730
+ */
1731
+ addSessionToTiles(sessionId) {
1732
+ if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
1733
+ return false;
1734
+ }
1735
+ const limit = this._tileGridLimit();
1736
+ const capacity = limit.capacity;
1737
+ const grid = this._tileGrid;
1738
+ if (grid?.open) {
1739
+ if (!grid.tiles.has(sessionId)) {
1740
+ if (grid.ids.length >= capacity) {
1741
+ this.showToast?.(limit.full, 'info');
1742
+ return true;
1743
+ }
1744
+ this.addTile(sessionId);
1745
+ }
1746
+ this.selectSession(sessionId);
1747
+ return true;
1748
+ }
1749
+ const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
1750
+ const stored = this._readStoredTileGrid();
1751
+ const plan = this._tileGridOpenSet(stored);
1752
+ if (plan?.source === 'stored') {
1753
+ let { ids, cells } = plan;
1754
+ if (!ids.includes(sessionId)) {
1755
+ if (ids.length < n) {
1756
+ ids = [...ids, sessionId];
1757
+ } else {
1758
+ const last = ids.at(-1);
1759
+ ids = ids.map((id) => (id === last ? sessionId : id));
1760
+ cells = cells.map((id) => (id === last ? sessionId : id));
1761
+ }
1762
+ }
1763
+ return this._openStoredTileGrid(
1764
+ stored,
1765
+ { ids, cells, focusedId: sessionId },
1766
+ { focusedId: sessionId, auto: false, keepCount: false }
1767
+ );
1768
+ }
1769
+ const base = plan?.ids || [];
1770
+ const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
1771
+ // Exactly these: an open split is already in `base` (tileGridOpenSet seeds
1772
+ // it), and merging it again went past the count (N+1) and the window.
1773
+ this.openTileGrid(ids, { focusedId: sessionId, auto: false, mergeSplit: false });
1774
+ return true;
1775
+ },
1776
+
1777
+ /**
1778
+ * "Open group as tiles" (the tab-group menu of the grouped rail): the
1779
+ * group's live sessions, as many as the window fits, become the grid,
1780
+ * replacing whatever it showed. Opening the grid is the app's choice of
1781
+ * focus, so no idle alert is spent.
1782
+ */
1783
+ openGroupAsTiles(groupId) {
1784
+ const group = (this.tabLayout?.groups || []).find((g) => g.id === groupId);
1785
+ if (!group || !this.canOpenTileGrid()) return false;
1786
+ const capacity = this._tileGridLimit().capacity;
1787
+ const ids = (group.refs || [])
1788
+ .filter((ref) => ref.kind === 'session')
1789
+ .map((ref) => ref.id)
1790
+ .filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id))
1791
+ .slice(0, capacity);
1792
+ if (ids.length === 0) {
1793
+ this.showToast?.('This group has no session to show as tiles', 'info');
1794
+ return false;
1795
+ }
1796
+ return this._replaceTileGrid(ids);
1797
+ },
1798
+
1799
+ /**
1800
+ * Opens the grid on `ids` in place of whatever it shows ("Open group as
1801
+ * tiles"): the session in focus keeps focus when `ids` holds
1802
+ * it, otherwise the first one takes it. Chosen BEFORE an open grid closes,
1803
+ * because the close drops activeSessionId: as in selectSession's tile
1804
+ * branch, the parked terminal still holds what it showed before the grid,
1805
+ * and re-parking must not snapshot it.
1806
+ *
1807
+ * @returns {boolean} whether the grid is open afterwards
1808
+ */
1809
+ _replaceTileGrid(ids) {
1810
+ const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
1811
+ if (this._tilesOwnTerminal()) {
1812
+ // Kept until the group's grid, opened next, takes its place.
1813
+ this.closeTileGrid({ keepStored: true, reselect: false });
1814
+ this.activeSessionId = null;
1815
+ }
1816
+ // Exactly the group: an open split closes without joining it (merged, its
1817
+ // two pushed group members out and the grid past the window's capacity).
1818
+ return this.openTileGrid(ids, { focusedId: focus, mergeSplit: false });
1819
+ },
1820
+
1821
+ /** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */
1822
+ _newTerminalTile(sessionId, body) {
1823
+ const session = this.sessions.get(sessionId);
1824
+ const tile = new window.TerminalTile(sessionId, body, {
1825
+ mode: session?.mode,
1826
+ fontSettings: this.loadAppSettingsFromStorage?.() || {},
1827
+ detachedSessions: this.detachedSessions,
1828
+ scheduleLoad: (t, kind, run) => this._tileLoadQueue().schedule(t, kind, run),
1829
+ scrollback: window.CodemanTileGrid.TILE_SCROLLBACK,
1830
+ fontSize: this._tileGridFontSize(),
1831
+ boundedLoad: true,
1832
+ onExit: (code) => this._onTileExit(sessionId, tile, code),
1833
+ });
1834
+ return tile;
1835
+ },
1836
+
1837
+ /**
1838
+ * A grid tile's TerminalTile goes for good: its loads still waiting in the
1839
+ * grid's queue are dropped first (resolved, never run, its loading state
1840
+ * cleared), then the tile itself (socket, xterm, listeners). Its element is
1841
+ * the caller's.
1842
+ */
1843
+ _destroyTerminalTile(tile) {
1844
+ this._tileGrid.queue?.drop(tile);
1845
+ tile.destroy();
1846
+ },
1847
+
1848
+ /**
1849
+ * Replaces a tile's TerminalTile with a fresh one in the same place (after
1850
+ * Attach: a tile whose socket stopped for good cannot reconnect, and a fresh
1851
+ * one loads the new pane from scratch). Keeps the keyboard if it had it.
1852
+ */
1853
+ _remountTile(sessionId) {
1854
+ const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
1855
+ if (!entry) return;
1856
+ const hadKeyboard = this._focusedTile === entry.tile;
1857
+ this._destroyTerminalTile(entry.tile);
1858
+ entry.tile = this._newTerminalTile(sessionId, entry.body);
1859
+ entry.connected = false;
1860
+ this._connectTile(sessionId);
1861
+ if (hadKeyboard) this._noteFocusedTile(entry.tile);
1862
+ },
1863
+
1864
+ /**
1865
+ * What the tile's body should say instead of a terminal, or null for none: a
1866
+ * session with no PTY attached (pid null) or a socket the server closed
1867
+ * because the session exited (4009), both of which Attach can start again;
1868
+ * or an agent that exited in a live pane (paneExit), which it cannot: the
1869
+ * attach and shell routes refuse while the pane's tmux client still runs
1870
+ * ("already has a running process"), and the single view has no restart for
1871
+ * it either, so the tile says so and points at Close session instead. Attach
1872
+ * was just pressed: nothing, while the server catches up.
1873
+ *
1874
+ * @returns {{text: string, attachable: boolean}|null}
1875
+ */
1876
+ _tileAttachReason(sessionId, tile) {
1877
+ const session = this.sessions.get(sessionId);
1878
+ if (!session) return null;
1879
+ const pending = this._tileAttachPending?.get(sessionId);
1880
+ if (pending && Date.now() - pending < 15000) return null;
1881
+ const exited = typeof paneExitLabel === 'function' ? paneExitLabel(session.paneExit) : '';
1882
+ if (exited) return { text: `The agent ${exited}`, attachable: false };
1883
+ if (session.pid === null) return { text: 'Not attached', attachable: true };
1884
+ if (tile?._stoppedCode === 4009) return { text: 'The session ended', attachable: true };
1885
+ return null;
1886
+ },
1887
+
1888
+ /**
1889
+ * The Attach overlay over a tile's body (absolute, so the body and its xterm
1890
+ * keep their size): why there is no terminal, and an Attach button.
1891
+ */
1892
+ _renderTileOverlay(sessionId) {
1893
+ const entry = this._tileGrid?.tiles.get(sessionId);
1894
+ if (!entry?.body) return;
1895
+ const session = this.sessions.get(sessionId);
1896
+ if (session && session.pid !== null && !session.paneExit) this._tileAttachPending?.delete(sessionId);
1897
+ const reason = this._tileAttachReason(sessionId, entry.tile);
1898
+ const busy = !!this._tileAttachInFlight?.has(sessionId);
1899
+ if (!reason && !busy) {
1900
+ if (entry.overlay) entry.overlay.hidden = true;
1901
+ return;
1902
+ }
1903
+ if (!entry.overlay) {
1904
+ const overlay = document.createElement('div');
1905
+ overlay.className = 'tile-attach';
1906
+ const text = document.createElement('span');
1907
+ text.className = 'tile-attach-text';
1908
+ const btn = document.createElement('button');
1909
+ btn.type = 'button';
1910
+ btn.className = 'tile-attach-btn';
1911
+ btn.textContent = 'Attach';
1912
+ btn.addEventListener('click', (e) => {
1913
+ e.stopPropagation();
1914
+ void this.attachTileSession(sessionId);
1915
+ });
1916
+ const hint = document.createElement('span');
1917
+ hint.className = 'tile-attach-hint';
1918
+ hint.textContent = 'It cannot be restarted in place: close it from \u22EF (Close session).';
1919
+ overlay.append(text, btn, hint);
1920
+ entry.body.appendChild(overlay);
1921
+ entry.overlay = overlay;
1922
+ entry.overlayText = text;
1923
+ entry.overlayBtn = btn;
1924
+ entry.overlayHint = hint;
1925
+ }
1926
+ entry.overlay.hidden = false;
1927
+ const text = busy ? 'Attaching\u2026' : reason.text;
1928
+ // Compared with the last English text set, never the DOM: with the zh-CN
1929
+ // translator on, the DOM holds the translation and would never match, so
1930
+ // every refresh would rewrite English for it to translate again.
1931
+ if (entry.overlayLabel !== text) {
1932
+ entry.overlayLabel = text;
1933
+ entry.overlayText.textContent = text;
1934
+ }
1935
+ const attachable = busy || reason.attachable;
1936
+ entry.overlayBtn.hidden = !attachable;
1937
+ entry.overlayHint.hidden = attachable;
1938
+ entry.overlayBtn.disabled = busy;
1939
+ },
1940
+
1941
+ /**
1942
+ * Attach: starts the session's CLI in its pane, exactly as the single view's
1943
+ * automatic re-attach does: `POST /interactive` (or `/shell` for a shell)
1944
+ * with NO body, at most one in flight per session (the route has no guard of
1945
+ * its own). A session whose PTY-exit breaker tripped goes through the same
1946
+ * confirm the single view asks before `clearBreaker: true`; nothing automatic
1947
+ * ever sends that. On success the tile is remounted onto the new pane.
1948
+ */
1949
+ async attachTileSession(sessionId) {
1950
+ const session = this.sessions.get(sessionId);
1951
+ if (!session) return false;
1952
+ // An agent that exited in a live pane cannot be started again in place.
1953
+ if (this._tileAttachReason(sessionId, this._tileFor(sessionId))?.attachable === false) return false;
1954
+ this._tileAttachInFlight ||= new Set();
1955
+ if (this._tileAttachInFlight.has(sessionId)) return false;
1956
+ let url = `/api/sessions/${sessionId}/${session.mode === 'shell' ? 'shell' : 'interactive'}`;
1957
+ let init = { method: 'POST' };
1958
+ if (session.respawnBlocked) {
1959
+ const label = session.name || 'Session';
1960
+ if (!window.confirm(`${label} was stopped after crashing repeatedly. Restart it?`)) return false;
1961
+ url = `/api/sessions/${sessionId}/interactive`;
1962
+ init = {
1963
+ method: 'POST',
1964
+ headers: { 'Content-Type': 'application/json' },
1965
+ body: JSON.stringify({ clearBreaker: true }),
1966
+ };
1967
+ }
1968
+ this._tileAttachInFlight.add(sessionId);
1969
+ this._renderTileOverlay(sessionId);
1970
+ let ok = false;
1971
+ try {
1972
+ const res = await fetch(url, init);
1973
+ // The routes report a refusal in the envelope of a 200.
1974
+ const body = await res?.json?.().catch(() => null);
1975
+ ok = !!res?.ok && body?.success !== false;
1976
+ } catch {
1977
+ ok = false;
1978
+ } finally {
1979
+ this._tileAttachInFlight.delete(sessionId);
1980
+ }
1981
+ if (ok) {
1982
+ if (init.body) session.respawnBlocked = false;
1983
+ session.status = 'busy';
1984
+ (this._tileAttachPending ||= new Map()).set(sessionId, Date.now());
1985
+ this._remountTile(sessionId);
1986
+ } else {
1987
+ this.showToast?.('Could not attach the session', 'error');
1988
+ }
1989
+ this._renderTileOverlay(sessionId);
1990
+ return ok;
1991
+ },
1992
+
1993
+ /**
1994
+ * `● [logo] name · model ..... ⋯ ⤢ ×`: the status dot (the six-state
1995
+ * classifier the tab rows and both home screens share), the harness logo, the
1996
+ * session name (double-click renames), the model it runs when known, the
1997
+ * session menu (the tab rail's own), zoom and remove-tile. Its
1998
+ * buttons stop pointerdown, so acting on a tile that is not focused does not
1999
+ * also focus it (and spend its idle alert). The rest of it is the handle that
2000
+ * moves the tile (_installTileMoveDrag).
2001
+ */
2002
+ _buildTileHeader(sessionId) {
2003
+ const el = document.createElement('div');
2004
+ el.className = 'tile-header';
2005
+ const dot = document.createElement('span');
2006
+ dot.className = 'tile-dot home-sessions-dot home-sessions-dot--idle';
2007
+ dot.setAttribute('aria-hidden', 'true');
2008
+ // The harness's logo: PR #532's `run-mode-dot <cliId>` slot, so the logos,
2009
+ // the skins and the plain dot for an id without one stay in styles.css. Its
2010
+ // tooltip and accessible name carry the harness and the model in full
2011
+ // (_paintSessionHarness).
2012
+ const harness = document.createElement('span');
2013
+ harness.className = 'tile-harness run-mode-dot';
2014
+ harness.setAttribute('role', 'img');
2015
+ const title = document.createElement('span');
2016
+ title.className = 'tile-title';
2017
+ const name = document.createElement('span');
2018
+ name.className = 'tile-name';
2019
+ // A session literally named like a UI string ("Sessions") must not be translated.
2020
+ name.setAttribute('data-i18n-skip', '');
2021
+ name.addEventListener('dblclick', (e) => {
2022
+ e.stopPropagation();
2023
+ this.startTileRename(sessionId);
2024
+ });
2025
+ // The model the session runs, when the server knows it: the name itself is
2026
+ // never translated (data-i18n-skip on the inner span), the tooltip on the
2027
+ // outer one may be. Hidden from screen readers: the logo's accessible name
2028
+ // already says it.
2029
+ const model = document.createElement('span');
2030
+ model.className = 'tile-model';
2031
+ model.setAttribute('aria-hidden', 'true');
2032
+ model.hidden = true;
2033
+ const modelName = document.createElement('span');
2034
+ modelName.setAttribute('data-i18n-skip', '');
2035
+ model.appendChild(modelName);
2036
+ title.append(name, model);
2037
+ const actions = document.createElement('span');
2038
+ actions.className = 'tile-actions';
2039
+ const button = (cls, label, glyph, onClick) => {
2040
+ const b = document.createElement('button');
2041
+ b.type = 'button';
2042
+ b.className = `tile-btn ${cls}`;
2043
+ b.title = label;
2044
+ b.setAttribute('aria-label', label);
2045
+ b.textContent = glyph;
2046
+ b.addEventListener('pointerdown', (e) => e.stopPropagation());
2047
+ b.addEventListener('click', (e) => {
2048
+ e.stopPropagation();
2049
+ onClick(e);
2050
+ });
2051
+ return b;
2052
+ };
2053
+ const zoomBtn = button('tile-zoom', 'Zoom this tile', '\u2922', () => this.zoomTile(sessionId));
2054
+ zoomBtn.setAttribute('aria-pressed', 'false');
2055
+ actions.append(
2056
+ button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)),
2057
+ zoomBtn,
2058
+ // (No + here, owner decision 9: tiles are added from the Tiles button
2059
+ // and its count menu, Ctrl/Cmd+click, a dragged tab, a tab group or Run.)
2060
+ // Removes the tile ONLY: the session keeps running. Killing it stays
2061
+ // behind the menu's Close session and its confirm.
2062
+ button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId))
2063
+ );
2064
+ el.append(dot, harness, title, actions);
2065
+ this._installTileMoveDrag(sessionId, el, actions);
2066
+ return { el, dot, harness, name, model, modelName, zoomBtn };
2067
+ },
2068
+
2069
+ /**
2070
+ * The header moves its tile: dragged onto another tile the two trade places,
2071
+ * onto an empty cell it moves there and leaves its own cell empty
2072
+ * (dropSessionOnTile / dropSessionOnSlot, the path a dragged tab takes,
2073
+ * through the same capture-phase drop targets, _acceptTabDrops). A native drag, so Escape and a drop anywhere else are the
2074
+ * browser's own cancel: nothing moves, and dragend clears what the drag
2075
+ * painted. The drag carries a type of its own and never text, so no text
2076
+ * field or terminal, in this page or another application, can take it as
2077
+ * typing; and it is not `draggedTabId`, so the tab strip ignores it.
2078
+ *
2079
+ * The handle is the header's free area: a press on a button or the rename
2080
+ * input starts no drag. dragstart's target is the header whatever was
2081
+ * pressed, so where the press landed is noted in the capture phase, ahead of
2082
+ * the buttons' own stopPropagation. `draggable` is off while moving is
2083
+ * (_paintTileHandle: a zoomed grid, a single tile, a rename in progress).
2084
+ */
2085
+ _installTileMoveDrag(sessionId, header, actions) {
2086
+ let pressedOnControl = false;
2087
+ header.addEventListener(
2088
+ 'pointerdown',
2089
+ (e) => {
2090
+ const target = e.target;
2091
+ pressedOnControl = !!actions.contains?.(target) || /^(INPUT|BUTTON)$/i.test(target?.tagName || '');
2092
+ },
2093
+ true
2094
+ );
2095
+ header.addEventListener('dragstart', (e) => {
2096
+ const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
2097
+ if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
2098
+ e.preventDefault?.();
2099
+ return;
2100
+ }
2101
+ this._draggedTileId = sessionId;
2102
+ entry.el.classList.add('tile--dragging');
2103
+ if (e.dataTransfer) {
2104
+ e.dataTransfer.effectAllowed = 'move';
2105
+ // Firefox starts no drag without data.
2106
+ e.dataTransfer.setData('application/x-codeman-tile', sessionId);
2107
+ }
2108
+ });
2109
+ header.addEventListener('dragend', () => this._endTileMoveDrag());
2110
+ },
2111
+
2112
+ /**
2113
+ * A header drag is over (dropped, cancelled with Escape, dropped outside),
2114
+ * or its tile or the grid went mid-drag: forget it and clear what it
2115
+ * painted. The drop targets' highlight too: a cancelled drag does not
2116
+ * reliably send dragleave.
2117
+ */
2118
+ _endTileMoveDrag() {
2119
+ this._draggedTileId = null;
2120
+ const grid = this._tileGrid;
2121
+ for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target');
2122
+ for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target');
2123
+ },
2124
+
2125
+ /**
2126
+ * The header as a handle: `draggable` while the tile can move (not while a
2127
+ * tile is zoomed, alone, or being renamed), and its tooltip, the state and
2128
+ * how long ("working 3m") plus, while it can move, that it drags. Diffs on
2129
+ * the last English text, never the DOM (translated in zh-CN; see
2130
+ * _renderTileOverlay).
2131
+ */
2132
+ _paintTileHandle(entry) {
2133
+ const movable = this._tilesMovable();
2134
+ const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n');
2135
+ if (entry.headerLabel !== title) {
2136
+ entry.headerLabel = title;
2137
+ entry.header.title = title;
2138
+ }
2139
+ const draggable = movable && !entry.renaming ? 'true' : 'false';
2140
+ if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
2141
+ },
2142
+
2143
+ /**
2144
+ * Refreshes one tile's header from the session: dot state, harness logo,
2145
+ * name, model, the hover label ("working 3m") and the `needs` border. Diffs on existing nodes only,
2146
+ * and cheap: it runs on every tab render (every status change).
2147
+ */
2148
+ _renderTileHeader(sessionId) {
2149
+ const entry = this._tileGrid?.tiles.get(sessionId);
2150
+ const session = this.sessions.get(sessionId);
2151
+ if (!entry?.header || !session) return;
2152
+ const row = this._sidebarRichRow?.(sessionId, session) || null;
2153
+ const state = row?.state || 'idle';
2154
+ const dotClass = `tile-dot home-sessions-dot home-sessions-dot--${row?.exited ? 'done' : state}`;
2155
+ if (entry.dot.className !== dotClass) entry.dot.className = dotClass;
2156
+ const since = row?.since?.at ? this._mobileOverviewStampText?.(row.since.at, 'for') : '';
2157
+ entry.stateLabel = row ? [row.pill, since].filter(Boolean).join(' ') : '';
2158
+ this._paintTileHandle(entry);
2159
+ // The input of a rename in progress has taken the name's place in the
2160
+ // header, so updating the detached name never touches what is being typed.
2161
+ // A rename still in flight shows as already done, as on the tab.
2162
+ const name =
2163
+ this._inlineRenamePending?.get(sessionId) || this.getSessionName?.(session) || session.name || 'Session';
2164
+ if (entry.name.textContent !== name) entry.name.textContent = name;
2165
+ this._paintSessionHarness(entry, session, 'tile-harness');
2166
+ // A permission prompt is visible across the room.
2167
+ entry.el.classList.toggle('tile--needs', state === 'needs');
2168
+ this._renderTileOverlay(sessionId);
2169
+ },
2170
+
2171
+ /** Every tile's header (after a tab render, i.e. any session change). */
2172
+ _renderTileChrome() {
2173
+ const grid = this._tileGrid;
2174
+ if (!grid?.open) return;
2175
+ for (const [id, entry] of grid.tiles) {
2176
+ this._renderTileHeader(id);
2177
+ // A pane that started after the tile connected (a session Run made
2178
+ // while the grid is open, an Attach from elsewhere) was spawned at the
2179
+ // server's default size: the tile's own resize went out before there
2180
+ // was a PTY to take it, and Run's resize measures the parked main
2181
+ // terminal (nothing). Keyed on the sessions map, so a handleInit after
2182
+ // an SSE drop is seen too; a render skipped during an inline tab rename
2183
+ // is caught up by the rename's own render.
2184
+ const pid = this.sessions.get(id)?.pid ?? null;
2185
+ if (pid !== null && pid !== entry.pid) entry.tile.paneStarted?.();
2186
+ entry.pid = pid;
2187
+ }
2188
+ },
2189
+
2190
+ /**
2191
+ * Double-click on a tile's name: an input in its place, Enter or leaving it
2192
+ * renames through the tab rename's own write queue, Escape cancels. The
2193
+ * header refresh leaves the name alone meanwhile.
2194
+ */
2195
+ startTileRename(sessionId) {
2196
+ const entry = this._tileGrid?.tiles.get(sessionId);
2197
+ const session = this.sessions.get(sessionId);
2198
+ if (!entry || !session || entry.renaming) return;
2199
+ entry.renaming = true;
2200
+ // No drag from the header while its input is in it (_paintTileHandle).
2201
+ this._paintTileHandle(entry);
2202
+ const input = document.createElement('input');
2203
+ input.type = 'text';
2204
+ input.className = 'tile-rename-input';
2205
+ input.setAttribute('aria-label', 'Session name');
2206
+ // A rename still in flight is the user's last word.
2207
+ input.value = this._inlineRenamePending?.get(sessionId) ?? session.name ?? '';
2208
+ let settled = false;
2209
+ const finish = (commit) => {
2210
+ if (settled) return;
2211
+ settled = true;
2212
+ input.replaceWith(entry.name);
2213
+ entry.renaming = false;
2214
+ const value = input.value.trim();
2215
+ if (commit && value && value !== session.name && this.sessions.has(sessionId)) {
2216
+ entry.name.textContent = value;
2217
+ void this._queueInlineSessionName?.(sessionId, value);
2218
+ }
2219
+ this._renderTileHeader(sessionId);
2220
+ };
2221
+ input.addEventListener('pointerdown', (e) => e.stopPropagation());
2222
+ // A click in the input is not the header's click, which would focus the
2223
+ // terminal away from it.
2224
+ input.addEventListener('click', (e) => e.stopPropagation());
2225
+ input.addEventListener('keydown', (e) => {
2226
+ // Enter and Escape during an IME composition belong to the IME.
2227
+ if (e.isComposing || e.keyCode === 229) return;
2228
+ if (e.key === 'Enter') {
2229
+ e.preventDefault();
2230
+ finish(true);
2231
+ } else if (e.key === 'Escape') {
2232
+ e.preventDefault();
2233
+ finish(false);
2234
+ }
2235
+ });
2236
+ input.addEventListener('blur', () => finish(true));
2237
+ entry.name.replaceWith(input);
2238
+ input.focus();
2239
+ input.select?.();
2240
+ },
2241
+
2242
+ /**
2243
+ * Starts a tile's terminal and socket (TerminalTile.connect builds the xterm
2244
+ * synchronously, then loads through the grid's queue). Once per tile. A
2245
+ * focus that selected this tile before its terminal existed lands now.
2246
+ */
2247
+ _connectTile(sessionId) {
2248
+ const grid = this._tileGrid;
2249
+ const entry = grid?.tiles.get(sessionId);
2250
+ if (!entry || entry.connected) return;
2251
+ entry.connected = true;
2252
+ entry.tile.connect().catch(() => {
2253
+ /* Best-effort, as the split's Pane B: live output arrives once the socket opens. */
2254
+ });
2255
+ if (grid.focusOnConnect === sessionId) {
2256
+ grid.focusOnConnect = null;
2257
+ entry.tile.terminal?.focus();
2258
+ }
2259
+ },
2260
+
2261
+ /**
2262
+ * Opening the grid: the tiles' terminals are built one per animation frame,
2263
+ * in `order` (the focused tile first), instead of all of them inside the
2264
+ * click. Building six xterms took about 100 ms of the click's 140 ms before
2265
+ * the first frame; paced, the click paints its empty frames in about 20 ms
2266
+ * and the entrance plays while the terminals are built. Nothing waits for
2267
+ * it: the load queue serves one capture at a time anyway, so only the
2268
+ * focused tile's connect is on its critical path, one frame later. Each
2269
+ * tile still fits once, at its final size (laid out before any connect),
2270
+ * and sends one PTY resize. A tile removed, replaced or already connected
2271
+ * meanwhile is skipped; a grid closed (or opened again) meanwhile stops it.
2272
+ */
2273
+ _connectTilesPaced(order) {
2274
+ const grid = this._tileGrid;
2275
+ const run = (grid.paceRun = (grid.paceRun || 0) + 1);
2276
+ const step = (k) => {
2277
+ if (!grid.open || grid.paceRun !== run || k >= order.length) return;
2278
+ this._connectTile(order[k]);
2279
+ requestAnimationFrame(() => step(k + 1));
2280
+ };
2281
+ requestAnimationFrame(() => step(0));
2282
+ },
2283
+
2284
+ /**
2285
+ * A tile's socket stopped for good. 4009 (the session exited) keeps the tile
2286
+ * with its "session ended" marker; 4003 (refused), 4004 (session gone) and
2287
+ * 4010 (another socket took over) remove it. Nobody here asked for that, so
2288
+ * the neighbour takes focus without the keyboard (`focus: false`): what the
2289
+ * user is typing never lands in another session's PTY.
2290
+ */
2291
+ _onTileExit(sessionId, tile, code) {
2292
+ if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return;
2293
+ // The session exited: the tile stays, with the Attach overlay over it.
2294
+ if (code === 4009) {
2295
+ this._renderTileOverlay(sessionId);
2296
+ return;
2297
+ }
2298
+ this.removeTile(sessionId, { focus: false, gone: true });
2299
+ },
2300
+
2301
+ /**
2302
+ * Columns x rows for the current tile count, applied to the grid section.
2303
+ * The shape comes from the count alone; within it each tile sits in its
2304
+ * cell (grid.cells) and any cell may be empty. When the shape changes, the
2305
+ * cells follow fitTileCells (each tile keeps its row and column if all fit,
2306
+ * else the tiles pack in reading order).
2307
+ */
2308
+ _applyTileLayout() {
2309
+ const grid = this._tileGrid;
2310
+ const section = this._tileGridSection();
2311
+ const { cols, rows, fits } = this._tileShapeFor(grid.ids.length);
2312
+ grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows);
2313
+ if (grid.colFr.length !== cols) grid.colFr = new Array(cols).fill(1);
2314
+ if (grid.rowFr.length !== rows) grid.rowFr = new Array(rows).fill(1);
2315
+ grid.cols = cols;
2316
+ grid.rows = rows;
2317
+ // A window too small for the tiles' minimum size shows the focused tile
2318
+ // alone, with a hint; once it fits again the grid comes back. A zoom the
2319
+ // user chose is theirs: it stays until they lift it.
2320
+ if (!fits && !grid.zoomedId && grid.focusedId) {
2321
+ grid.zoomedId = grid.focusedId;
2322
+ grid.autoZoom = true;
2323
+ this.showToast?.(`The window is too small for ${grid.ids.length} tiles: showing the focused one`, 'info');
2324
+ } else if (fits && grid.autoZoom) {
2325
+ grid.zoomedId = null;
2326
+ grid.autoZoom = false;
2327
+ }
2328
+ const zoomed = grid.zoomedId && grid.tiles.has(grid.zoomedId) ? grid.zoomedId : null;
2329
+ section.classList.toggle('tile-grid--zoomed', !!zoomed);
2330
+ this._syncTileZoom(zoomed);
2331
+ // Moving is off while a tile is zoomed or alone: the headers say so.
2332
+ for (const entry of grid.tiles.values()) this._paintTileHandle(entry);
2333
+ // Zoomed: one cell; the other tiles stay connected but hidden (CSS), so
2334
+ // they measure nothing and send no resize. Otherwise every tile is placed
2335
+ // explicitly in its cell, with a divider track between columns and between
2336
+ // rows, and every empty cell holds a drop slot.
2337
+ section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.colFr);
2338
+ section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.rowFr);
2339
+ const holes = [];
2340
+ grid.cells.forEach((id, k) => {
2341
+ if (!id) {
2342
+ holes.push(k);
2343
+ return;
2344
+ }
2345
+ const el = grid.tiles.get(id)?.el;
2346
+ if (!el) return;
2347
+ el.style.gridColumn = id === zoomed ? '1' : String(2 * (k % cols) + 1);
2348
+ el.style.gridRow = id === zoomed ? '1' : String(2 * Math.floor(k / cols) + 1);
2349
+ });
2350
+ this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows);
2351
+ this._syncTileSlots(zoomed ? [] : holes, cols);
2352
+ this._persistTileGrid();
2353
+ },
2354
+
2355
+ // Each tile's zoom state: the zoomed one is shown alone (CSS), and every ⤢
2356
+ // button says what pressing it does next.
2357
+ _syncTileZoom(zoomed) {
2358
+ for (const [id, entry] of this._tileGrid.tiles) {
2359
+ const on = id === zoomed;
2360
+ entry.el.classList.toggle('tile--zoomed', on);
2361
+ const zoomBtn = entry.zoomBtn;
2362
+ if (!zoomBtn) continue;
2363
+ zoomBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
2364
+ const label = on ? 'Restore the grid' : 'Zoom this tile';
2365
+ // Against the last English label, never the DOM (translated in zh-CN).
2366
+ if (entry.zoomLabel !== label) {
2367
+ entry.zoomLabel = label;
2368
+ zoomBtn.title = label;
2369
+ zoomBtn.setAttribute('aria-label', label);
2370
+ }
2371
+ }
2372
+ },
2373
+
2374
+ // The empty cells of a layout that is not full (3 tiles in a 2x2, 5 in a
2375
+ // 3x2), wherever they are: drop targets for a tab (it joins there) and for a
2376
+ // tile (it moves there). Each slot knows its cell (`data-cell`).
2377
+ _syncTileSlots(holes, cols) {
2378
+ const grid = this._tileGrid;
2379
+ grid.slots ||= [];
2380
+ while (grid.slots.length > holes.length) grid.slots.pop().remove();
2381
+ while (grid.slots.length < holes.length) {
2382
+ const slot = document.createElement('div');
2383
+ slot.className = 'tile-slot';
2384
+ slot.textContent = 'Drop a tab or a tile here';
2385
+ this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId, Number(slot.dataset.cell)));
2386
+ this._tileGridSection().appendChild(slot);
2387
+ grid.slots.push(slot);
2388
+ }
2389
+ grid.slots.forEach((slot, i) => {
2390
+ const k = holes[i];
2391
+ slot.dataset.cell = String(k);
2392
+ slot.style.gridColumn = String(2 * (k % cols) + 1);
2393
+ slot.style.gridRow = String(2 * Math.floor(k / cols) + 1);
2394
+ });
2395
+ },
2396
+
2397
+ // One divider per gap between columns and between rows, created and dropped
2398
+ // as the counts change (never rebuilt while they stay, so a drag in progress
2399
+ // keeps its element).
2400
+ _syncTileDividers(cols, rows) {
2401
+ const grid = this._tileGrid;
2402
+ const section = this._tileGridSection();
2403
+ const wanted = new Set();
2404
+ for (let i = 0; i < cols - 1; i++) wanted.add(`col-${i}`);
2405
+ for (let i = 0; i < rows - 1; i++) wanted.add(`row-${i}`);
2406
+ for (const [key, el] of grid.dividers) {
2407
+ if (wanted.has(key)) continue;
2408
+ if (this._tileDividerDrag?.key === key) this._tileDividerDragTeardown?.();
2409
+ el.remove();
2410
+ grid.dividers.delete(key);
2411
+ }
2412
+ for (const key of wanted) {
2413
+ let el = grid.dividers.get(key);
2414
+ const [axis, n] = key.split('-');
2415
+ const index = Number(n);
2416
+ if (!el) {
2417
+ el = document.createElement('div');
2418
+ el.className = `tile-divider tile-divider--${axis}`;
2419
+ el.setAttribute('role', 'separator');
2420
+ el.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal');
2421
+ el.setAttribute('aria-label', axis === 'col' ? 'Resize tile columns' : 'Resize tile rows');
2422
+ el.addEventListener('pointerdown', (e) => this._startTileDividerDrag(e, axis, index, el, key));
2423
+ section.appendChild(el);
2424
+ grid.dividers.set(key, el);
2425
+ }
2426
+ el.style.gridColumn = axis === 'col' ? String(2 * index + 2) : '1 / -1';
2427
+ el.style.gridRow = axis === 'col' ? '1 / -1' : String(2 * index + 2);
2428
+ }
2429
+ },
2430
+
2431
+ /**
2432
+ * Drags a column or row divider: the two tracks either side trade size, each
2433
+ * kept at the minimum tile size (dragTrackFractions, constants.js). The
2434
+ * affected tiles reflow locally once per animation frame; their PTYs hear
2435
+ * ONE resize each, at pointer-up, never per move (each one is a tmux resize
2436
+ * and a SIGWINCH). Pointer capture keeps the drag on the divider whatever is
2437
+ * under the pointer; closing the grid or removing a tile mid-drag tears it
2438
+ * down through _tileDividerDragTeardown.
2439
+ */
2440
+ _startTileDividerDrag(e, axis, index, divider, key) {
2441
+ if (e.button !== undefined && e.button !== 0) return;
2442
+ const grid = this._tileGrid;
2443
+ if (!grid?.open) return;
2444
+ e.preventDefault?.();
2445
+ e.stopPropagation?.();
2446
+ this._tileDividerDragTeardown?.();
2447
+ const T = window.CodemanTileGrid;
2448
+ const section = this._tileGridSection();
2449
+ const rect = section.getBoundingClientRect();
2450
+ const isCol = axis === 'col';
2451
+ const count = isCol ? grid.cols : grid.rows;
2452
+ const total = (isCol ? rect.width : rect.height) - 2 * TILE_GRID_PADDING_PX - TILE_DIVIDER_PX * (count - 1);
2453
+ const startFr = (isCol ? grid.colFr : grid.rowFr).slice();
2454
+ const start = isCol ? e.clientX : e.clientY;
2455
+ const minPx = isCol ? T.TILE_MIN_W : T.TILE_MIN_H;
2456
+ const affected = [];
2457
+ grid.cells.forEach((id, k) => {
2458
+ if (!id) return;
2459
+ const track = isCol ? k % grid.cols : Math.floor(k / grid.cols);
2460
+ if (track === index || track === index + 1) affected.push(grid.tiles.get(id).tile);
2461
+ });
2462
+ let raf = null;
2463
+ let pending = start;
2464
+ let capturedPointerId = null;
2465
+ const apply = (pos) => {
2466
+ if (!grid.open) return;
2467
+ const fr = T.dragTrackFractions(startFr, index, pos - start, total, minPx);
2468
+ if (isCol) grid.colFr = fr;
2469
+ else grid.rowFr = fr;
2470
+ section.style[isCol ? 'gridTemplateColumns' : 'gridTemplateRows'] = tileGridTracks(fr);
2471
+ for (const tile of affected) tile.localFit();
2472
+ };
2473
+ const onMove = (ev) => {
2474
+ pending = isCol ? ev.clientX : ev.clientY;
2475
+ if (raf !== null) return;
2476
+ raf = requestAnimationFrame(() => {
2477
+ raf = null;
2478
+ apply(pending);
2479
+ });
2480
+ };
2481
+ const endDrag = () => {
2482
+ divider.classList.remove('dragging');
2483
+ document.body.classList.remove('tile-grid-resizing', `tile-grid-resizing--${axis}`);
2484
+ if (capturedPointerId !== null) {
2485
+ try {
2486
+ divider.releasePointerCapture?.(capturedPointerId);
2487
+ } catch {
2488
+ /* Already released. */
2489
+ }
2490
+ capturedPointerId = null;
2491
+ }
2492
+ divider.removeEventListener('pointermove', onMove);
2493
+ divider.removeEventListener('pointerup', onUp);
2494
+ divider.removeEventListener('pointercancel', onUp);
2495
+ if (raf !== null) {
2496
+ cancelAnimationFrame(raf);
2497
+ raf = null;
2498
+ }
2499
+ if (this._tileDividerDragTeardown === endDrag) {
2500
+ this._tileDividerDragTeardown = null;
2501
+ this._tileDividerDrag = null;
2502
+ }
2503
+ };
2504
+ const onUp = () => {
2505
+ // The last queued frame carries the final pointer position.
2506
+ const queued = raf !== null;
2507
+ endDrag();
2508
+ if (queued) apply(pending);
2509
+ for (const tile of affected) {
2510
+ if (!tile._destroyed) tile.fit();
2511
+ }
2512
+ this._persistTileGrid();
2513
+ };
2514
+ divider.classList.add('dragging');
2515
+ document.body.classList.add('tile-grid-resizing', `tile-grid-resizing--${axis}`);
2516
+ try {
2517
+ divider.setPointerCapture?.(e.pointerId);
2518
+ capturedPointerId = e.pointerId ?? null;
2519
+ } catch {
2520
+ /* The drag still works through the listeners below. */
2521
+ }
2522
+ divider.addEventListener('pointermove', onMove);
2523
+ divider.addEventListener('pointerup', onUp);
2524
+ divider.addEventListener('pointercancel', onUp);
2525
+ this._tileDividerDragTeardown = endDrag;
2526
+ this._tileDividerDrag = { key };
2527
+ },
2528
+
2529
+ /**
2530
+ * Zooms a tile to fill the grid, like tmux zoom, or restores the grid when it
2531
+ * is the one zoomed. A tile that is not focused is focused first (a human
2532
+ * selection: the user asked to look at it). Every tile is refitted after, the
2533
+ * shown ones to their new size and the zoomed one to the whole grid.
2534
+ */
2535
+ zoomTile(sessionId) {
2536
+ const grid = this._tileGrid;
2537
+ if (!grid?.open || !grid.tiles.has(sessionId)) return;
2538
+ if (grid.zoomedId === sessionId) {
2539
+ grid.zoomedId = null;
2540
+ grid.autoZoom = false;
2541
+ } else {
2542
+ if (grid.focusedId !== sessionId) this.selectSession(sessionId);
2543
+ grid.zoomedId = sessionId;
2544
+ grid.autoZoom = false;
2545
+ }
2546
+ this._applyTileLayout();
2547
+ this._scheduleTileGridRefit();
2548
+ },
2549
+
2550
+ // Refits every tile once the grid area has settled: one xterm resize and one
2551
+ // PTY resize together per tile (#464), on the trailing edge.
2552
+ _scheduleTileGridRefit() {
2553
+ const grid = this._tileGrid;
2554
+ if (!grid?.open) return;
2555
+ clearTimeout(grid.refitTimer);
2556
+ grid.refitTimer = setTimeout(() => {
2557
+ grid.refitTimer = null;
2558
+ if (!grid.open) return;
2559
+ // The 3-tile layout depends on the width (3x1 or 2x2).
2560
+ this._applyTileLayout();
2561
+ for (const { tile } of grid.tiles.values()) tile.fit();
2562
+ }, TILE_GRID_REFIT_MS);
2563
+ },
2564
+
2565
+ // Narrowing the window past the desktop gate returns to the single view of the
2566
+ // focused session; the grid is remembered.
2567
+ _installTileGridWidthGate() {
2568
+ if (this._tileGridWidthGateInstalled || !window.matchMedia) return;
2569
+ this._tileGridWidthGateInstalled = true;
2570
+ const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
2571
+ mq.addEventListener('change', (e) => {
2572
+ if (!e.matches && this._tileGrid?.open) this.closeTileGrid({ keepStored: true, reselect: true });
2573
+ });
2574
+ },
2575
+
2576
+ _paintTileFocus() {
2577
+ const grid = this._tileGrid;
2578
+ for (const [id, { el }] of grid?.tiles || []) el.classList.toggle('focused', id === grid.focusedId);
2579
+ },
2580
+
2581
+ /**
2582
+ * Focuses a tiled session: the tile branch of selectSession. Moving focus is
2583
+ * an `activeSessionId` change plus `xterm.focus()`: no fetch, no replay. It
2584
+ * runs the panel refresh a normal switch runs and skips everything bound to
2585
+ * the main terminal (cleanup, replay, resize, its socket, local echo). Only a
2586
+ * USER-initiated selection (`auto` not true) acknowledges the idle alert.
2587
+ *
2588
+ * @param {string} sessionId
2589
+ * @param {{auto?: boolean, focus?: boolean}} [options] - `focus: false` leaves
2590
+ * DOM focus where it is (an app-driven reconcile must not steal it)
2591
+ */
2592
+ _selectTiledSession(sessionId, options = {}) {
2593
+ const grid = this._tileGrid;
2594
+ const entry = grid?.open ? grid.tiles.get(sessionId) : null;
2595
+ if (!entry) return;
2596
+ const userInitiated = options.auto !== true;
2597
+ // Aborts any in-flight normal select at its next _isStaleSelect check.
2598
+ const selectGen = ++this._selectGeneration;
2599
+ this._hideWebviewLayer?.();
2600
+ this.activeSessionId = sessionId;
2601
+ grid.focusedId = sessionId;
2602
+ // Moving focus off a zoomed tile restores the grid, as selecting another
2603
+ // pane does in tmux. An automatic zoom (the window cannot fit the tiles)
2604
+ // follows focus instead: there is no grid to restore.
2605
+ if (grid.zoomedId && grid.zoomedId !== sessionId) {
2606
+ grid.zoomedId = grid.autoZoom ? sessionId : null;
2607
+ this._applyTileLayout();
2608
+ this._scheduleTileGridRefit();
2609
+ }
2610
+ this._activateFileBrowserSession?.(sessionId);
2611
+ try {
2612
+ localStorage.setItem('codeman-active-session', sessionId);
2613
+ } catch {
2614
+ /* Per-device convenience only. */
2615
+ }
2616
+ // The SSE subscription follows the focused session as in the single view;
2617
+ // its terminal frames are dropped by the parking guards.
2618
+ this._updateSseSubscription?.(sessionId);
2619
+ this.hideWelcome();
2620
+ if (userInitiated) this.markIdleAlertSeen(sessionId);
2621
+ this._paintTileFocus();
2622
+ this._updateActiveTabImmediate?.(sessionId);
2623
+ this.closeSessionSidebarOnHandheld?.();
2624
+ this.renderSessionTabs?.();
2625
+ const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
2626
+ // Not while it still glows: on every skin but OG the glow is `animation:
2627
+ // none`, so animationend never comes and each tile focus used to leave
2628
+ // another listener behind on the tab (49 after 50 focus changes).
2629
+ if (activeTab && !activeTab.classList.contains('tab-glow')) {
2630
+ activeTab.classList.add('tab-glow');
2631
+ activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true });
2632
+ }
2633
+ this.updateAttachmentHistoryBadge?.();
2634
+ if (this.attachmentHistoryDrawerOpen) this.loadAttachmentHistory?.(sessionId);
2635
+ if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession();
2636
+ this.refreshHostWakeBanner?.(sessionId);
2637
+ this.currentSessionWorkingDir = this.sessions.get(sessionId)?.workingDir || null;
2638
+ const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16);
2639
+ idleCb(() => this._refreshSessionPanels(sessionId, selectGen));
2640
+ // The keyboard follows focus: shortcuts, voice and paste act on this tile.
2641
+ // A tile whose terminal is not built yet (the grid just opened, paced)
2642
+ // takes it as soon as it is (_connectTile).
2643
+ this._noteFocusedTile(entry.tile);
2644
+ grid.focusOnConnect = null;
2645
+ if (options.focus !== false) {
2646
+ if (entry.connected) entry.tile.terminal?.focus();
2647
+ else grid.focusOnConnect = sessionId;
2648
+ }
2649
+ this._persistTileGrid();
2650
+ },
2651
+
2652
+ // ── Persistence (codeman:tile-grid, per browser: ids and layout, never content) ──
2653
+
2654
+ /**
2655
+ * Writes the open grid as it is, on every change (a move, a divider drag, a
2656
+ * tile added or removed, a count picked, a focus, a zoom): its cells, its
2657
+ * count, focus, a zoom the user chose (an automatic one is worked out again
2658
+ * from the window) and the divider fractions. Never content, never to the
2659
+ * server. `open: false` is the closed-but-remembered state. Never in a solo
2660
+ * window, nor while a stored grid is being put back (_openStoredTileGrid
2661
+ * writes once it is); a storage failure only costs the convenience.
2662
+ */
2663
+ _persistTileGrid({ open = true } = {}) {
2664
+ const grid = this._tileGrid;
2665
+ if (this.isSoloWindow || this._tilePersistHold || !grid || grid.ids.length === 0) return;
2666
+ if (open && !grid.open) return;
2667
+ const state = {
2668
+ v: 1,
2669
+ open,
2670
+ // The cells, null for an empty one, so a hole comes back where it was
2671
+ // (sanitizeTileGridState; a build before cells drops the nulls and reads
2672
+ // the tiles packed, as it always did).
2673
+ ids: grid.cells.slice(),
2674
+ // Never fewer than the tiles shown (a build before `count` derives it
2675
+ // from `ids`, and ignores it).
2676
+ count: Math.min(Math.max(grid.count || 0, grid.ids.length), window.CodemanTileGrid.TILE_GRID_MAX),
2677
+ focused: grid.focusedId,
2678
+ zoomed: grid.autoZoom ? null : grid.zoomedId,
2679
+ colFr: grid.colFr.slice(),
2680
+ rowFr: grid.rowFr.slice(),
2681
+ };
2682
+ try {
2683
+ localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify(state));
2684
+ } catch {
2685
+ /* Per-device convenience only. */
2686
+ }
2687
+ },
2688
+
2689
+ _forgetStoredTileGrid() {
2690
+ try {
2691
+ localStorage.removeItem(TILE_GRID_STORAGE_KEY);
2692
+ } catch {
2693
+ /* Nothing stored. */
2694
+ }
2695
+ },
2696
+
2697
+ /** The stored grid, sanitized against the sessions this page knows now, or null. */
2698
+ _readStoredTileGrid() {
2699
+ if (this.isSoloWindow) return null;
2700
+ let raw = null;
2701
+ try {
2702
+ raw = localStorage.getItem(TILE_GRID_STORAGE_KEY);
2703
+ } catch {
2704
+ return null;
2705
+ }
2706
+ if (!raw) return null;
2707
+ return window.CodemanTileGrid.sanitizeTileGridState(raw, this.sessions, this.detachedSessions);
2708
+ },
2709
+
2710
+ /**
2711
+ * The stored grid as it would come back now (restoreTileGridCells): its
2712
+ * cells as stored, a cell whose session no longer exists filled from the
2713
+ * ranking while the grid holds fewer than its count. Null when nothing is
2714
+ * stored or none of its sessions survive.
2715
+ */
2716
+ _storedTileGridSet(stored = this._readStoredTileGrid()) {
2717
+ if (!stored) return null;
2718
+ return window.CodemanTileGrid.restoreTileGridCells(stored, this._tileGridRanking());
2719
+ },
2720
+
2721
+ /**
2722
+ * Opens a stored grid as the user left it: `set` (the stored cells with any
2723
+ * freed cell filled, _storedTileGridSet, by default; the Tiles button may
2724
+ * pass it trimmed or filled to a picked count, or with a Ctrl/Cmd+clicked
2725
+ * session in it) goes back into its cells, holes included, a shape change
2726
+ * following the cell model's rule (reformTileCells: the tiles keep their row
2727
+ * and column when all fit, the ones that join fill the empty cells in
2728
+ * reading order); then the fractions it had (only if they still match the
2729
+ * layout) and a zoom the user chose on the focused tile. A grid larger than
2730
+ * the window fits is never trimmed: the focused tile shows alone until the
2731
+ * window fits it (_applyTileLayout), and the arrangement stays. `auto`: the
2732
+ * app is putting it back, so no idle alert is spent. `keepCount`: the
2733
+ * stored count stays the grid's (a session that went away may be filled the
2734
+ * next time); false makes it what opens (a pick, a Ctrl/Cmd+click). Nothing
2735
+ * is written until the grid is back, so a stored grid is never overwritten
2736
+ * by a half-built one.
2737
+ */
2738
+ _openStoredTileGrid(
2739
+ stored,
2740
+ set = this._storedTileGridSet(stored),
2741
+ { focusedId = null, auto = true, keepCount = true } = {}
2742
+ ) {
2743
+ if (!set?.ids?.length) return false;
2744
+ const T = window.CodemanTileGrid;
2745
+ const focus = focusedId || set.focusedId;
2746
+ // An open split closes without joining it (decision 8, case a).
2747
+ this._tilePersistHold = true;
2748
+ let opened = false;
2749
+ try {
2750
+ opened = this.openTileGrid(set.ids, { focusedId: focus, auto, mergeSplit: false });
2751
+ } finally {
2752
+ this._tilePersistHold = false;
2753
+ }
2754
+ if (!opened) return false;
2755
+ const grid = this._tileGrid;
2756
+ // openTileGrid packed the tiles from the first cell. They go back to their
2757
+ // cells (a session gone since leaves its cell empty, unless the ranking
2758
+ // filled it).
2759
+ const base = (set.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
2760
+ const kept = base.filter(Boolean);
2761
+ const cells = T.reformTileCells(
2762
+ base,
2763
+ T.tileCellCols(base.length),
2764
+ kept,
2765
+ grid.ids.filter((id) => !kept.includes(id)),
2766
+ grid.cols,
2767
+ grid.rows
2768
+ );
2769
+ if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
2770
+ grid.cells = cells;
2771
+ }
2772
+ grid.count = keepCount ? Math.min(Math.max(grid.ids.length, stored.count || 0), T.TILE_GRID_MAX) : grid.ids.length;
2773
+ // openTileGrid laid the grid out with equal tracks. The stored ones go back
2774
+ // on; _applyTileLayout drops them again if they do not match the column or
2775
+ // row count (the window may have changed the layout since).
2776
+ if (stored.colFr) grid.colFr = stored.colFr.slice();
2777
+ if (stored.rowFr) grid.rowFr = stored.rowFr.slice();
2778
+ if (stored.zoomed && stored.zoomed === grid.focusedId && grid.tiles.has(stored.zoomed)) {
2779
+ grid.zoomedId = stored.zoomed;
2780
+ grid.autoZoom = false;
2781
+ }
2782
+ this._applyTileLayout();
2783
+ this._scheduleTileGridRefit();
2784
+ return true;
2785
+ },
2786
+
2787
+ /**
2788
+ * Page load (handleInit, in place of selecting the session to restore): a
2789
+ * grid stored OPEN on this device comes back, ids sanitized against the
2790
+ * session list (deleted, detached and duplicate ids dropped). The main
2791
+ * terminal then never loads on this page load, so no `full=1` capture is
2792
+ * paid for a terminal about to be parked. Not in a solo window (nothing is
2793
+ * read there), nor on a window too narrow for the grid (openTileGrid
2794
+ * refuses; the stored grid waits for a wide one).
2795
+ *
2796
+ * @returns {boolean} whether the grid was restored
2797
+ */
2798
+ _restoreTileGrid() {
2799
+ if (this._tilesOwnTerminal()) return false;
2800
+ const stored = this._readStoredTileGrid();
2801
+ if (!stored?.open || stored.ids.length === 0) return false;
2802
+ // Put back, not opened: the tiles settle in whatever the entrance theme.
2803
+ this._tileEnterQuiet = true;
2804
+ try {
2805
+ return this._openStoredTileGrid(stored);
2806
+ } finally {
2807
+ this._tileEnterQuiet = false;
2808
+ }
2809
+ },
2810
+
2811
+ /**
2812
+ * A followed `#session=` link took the screen on load: the stored grid stays
2813
+ * remembered, closed. Only `open` changes: the value is written back as it
2814
+ * was stored, so a session gone since still frees its cell for the ranking
2815
+ * the next time the grid opens.
2816
+ */
2817
+ _closeStoredTileGrid() {
2818
+ const stored = this._readStoredTileGrid();
2819
+ if (!stored?.open) return;
2820
+ try {
2821
+ const raw = JSON.parse(localStorage.getItem(TILE_GRID_STORAGE_KEY));
2822
+ localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...raw, open: false }));
2823
+ } catch {
2824
+ /* Per-device convenience only. */
2825
+ }
2826
+ },
2827
+
2828
+ /** Header connection state while tiles own the terminal: every live tile socket open, or not. */
2829
+ _tileGridSocketState() {
2830
+ for (const { tile } of this._tileGrid?.tiles.values() || []) {
2831
+ // A tile stopped for good (its session exited) has nothing to reconnect.
2832
+ if (tile._stoppedCode !== null && tile._stoppedCode !== undefined) continue;
2833
+ if (!tile._wsReady) return 'reconnecting';
2834
+ }
2835
+ return 'connected';
2836
+ },
2837
+
2838
+ /**
2839
+ * handleInit (page state reloaded, or SSE back after a server restart) with
2840
+ * the grid open: tiles whose sessions are gone or popped out are removed, the
2841
+ * rest are KEPT (never rebuilt) and told to reconnect now instead of waiting
2842
+ * out their backoff, and focus stays on a live tile.
2843
+ *
2844
+ * @returns {boolean} whether the grid is still open (handleInit then skips
2845
+ * restoring the main terminal)
2846
+ */
2847
+ _reconcileTileGrid() {
2848
+ const grid = this._tileGrid;
2849
+ if (!grid?.open) return false;
2850
+ for (const id of grid.ids.slice()) {
2851
+ if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false, gone: true });
2852
+ }
2853
+ if (!grid.open) return false;
2854
+ // Only a focus that is gone moves: re-selecting the same tile would hide an
2855
+ // active web tab on every SSE blip (_selectTiledSession hides the web layer),
2856
+ // which the single view's reconnect never does. The app's choice, so DOM
2857
+ // focus stays put (an open modal or text field keeps the keyboard).
2858
+ if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true, focus: false });
2859
+ for (const { tile } of grid.tiles.values()) tile.reconnectNow();
2860
+ return true;
2861
+ },
2862
+ });
2863
+
2864
+ // A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
2865
+ // the neighbouring tile takes it (`auto`: the app chose, so no idle alert is
2866
+ // spent; `focus: false`: the keyboard stays put, so what the user was typing
2867
+ // never goes on into the neighbour's PTY, as the single view sends it
2868
+ // nowhere). Done BEFORE the original handler, so activeSessionId no longer names
2869
+ // the deleted id and its welcome-screen handoff stays out of it. The last tile
2870
+ // closes the grid without a reselect, and the original handler then shows the
2871
+ // welcome screen as in the single view. A close started from this tab
2872
+ // (closeSession, in _closingSessions) owns its own follow-up: only the tile goes.
2873
+ const _tileGridOriginalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
2874
+ CodemanApp.prototype._onSessionDeleted = function (data) {
2875
+ const grid = this._tileGrid;
2876
+ if (grid?.has(data.id)) {
2877
+ const wasFocused = grid.focusedId === data.id;
2878
+ const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
2879
+ this.removeTile(data.id, { refocus: false, gone: true });
2880
+ if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
2881
+ this._selectTiledSession(neighbor, { auto: true, focus: false });
2882
+ }
2883
+ }
2884
+ return _tileGridOriginalOnSessionDeleted.call(this, data);
2885
+ };
2886
+
2887
+ // Every tab render (any session change: status, hooks, name) refreshes the
2888
+ // tile headers too, so a tile's dot, name and needs border follow the same
2889
+ // state the tab shows.
2890
+ const _tileGridOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
2891
+ CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
2892
+ const result = _tileGridOriginalRenderSessionTabsImmediate.apply(this, args);
2893
+ this._renderTileChrome?.();
2894
+ return result;
2895
+ };