aicodeman 1.40.0 → 1.41.1

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 (351) hide show
  1. package/README.md +81 -13
  2. package/dist/attachment-registry.d.ts +2 -0
  3. package/dist/attachment-registry.d.ts.map +1 -1
  4. package/dist/attachment-registry.js +14 -3
  5. package/dist/attachment-registry.js.map +1 -1
  6. package/dist/cli-agent.d.ts +227 -0
  7. package/dist/cli-agent.d.ts.map +1 -0
  8. package/dist/cli-agent.js +771 -0
  9. package/dist/cli-agent.js.map +1 -0
  10. package/dist/cli.d.ts +25 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +103 -41
  13. package/dist/cli.js.map +1 -1
  14. package/dist/codeman-credentials.d.ts +27 -0
  15. package/dist/codeman-credentials.d.ts.map +1 -0
  16. package/dist/codeman-credentials.js +66 -0
  17. package/dist/codeman-credentials.js.map +1 -0
  18. package/dist/config/buffer-limits.d.ts +9 -0
  19. package/dist/config/buffer-limits.d.ts.map +1 -1
  20. package/dist/config/buffer-limits.js +9 -0
  21. package/dist/config/buffer-limits.js.map +1 -1
  22. package/dist/config/cli-registry/schema.d.ts +10 -0
  23. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  24. package/dist/config/cli-registry/schema.js +15 -0
  25. package/dist/config/cli-registry/schema.js.map +1 -1
  26. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  27. package/dist/config/cli-registry/stock.js +47 -0
  28. package/dist/config/cli-registry/stock.js.map +1 -1
  29. package/dist/config/cli-registry/types.d.ts +47 -1
  30. package/dist/config/cli-registry/types.d.ts.map +1 -1
  31. package/dist/folder-listing.d.ts +94 -0
  32. package/dist/folder-listing.d.ts.map +1 -0
  33. package/dist/folder-listing.js +241 -0
  34. package/dist/folder-listing.js.map +1 -0
  35. package/dist/git-clone.d.ts.map +1 -1
  36. package/dist/git-clone.js +4 -0
  37. package/dist/git-clone.js.map +1 -1
  38. package/dist/hooks-config.d.ts +36 -16
  39. package/dist/hooks-config.d.ts.map +1 -1
  40. package/dist/hooks-config.js +45 -20
  41. package/dist/hooks-config.js.map +1 -1
  42. package/dist/html-view-capabilities.d.ts +54 -0
  43. package/dist/html-view-capabilities.d.ts.map +1 -0
  44. package/dist/html-view-capabilities.js +103 -0
  45. package/dist/html-view-capabilities.js.map +1 -0
  46. package/dist/image-watcher.d.ts.map +1 -1
  47. package/dist/image-watcher.js +6 -2
  48. package/dist/image-watcher.js.map +1 -1
  49. package/dist/mcp-server.d.ts +114 -0
  50. package/dist/mcp-server.d.ts.map +1 -0
  51. package/dist/mcp-server.js +917 -0
  52. package/dist/mcp-server.js.map +1 -0
  53. package/dist/mcp-sync-targets.d.ts +34 -0
  54. package/dist/mcp-sync-targets.d.ts.map +1 -0
  55. package/dist/mcp-sync-targets.js +55 -0
  56. package/dist/mcp-sync-targets.js.map +1 -0
  57. package/dist/mcp-sync.d.ts +7 -1
  58. package/dist/mcp-sync.d.ts.map +1 -1
  59. package/dist/mcp-sync.js +113 -6
  60. package/dist/mcp-sync.js.map +1 -1
  61. package/dist/mux-interface.d.ts +36 -1
  62. package/dist/mux-interface.d.ts.map +1 -1
  63. package/dist/remote-files.d.ts +37 -0
  64. package/dist/remote-files.d.ts.map +1 -1
  65. package/dist/remote-files.js +103 -0
  66. package/dist/remote-files.js.map +1 -1
  67. package/dist/remote-pane-history.d.ts +103 -0
  68. package/dist/remote-pane-history.d.ts.map +1 -0
  69. package/dist/remote-pane-history.js +258 -0
  70. package/dist/remote-pane-history.js.map +1 -0
  71. package/dist/remote-ssh-limiter.d.ts +16 -3
  72. package/dist/remote-ssh-limiter.d.ts.map +1 -1
  73. package/dist/remote-ssh-limiter.js +37 -6
  74. package/dist/remote-ssh-limiter.js.map +1 -1
  75. package/dist/session.d.ts +29 -1
  76. package/dist/session.d.ts.map +1 -1
  77. package/dist/session.js +63 -4
  78. package/dist/session.js.map +1 -1
  79. package/dist/subagent-watcher.d.ts.map +1 -1
  80. package/dist/subagent-watcher.js +29 -6
  81. package/dist/subagent-watcher.js.map +1 -1
  82. package/dist/tab-layout-legacy-order.d.ts.map +1 -1
  83. package/dist/tab-layout-legacy-order.js +72 -13
  84. package/dist/tab-layout-legacy-order.js.map +1 -1
  85. package/dist/tab-layout-service.d.ts +33 -2
  86. package/dist/tab-layout-service.d.ts.map +1 -1
  87. package/dist/tab-layout-service.js +102 -7
  88. package/dist/tab-layout-service.js.map +1 -1
  89. package/dist/tab-layout.d.ts +9 -0
  90. package/dist/tab-layout.d.ts.map +1 -1
  91. package/dist/tab-layout.js +15 -2
  92. package/dist/tab-layout.js.map +1 -1
  93. package/dist/templates/case-template.md +2 -0
  94. package/dist/tmux-manager.d.ts +76 -2
  95. package/dist/tmux-manager.d.ts.map +1 -1
  96. package/dist/tmux-manager.js +216 -40
  97. package/dist/tmux-manager.js.map +1 -1
  98. package/dist/tui/tui-client.d.ts +3 -17
  99. package/dist/tui/tui-client.d.ts.map +1 -1
  100. package/dist/tui/tui-client.js +3 -44
  101. package/dist/tui/tui-client.js.map +1 -1
  102. package/dist/types/session.d.ts +53 -0
  103. package/dist/types/session.d.ts.map +1 -1
  104. package/dist/types/session.js.map +1 -1
  105. package/dist/usage-telemetry.d.ts +44 -7
  106. package/dist/usage-telemetry.d.ts.map +1 -1
  107. package/dist/usage-telemetry.js +72 -7
  108. package/dist/usage-telemetry.js.map +1 -1
  109. package/dist/utils/index.d.ts +1 -0
  110. package/dist/utils/index.d.ts.map +1 -1
  111. package/dist/utils/index.js +1 -0
  112. package/dist/utils/index.js.map +1 -1
  113. package/dist/utils/process-exit-wait.d.ts +40 -0
  114. package/dist/utils/process-exit-wait.d.ts.map +1 -0
  115. package/dist/utils/process-exit-wait.js +68 -0
  116. package/dist/utils/process-exit-wait.js.map +1 -0
  117. package/dist/web/approval-inbox.d.ts +12 -0
  118. package/dist/web/approval-inbox.d.ts.map +1 -1
  119. package/dist/web/approval-inbox.js +1 -0
  120. package/dist/web/approval-inbox.js.map +1 -1
  121. package/dist/web/middleware/auth.d.ts.map +1 -1
  122. package/dist/web/middleware/auth.js +33 -0
  123. package/dist/web/middleware/auth.js.map +1 -1
  124. package/dist/web/paste-image-gc.d.ts +46 -6
  125. package/dist/web/paste-image-gc.d.ts.map +1 -1
  126. package/dist/web/paste-image-gc.js +106 -32
  127. package/dist/web/paste-image-gc.js.map +1 -1
  128. package/dist/web/ports/session-port.d.ts +8 -1
  129. package/dist/web/ports/session-port.d.ts.map +1 -1
  130. package/dist/web/public/admin-ui.js.gz +0 -0
  131. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  132. package/dist/web/public/app.24689381.js +43 -0
  133. package/dist/web/public/app.24689381.js.br +0 -0
  134. package/dist/web/public/app.24689381.js.gz +0 -0
  135. package/dist/web/public/approvals-ui.js +4 -0
  136. package/dist/web/public/approvals-ui.js.br +0 -0
  137. package/dist/web/public/approvals-ui.js.gz +0 -0
  138. package/dist/web/public/{constants.c0f8f9de.js → constants.17d77b65.js} +690 -35
  139. package/dist/web/public/constants.17d77b65.js.br +0 -0
  140. package/dist/web/public/constants.17d77b65.js.gz +0 -0
  141. package/dist/web/public/cron-ui.js.gz +0 -0
  142. package/dist/web/public/entrance-animations.js +490 -43
  143. package/dist/web/public/entrance-animations.js.br +0 -0
  144. package/dist/web/public/entrance-animations.js.gz +0 -0
  145. package/dist/web/public/gesture/gesture-codeman.js +112 -6
  146. package/dist/web/public/git-status-ui.js.gz +0 -0
  147. package/dist/web/public/home-sessions.js +2 -1
  148. package/dist/web/public/home-sessions.js.br +0 -0
  149. package/dist/web/public/home-sessions.js.gz +0 -0
  150. package/dist/web/public/host-wake-ui.js.gz +0 -0
  151. package/dist/web/public/i18n.9afd8e4d.js +2 -0
  152. package/dist/web/public/i18n.9afd8e4d.js.br +0 -0
  153. package/dist/web/public/i18n.9afd8e4d.js.gz +0 -0
  154. package/dist/web/public/image-input.9ed1d0d2.js +453 -0
  155. package/dist/web/public/image-input.9ed1d0d2.js.br +0 -0
  156. package/dist/web/public/image-input.9ed1d0d2.js.gz +0 -0
  157. package/dist/web/public/index.html +223 -46
  158. package/dist/web/public/index.html.br +0 -0
  159. package/dist/web/public/index.html.gz +0 -0
  160. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  161. package/dist/web/public/{keyboard-accessory.c1e7c7b7.js → keyboard-accessory.c458eea8.js} +115 -35
  162. package/dist/web/public/keyboard-accessory.c458eea8.js.br +0 -0
  163. package/dist/web/public/keyboard-accessory.c458eea8.js.gz +0 -0
  164. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  165. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  166. package/dist/web/public/mobile-overview.js +11 -2
  167. package/dist/web/public/mobile-overview.js.br +0 -0
  168. package/dist/web/public/mobile-overview.js.gz +0 -0
  169. package/dist/web/public/mobile.2dff1580.css +1 -0
  170. package/dist/web/public/mobile.2dff1580.css.br +0 -0
  171. package/dist/web/public/mobile.2dff1580.css.gz +0 -0
  172. package/dist/web/public/{notification-manager.50bb87d8.js → notification-manager.feb82c04.js} +19 -2
  173. package/dist/web/public/notification-manager.feb82c04.js.br +0 -0
  174. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  175. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  176. package/dist/web/public/{panels-ui.4735dfc2.js → panels-ui.73ec0137.js} +87 -87
  177. package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
  178. package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
  179. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  180. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  181. package/dist/web/public/readmymind-ui.js.gz +0 -0
  182. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  183. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  184. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  185. package/dist/web/public/session-lineage.js.gz +0 -0
  186. package/dist/web/public/{session-ui.f731602c.js → session-ui.dbc6819b.js} +33 -33
  187. package/dist/web/public/session-ui.dbc6819b.js.br +0 -0
  188. package/dist/web/public/session-ui.dbc6819b.js.gz +0 -0
  189. package/dist/web/public/settings-ui.81ac264e.js +97 -0
  190. package/dist/web/public/settings-ui.81ac264e.js.br +0 -0
  191. package/dist/web/public/settings-ui.81ac264e.js.gz +0 -0
  192. package/dist/web/public/spreadsheet-preview-worker.js +11 -0
  193. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  194. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  195. package/dist/web/public/{spreadsheet-preview.af82fa2f.js → spreadsheet-preview.75c751de.js} +1 -1
  196. package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
  197. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  198. package/dist/web/public/spreadsheet-xlsx-core.js +110 -0
  199. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  200. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  201. package/dist/web/public/styles.900a28e4.css +1 -0
  202. package/dist/web/public/styles.900a28e4.css.br +0 -0
  203. package/dist/web/public/styles.900a28e4.css.gz +0 -0
  204. package/dist/web/public/{subagent-windows.e6ca799f.js → subagent-windows.07277a58.js} +7 -5
  205. package/dist/web/public/subagent-windows.07277a58.js.br +0 -0
  206. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  207. package/dist/web/public/sw.js +6 -2
  208. package/dist/web/public/sw.js.br +0 -0
  209. package/dist/web/public/sw.js.gz +0 -0
  210. package/dist/web/public/tab-layout-browser.2d6189f7.js +1 -0
  211. package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
  212. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  213. package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
  214. package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
  215. package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
  216. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  217. package/dist/web/public/terminal-split.js.gz +0 -0
  218. package/dist/web/public/terminal-tile.js +135 -38
  219. package/dist/web/public/terminal-tile.js.br +0 -0
  220. package/dist/web/public/terminal-tile.js.gz +0 -0
  221. package/dist/web/public/terminal-ui.858f5da5.js +2 -0
  222. package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
  223. package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
  224. package/dist/web/public/tile-grid.js +769 -137
  225. package/dist/web/public/tile-grid.js.br +0 -0
  226. package/dist/web/public/tile-grid.js.gz +0 -0
  227. package/dist/web/public/ultracode-panel.js.gz +0 -0
  228. package/dist/web/public/ultracode-windows.js +9 -6
  229. package/dist/web/public/ultracode-windows.js.br +0 -0
  230. package/dist/web/public/ultracode-windows.js.gz +0 -0
  231. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  232. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  233. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  234. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  235. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  236. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  237. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  238. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  239. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  240. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +4 -0
  241. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
  242. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  243. package/dist/web/public/vendor/xterm.css.gz +0 -0
  244. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  245. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  246. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  247. package/dist/web/public/webview-tabs.js +12 -0
  248. package/dist/web/public/webview-tabs.js.br +0 -0
  249. package/dist/web/public/webview-tabs.js.gz +0 -0
  250. package/dist/web/route-helpers.d.ts +8 -0
  251. package/dist/web/route-helpers.d.ts.map +1 -1
  252. package/dist/web/route-helpers.js +8 -0
  253. package/dist/web/route-helpers.js.map +1 -1
  254. package/dist/web/routes/admin-routes.d.ts.map +1 -1
  255. package/dist/web/routes/admin-routes.js +4 -1
  256. package/dist/web/routes/admin-routes.js.map +1 -1
  257. package/dist/web/routes/approval-routes.d.ts.map +1 -1
  258. package/dist/web/routes/approval-routes.js +6 -8
  259. package/dist/web/routes/approval-routes.js.map +1 -1
  260. package/dist/web/routes/case-routes.js +1 -1
  261. package/dist/web/routes/case-routes.js.map +1 -1
  262. package/dist/web/routes/cli-registry-routes.d.ts +23 -4
  263. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  264. package/dist/web/routes/cli-registry-routes.js +92 -7
  265. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  266. package/dist/web/routes/file-routes.d.ts +46 -1
  267. package/dist/web/routes/file-routes.d.ts.map +1 -1
  268. package/dist/web/routes/file-routes.js +214 -27
  269. package/dist/web/routes/file-routes.js.map +1 -1
  270. package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
  271. package/dist/web/routes/hook-event-routes.js +8 -1
  272. package/dist/web/routes/hook-event-routes.js.map +1 -1
  273. package/dist/web/routes/html-view-routes.d.ts +60 -0
  274. package/dist/web/routes/html-view-routes.d.ts.map +1 -0
  275. package/dist/web/routes/html-view-routes.js +288 -0
  276. package/dist/web/routes/html-view-routes.js.map +1 -0
  277. package/dist/web/routes/index.d.ts +1 -0
  278. package/dist/web/routes/index.d.ts.map +1 -1
  279. package/dist/web/routes/index.js +1 -0
  280. package/dist/web/routes/index.js.map +1 -1
  281. package/dist/web/routes/mcp-sync-routes.d.ts +7 -2
  282. package/dist/web/routes/mcp-sync-routes.d.ts.map +1 -1
  283. package/dist/web/routes/mcp-sync-routes.js +10 -3
  284. package/dist/web/routes/mcp-sync-routes.js.map +1 -1
  285. package/dist/web/routes/reboot-restore-routes.js +3 -3
  286. package/dist/web/routes/reboot-restore-routes.js.map +1 -1
  287. package/dist/web/routes/session-routes.d.ts +1 -0
  288. package/dist/web/routes/session-routes.d.ts.map +1 -1
  289. package/dist/web/routes/session-routes.js +345 -90
  290. package/dist/web/routes/session-routes.js.map +1 -1
  291. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  292. package/dist/web/routes/status-telemetry-routes.js +16 -2
  293. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  294. package/dist/web/schemas.d.ts +32 -0
  295. package/dist/web/schemas.d.ts.map +1 -1
  296. package/dist/web/schemas.js +74 -0
  297. package/dist/web/schemas.js.map +1 -1
  298. package/dist/web/server.d.ts +7 -2
  299. package/dist/web/server.d.ts.map +1 -1
  300. package/dist/web/server.js +77 -24
  301. package/dist/web/server.js.map +1 -1
  302. package/dist/web/sse-events.d.ts +5 -2
  303. package/dist/web/sse-events.d.ts.map +1 -1
  304. package/dist/web/sse-events.js +5 -2
  305. package/dist/web/sse-events.js.map +1 -1
  306. package/package.json +5 -4
  307. package/skills/codeman/SKILL.md +30 -7
  308. package/README.zh-CN.md +0 -1147
  309. package/dist/web/public/app.abadc970.js +0 -43
  310. package/dist/web/public/app.abadc970.js.br +0 -0
  311. package/dist/web/public/app.abadc970.js.gz +0 -0
  312. package/dist/web/public/constants.c0f8f9de.js.br +0 -0
  313. package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
  314. package/dist/web/public/i18n.a31747a9.js +0 -2
  315. package/dist/web/public/i18n.a31747a9.js.br +0 -0
  316. package/dist/web/public/i18n.a31747a9.js.gz +0 -0
  317. package/dist/web/public/image-input.898a538b.js +0 -281
  318. package/dist/web/public/image-input.898a538b.js.br +0 -0
  319. package/dist/web/public/image-input.898a538b.js.gz +0 -0
  320. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
  321. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  322. package/dist/web/public/mobile.111b187d.css +0 -1
  323. package/dist/web/public/mobile.111b187d.css.br +0 -0
  324. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  325. package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
  326. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  327. package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
  328. package/dist/web/public/panels-ui.4735dfc2.js.gz +0 -0
  329. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  330. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  331. package/dist/web/public/settings-ui.a97ab16f.js +0 -97
  332. package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
  333. package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
  334. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  335. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  336. package/dist/web/public/styles.8ee12666.css +0 -1
  337. package/dist/web/public/styles.8ee12666.css.br +0 -0
  338. package/dist/web/public/styles.8ee12666.css.gz +0 -0
  339. package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
  340. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  341. package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
  342. package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
  343. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  344. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  345. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  346. package/dist/web/public/terminal-ui.5059b30e.js +0 -2
  347. package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
  348. package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
  349. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js +0 -4
  350. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.br +0 -0
  351. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
@@ -23,14 +23,18 @@
23
23
 
24
24
  // Per-device tile font size (a tile is a fraction of the screen).
25
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.
26
+ // The grid this browser last had, as the user left it (sanitizeTileGridState,
27
+ // constants.js): `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`,
28
+ // `ids` being the cells in reading order with `null` for an empty one, `count`
29
+ // how many tiles the user's own last change left (a session that went away by
30
+ // itself does not lower it). Session ids and layout only, never content.
31
+ // Written on every change while the grid is open; `open: false` keeps it for
32
+ // the Tiles toggle, which brings it back exactly; a grid stored open is
33
+ // restored on reload inside handleInit. Never sent to the server.
30
34
  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.
35
+ // The count last picked in the Tiles button's right-click menu (2, 4 or 6;
36
+ // owner decision 10), per device: what a click opens when there is no stored
37
+ // grid to bring back, and what a pick re-forms the grid to.
34
38
  const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
35
39
  // The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still
36
40
  // copy of a closing grid goes even if animationend never comes (a hidden tab,
@@ -52,6 +56,26 @@ const TILE_GRID_REFIT_MS = 150;
52
56
  // the grid section's padding (styles.css .tile-grid), for the drag math.
53
57
  const TILE_DIVIDER_PX = 6;
54
58
  const TILE_GRID_PADDING_PX = 4;
59
+ // What a tile's header drag (and a popped-out window's title drag) carries:
60
+ // the session id under a type of its own, never text, so no text field or
61
+ // terminal takes it as typing. Another Codeman window reads it on drop.
62
+ const TILE_DRAG_TYPE = 'application/x-codeman-tile';
63
+ // Detach Tiles: a tile dropped outside every window waits this long before it
64
+ // opens as a window of its own, so another window that took it (a
65
+ // 'tile-adopted' message on the window channel) can still say so first.
66
+ const TILE_TEAR_OFF_SETTLE_MS = 120;
67
+ // How long after its drag ended a tile can still be claimed by another
68
+ // window's drop (the claim and the dragend come from two windows).
69
+ const TILE_ADOPT_WINDOW_MS = 5000;
70
+ // A session docked here from another window: its old window's 'detached'
71
+ // announcements this soon after are stale (it is closing), not a new pop-out.
72
+ const TILE_DOCK_GRACE_MS = 2000;
73
+ // The smallest window a detached tile opens as, and roughly how much browser
74
+ // chrome sits above a pop-out's content (its title and address bars), so the
75
+ // window's own header lands near where the tile was let go.
76
+ const TILE_WINDOW_MIN_W = 480;
77
+ const TILE_WINDOW_MIN_H = 320;
78
+ const TILE_WINDOW_CHROME_PX = 60;
55
79
 
56
80
  /** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
57
81
  function tileGridTracks(fr) {
@@ -147,6 +171,11 @@ class TileGridModel {
147
171
  // session id, or null for an empty cell. THE source of truth for where
148
172
  // each tile is (owner: an empty cell can be any cell); `ids` derives from it.
149
173
  this.cells = [];
174
+ // How many tiles the user's own last change left (open, add, remove, a
175
+ // count picked): a session that goes away by itself (deleted, popped out,
176
+ // refused) does not lower it, so the next time the grid opens the ranking
177
+ // fills that place. Stored with the grid (_persistTileGrid).
178
+ this.count = 0;
150
179
  // id -> { tile: TerminalTile, el: HTMLElement }
151
180
  this.tiles = new Map();
152
181
  this.focusedId = null;
@@ -237,8 +266,15 @@ Object.assign(CodemanApp.prototype, {
237
266
  if (state !== 'idle') this._setTileLoadingLabel(entry.body);
238
267
  entry.el.classList.toggle('tile--loading', state !== 'idle');
239
268
  // 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');
269
+ // whole, instead of showing its replay scroll by, or plays the
270
+ // terminal pane's entrance style (entrance-animations.js).
271
+ if (state === 'idle') {
272
+ entry.el.classList.remove('tile--revealing');
273
+ if (entry.screenOwed) {
274
+ entry.screenOwed = false;
275
+ this.playTileScreenEntrance?.(entry.body);
276
+ }
277
+ }
242
278
  },
243
279
  });
244
280
  }
@@ -308,12 +344,14 @@ Object.assign(CodemanApp.prototype, {
308
344
  const sessionId = tileAt(e.target);
309
345
  const files = Array.from(e.dataTransfer?.files || []);
310
346
  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');
347
+ // The one classifier every pick and drop filter reads (image-input.js):
348
+ // MIME first, then the name's extension, so documents and videos count too.
349
+ const media = files.filter((f) => this._promptAttachKind?.(f));
350
+ if (media.length === 0) {
351
+ this.showToast?.('Unsupported file type', 'error');
314
352
  return;
315
353
  }
316
- this._uploadAndInsertImages?.(images, { sessionId });
354
+ this._uploadAndInsertImages?.(media, { sessionId });
317
355
  });
318
356
  },
319
357
 
@@ -389,6 +427,9 @@ Object.assign(CodemanApp.prototype, {
389
427
  wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
390
428
  // Packed from the first cell (_applyTileLayout pads the shape with empty cells).
391
429
  grid.cells = wanted.filter((id) => grid.tiles.has(id));
430
+ // What the user opened is the grid they want (_openStoredTileGrid keeps a
431
+ // stored count instead).
432
+ grid.count = grid.ids.length;
392
433
  this._applyTileLayout();
393
434
  // The tiles' frames paint first; their terminals are built one per frame
394
435
  // after it, the focused tile's first, so its capture is the one the queue
@@ -412,11 +453,13 @@ Object.assign(CodemanApp.prototype, {
412
453
  * Leaves the grid: every tile destroyed (sockets closed, xterms disposed,
413
454
  * queued loads dropped), the main terminal unparked.
414
455
  *
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).
456
+ * `keepStored` (every caller) keeps the grid as it was, holes, count, sizes,
457
+ * focus and zoom, closed, for the Tiles toggle to bring back (a reload
458
+ * restores only a grid stored open); false would forget it. `reselect` shows the focused session in the single
459
+ * view through a forced reload; pass false when the caller selects
460
+ * something itself. `animate` (the Tiles toggle only, owner answer 4)
461
+ * leaves a still copy of the tiles over the stage until the single view has
462
+ * its content (_ghostTileGrid).
420
463
  *
421
464
  * The main terminal's cached content for EVERY tiled id is invalidated: it was
422
465
  * written before the grid opened, possibly hours ago, and selectSession paints
@@ -457,6 +500,7 @@ Object.assign(CodemanApp.prototype, {
457
500
  grid.colFr = [];
458
501
  grid.rowFr = [];
459
502
  grid.cells = [];
503
+ grid.count = 0;
460
504
  grid.cols = 0;
461
505
  grid.rows = 0;
462
506
  grid.focusedId = null;
@@ -536,14 +580,28 @@ Object.assign(CodemanApp.prototype, {
536
580
  // Zoomed, only the zoomed tile is on screen (the others are display: none
537
581
  // under .tile-grid--zoomed, a rule the ghost layer does not carry).
538
582
  const zoomed = section.classList.contains('tile-grid--zoomed');
583
+ const copies = [];
539
584
  for (const id of grid.ids) {
540
585
  const el = grid.tiles.get(id)?.el;
541
586
  if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
542
587
  const ghost = el.cloneNode(true);
543
- ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging');
588
+ ghost.classList.remove(
589
+ 'tile--entering',
590
+ 'tile--enter-themed',
591
+ 'tile--enter-hold',
592
+ 'tile--needs',
593
+ 'tile--loading',
594
+ 'tile--drop-target',
595
+ 'tile--dragging'
596
+ );
597
+ ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
544
598
  ghost.classList.add('tile--leaving');
545
599
  layer.appendChild(ghost);
600
+ copies.push({ ghost, el, sessionId: id });
546
601
  }
602
+ // The entrance style's own way out (entrance-animations.js): back into the
603
+ // tabs, a CRT switch-off. Placed while the real tiles are still measurable.
604
+ const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
547
605
  main.appendChild(layer);
548
606
  let fallback = null;
549
607
  let holdCap = null;
@@ -557,10 +615,11 @@ Object.assign(CodemanApp.prototype, {
557
615
  if (fallback !== null || !layer.isConnected) return;
558
616
  clearTimeout(holdCap);
559
617
  layer.classList.add('tile-grid-ghosts--release');
560
- fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
618
+ fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
561
619
  };
562
620
  layer.addEventListener('animationend', (e) => {
563
- if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
621
+ if (e.pseudoElement || e.target !== layer.lastElementChild) return;
622
+ if (/^tile-leave/.test(e.animationName)) done();
564
623
  });
565
624
  if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
566
625
  else release();
@@ -695,8 +754,11 @@ Object.assign(CodemanApp.prototype, {
695
754
 
696
755
  /**
697
756
  * 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).
757
+ * window fits. With the grid closed and a stored grid to bring back, the
758
+ * click opens that grid as it was (never trimmed to the window), so the
759
+ * window line only says what fits. Compared with the last English text set,
760
+ * never the DOM (in zh-CN the DOM holds the translation; see
761
+ * _renderTileOverlay).
700
762
  */
701
763
  _renderTileHint() {
702
764
  const hint = this._tileHint;
@@ -712,10 +774,12 @@ Object.assign(CodemanApp.prototype, {
712
774
  set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
713
775
  set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
714
776
  const plural = capacity === 1 ? '' : 's';
777
+ const restoring = open ? null : this._storedTileGridSet();
778
+ const opens = restoring ? restoring.ids.length : count;
715
779
  const fitsText =
716
- count <= capacity
780
+ opens <= capacity
717
781
  ? ''
718
- : open
782
+ : open || restoring
719
783
  ? `This window fits ${capacity} tile${plural}`
720
784
  : `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
721
785
  set(hint.fits.text, 'fitsText', fitsText);
@@ -976,22 +1040,25 @@ Object.assign(CodemanApp.prototype, {
976
1040
 
977
1041
  /**
978
1042
  * 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.
1043
+ * with that many tiles (a stored grid re-formed to it, its tiles first in
1044
+ * their cells), or the open grid is re-formed to it (_reformTileGrid).
1045
+ * Never more than the window fits; fewer open sessions than the count give
1046
+ * fewer tiles. Either way the grid's count is what the pick left.
982
1047
  */
983
1048
  _pickTileCount(count) {
984
1049
  this.closeTileCountMenu({ refocus: false });
985
1050
  this._rememberTileGridCount(count);
986
1051
  const grid = this._tileGrid;
987
1052
  if (!grid?.open) {
988
- this.toggleTileGrid();
1053
+ this._activateTileGrid({ count });
989
1054
  return;
990
1055
  }
991
1056
  const T = window.CodemanTileGrid;
992
1057
  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));
1058
+ // The tiles that join are the ranking's best (_tileGridRanking).
1059
+ this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId));
1060
+ grid.count = grid.ids.length;
1061
+ this._persistTileGrid();
995
1062
  },
996
1063
 
997
1064
  /**
@@ -1126,14 +1193,18 @@ Object.assign(CodemanApp.prototype, {
1126
1193
  /**
1127
1194
  * The Tiles button's click and Ctrl+Shift+G, one function so the two never
1128
1195
  * 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).
1196
+ * closes it to the single view of the focused session. The grid this
1197
+ * browser last had comes back EXACTLY as the user left it (owner request:
1198
+ * "when I turn tiles off and on, always keep what the last setting was"):
1199
+ * its tiles in their cells, holes included, its count, divider sizes, focus
1200
+ * and zoom; a cell whose session no longer exists is filled from the
1201
+ * ranking (_openStoredTileGrid). With none of its sessions left (or nothing
1202
+ * stored), `tileGridOpenSet` (constants.js) takes an open split's two
1203
+ * sessions, else the open sessions as the ranking orders them
1204
+ * (_tileGridRanking: working, then needing input, then the most recent),
1205
+ * the active one always among them and focused, filled to the remembered
1206
+ * count (the right-click menu's last pick, default 6, at most what the
1207
+ * window fits; owner decision 10).
1137
1208
  */
1138
1209
  toggleTileGrid() {
1139
1210
  this.closeTileCountMenu();
@@ -1141,28 +1212,43 @@ Object.assign(CodemanApp.prototype, {
1141
1212
  this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
1142
1213
  return;
1143
1214
  }
1144
- if (!this.canOpenTileGrid()) return;
1215
+ this._activateTileGrid();
1216
+ },
1217
+
1218
+ /**
1219
+ * Opens the grid as the toggle does (toggleTileGrid), or, with `count` (a
1220
+ * pick in the count menu while the grid is closed), with that many tiles: a
1221
+ * stored grid re-formed to it, its tiles first in their cells, the ones the
1222
+ * count adds filling its empty cells first, the rest from the ranking.
1223
+ *
1224
+ * @returns {boolean} whether the grid opened
1225
+ */
1226
+ _activateTileGrid({ count = null } = {}) {
1227
+ if (!this.canOpenTileGrid()) return false;
1145
1228
  const stored = this._readStoredTileGrid();
1146
- const set = this._tileGridOpenSet(stored);
1229
+ const set = this._tileGridOpenSet(stored, count);
1147
1230
  if (!set) {
1148
1231
  this.showToast?.('No sessions to show as tiles', 'info');
1149
- return;
1232
+ return false;
1150
1233
  }
1151
- if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids);
1152
- else this.openTileGrid(set.ids, { focusedId: set.focusedId });
1234
+ if (set.source === 'stored') return this._openStoredTileGrid(stored, set, { keepCount: count === null });
1235
+ return this.openTileGrid(set.ids, { focusedId: set.focusedId });
1153
1236
  },
1154
1237
 
1155
1238
  /**
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.
1239
+ * What the toggle would open now (see toggleTileGrid): a stored grid as it
1240
+ * was, or `count` tiles (the remembered count by default), at most what the
1241
+ * window fits. With an explicit `count`, a stored grid is trimmed or filled
1242
+ * to it too. `stored` saves a second read.
1159
1243
  */
1160
- _tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) {
1244
+ _tileGridOpenSet(stored = this._readStoredTileGrid(), count = null) {
1161
1245
  const T = window.CodemanTileGrid;
1162
- const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity));
1246
+ const n = Math.max(1, Math.min(count ?? this._tileGridCount(), this._tileGridLimit().capacity));
1247
+ const ranked = this._tileGridRanking();
1163
1248
  const set = T.tileGridOpenSet({
1164
1249
  stored,
1165
1250
  split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
1251
+ ranked,
1166
1252
  sessions: this.sessions,
1167
1253
  sessionOrder: this.sessionOrder,
1168
1254
  detachedIds: this.detachedSessions,
@@ -1170,8 +1256,42 @@ Object.assign(CodemanApp.prototype, {
1170
1256
  limit: n,
1171
1257
  });
1172
1258
  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) };
1259
+ // A stored grid comes back as it was: the count does not apply to it
1260
+ // unless one was asked for.
1261
+ if (set.source === 'stored' && count === null) return set;
1262
+ return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) };
1263
+ },
1264
+
1265
+ /**
1266
+ * The open sessions the grid takes when nobody said which (the Tiles button
1267
+ * with nothing stored, and every tile it fills on its own), best first:
1268
+ * working (the most recently started turn first), then the ones needing
1269
+ * input (the red and yellow tab alerts), then the rest by most recent
1270
+ * activity, tab order breaking ties (rankTileSessions, constants.js). The
1271
+ * states and stamps are the home screens' own (`_mobileOverviewState()`,
1272
+ * mobile-overview.js). Detached sessions are never in it. Guarded like
1273
+ * the sorted rail: without the classifier (a stale cached
1274
+ * mobile-overview.js) it is plain tab order.
1275
+ *
1276
+ * @returns {string[]}
1277
+ */
1278
+ _tileGridRanking() {
1279
+ const T = window.CodemanTileGrid;
1280
+ const open = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
1281
+ if (typeof this._mobileOverviewState !== 'function' || typeof T.rankTileSessions !== 'function') {
1282
+ return open.map((c) => c.id);
1283
+ }
1284
+ const rows = open.map(({ id }, orderIndex) => {
1285
+ const session = this.sessions.get(id);
1286
+ return {
1287
+ id,
1288
+ state: this._mobileOverviewState(session, this.pendingHooks?.get(id)),
1289
+ lastActivityAt: Number(session.lastActivityAt) || 0,
1290
+ lastSubmitAt: Number(session.lastSubmitAt) || 0,
1291
+ orderIndex,
1292
+ };
1293
+ });
1294
+ return T.rankTileSessions(rows);
1175
1295
  },
1176
1296
 
1177
1297
  /** Alt+Shift+Arrows: a human selection of the tile in that direction. */
@@ -1242,6 +1362,7 @@ Object.assign(CodemanApp.prototype, {
1242
1362
  if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
1243
1363
  if (!this._mountTile(sessionId, { enterIndex })) return false;
1244
1364
  this._placeTile(sessionId, cell);
1365
+ grid.count = grid.ids.length;
1245
1366
  // A tile added while one is zoomed by hand is meant to be seen.
1246
1367
  if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
1247
1368
  this._applyTileLayout();
@@ -1286,17 +1407,20 @@ Object.assign(CodemanApp.prototype, {
1286
1407
  * moves focus to the neighbouring tile (next in grid order, else previous),
1287
1408
  * as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
1288
1409
  * 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.
1410
+ * so keystrokes never land in the neighbour's PTY unasked. `gone`: the
1411
+ * session went away by itself (deleted, popped out, its socket refused), not
1412
+ * by the user's hand, so the grid's count stays and the next time it opens
1413
+ * the ranking fills that place. The last tile leaving closes the grid, kept
1414
+ * as it was: with `refocus` the single view then shows that session (or,
1415
+ * popped out, the next one: _selectAfterTileGrid), without it the caller
1416
+ * decides what comes next.
1293
1417
  */
1294
- removeTile(sessionId, { refocus = true, focus = true } = {}) {
1418
+ removeTile(sessionId, { refocus = true, focus = true, gone = false } = {}) {
1295
1419
  const grid = this._tileGrid;
1296
1420
  const entry = grid?.open ? grid.tiles.get(sessionId) : null;
1297
1421
  if (!entry) return false;
1298
1422
  if (grid.ids.length === 1) {
1299
- this.closeTileGrid({ keepStored: false, reselect: refocus });
1423
+ this.closeTileGrid({ keepStored: true, reselect: refocus });
1300
1424
  return true;
1301
1425
  }
1302
1426
  const wasFocused = grid.focusedId === sessionId;
@@ -1312,6 +1436,7 @@ Object.assign(CodemanApp.prototype, {
1312
1436
  entry.el.remove();
1313
1437
  grid.tiles.delete(sessionId);
1314
1438
  grid.cells[grid.cells.indexOf(sessionId)] = null;
1439
+ if (!gone) grid.count = grid.ids.length;
1315
1440
  // Before the layout, which may zoom the focused tile on a small window.
1316
1441
  if (wasFocused) grid.focusedId = null;
1317
1442
  this._applyTileLayout();
@@ -1323,11 +1448,10 @@ Object.assign(CodemanApp.prototype, {
1323
1448
 
1324
1449
  /**
1325
1450
  * 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.
1451
+ * caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
1452
+ * group (_beginTileEntrance), and its terminal stays transparent until its
1453
+ * first capture lands (.tile--revealing, cleared by the load queue, with a
1454
+ * backstop timer). Opacity and transform only, never anything its fit reads.
1331
1455
  */
1332
1456
  _mountTile(sessionId, { enterIndex = 0 } = {}) {
1333
1457
  const grid = this._tileGrid;
@@ -1336,16 +1460,11 @@ Object.assign(CodemanApp.prototype, {
1336
1460
  const el = document.createElement('div');
1337
1461
  el.className = 'tile tile--revealing';
1338
1462
  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
- }
1463
+ // Its screen plays the terminal pane's entrance once its first capture
1464
+ // lands, when its frame entered in a tile style (Tile Animations): never
1465
+ // with the default `settle`, nor on a reload's restore, which settles.
1466
+ const entered = this._beginTileEntrance(el, sessionId, enterIndex);
1467
+ const screenOwed = !!entered && entered !== 'settle';
1349
1468
  el.dataset.sessionId = sessionId;
1350
1469
  // Header and body are siblings: the chrome is refreshed in place
1351
1470
  // (_renderTileHeader), never by rewriting the tile, which would take the
@@ -1391,12 +1510,61 @@ Object.assign(CodemanApp.prototype, {
1391
1510
  renaming: false,
1392
1511
  // The pid this tile last saw, so a pane that starts later is noticed.
1393
1512
  pid: this.sessions.get(sessionId)?.pid ?? null,
1513
+ // Its screen plays the terminal entrance once its first capture lands.
1514
+ screenOwed,
1394
1515
  });
1395
1516
  // Where it goes is the caller's (grid.cells).
1396
1517
  this._renderTileHeader(sessionId);
1397
1518
  return true;
1398
1519
  },
1399
1520
 
1521
+ /**
1522
+ * A tile's frame enters as the `enterIndex`-th of a staggered group, in the
1523
+ * entrance style (entrance-animations.js, App Settings → Entrance
1524
+ * Animations). `settle` is the grid's own fade and settle (styles.css
1525
+ * .tile--entering), and what a reload restores with whatever the theme:
1526
+ * nothing animates on page load. Any other style is timed by the entrance
1527
+ * module a frame later, once the layout is final (_stageTileEntrance).
1528
+ * Opacity and transform only, never anything the fit reads.
1529
+ *
1530
+ * @returns {string|null} the style it enters with, or null when it does not animate
1531
+ */
1532
+ _beginTileEntrance(el, sessionId, enterIndex = 0) {
1533
+ const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
1534
+ if (!this._tileMotionAllowed() || style === 'off') return null;
1535
+ el.classList.add('tile--entering');
1536
+ el.style.setProperty('--tile-enter-index', String(enterIndex));
1537
+ let backstop = null;
1538
+ const finish = () => {
1539
+ clearTimeout(backstop);
1540
+ el.removeEventListener('animationend', onEnd);
1541
+ el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
1542
+ if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
1543
+ };
1544
+ // The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
1545
+ // never a child's animation or a wash on its ::before.
1546
+ const onEnd = (e) => {
1547
+ if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
1548
+ finish();
1549
+ };
1550
+ el.addEventListener('animationend', onEnd);
1551
+ el._tileEnterFinish = finish;
1552
+ if (style !== 'settle') {
1553
+ this._stageTileEntrance?.(el, sessionId, (ms) => {
1554
+ clearTimeout(backstop);
1555
+ backstop = setTimeout(finish, ms);
1556
+ });
1557
+ }
1558
+ return style;
1559
+ },
1560
+
1561
+ /** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
1562
+ _replayTileEntrance(el, sessionId, enterIndex = 0) {
1563
+ el._tileEnterFinish?.();
1564
+ void el.offsetWidth;
1565
+ return this._beginTileEntrance(el, sessionId, enterIndex);
1566
+ },
1567
+
1400
1568
  /**
1401
1569
  * Makes `el` a drop target for a session tab dragged from the strip (the
1402
1570
  * strip's own drag sets `draggedTabId`) and for a tile dragged by its header
@@ -1406,8 +1574,10 @@ Object.assign(CodemanApp.prototype, {
1406
1574
  * `accepts(id)` is the target's own rule (a tile takes any session but its
1407
1575
  * own; an empty cell takes any). A session it does not accept
1408
1576
  * 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).
1577
+ * drop follows). With Detach Tiles on, a tile dragged from ANOTHER Codeman
1578
+ * window (no drag of this page's own, the tile type in the drag) is taken
1579
+ * too and docks here (_dockForeignTile). Any other drag (a file) is left to
1580
+ * the grid section's own guard (_installTileFileDrop).
1411
1581
  */
1412
1582
  _acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
1413
1583
  const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
@@ -1415,7 +1585,16 @@ Object.assign(CodemanApp.prototype, {
1415
1585
  'dragover',
1416
1586
  (e) => {
1417
1587
  const id = dragged();
1418
- if (!id) return;
1588
+ if (!id) {
1589
+ // A tile from another Codeman window (Detach Tiles). Its id can
1590
+ // only be read on drop, so it is held here whatever it is.
1591
+ if (!this._isForeignTileDrag(e)) return;
1592
+ e.preventDefault?.();
1593
+ e.stopPropagation?.();
1594
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
1595
+ el.classList.add('tile--drop-target');
1596
+ return;
1597
+ }
1419
1598
  e.preventDefault?.();
1420
1599
  e.stopPropagation?.();
1421
1600
  const ok = accepts(id);
@@ -1432,7 +1611,13 @@ Object.assign(CodemanApp.prototype, {
1432
1611
  (e) => {
1433
1612
  el.classList.remove('tile--drop-target');
1434
1613
  const id = dragged();
1435
- if (!id) return;
1614
+ if (!id) {
1615
+ if (!this._isForeignTileDrag(e)) return;
1616
+ e.preventDefault?.();
1617
+ e.stopPropagation?.();
1618
+ this._dockForeignTile(e.dataTransfer?.getData?.(TILE_DRAG_TYPE), onDrop);
1619
+ return;
1620
+ }
1436
1621
  e.preventDefault?.();
1437
1622
  e.stopPropagation?.();
1438
1623
  if (accepts(id)) onDrop(id);
@@ -1575,10 +1760,12 @@ Object.assign(CodemanApp.prototype, {
1575
1760
  * Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
1576
1761
  * human selection: the user clicked its tab). With the grid closed it opens
1577
1762
  * 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.
1763
+ * focused, never past the remembered count (owner answer to decision 10's
1764
+ * questions: N, not N+1): it joins while the grid holds fewer than the
1765
+ * count (a stored grid's first empty cell), else it takes the last tile's
1766
+ * place. A stored grid keeps its cells, sizes and holes around it.
1767
+ * Returns false when the grid cannot open here (narrow or solo window), so
1768
+ * the click is an ordinary one.
1582
1769
  */
1583
1770
  addSessionToTiles(sessionId) {
1584
1771
  if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
@@ -1599,7 +1786,26 @@ Object.assign(CodemanApp.prototype, {
1599
1786
  return true;
1600
1787
  }
1601
1788
  const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
1602
- const base = this._tileGridOpenSet()?.ids || [];
1789
+ const stored = this._readStoredTileGrid();
1790
+ const plan = this._tileGridOpenSet(stored);
1791
+ if (plan?.source === 'stored') {
1792
+ let { ids, cells } = plan;
1793
+ if (!ids.includes(sessionId)) {
1794
+ if (ids.length < n) {
1795
+ ids = [...ids, sessionId];
1796
+ } else {
1797
+ const last = ids.at(-1);
1798
+ ids = ids.map((id) => (id === last ? sessionId : id));
1799
+ cells = cells.map((id) => (id === last ? sessionId : id));
1800
+ }
1801
+ }
1802
+ return this._openStoredTileGrid(
1803
+ stored,
1804
+ { ids, cells, focusedId: sessionId },
1805
+ { focusedId: sessionId, auto: false, keepCount: false }
1806
+ );
1807
+ }
1808
+ const base = plan?.ids || [];
1603
1809
  const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
1604
1810
  // Exactly these: an open split is already in `base` (tileGridOpenSet seeds
1605
1811
  // it), and merging it again went past the count (N+1) and the window.
@@ -1642,7 +1848,8 @@ Object.assign(CodemanApp.prototype, {
1642
1848
  _replaceTileGrid(ids) {
1643
1849
  const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
1644
1850
  if (this._tilesOwnTerminal()) {
1645
- this.closeTileGrid({ keepStored: false, reselect: false });
1851
+ // Kept until the group's grid, opened next, takes its place.
1852
+ this.closeTileGrid({ keepStored: true, reselect: false });
1646
1853
  this.activeSessionId = null;
1647
1854
  }
1648
1855
  // Exactly the group: an open split closes without joining it (merged, its
@@ -1912,7 +2119,10 @@ Object.assign(CodemanApp.prototype, {
1912
2119
  * input starts no drag. dragstart's target is the header whatever was
1913
2120
  * pressed, so where the press landed is noted in the capture phase, ahead of
1914
2121
  * the buttons' own stopPropagation. `draggable` is off while moving is
1915
- * (_paintTileHandle: a zoomed grid, a single tile, a rename in progress).
2122
+ * (_paintTileHandle: a zoomed grid, a single tile, a rename in progress),
2123
+ * unless Detach Tiles is on: then a drop outside the window pops the tile
2124
+ * out, and a drop on another Codeman window's tiles moves it there (see the
2125
+ * Detach Tiles section); a rename still turns it off.
1916
2126
  */
1917
2127
  _installTileMoveDrag(sessionId, header, actions) {
1918
2128
  let pressedOnControl = false;
@@ -1926,7 +2136,10 @@ Object.assign(CodemanApp.prototype, {
1926
2136
  );
1927
2137
  header.addEventListener('dragstart', (e) => {
1928
2138
  const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
1929
- if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
2139
+ // Detach Tiles lets a tile that cannot move (alone, or zoomed) still be
2140
+ // dragged, out of the window; no drop target here takes it then.
2141
+ const detachable = this._tileDetachAllowed();
2142
+ if (!entry || pressedOnControl || entry.renaming || !(this._tilesMovable() || detachable)) {
1930
2143
  e.preventDefault?.();
1931
2144
  return;
1932
2145
  }
@@ -1934,11 +2147,16 @@ Object.assign(CodemanApp.prototype, {
1934
2147
  entry.el.classList.add('tile--dragging');
1935
2148
  if (e.dataTransfer) {
1936
2149
  e.dataTransfer.effectAllowed = 'move';
1937
- // Firefox starts no drag without data.
1938
- e.dataTransfer.setData('application/x-codeman-tile', sessionId);
2150
+ // Firefox starts no drag without data. Another Codeman window reads
2151
+ // the id from it (Detach Tiles).
2152
+ e.dataTransfer.setData(TILE_DRAG_TYPE, sessionId);
1939
2153
  }
2154
+ if (detachable) this._beginTileTearOff(sessionId, entry, e);
2155
+ });
2156
+ header.addEventListener('dragend', (e) => {
2157
+ this._settleTileTearOff(sessionId, e);
2158
+ this._endTileMoveDrag();
1940
2159
  });
1941
- header.addEventListener('dragend', () => this._endTileMoveDrag());
1942
2160
  },
1943
2161
 
1944
2162
  /**
@@ -1949,6 +2167,7 @@ Object.assign(CodemanApp.prototype, {
1949
2167
  */
1950
2168
  _endTileMoveDrag() {
1951
2169
  this._draggedTileId = null;
2170
+ this._stopTileTearOffTracking();
1952
2171
  const grid = this._tileGrid;
1953
2172
  for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target');
1954
2173
  for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target');
@@ -1956,19 +2175,25 @@ Object.assign(CodemanApp.prototype, {
1956
2175
 
1957
2176
  /**
1958
2177
  * 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
2178
+ * tile is zoomed, alone, or being renamed) or, with Detach Tiles on, be
2179
+ * dragged out of the window (then also alone or zoomed), and its tooltip,
2180
+ * the state and how long ("working 3m") plus what a drag does. Diffs on the
2181
+ * last English text, never the DOM (translated in zh-CN; see
1962
2182
  * _renderTileOverlay).
1963
2183
  */
1964
2184
  _paintTileHandle(entry) {
1965
2185
  const movable = this._tilesMovable();
1966
- const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n');
2186
+ const detachable = this._tileDetachAllowed();
2187
+ let hint = '';
2188
+ if (movable && detachable) hint = 'Drag to move the tile, or out of the window to open it on its own';
2189
+ else if (movable) hint = 'Drag to move the tile';
2190
+ else if (detachable) hint = 'Drag out of the window to open the tile on its own';
2191
+ const title = [entry.stateLabel, hint].filter(Boolean).join('\n');
1967
2192
  if (entry.headerLabel !== title) {
1968
2193
  entry.headerLabel = title;
1969
2194
  entry.header.title = title;
1970
2195
  }
1971
- const draggable = movable && !entry.renaming ? 'true' : 'false';
2196
+ const draggable = (movable || detachable) && !entry.renaming ? 'true' : 'false';
1972
2197
  if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
1973
2198
  },
1974
2199
 
@@ -2127,7 +2352,7 @@ Object.assign(CodemanApp.prototype, {
2127
2352
  this._renderTileOverlay(sessionId);
2128
2353
  return;
2129
2354
  }
2130
- this.removeTile(sessionId, { focus: false });
2355
+ this.removeTile(sessionId, { focus: false, gone: true });
2131
2356
  },
2132
2357
 
2133
2358
  /**
@@ -2481,17 +2706,20 @@ Object.assign(CodemanApp.prototype, {
2481
2706
  this._persistTileGrid();
2482
2707
  },
2483
2708
 
2484
- // ── Persistence (codeman:tile-grid, per device, ids only) ────────────────
2709
+ // ── Persistence (codeman:tile-grid, per browser: ids and layout, never content) ──
2485
2710
 
2486
2711
  /**
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.
2712
+ * Writes the open grid as it is, on every change (a move, a divider drag, a
2713
+ * tile added or removed, a count picked, a focus, a zoom): its cells, its
2714
+ * count, focus, a zoom the user chose (an automatic one is worked out again
2715
+ * from the window) and the divider fractions. Never content, never to the
2716
+ * server. `open: false` is the closed-but-remembered state. Never in a solo
2717
+ * window, nor while a stored grid is being put back (_openStoredTileGrid
2718
+ * writes once it is); a storage failure only costs the convenience.
2491
2719
  */
2492
2720
  _persistTileGrid({ open = true } = {}) {
2493
2721
  const grid = this._tileGrid;
2494
- if (this.isSoloWindow || !grid || grid.ids.length === 0) return;
2722
+ if (this.isSoloWindow || this._tilePersistHold || !grid || grid.ids.length === 0) return;
2495
2723
  if (open && !grid.open) return;
2496
2724
  const state = {
2497
2725
  v: 1,
@@ -2500,6 +2728,9 @@ Object.assign(CodemanApp.prototype, {
2500
2728
  // (sanitizeTileGridState; a build before cells drops the nulls and reads
2501
2729
  // the tiles packed, as it always did).
2502
2730
  ids: grid.cells.slice(),
2731
+ // Never fewer than the tiles shown (a build before `count` derives it
2732
+ // from `ids`, and ignores it).
2733
+ count: Math.min(Math.max(grid.count || 0, grid.ids.length), window.CodemanTileGrid.TILE_GRID_MAX),
2503
2734
  focused: grid.focusedId,
2504
2735
  zoomed: grid.autoZoom ? null : grid.zoomedId,
2505
2736
  colFr: grid.colFr.slice(),
@@ -2534,47 +2765,74 @@ Object.assign(CodemanApp.prototype, {
2534
2765
  },
2535
2766
 
2536
2767
  /**
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).
2768
+ * The stored grid as it would come back now (restoreTileGridCells): its
2769
+ * cells as stored, a cell whose session no longer exists filled from the
2770
+ * ranking while the grid holds fewer than its count. Null when nothing is
2771
+ * stored or none of its sessions survive.
2543
2772
  */
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;
2773
+ _storedTileGridSet(stored = this._readStoredTileGrid()) {
2774
+ if (!stored) return null;
2775
+ return window.CodemanTileGrid.restoreTileGridCells(stored, this._tileGridRanking());
2776
+ },
2777
+
2778
+ /**
2779
+ * Opens a stored grid as the user left it: `set` (the stored cells with any
2780
+ * freed cell filled, _storedTileGridSet, by default; the Tiles button may
2781
+ * pass it trimmed or filled to a picked count, or with a Ctrl/Cmd+clicked
2782
+ * session in it) goes back into its cells, holes included, a shape change
2783
+ * following the cell model's rule (reformTileCells: the tiles keep their row
2784
+ * and column when all fit, the ones that join fill the empty cells in
2785
+ * reading order); then the fractions it had (only if they still match the
2786
+ * layout) and a zoom the user chose on the focused tile. A grid larger than
2787
+ * the window fits is never trimmed: the focused tile shows alone until the
2788
+ * window fits it (_applyTileLayout), and the arrangement stays. `auto`: the
2789
+ * app is putting it back, so no idle alert is spent. `keepCount`: the
2790
+ * stored count stays the grid's (a session that went away may be filled the
2791
+ * next time); false makes it what opens (a pick, a Ctrl/Cmd+click). Nothing
2792
+ * is written until the grid is back, so a stored grid is never overwritten
2793
+ * by a half-built one.
2794
+ */
2795
+ _openStoredTileGrid(
2796
+ stored,
2797
+ set = this._storedTileGridSet(stored),
2798
+ { focusedId = null, auto = true, keepCount = true } = {}
2799
+ ) {
2800
+ if (!set?.ids?.length) return false;
2550
2801
  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
- );
2802
+ const focus = focusedId || set.focusedId;
2803
+ // An open split closes without joining it (decision 8, case a).
2804
+ this._tilePersistHold = true;
2805
+ let opened = false;
2806
+ try {
2807
+ opened = this.openTileGrid(set.ids, { focusedId: focus, auto, mergeSplit: false });
2808
+ } finally {
2809
+ this._tilePersistHold = false;
2810
+ }
2811
+ if (!opened) return false;
2812
+ const grid = this._tileGrid;
2813
+ // openTileGrid packed the tiles from the first cell. They go back to their
2814
+ // cells (a session gone since leaves its cell empty, unless the ranking
2815
+ // filled it).
2816
+ const base = (set.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
2817
+ const kept = base.filter(Boolean);
2818
+ const cells = T.reformTileCells(
2819
+ base,
2820
+ T.tileCellCols(base.length),
2821
+ kept,
2822
+ grid.ids.filter((id) => !kept.includes(id)),
2823
+ grid.cols,
2824
+ grid.rows
2825
+ );
2569
2826
  if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
2570
2827
  grid.cells = cells;
2571
2828
  }
2829
+ grid.count = keepCount ? Math.min(Math.max(grid.ids.length, stored.count || 0), T.TILE_GRID_MAX) : grid.ids.length;
2572
2830
  // openTileGrid laid the grid out with equal tracks. The stored ones go back
2573
2831
  // on; _applyTileLayout drops them again if they do not match the column or
2574
2832
  // row count (the window may have changed the layout since).
2575
2833
  if (stored.colFr) grid.colFr = stored.colFr.slice();
2576
2834
  if (stored.rowFr) grid.rowFr = stored.rowFr.slice();
2577
- if (stored.zoomed && grid.tiles.has(stored.zoomed)) {
2835
+ if (stored.zoomed && stored.zoomed === grid.focusedId && grid.tiles.has(stored.zoomed)) {
2578
2836
  grid.zoomedId = stored.zoomed;
2579
2837
  grid.autoZoom = false;
2580
2838
  }
@@ -2598,17 +2856,27 @@ Object.assign(CodemanApp.prototype, {
2598
2856
  if (this._tilesOwnTerminal()) return false;
2599
2857
  const stored = this._readStoredTileGrid();
2600
2858
  if (!stored?.open || stored.ids.length === 0) return false;
2601
- return this._openStoredTileGrid(stored);
2859
+ // Put back, not opened: the tiles settle in whatever the entrance theme.
2860
+ this._tileEnterQuiet = true;
2861
+ try {
2862
+ return this._openStoredTileGrid(stored);
2863
+ } finally {
2864
+ this._tileEnterQuiet = false;
2865
+ }
2602
2866
  },
2603
2867
 
2604
- /** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
2868
+ /**
2869
+ * A followed `#session=` link took the screen on load: the stored grid stays
2870
+ * remembered, closed. Only `open` changes: the value is written back as it
2871
+ * was stored, so a session gone since still frees its cell for the ranking
2872
+ * the next time the grid opens.
2873
+ */
2605
2874
  _closeStoredTileGrid() {
2606
2875
  const stored = this._readStoredTileGrid();
2607
2876
  if (!stored?.open) return;
2608
- // Stored as it was read, holes included (`ids` carries the cells).
2609
- const { cells, ...rest } = stored;
2610
2877
  try {
2611
- localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false }));
2878
+ const raw = JSON.parse(localStorage.getItem(TILE_GRID_STORAGE_KEY));
2879
+ localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...raw, open: false }));
2612
2880
  } catch {
2613
2881
  /* Per-device convenience only. */
2614
2882
  }
@@ -2637,7 +2905,7 @@ Object.assign(CodemanApp.prototype, {
2637
2905
  const grid = this._tileGrid;
2638
2906
  if (!grid?.open) return false;
2639
2907
  for (const id of grid.ids.slice()) {
2640
- if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false });
2908
+ if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false, gone: true });
2641
2909
  }
2642
2910
  if (!grid.open) return false;
2643
2911
  // Only a focus that is gone moves: re-selecting the same tile would hide an
@@ -2648,6 +2916,370 @@ Object.assign(CodemanApp.prototype, {
2648
2916
  for (const { tile } of grid.tiles.values()) tile.reconnectNow();
2649
2917
  return true;
2650
2918
  },
2919
+
2920
+ // ── Detach Tiles (per-device `tileDetachEnabled`, default OFF) ───────────
2921
+ //
2922
+ // A tile leaves the grid for a window of its own, and comes back into any
2923
+ // Codeman window's grid, by dragging:
2924
+ //
2925
+ // - its header dropped OUTSIDE every window opens it as a pop-out window
2926
+ // (detachSession, the tab's own "Open in a new window"), at the drop point
2927
+ // and the tile's size;
2928
+ // - its header dropped on ANOTHER Codeman window's tile or empty cell moves
2929
+ // it there: that window docks it and says so on the window channel
2930
+ // ('tile-adopted'), and this one lets the tile go;
2931
+ // - a pop-out window's title dropped on a window's tile or empty cell docks
2932
+ // the session there, and the pop-out closes.
2933
+ //
2934
+ // Nothing new on the server: a pop-out is another client of the same session
2935
+ // (see detachSession), and a tile is a TerminalTile in whichever window has it.
2936
+ // A session stays in ONE place in this browser, as with every pop-out.
2937
+
2938
+ /** Whether Detach Tiles is on for this device. */
2939
+ tileDetachEnabled() {
2940
+ return this.loadAppSettingsFromStorage?.()?.tileDetachEnabled === true;
2941
+ },
2942
+
2943
+ /** Detach Tiles applies here: on, and a dashboard (a pop-out has no grid to drag from). */
2944
+ _tileDetachAllowed() {
2945
+ return !this.isSoloWindow && this.tileDetachEnabled();
2946
+ },
2947
+
2948
+ /**
2949
+ * A drag over this page that is a tile from another Codeman window: none of
2950
+ * this page's own drags is in flight, Detach Tiles is on and the drag
2951
+ * carries the tile type (its id is only readable on drop).
2952
+ */
2953
+ _isForeignTileDrag(e) {
2954
+ if (this.draggedTabId || this._draggedTileId || !this._tileGrid?.open || !this._tileDetachAllowed()) return false;
2955
+ const types = e?.dataTransfer?.types;
2956
+ return !!types && Array.from(types).includes(TILE_DRAG_TYPE);
2957
+ },
2958
+
2959
+ /**
2960
+ * A tile from another window dropped on one of this grid's targets: it docks
2961
+ * through the target's own drop (`place`: a tile it replaces or trades
2962
+ * places with, an empty cell it fills), and takes focus there (a human
2963
+ * selection: the user just put it there). A session that was popped out comes
2964
+ * back first (its window is asked to close). Once it is a tile here, every
2965
+ * other window hears 'tile-adopted', and the one it was dragged from lets
2966
+ * its tile go (_onTileAdoptedElsewhere). An id this page does not know (a
2967
+ * session closed meanwhile, another user's) docks nowhere.
2968
+ *
2969
+ * @returns {boolean} whether the session is a tile here afterwards
2970
+ */
2971
+ _dockForeignTile(sessionId, place) {
2972
+ const grid = this._tileGrid;
2973
+ if (!grid?.open || typeof sessionId !== 'string' || !this.sessions.has(sessionId)) return false;
2974
+ const poppedOut = !!this.detachedSessions?.has(sessionId);
2975
+ // A pop-out window owns its session's place: take it back before mounting
2976
+ // (a detached session never mounts).
2977
+ if (poppedOut) this._redock?.(sessionId);
2978
+ // Already a tile here too (two windows showed it): it moves to where it
2979
+ // was dropped, as a drag inside this window would move it.
2980
+ place(sessionId);
2981
+ if (!grid.has(sessionId)) return false;
2982
+ (this._tileDockedAt ||= new Map()).set(sessionId, Date.now());
2983
+ // Asked unconditionally: a pop-out this window never heard of (opened from
2984
+ // another dashboard) closes too. Dashboards ignore it; only that id's
2985
+ // pop-out reacts.
2986
+ this._postWindowMessage?.({ type: 'close-request', id: sessionId });
2987
+ this._postWindowMessage?.({ type: 'tile-adopted', id: sessionId, by: this._wsTabNonce });
2988
+ return true;
2989
+ },
2990
+
2991
+ /**
2992
+ * True for a session this window docked from another one a moment ago: a
2993
+ * 'detached' announcement from its old pop-out is that window on its way
2994
+ * out, never a new pop-out (it would take the tile straight back off).
2995
+ */
2996
+ _tileDockedRecently(sessionId) {
2997
+ const at = this._tileDockedAt?.get(sessionId);
2998
+ return typeof at === 'number' && Date.now() - at < TILE_DOCK_GRACE_MS;
2999
+ },
3000
+
3001
+ /**
3002
+ * Another window docked `sessionId` ('tile-adopted'). Only the window it
3003
+ * was dragged FROM acts: its drag is still settling or ended a moment ago.
3004
+ * The tile goes without taking the keyboard anywhere (the user is in the
3005
+ * other window now). The last tile leaving closes the grid onto the welcome
3006
+ * screen, never onto that session: two windows sizing one PTY garble it.
3007
+ */
3008
+ _onTileAdoptedElsewhere(sessionId) {
3009
+ const recent = this._lastTileDrag;
3010
+ // A header drag counts even without a tear-off (Detach Tiles off here): the
3011
+ // other window can take the tile before this drag ends.
3012
+ const dragging = this._tileTearOff?.id === sessionId || this._draggedTileId === sessionId;
3013
+ const pending = this._tileTearOffPending?.id === sessionId;
3014
+ const justEnded = recent?.id === sessionId && Date.now() - recent.at < TILE_ADOPT_WINDOW_MS;
3015
+ if (!dragging && !pending && !justEnded) return false;
3016
+ this._cancelTileTearOff();
3017
+ this._lastTileDrag = null;
3018
+ const grid = this._tileGrid;
3019
+ if (!grid?.has(sessionId)) return false;
3020
+ // One stored grid per browser, and the window that docked the tile has
3021
+ // just written it: this window losing the tile is no change of the
3022
+ // user's to remember over that one (nor to forget it).
3023
+ this._tilePersistHold = true;
3024
+ try {
3025
+ if (grid.ids.length > 1) return this.removeTile(sessionId, { focus: false });
3026
+ this.closeTileGrid({ keepStored: true, reselect: false });
3027
+ } finally {
3028
+ this._tilePersistHold = false;
3029
+ }
3030
+ this.activeSessionId = null;
3031
+ try {
3032
+ localStorage.removeItem('codeman-active-session');
3033
+ } catch {
3034
+ /* Nothing stored. */
3035
+ }
3036
+ this.terminal?.clear();
3037
+ this.showWelcome?.();
3038
+ return true;
3039
+ },
3040
+
3041
+ /**
3042
+ * A header drag starts with Detach Tiles on: note the tile's size and where
3043
+ * in it the press landed (the pop-out opens that size, under the pointer),
3044
+ * and watch whether the pointer leaves the page. Two signals, since
3045
+ * browsers differ in what dragend reports for a drop outside the window:
3046
+ * its coordinates, and the page's own dragleave at the viewport's edge
3047
+ * with nothing entered (cleared again by any dragover inside).
3048
+ */
3049
+ _beginTileTearOff(sessionId, entry, e) {
3050
+ this._stopTileTearOffTracking();
3051
+ const rect = entry.el.getBoundingClientRect();
3052
+ const within = (v, max) => (Number.isFinite(v) ? Math.max(0, Math.min(v, max)) : 0);
3053
+ const drag = {
3054
+ id: sessionId,
3055
+ width: rect.width,
3056
+ height: rect.height,
3057
+ offsetX: within(e?.clientX - rect.left, rect.width),
3058
+ offsetY: within(e?.clientY - rect.top, rect.height),
3059
+ outside: false,
3060
+ stop: null,
3061
+ };
3062
+ const onOver = () => {
3063
+ drag.outside = false;
3064
+ };
3065
+ const onLeave = (ev) => {
3066
+ if (ev.relatedTarget) return;
3067
+ const x = ev.clientX;
3068
+ const y = ev.clientY;
3069
+ if (x <= 0 || y <= 0 || x >= window.innerWidth - 1 || y >= window.innerHeight - 1) drag.outside = true;
3070
+ };
3071
+ document.addEventListener('dragover', onOver, true);
3072
+ document.addEventListener('dragleave', onLeave, true);
3073
+ drag.stop = () => {
3074
+ document.removeEventListener('dragover', onOver, true);
3075
+ document.removeEventListener('dragleave', onLeave, true);
3076
+ };
3077
+ this._tileTearOff = drag;
3078
+ },
3079
+
3080
+ /** The header drag is over (or the grid or tile went mid-drag): stop watching the pointer. */
3081
+ _stopTileTearOffTracking() {
3082
+ const drag = this._tileTearOff;
3083
+ if (!drag) return;
3084
+ this._tileTearOff = null;
3085
+ drag.stop?.();
3086
+ },
3087
+
3088
+ /**
3089
+ * dragend of a tile's header with Detach Tiles on. Taken by a drop target
3090
+ * (here or in another window: `dropEffect` is not 'none'), it stays where
3091
+ * that put it. Let go OUTSIDE the window (the dragend point, or the page's
3092
+ * own record of the pointer leaving), it opens as a window of its own after
3093
+ * TILE_TEAR_OFF_SETTLE_MS, unless another window claims it first. Let go
3094
+ * inside, on nothing, nothing happens, as before.
3095
+ */
3096
+ _settleTileTearOff(sessionId, e) {
3097
+ const drag = this._tileTearOff?.id === sessionId ? this._tileTearOff : null;
3098
+ this._lastTileDrag = { id: sessionId, at: Date.now() };
3099
+ if (!drag) return false;
3100
+ this._stopTileTearOffTracking();
3101
+ const effect = e?.dataTransfer?.dropEffect;
3102
+ if (effect && effect !== 'none') return false;
3103
+ const cx = e?.clientX;
3104
+ const cy = e?.clientY;
3105
+ // (0, 0) is what a browser reports when it does not know where the drop was.
3106
+ const known = Number.isFinite(cx) && Number.isFinite(cy) && !(cx === 0 && cy === 0);
3107
+ const outside = known ? cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight : drag.outside;
3108
+ if (!outside) return false;
3109
+ const sx = e?.screenX;
3110
+ const sy = e?.screenY;
3111
+ const placed = Number.isFinite(sx) && Number.isFinite(sy) && !(sx === 0 && sy === 0);
3112
+ const placement = {
3113
+ width: drag.width,
3114
+ height: drag.height,
3115
+ ...(placed ? { screenX: sx, screenY: sy, offsetX: drag.offsetX, offsetY: drag.offsetY } : {}),
3116
+ };
3117
+ this._cancelTileTearOff();
3118
+ const pending = { id: sessionId, timer: null };
3119
+ pending.timer = setTimeout(() => {
3120
+ if (this._tileTearOffPending !== pending) return;
3121
+ this._tileTearOffPending = null;
3122
+ this.detachTile(sessionId, placement);
3123
+ }, TILE_TEAR_OFF_SETTLE_MS);
3124
+ this._tileTearOffPending = pending;
3125
+ return true;
3126
+ },
3127
+
3128
+ _cancelTileTearOff() {
3129
+ const pending = this._tileTearOffPending;
3130
+ if (!pending) return;
3131
+ this._tileTearOffPending = null;
3132
+ clearTimeout(pending.timer);
3133
+ },
3134
+
3135
+ /**
3136
+ * Opens a tile as a window of its own (detachSession, which takes it out of
3137
+ * the grid), the tile's size (at least TILE_WINDOW_MIN_W x MIN_H). Placed
3138
+ * with its header under a screen point when given one (`screenX`/`screenY`
3139
+ * and where in the tile it was held, `offsetX`/`offsetY`: a drop outside the
3140
+ * window, a hand let go), otherwise over the tile itself (the ⋯ menu).
3141
+ *
3142
+ * @returns {boolean} whether the session is popped out afterwards
3143
+ */
3144
+ detachTile(sessionId, { screenX, screenY, offsetX = 0, offsetY = 0, width, height } = {}) {
3145
+ const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
3146
+ if (!entry || !this._tileDetachAllowed()) return false;
3147
+ const rect = entry.el.getBoundingClientRect();
3148
+ const availW = window.screen?.availWidth || Infinity;
3149
+ const availH = window.screen?.availHeight || Infinity;
3150
+ const size = (v, min, max) => Math.round(Math.min(Math.max(Number.isFinite(v) ? v : min, min), max));
3151
+ const placement = {
3152
+ width: size(width ?? rect.width, TILE_WINDOW_MIN_W, availW),
3153
+ height: size(height ?? rect.height, TILE_WINDOW_MIN_H, availH),
3154
+ };
3155
+ if (Number.isFinite(screenX) && Number.isFinite(screenY)) {
3156
+ placement.left = Math.round(screenX - offsetX);
3157
+ placement.top = Math.round(screenY - offsetY - TILE_WINDOW_CHROME_PX);
3158
+ } else if (Number.isFinite(window.screenX) && Number.isFinite(window.screenY)) {
3159
+ // Over the tile: the page's own chrome sits above its viewport.
3160
+ const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
3161
+ placement.left = Math.round(window.screenX + rect.left);
3162
+ placement.top = Math.round(window.screenY + chrome + rect.top - TILE_WINDOW_CHROME_PX);
3163
+ }
3164
+ this.detachSession?.(sessionId, placement);
3165
+ return !!this.detachedSessions?.has(sessionId);
3166
+ },
3167
+
3168
+ // ── For the gesture overlay (packages/gesture-control, src/codeman/entry.ts)
3169
+ //
3170
+ // A hand is no native drag: the overlay asks where it is and what is under
3171
+ // it, and every move still goes through the paths a mouse drag takes
3172
+ // (dropSessionOnTile / dropSessionOnSlot, and so _reorderTiles), the
3173
+ // pop-out through detachTile.
3174
+
3175
+ /** The tiled session whose tile is at viewport point (x, y), or null. */
3176
+ tileAtPoint(x, y) {
3177
+ const grid = this._tileGrid;
3178
+ if (!grid?.open) return null;
3179
+ const hit = document.elementFromPoint?.(x, y);
3180
+ if (!hit) return null;
3181
+ for (const [id, entry] of grid.tiles) if (entry.el.contains?.(hit)) return id;
3182
+ return null;
3183
+ },
3184
+
3185
+ /** Whether a hand may pick up this tile: it can move, or (Detach Tiles) leave the window. */
3186
+ canGrabTile(sessionId) {
3187
+ return !!this._tileGrid?.has(sessionId) && (this._tilesMovable() || this._tileDetachAllowed());
3188
+ },
3189
+
3190
+ /**
3191
+ * What a session dragged to viewport point (x, y) would drop onto, by the
3192
+ * mouse drag's own rules: another tile (it takes that tile's place, or the
3193
+ * two trade places), an empty cell (it moves or joins there). A tiled
3194
+ * session only while tiles can move (not zoomed). Null for anything else,
3195
+ * a popped-out session included.
3196
+ *
3197
+ * @returns {{kind: 'tile', id: string, el: HTMLElement}|{kind: 'cell', cell: number, el: HTMLElement}|null}
3198
+ */
3199
+ tileDropTargetAt(x, y, draggedId) {
3200
+ const grid = this._tileGrid;
3201
+ if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return null;
3202
+ if (grid.tiles.has(draggedId) && !this._tilesMovable()) return null;
3203
+ const hit = document.elementFromPoint?.(x, y);
3204
+ if (!hit) return null;
3205
+ for (const [id, entry] of grid.tiles) {
3206
+ if (entry.el.contains?.(hit)) return id === draggedId ? null : { kind: 'tile', id, el: entry.el };
3207
+ }
3208
+ for (const slot of grid.slots || []) {
3209
+ if (slot.contains?.(hit)) return { kind: 'cell', cell: Number(slot.dataset.cell), el: slot };
3210
+ }
3211
+ return null;
3212
+ },
3213
+
3214
+ /**
3215
+ * Drops a session on a target tileDropTargetAt returned (the hand let go).
3216
+ *
3217
+ * @returns {boolean} whether the session is a tile afterwards
3218
+ */
3219
+ dropOnTileTarget(draggedId, target) {
3220
+ if (!target || !this._tileGrid?.open) return false;
3221
+ if (target.kind === 'tile') this.dropSessionOnTile(draggedId, target.id);
3222
+ else if (target.kind === 'cell') this.dropSessionOnSlot(draggedId, target.cell);
3223
+ return !!this._tileGrid?.has(draggedId);
3224
+ },
3225
+
3226
+ /** Whether viewport point (x, y) is over the open grid (a hand let go outside it pops the tile out). */
3227
+ isOverTileGrid(x, y) {
3228
+ if (!this._tileGrid?.open) return false;
3229
+ const rect = document.getElementById('tileGrid')?.getBoundingClientRect?.();
3230
+ return !!rect && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
3231
+ },
3232
+
3233
+ /**
3234
+ * A hand let a tile go outside the grid (Detach Tiles): it opens as a window
3235
+ * of its own with its header near that point. A script opening a window
3236
+ * with no click behind it is what popup blockers stop; detachSession then
3237
+ * says so and the tile stays.
3238
+ */
3239
+ detachTileAtPoint(sessionId, x, y, { offsetX = 0, offsetY = 0 } = {}) {
3240
+ const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
3241
+ const sx = (window.screenX || 0) + x;
3242
+ const sy = (window.screenY || 0) + chrome + y;
3243
+ return this.detachTile(sessionId, { screenX: sx, screenY: sy, offsetX, offsetY });
3244
+ },
3245
+
3246
+ /**
3247
+ * A pop-out window's title, as a handle (Detach Tiles): dragged onto a
3248
+ * Codeman window's tile or empty cell, the session docks there as a tile
3249
+ * and this window closes (_dockForeignTile, which asks it to). The same
3250
+ * drag type as a tile's header; on or off follows the setting, also when it
3251
+ * changes in another window.
3252
+ */
3253
+ _installSoloTileHandle() {
3254
+ const title = document.getElementById('soloSessionTitle');
3255
+ if (!this.isSoloWindow || !title || title._codemanTileHandle) return;
3256
+ title._codemanTileHandle = true;
3257
+ const sync = () => {
3258
+ this._cachedAppSettings = null;
3259
+ const on = this.tileDetachEnabled();
3260
+ title.setAttribute('draggable', on ? 'true' : 'false');
3261
+ title.classList.toggle('solo-session-title--handle', on);
3262
+ const label = on ? "Drag onto a Codeman window's tiles to dock this session there" : '';
3263
+ if (title._codemanTileHandleLabel !== label) {
3264
+ title._codemanTileHandleLabel = label;
3265
+ title.title = label;
3266
+ }
3267
+ };
3268
+ sync();
3269
+ window.addEventListener('storage', (e) => {
3270
+ if (!e.key || e.key === this.getSettingsStorageKey?.()) sync();
3271
+ });
3272
+ title.addEventListener('dragstart', (e) => {
3273
+ if (!this.tileDetachEnabled() || !this.soloSessionId) {
3274
+ e.preventDefault?.();
3275
+ return;
3276
+ }
3277
+ if (e.dataTransfer) {
3278
+ e.dataTransfer.effectAllowed = 'move';
3279
+ e.dataTransfer.setData(TILE_DRAG_TYPE, this.soloSessionId);
3280
+ }
3281
+ });
3282
+ },
2651
3283
  });
2652
3284
 
2653
3285
  // A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
@@ -2665,7 +3297,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
2665
3297
  if (grid?.has(data.id)) {
2666
3298
  const wasFocused = grid.focusedId === data.id;
2667
3299
  const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
2668
- this.removeTile(data.id, { refocus: false });
3300
+ this.removeTile(data.id, { refocus: false, gone: true });
2669
3301
  if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
2670
3302
  this._selectTiledSession(neighbor, { auto: true, focus: false });
2671
3303
  }