aicodeman 1.40.0 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +25 -7
  2. package/dist/cli-agent.d.ts +225 -0
  3. package/dist/cli-agent.d.ts.map +1 -0
  4. package/dist/cli-agent.js +767 -0
  5. package/dist/cli-agent.js.map +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +6 -30
  8. package/dist/cli.js.map +1 -1
  9. package/dist/codeman-credentials.d.ts +27 -0
  10. package/dist/codeman-credentials.d.ts.map +1 -0
  11. package/dist/codeman-credentials.js +66 -0
  12. package/dist/codeman-credentials.js.map +1 -0
  13. package/dist/config/cli-registry/schema.d.ts +2 -0
  14. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  15. package/dist/config/cli-registry/schema.js +3 -0
  16. package/dist/config/cli-registry/schema.js.map +1 -1
  17. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  18. package/dist/config/cli-registry/stock.js +5 -0
  19. package/dist/config/cli-registry/stock.js.map +1 -1
  20. package/dist/config/cli-registry/types.d.ts +12 -1
  21. package/dist/config/cli-registry/types.d.ts.map +1 -1
  22. package/dist/git-clone.d.ts.map +1 -1
  23. package/dist/git-clone.js +4 -0
  24. package/dist/git-clone.js.map +1 -1
  25. package/dist/image-watcher.d.ts.map +1 -1
  26. package/dist/image-watcher.js +6 -2
  27. package/dist/image-watcher.js.map +1 -1
  28. package/dist/mcp-sync-targets.d.ts +34 -0
  29. package/dist/mcp-sync-targets.d.ts.map +1 -0
  30. package/dist/mcp-sync-targets.js +55 -0
  31. package/dist/mcp-sync-targets.js.map +1 -0
  32. package/dist/mcp-sync.d.ts +2 -1
  33. package/dist/mcp-sync.d.ts.map +1 -1
  34. package/dist/mcp-sync.js +62 -1
  35. package/dist/mcp-sync.js.map +1 -1
  36. package/dist/session.d.ts.map +1 -1
  37. package/dist/session.js +10 -4
  38. package/dist/session.js.map +1 -1
  39. package/dist/subagent-watcher.d.ts.map +1 -1
  40. package/dist/subagent-watcher.js +29 -6
  41. package/dist/subagent-watcher.js.map +1 -1
  42. package/dist/templates/case-template.md +2 -0
  43. package/dist/tmux-manager.d.ts +5 -0
  44. package/dist/tmux-manager.d.ts.map +1 -1
  45. package/dist/tmux-manager.js +47 -22
  46. package/dist/tmux-manager.js.map +1 -1
  47. package/dist/tui/tui-client.d.ts +3 -17
  48. package/dist/tui/tui-client.d.ts.map +1 -1
  49. package/dist/tui/tui-client.js +3 -44
  50. package/dist/tui/tui-client.js.map +1 -1
  51. package/dist/utils/index.d.ts +1 -0
  52. package/dist/utils/index.d.ts.map +1 -1
  53. package/dist/utils/index.js +1 -0
  54. package/dist/utils/index.js.map +1 -1
  55. package/dist/utils/process-exit-wait.d.ts +40 -0
  56. package/dist/utils/process-exit-wait.d.ts.map +1 -0
  57. package/dist/utils/process-exit-wait.js +68 -0
  58. package/dist/utils/process-exit-wait.js.map +1 -0
  59. package/dist/web/paste-image-gc.d.ts +46 -6
  60. package/dist/web/paste-image-gc.d.ts.map +1 -1
  61. package/dist/web/paste-image-gc.js +106 -32
  62. package/dist/web/paste-image-gc.js.map +1 -1
  63. package/dist/web/public/admin-ui.js.gz +0 -0
  64. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  65. package/dist/web/public/app.6f8552e1.js +43 -0
  66. package/dist/web/public/app.6f8552e1.js.br +0 -0
  67. package/dist/web/public/app.6f8552e1.js.gz +0 -0
  68. package/dist/web/public/approvals-ui.js.gz +0 -0
  69. package/dist/web/public/{constants.c0f8f9de.js → constants.bc9c1f63.js} +295 -28
  70. package/dist/web/public/constants.bc9c1f63.js.br +0 -0
  71. package/dist/web/public/constants.bc9c1f63.js.gz +0 -0
  72. package/dist/web/public/cron-ui.js.gz +0 -0
  73. package/dist/web/public/entrance-animations.js +490 -43
  74. package/dist/web/public/entrance-animations.js.br +0 -0
  75. package/dist/web/public/entrance-animations.js.gz +0 -0
  76. package/dist/web/public/git-status-ui.js.gz +0 -0
  77. package/dist/web/public/home-sessions.js +2 -1
  78. package/dist/web/public/home-sessions.js.br +0 -0
  79. package/dist/web/public/home-sessions.js.gz +0 -0
  80. package/dist/web/public/host-wake-ui.js.gz +0 -0
  81. package/dist/web/public/i18n.a9840ea2.js +2 -0
  82. package/dist/web/public/i18n.a9840ea2.js.br +0 -0
  83. package/dist/web/public/i18n.a9840ea2.js.gz +0 -0
  84. package/dist/web/public/{image-input.898a538b.js → image-input.e00471c6.js} +3 -1
  85. package/dist/web/public/image-input.e00471c6.js.br +0 -0
  86. package/dist/web/public/image-input.e00471c6.js.gz +0 -0
  87. package/dist/web/public/index.html +128 -33
  88. package/dist/web/public/index.html.br +0 -0
  89. package/dist/web/public/index.html.gz +0 -0
  90. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  91. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  92. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  93. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  94. package/dist/web/public/mobile-overview.js.gz +0 -0
  95. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  96. package/dist/web/public/{notification-manager.50bb87d8.js → notification-manager.feb82c04.js} +19 -2
  97. package/dist/web/public/notification-manager.feb82c04.js.br +0 -0
  98. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  99. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  100. package/dist/web/public/{panels-ui.4735dfc2.js → panels-ui.71846fcb.js} +1 -1
  101. package/dist/web/public/panels-ui.71846fcb.js.br +0 -0
  102. package/dist/web/public/panels-ui.71846fcb.js.gz +0 -0
  103. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  104. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  105. package/dist/web/public/readmymind-ui.js.gz +0 -0
  106. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  107. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  108. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  109. package/dist/web/public/session-lineage.js.gz +0 -0
  110. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  111. package/dist/web/public/settings-ui.4e33390f.js +97 -0
  112. package/dist/web/public/settings-ui.4e33390f.js.br +0 -0
  113. package/dist/web/public/settings-ui.4e33390f.js.gz +0 -0
  114. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  115. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  116. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  117. package/dist/web/public/styles.81a72906.css +1 -0
  118. package/dist/web/public/styles.81a72906.css.br +0 -0
  119. package/dist/web/public/styles.81a72906.css.gz +0 -0
  120. package/dist/web/public/{subagent-windows.e6ca799f.js → subagent-windows.07277a58.js} +7 -5
  121. package/dist/web/public/subagent-windows.07277a58.js.br +0 -0
  122. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  123. package/dist/web/public/sw.js +2 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  127. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  128. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  129. package/dist/web/public/terminal-split.js.gz +0 -0
  130. package/dist/web/public/terminal-tile.js +92 -15
  131. package/dist/web/public/terminal-tile.js.br +0 -0
  132. package/dist/web/public/terminal-tile.js.gz +0 -0
  133. package/dist/web/public/terminal-ui.b7267005.js +2 -0
  134. package/dist/web/public/terminal-ui.b7267005.js.br +0 -0
  135. package/dist/web/public/terminal-ui.b7267005.js.gz +0 -0
  136. package/dist/web/public/tile-grid.js +330 -119
  137. package/dist/web/public/tile-grid.js.br +0 -0
  138. package/dist/web/public/tile-grid.js.gz +0 -0
  139. package/dist/web/public/ultracode-panel.js.gz +0 -0
  140. package/dist/web/public/ultracode-windows.js +9 -6
  141. package/dist/web/public/ultracode-windows.js.br +0 -0
  142. package/dist/web/public/ultracode-windows.js.gz +0 -0
  143. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  144. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  145. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  146. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  147. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  148. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  149. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  150. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  151. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  152. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +4 -0
  153. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
  154. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  155. package/dist/web/public/vendor/xterm.css.gz +0 -0
  156. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  157. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  158. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  159. package/dist/web/public/webview-tabs.js.gz +0 -0
  160. package/dist/web/routes/cli-registry-routes.d.ts +23 -4
  161. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  162. package/dist/web/routes/cli-registry-routes.js +92 -7
  163. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  164. package/dist/web/routes/mcp-sync-routes.d.ts +7 -2
  165. package/dist/web/routes/mcp-sync-routes.d.ts.map +1 -1
  166. package/dist/web/routes/mcp-sync-routes.js +10 -3
  167. package/dist/web/routes/mcp-sync-routes.js.map +1 -1
  168. package/dist/web/routes/reboot-restore-routes.js +1 -1
  169. package/dist/web/routes/reboot-restore-routes.js.map +1 -1
  170. package/dist/web/routes/session-routes.d.ts.map +1 -1
  171. package/dist/web/routes/session-routes.js +68 -35
  172. package/dist/web/routes/session-routes.js.map +1 -1
  173. package/dist/web/schemas.d.ts +3 -0
  174. package/dist/web/schemas.d.ts.map +1 -1
  175. package/dist/web/schemas.js +9 -0
  176. package/dist/web/schemas.js.map +1 -1
  177. package/dist/web/server.d.ts +1 -1
  178. package/dist/web/server.d.ts.map +1 -1
  179. package/dist/web/server.js +15 -10
  180. package/dist/web/server.js.map +1 -1
  181. package/package.json +4 -4
  182. package/skills/codeman/SKILL.md +6 -0
  183. package/README.zh-CN.md +0 -1147
  184. package/dist/web/public/app.abadc970.js +0 -43
  185. package/dist/web/public/app.abadc970.js.br +0 -0
  186. package/dist/web/public/app.abadc970.js.gz +0 -0
  187. package/dist/web/public/constants.c0f8f9de.js.br +0 -0
  188. package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
  189. package/dist/web/public/i18n.a31747a9.js +0 -2
  190. package/dist/web/public/i18n.a31747a9.js.br +0 -0
  191. package/dist/web/public/i18n.a31747a9.js.gz +0 -0
  192. package/dist/web/public/image-input.898a538b.js.br +0 -0
  193. package/dist/web/public/image-input.898a538b.js.gz +0 -0
  194. package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
  195. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  196. package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
  197. package/dist/web/public/panels-ui.4735dfc2.js.gz +0 -0
  198. package/dist/web/public/settings-ui.a97ab16f.js +0 -97
  199. package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
  200. package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
  201. package/dist/web/public/styles.8ee12666.css +0 -1
  202. package/dist/web/public/styles.8ee12666.css.br +0 -0
  203. package/dist/web/public/styles.8ee12666.css.gz +0 -0
  204. package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
  205. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  206. package/dist/web/public/terminal-ui.5059b30e.js +0 -2
  207. package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
  208. package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
  209. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js +0 -4
  210. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.br +0 -0
  211. 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,
@@ -147,6 +151,11 @@ class TileGridModel {
147
151
  // session id, or null for an empty cell. THE source of truth for where
148
152
  // each tile is (owner: an empty cell can be any cell); `ids` derives from it.
149
153
  this.cells = [];
154
+ // How many tiles the user's own last change left (open, add, remove, a
155
+ // count picked): a session that goes away by itself (deleted, popped out,
156
+ // refused) does not lower it, so the next time the grid opens the ranking
157
+ // fills that place. Stored with the grid (_persistTileGrid).
158
+ this.count = 0;
150
159
  // id -> { tile: TerminalTile, el: HTMLElement }
151
160
  this.tiles = new Map();
152
161
  this.focusedId = null;
@@ -237,8 +246,15 @@ Object.assign(CodemanApp.prototype, {
237
246
  if (state !== 'idle') this._setTileLoadingLabel(entry.body);
238
247
  entry.el.classList.toggle('tile--loading', state !== 'idle');
239
248
  // 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');
249
+ // whole, instead of showing its replay scroll by, or plays the
250
+ // terminal pane's entrance style (entrance-animations.js).
251
+ if (state === 'idle') {
252
+ entry.el.classList.remove('tile--revealing');
253
+ if (entry.screenOwed) {
254
+ entry.screenOwed = false;
255
+ this.playTileScreenEntrance?.(entry.body);
256
+ }
257
+ }
242
258
  },
243
259
  });
244
260
  }
@@ -389,6 +405,9 @@ Object.assign(CodemanApp.prototype, {
389
405
  wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
390
406
  // Packed from the first cell (_applyTileLayout pads the shape with empty cells).
391
407
  grid.cells = wanted.filter((id) => grid.tiles.has(id));
408
+ // What the user opened is the grid they want (_openStoredTileGrid keeps a
409
+ // stored count instead).
410
+ grid.count = grid.ids.length;
392
411
  this._applyTileLayout();
393
412
  // The tiles' frames paint first; their terminals are built one per frame
394
413
  // after it, the focused tile's first, so its capture is the one the queue
@@ -412,11 +431,13 @@ Object.assign(CodemanApp.prototype, {
412
431
  * Leaves the grid: every tile destroyed (sockets closed, xterms disposed,
413
432
  * queued loads dropped), the main terminal unparked.
414
433
  *
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).
434
+ * `keepStored` (every caller) keeps the grid as it was, holes, count, sizes,
435
+ * focus and zoom, closed, for the Tiles toggle to bring back (a reload
436
+ * restores only a grid stored open); false would forget it. `reselect` shows the focused session in the single
437
+ * view through a forced reload; pass false when the caller selects
438
+ * something itself. `animate` (the Tiles toggle only, owner answer 4)
439
+ * leaves a still copy of the tiles over the stage until the single view has
440
+ * its content (_ghostTileGrid).
420
441
  *
421
442
  * The main terminal's cached content for EVERY tiled id is invalidated: it was
422
443
  * written before the grid opened, possibly hours ago, and selectSession paints
@@ -457,6 +478,7 @@ Object.assign(CodemanApp.prototype, {
457
478
  grid.colFr = [];
458
479
  grid.rowFr = [];
459
480
  grid.cells = [];
481
+ grid.count = 0;
460
482
  grid.cols = 0;
461
483
  grid.rows = 0;
462
484
  grid.focusedId = null;
@@ -536,14 +558,28 @@ Object.assign(CodemanApp.prototype, {
536
558
  // Zoomed, only the zoomed tile is on screen (the others are display: none
537
559
  // under .tile-grid--zoomed, a rule the ghost layer does not carry).
538
560
  const zoomed = section.classList.contains('tile-grid--zoomed');
561
+ const copies = [];
539
562
  for (const id of grid.ids) {
540
563
  const el = grid.tiles.get(id)?.el;
541
564
  if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
542
565
  const ghost = el.cloneNode(true);
543
- ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging');
566
+ ghost.classList.remove(
567
+ 'tile--entering',
568
+ 'tile--enter-themed',
569
+ 'tile--enter-hold',
570
+ 'tile--needs',
571
+ 'tile--loading',
572
+ 'tile--drop-target',
573
+ 'tile--dragging'
574
+ );
575
+ ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
544
576
  ghost.classList.add('tile--leaving');
545
577
  layer.appendChild(ghost);
578
+ copies.push({ ghost, el, sessionId: id });
546
579
  }
580
+ // The entrance style's own way out (entrance-animations.js): back into the
581
+ // tabs, a CRT switch-off. Placed while the real tiles are still measurable.
582
+ const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
547
583
  main.appendChild(layer);
548
584
  let fallback = null;
549
585
  let holdCap = null;
@@ -557,10 +593,11 @@ Object.assign(CodemanApp.prototype, {
557
593
  if (fallback !== null || !layer.isConnected) return;
558
594
  clearTimeout(holdCap);
559
595
  layer.classList.add('tile-grid-ghosts--release');
560
- fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
596
+ fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
561
597
  };
562
598
  layer.addEventListener('animationend', (e) => {
563
- if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
599
+ if (e.pseudoElement || e.target !== layer.lastElementChild) return;
600
+ if (/^tile-leave/.test(e.animationName)) done();
564
601
  });
565
602
  if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
566
603
  else release();
@@ -695,8 +732,11 @@ Object.assign(CodemanApp.prototype, {
695
732
 
696
733
  /**
697
734
  * 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).
735
+ * window fits. With the grid closed and a stored grid to bring back, the
736
+ * click opens that grid as it was (never trimmed to the window), so the
737
+ * window line only says what fits. Compared with the last English text set,
738
+ * never the DOM (in zh-CN the DOM holds the translation; see
739
+ * _renderTileOverlay).
700
740
  */
701
741
  _renderTileHint() {
702
742
  const hint = this._tileHint;
@@ -712,10 +752,12 @@ Object.assign(CodemanApp.prototype, {
712
752
  set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
713
753
  set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
714
754
  const plural = capacity === 1 ? '' : 's';
755
+ const restoring = open ? null : this._storedTileGridSet();
756
+ const opens = restoring ? restoring.ids.length : count;
715
757
  const fitsText =
716
- count <= capacity
758
+ opens <= capacity
717
759
  ? ''
718
- : open
760
+ : open || restoring
719
761
  ? `This window fits ${capacity} tile${plural}`
720
762
  : `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
721
763
  set(hint.fits.text, 'fitsText', fitsText);
@@ -976,22 +1018,25 @@ Object.assign(CodemanApp.prototype, {
976
1018
 
977
1019
  /**
978
1020
  * 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.
1021
+ * with that many tiles (a stored grid re-formed to it, its tiles first in
1022
+ * their cells), or the open grid is re-formed to it (_reformTileGrid).
1023
+ * Never more than the window fits; fewer open sessions than the count give
1024
+ * fewer tiles. Either way the grid's count is what the pick left.
982
1025
  */
983
1026
  _pickTileCount(count) {
984
1027
  this.closeTileCountMenu({ refocus: false });
985
1028
  this._rememberTileGridCount(count);
986
1029
  const grid = this._tileGrid;
987
1030
  if (!grid?.open) {
988
- this.toggleTileGrid();
1031
+ this._activateTileGrid({ count });
989
1032
  return;
990
1033
  }
991
1034
  const T = window.CodemanTileGrid;
992
1035
  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));
1036
+ // The tiles that join are the ranking's best (_tileGridRanking).
1037
+ this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId));
1038
+ grid.count = grid.ids.length;
1039
+ this._persistTileGrid();
995
1040
  },
996
1041
 
997
1042
  /**
@@ -1126,14 +1171,18 @@ Object.assign(CodemanApp.prototype, {
1126
1171
  /**
1127
1172
  * The Tiles button's click and Ctrl+Shift+G, one function so the two never
1128
1173
  * 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).
1174
+ * closes it to the single view of the focused session. The grid this
1175
+ * browser last had comes back EXACTLY as the user left it (owner request:
1176
+ * "when I turn tiles off and on, always keep what the last setting was"):
1177
+ * its tiles in their cells, holes included, its count, divider sizes, focus
1178
+ * and zoom; a cell whose session no longer exists is filled from the
1179
+ * ranking (_openStoredTileGrid). With none of its sessions left (or nothing
1180
+ * stored), `tileGridOpenSet` (constants.js) takes an open split's two
1181
+ * sessions, else the open sessions as the ranking orders them
1182
+ * (_tileGridRanking: working, then needing input, then the most recent),
1183
+ * the active one always among them and focused, filled to the remembered
1184
+ * count (the right-click menu's last pick, default 6, at most what the
1185
+ * window fits; owner decision 10).
1137
1186
  */
1138
1187
  toggleTileGrid() {
1139
1188
  this.closeTileCountMenu();
@@ -1141,28 +1190,43 @@ Object.assign(CodemanApp.prototype, {
1141
1190
  this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
1142
1191
  return;
1143
1192
  }
1144
- if (!this.canOpenTileGrid()) return;
1193
+ this._activateTileGrid();
1194
+ },
1195
+
1196
+ /**
1197
+ * Opens the grid as the toggle does (toggleTileGrid), or, with `count` (a
1198
+ * pick in the count menu while the grid is closed), with that many tiles: a
1199
+ * stored grid re-formed to it, its tiles first in their cells, the ones the
1200
+ * count adds filling its empty cells first, the rest from the ranking.
1201
+ *
1202
+ * @returns {boolean} whether the grid opened
1203
+ */
1204
+ _activateTileGrid({ count = null } = {}) {
1205
+ if (!this.canOpenTileGrid()) return false;
1145
1206
  const stored = this._readStoredTileGrid();
1146
- const set = this._tileGridOpenSet(stored);
1207
+ const set = this._tileGridOpenSet(stored, count);
1147
1208
  if (!set) {
1148
1209
  this.showToast?.('No sessions to show as tiles', 'info');
1149
- return;
1210
+ return false;
1150
1211
  }
1151
- if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids);
1152
- else this.openTileGrid(set.ids, { focusedId: set.focusedId });
1212
+ if (set.source === 'stored') return this._openStoredTileGrid(stored, set, { keepCount: count === null });
1213
+ return this.openTileGrid(set.ids, { focusedId: set.focusedId });
1153
1214
  },
1154
1215
 
1155
1216
  /**
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.
1217
+ * What the toggle would open now (see toggleTileGrid): a stored grid as it
1218
+ * was, or `count` tiles (the remembered count by default), at most what the
1219
+ * window fits. With an explicit `count`, a stored grid is trimmed or filled
1220
+ * to it too. `stored` saves a second read.
1159
1221
  */
1160
- _tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) {
1222
+ _tileGridOpenSet(stored = this._readStoredTileGrid(), count = null) {
1161
1223
  const T = window.CodemanTileGrid;
1162
- const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity));
1224
+ const n = Math.max(1, Math.min(count ?? this._tileGridCount(), this._tileGridLimit().capacity));
1225
+ const ranked = this._tileGridRanking();
1163
1226
  const set = T.tileGridOpenSet({
1164
1227
  stored,
1165
1228
  split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
1229
+ ranked,
1166
1230
  sessions: this.sessions,
1167
1231
  sessionOrder: this.sessionOrder,
1168
1232
  detachedIds: this.detachedSessions,
@@ -1170,8 +1234,42 @@ Object.assign(CodemanApp.prototype, {
1170
1234
  limit: n,
1171
1235
  });
1172
1236
  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) };
1237
+ // A stored grid comes back as it was: the count does not apply to it
1238
+ // unless one was asked for.
1239
+ if (set.source === 'stored' && count === null) return set;
1240
+ return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) };
1241
+ },
1242
+
1243
+ /**
1244
+ * The open sessions the grid takes when nobody said which (the Tiles button
1245
+ * with nothing stored, and every tile it fills on its own), best first:
1246
+ * working (the most recently started turn first), then the ones needing
1247
+ * input (the red and yellow tab alerts), then the rest by most recent
1248
+ * activity, tab order breaking ties (rankTileSessions, constants.js). The
1249
+ * states and stamps are the home screens' own (`_mobileOverviewState()`,
1250
+ * mobile-overview.js). Detached sessions are never in it. Guarded like
1251
+ * the sorted rail: without the classifier (a stale cached
1252
+ * mobile-overview.js) it is plain tab order.
1253
+ *
1254
+ * @returns {string[]}
1255
+ */
1256
+ _tileGridRanking() {
1257
+ const T = window.CodemanTileGrid;
1258
+ const open = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
1259
+ if (typeof this._mobileOverviewState !== 'function' || typeof T.rankTileSessions !== 'function') {
1260
+ return open.map((c) => c.id);
1261
+ }
1262
+ const rows = open.map(({ id }, orderIndex) => {
1263
+ const session = this.sessions.get(id);
1264
+ return {
1265
+ id,
1266
+ state: this._mobileOverviewState(session, this.pendingHooks?.get(id)),
1267
+ lastActivityAt: Number(session.lastActivityAt) || 0,
1268
+ lastSubmitAt: Number(session.lastSubmitAt) || 0,
1269
+ orderIndex,
1270
+ };
1271
+ });
1272
+ return T.rankTileSessions(rows);
1175
1273
  },
1176
1274
 
1177
1275
  /** Alt+Shift+Arrows: a human selection of the tile in that direction. */
@@ -1242,6 +1340,7 @@ Object.assign(CodemanApp.prototype, {
1242
1340
  if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
1243
1341
  if (!this._mountTile(sessionId, { enterIndex })) return false;
1244
1342
  this._placeTile(sessionId, cell);
1343
+ grid.count = grid.ids.length;
1245
1344
  // A tile added while one is zoomed by hand is meant to be seen.
1246
1345
  if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
1247
1346
  this._applyTileLayout();
@@ -1286,17 +1385,20 @@ Object.assign(CodemanApp.prototype, {
1286
1385
  * moves focus to the neighbouring tile (next in grid order, else previous),
1287
1386
  * as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
1288
1387
  * 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.
1388
+ * so keystrokes never land in the neighbour's PTY unasked. `gone`: the
1389
+ * session went away by itself (deleted, popped out, its socket refused), not
1390
+ * by the user's hand, so the grid's count stays and the next time it opens
1391
+ * the ranking fills that place. The last tile leaving closes the grid, kept
1392
+ * as it was: with `refocus` the single view then shows that session (or,
1393
+ * popped out, the next one: _selectAfterTileGrid), without it the caller
1394
+ * decides what comes next.
1293
1395
  */
1294
- removeTile(sessionId, { refocus = true, focus = true } = {}) {
1396
+ removeTile(sessionId, { refocus = true, focus = true, gone = false } = {}) {
1295
1397
  const grid = this._tileGrid;
1296
1398
  const entry = grid?.open ? grid.tiles.get(sessionId) : null;
1297
1399
  if (!entry) return false;
1298
1400
  if (grid.ids.length === 1) {
1299
- this.closeTileGrid({ keepStored: false, reselect: refocus });
1401
+ this.closeTileGrid({ keepStored: true, reselect: refocus });
1300
1402
  return true;
1301
1403
  }
1302
1404
  const wasFocused = grid.focusedId === sessionId;
@@ -1312,6 +1414,7 @@ Object.assign(CodemanApp.prototype, {
1312
1414
  entry.el.remove();
1313
1415
  grid.tiles.delete(sessionId);
1314
1416
  grid.cells[grid.cells.indexOf(sessionId)] = null;
1417
+ if (!gone) grid.count = grid.ids.length;
1315
1418
  // Before the layout, which may zoom the focused tile on a small window.
1316
1419
  if (wasFocused) grid.focusedId = null;
1317
1420
  this._applyTileLayout();
@@ -1323,11 +1426,10 @@ Object.assign(CodemanApp.prototype, {
1323
1426
 
1324
1427
  /**
1325
1428
  * 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.
1429
+ * caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
1430
+ * group (_beginTileEntrance), and its terminal stays transparent until its
1431
+ * first capture lands (.tile--revealing, cleared by the load queue, with a
1432
+ * backstop timer). Opacity and transform only, never anything its fit reads.
1331
1433
  */
1332
1434
  _mountTile(sessionId, { enterIndex = 0 } = {}) {
1333
1435
  const grid = this._tileGrid;
@@ -1336,16 +1438,11 @@ Object.assign(CodemanApp.prototype, {
1336
1438
  const el = document.createElement('div');
1337
1439
  el.className = 'tile tile--revealing';
1338
1440
  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
- }
1441
+ // Its screen plays the terminal pane's entrance once its first capture
1442
+ // lands, when its frame entered in a tile style (Tile Animations): never
1443
+ // with the default `settle`, nor on a reload's restore, which settles.
1444
+ const entered = this._beginTileEntrance(el, sessionId, enterIndex);
1445
+ const screenOwed = !!entered && entered !== 'settle';
1349
1446
  el.dataset.sessionId = sessionId;
1350
1447
  // Header and body are siblings: the chrome is refreshed in place
1351
1448
  // (_renderTileHeader), never by rewriting the tile, which would take the
@@ -1391,12 +1488,61 @@ Object.assign(CodemanApp.prototype, {
1391
1488
  renaming: false,
1392
1489
  // The pid this tile last saw, so a pane that starts later is noticed.
1393
1490
  pid: this.sessions.get(sessionId)?.pid ?? null,
1491
+ // Its screen plays the terminal entrance once its first capture lands.
1492
+ screenOwed,
1394
1493
  });
1395
1494
  // Where it goes is the caller's (grid.cells).
1396
1495
  this._renderTileHeader(sessionId);
1397
1496
  return true;
1398
1497
  },
1399
1498
 
1499
+ /**
1500
+ * A tile's frame enters as the `enterIndex`-th of a staggered group, in the
1501
+ * entrance style (entrance-animations.js, App Settings → Entrance
1502
+ * Animations). `settle` is the grid's own fade and settle (styles.css
1503
+ * .tile--entering), and what a reload restores with whatever the theme:
1504
+ * nothing animates on page load. Any other style is timed by the entrance
1505
+ * module a frame later, once the layout is final (_stageTileEntrance).
1506
+ * Opacity and transform only, never anything the fit reads.
1507
+ *
1508
+ * @returns {string|null} the style it enters with, or null when it does not animate
1509
+ */
1510
+ _beginTileEntrance(el, sessionId, enterIndex = 0) {
1511
+ const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
1512
+ if (!this._tileMotionAllowed() || style === 'off') return null;
1513
+ el.classList.add('tile--entering');
1514
+ el.style.setProperty('--tile-enter-index', String(enterIndex));
1515
+ let backstop = null;
1516
+ const finish = () => {
1517
+ clearTimeout(backstop);
1518
+ el.removeEventListener('animationend', onEnd);
1519
+ el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
1520
+ if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
1521
+ };
1522
+ // The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
1523
+ // never a child's animation or a wash on its ::before.
1524
+ const onEnd = (e) => {
1525
+ if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
1526
+ finish();
1527
+ };
1528
+ el.addEventListener('animationend', onEnd);
1529
+ el._tileEnterFinish = finish;
1530
+ if (style !== 'settle') {
1531
+ this._stageTileEntrance?.(el, sessionId, (ms) => {
1532
+ clearTimeout(backstop);
1533
+ backstop = setTimeout(finish, ms);
1534
+ });
1535
+ }
1536
+ return style;
1537
+ },
1538
+
1539
+ /** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
1540
+ _replayTileEntrance(el, sessionId, enterIndex = 0) {
1541
+ el._tileEnterFinish?.();
1542
+ void el.offsetWidth;
1543
+ return this._beginTileEntrance(el, sessionId, enterIndex);
1544
+ },
1545
+
1400
1546
  /**
1401
1547
  * Makes `el` a drop target for a session tab dragged from the strip (the
1402
1548
  * strip's own drag sets `draggedTabId`) and for a tile dragged by its header
@@ -1575,10 +1721,12 @@ Object.assign(CodemanApp.prototype, {
1575
1721
  * Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
1576
1722
  * human selection: the user clicked its tab). With the grid closed it opens
1577
1723
  * 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.
1724
+ * focused, never past the remembered count (owner answer to decision 10's
1725
+ * questions: N, not N+1): it joins while the grid holds fewer than the
1726
+ * count (a stored grid's first empty cell), else it takes the last tile's
1727
+ * place. A stored grid keeps its cells, sizes and holes around it.
1728
+ * Returns false when the grid cannot open here (narrow or solo window), so
1729
+ * the click is an ordinary one.
1582
1730
  */
1583
1731
  addSessionToTiles(sessionId) {
1584
1732
  if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
@@ -1599,7 +1747,26 @@ Object.assign(CodemanApp.prototype, {
1599
1747
  return true;
1600
1748
  }
1601
1749
  const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
1602
- const base = this._tileGridOpenSet()?.ids || [];
1750
+ const stored = this._readStoredTileGrid();
1751
+ const plan = this._tileGridOpenSet(stored);
1752
+ if (plan?.source === 'stored') {
1753
+ let { ids, cells } = plan;
1754
+ if (!ids.includes(sessionId)) {
1755
+ if (ids.length < n) {
1756
+ ids = [...ids, sessionId];
1757
+ } else {
1758
+ const last = ids.at(-1);
1759
+ ids = ids.map((id) => (id === last ? sessionId : id));
1760
+ cells = cells.map((id) => (id === last ? sessionId : id));
1761
+ }
1762
+ }
1763
+ return this._openStoredTileGrid(
1764
+ stored,
1765
+ { ids, cells, focusedId: sessionId },
1766
+ { focusedId: sessionId, auto: false, keepCount: false }
1767
+ );
1768
+ }
1769
+ const base = plan?.ids || [];
1603
1770
  const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
1604
1771
  // Exactly these: an open split is already in `base` (tileGridOpenSet seeds
1605
1772
  // it), and merging it again went past the count (N+1) and the window.
@@ -1642,7 +1809,8 @@ Object.assign(CodemanApp.prototype, {
1642
1809
  _replaceTileGrid(ids) {
1643
1810
  const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
1644
1811
  if (this._tilesOwnTerminal()) {
1645
- this.closeTileGrid({ keepStored: false, reselect: false });
1812
+ // Kept until the group's grid, opened next, takes its place.
1813
+ this.closeTileGrid({ keepStored: true, reselect: false });
1646
1814
  this.activeSessionId = null;
1647
1815
  }
1648
1816
  // Exactly the group: an open split closes without joining it (merged, its
@@ -2127,7 +2295,7 @@ Object.assign(CodemanApp.prototype, {
2127
2295
  this._renderTileOverlay(sessionId);
2128
2296
  return;
2129
2297
  }
2130
- this.removeTile(sessionId, { focus: false });
2298
+ this.removeTile(sessionId, { focus: false, gone: true });
2131
2299
  },
2132
2300
 
2133
2301
  /**
@@ -2481,17 +2649,20 @@ Object.assign(CodemanApp.prototype, {
2481
2649
  this._persistTileGrid();
2482
2650
  },
2483
2651
 
2484
- // ── Persistence (codeman:tile-grid, per device, ids only) ────────────────
2652
+ // ── Persistence (codeman:tile-grid, per browser: ids and layout, never content) ──
2485
2653
 
2486
2654
  /**
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.
2655
+ * Writes the open grid as it is, on every change (a move, a divider drag, a
2656
+ * tile added or removed, a count picked, a focus, a zoom): its cells, its
2657
+ * count, focus, a zoom the user chose (an automatic one is worked out again
2658
+ * from the window) and the divider fractions. Never content, never to the
2659
+ * server. `open: false` is the closed-but-remembered state. Never in a solo
2660
+ * window, nor while a stored grid is being put back (_openStoredTileGrid
2661
+ * writes once it is); a storage failure only costs the convenience.
2491
2662
  */
2492
2663
  _persistTileGrid({ open = true } = {}) {
2493
2664
  const grid = this._tileGrid;
2494
- if (this.isSoloWindow || !grid || grid.ids.length === 0) return;
2665
+ if (this.isSoloWindow || this._tilePersistHold || !grid || grid.ids.length === 0) return;
2495
2666
  if (open && !grid.open) return;
2496
2667
  const state = {
2497
2668
  v: 1,
@@ -2500,6 +2671,9 @@ Object.assign(CodemanApp.prototype, {
2500
2671
  // (sanitizeTileGridState; a build before cells drops the nulls and reads
2501
2672
  // the tiles packed, as it always did).
2502
2673
  ids: grid.cells.slice(),
2674
+ // Never fewer than the tiles shown (a build before `count` derives it
2675
+ // from `ids`, and ignores it).
2676
+ count: Math.min(Math.max(grid.count || 0, grid.ids.length), window.CodemanTileGrid.TILE_GRID_MAX),
2503
2677
  focused: grid.focusedId,
2504
2678
  zoomed: grid.autoZoom ? null : grid.zoomedId,
2505
2679
  colFr: grid.colFr.slice(),
@@ -2534,47 +2708,74 @@ Object.assign(CodemanApp.prototype, {
2534
2708
  },
2535
2709
 
2536
2710
  /**
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).
2711
+ * The stored grid as it would come back now (restoreTileGridCells): its
2712
+ * cells as stored, a cell whose session no longer exists filled from the
2713
+ * ranking while the grid holds fewer than its count. Null when nothing is
2714
+ * stored or none of its sessions survive.
2543
2715
  */
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;
2716
+ _storedTileGridSet(stored = this._readStoredTileGrid()) {
2717
+ if (!stored) return null;
2718
+ return window.CodemanTileGrid.restoreTileGridCells(stored, this._tileGridRanking());
2719
+ },
2720
+
2721
+ /**
2722
+ * Opens a stored grid as the user left it: `set` (the stored cells with any
2723
+ * freed cell filled, _storedTileGridSet, by default; the Tiles button may
2724
+ * pass it trimmed or filled to a picked count, or with a Ctrl/Cmd+clicked
2725
+ * session in it) goes back into its cells, holes included, a shape change
2726
+ * following the cell model's rule (reformTileCells: the tiles keep their row
2727
+ * and column when all fit, the ones that join fill the empty cells in
2728
+ * reading order); then the fractions it had (only if they still match the
2729
+ * layout) and a zoom the user chose on the focused tile. A grid larger than
2730
+ * the window fits is never trimmed: the focused tile shows alone until the
2731
+ * window fits it (_applyTileLayout), and the arrangement stays. `auto`: the
2732
+ * app is putting it back, so no idle alert is spent. `keepCount`: the
2733
+ * stored count stays the grid's (a session that went away may be filled the
2734
+ * next time); false makes it what opens (a pick, a Ctrl/Cmd+click). Nothing
2735
+ * is written until the grid is back, so a stored grid is never overwritten
2736
+ * by a half-built one.
2737
+ */
2738
+ _openStoredTileGrid(
2739
+ stored,
2740
+ set = this._storedTileGridSet(stored),
2741
+ { focusedId = null, auto = true, keepCount = true } = {}
2742
+ ) {
2743
+ if (!set?.ids?.length) return false;
2550
2744
  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
- );
2745
+ const focus = focusedId || set.focusedId;
2746
+ // An open split closes without joining it (decision 8, case a).
2747
+ this._tilePersistHold = true;
2748
+ let opened = false;
2749
+ try {
2750
+ opened = this.openTileGrid(set.ids, { focusedId: focus, auto, mergeSplit: false });
2751
+ } finally {
2752
+ this._tilePersistHold = false;
2753
+ }
2754
+ if (!opened) return false;
2755
+ const grid = this._tileGrid;
2756
+ // openTileGrid packed the tiles from the first cell. They go back to their
2757
+ // cells (a session gone since leaves its cell empty, unless the ranking
2758
+ // filled it).
2759
+ const base = (set.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
2760
+ const kept = base.filter(Boolean);
2761
+ const cells = T.reformTileCells(
2762
+ base,
2763
+ T.tileCellCols(base.length),
2764
+ kept,
2765
+ grid.ids.filter((id) => !kept.includes(id)),
2766
+ grid.cols,
2767
+ grid.rows
2768
+ );
2569
2769
  if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
2570
2770
  grid.cells = cells;
2571
2771
  }
2772
+ grid.count = keepCount ? Math.min(Math.max(grid.ids.length, stored.count || 0), T.TILE_GRID_MAX) : grid.ids.length;
2572
2773
  // openTileGrid laid the grid out with equal tracks. The stored ones go back
2573
2774
  // on; _applyTileLayout drops them again if they do not match the column or
2574
2775
  // row count (the window may have changed the layout since).
2575
2776
  if (stored.colFr) grid.colFr = stored.colFr.slice();
2576
2777
  if (stored.rowFr) grid.rowFr = stored.rowFr.slice();
2577
- if (stored.zoomed && grid.tiles.has(stored.zoomed)) {
2778
+ if (stored.zoomed && stored.zoomed === grid.focusedId && grid.tiles.has(stored.zoomed)) {
2578
2779
  grid.zoomedId = stored.zoomed;
2579
2780
  grid.autoZoom = false;
2580
2781
  }
@@ -2598,17 +2799,27 @@ Object.assign(CodemanApp.prototype, {
2598
2799
  if (this._tilesOwnTerminal()) return false;
2599
2800
  const stored = this._readStoredTileGrid();
2600
2801
  if (!stored?.open || stored.ids.length === 0) return false;
2601
- return this._openStoredTileGrid(stored);
2802
+ // Put back, not opened: the tiles settle in whatever the entrance theme.
2803
+ this._tileEnterQuiet = true;
2804
+ try {
2805
+ return this._openStoredTileGrid(stored);
2806
+ } finally {
2807
+ this._tileEnterQuiet = false;
2808
+ }
2602
2809
  },
2603
2810
 
2604
- /** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
2811
+ /**
2812
+ * A followed `#session=` link took the screen on load: the stored grid stays
2813
+ * remembered, closed. Only `open` changes: the value is written back as it
2814
+ * was stored, so a session gone since still frees its cell for the ranking
2815
+ * the next time the grid opens.
2816
+ */
2605
2817
  _closeStoredTileGrid() {
2606
2818
  const stored = this._readStoredTileGrid();
2607
2819
  if (!stored?.open) return;
2608
- // Stored as it was read, holes included (`ids` carries the cells).
2609
- const { cells, ...rest } = stored;
2610
2820
  try {
2611
- localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false }));
2821
+ const raw = JSON.parse(localStorage.getItem(TILE_GRID_STORAGE_KEY));
2822
+ localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...raw, open: false }));
2612
2823
  } catch {
2613
2824
  /* Per-device convenience only. */
2614
2825
  }
@@ -2637,7 +2848,7 @@ Object.assign(CodemanApp.prototype, {
2637
2848
  const grid = this._tileGrid;
2638
2849
  if (!grid?.open) return false;
2639
2850
  for (const id of grid.ids.slice()) {
2640
- if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false });
2851
+ if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false, gone: true });
2641
2852
  }
2642
2853
  if (!grid.open) return false;
2643
2854
  // Only a focus that is gone moves: re-selecting the same tile would hide an
@@ -2665,7 +2876,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
2665
2876
  if (grid?.has(data.id)) {
2666
2877
  const wasFocused = grid.focusedId === data.id;
2667
2878
  const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
2668
- this.removeTile(data.id, { refocus: false });
2879
+ this.removeTile(data.id, { refocus: false, gone: true });
2669
2880
  if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
2670
2881
  this._selectTiledSession(neighbor, { auto: true, focus: false });
2671
2882
  }