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