diff --git a/CLAUDE.md b/CLAUDE.md index 438ac05e..00988773 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 240b18a3..fda9d65b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -805,7 +805,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ### Tile grid -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `
` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view. +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `
` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view. ⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`. diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index ff810617..01148ca2 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -153,6 +153,8 @@ 'Restore the grid': '恢复平铺网格', 'Remove tile (the session keeps running)': '移除窗格(会话继续运行)', 'Drop a tab or a tile here': '将标签页或窗格拖放到此处', + // A file dropped on a tile (tile-grid.js) or the single view (image-input.js). + 'Only image files are supported': '仅支持图像文件', // A tile header's tooltip while tiles can move (with the state above it: a pattern below). 'Drag to move the tile': '拖动可移动窗格', 'Resize tile columns': '调整窗格列宽', diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index b1ec2194..e2fb1083 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -3549,7 +3549,14 @@ Object.assign(CodemanApp.prototype, { } // Desktop defaults - rely on ?? operators in apply functions // This allows desktop to have different defaults without duplication - return {}; + // A touch-primary tablet (iPad, an Android tablet: not a handheld, so it + // lands here) keeps the Tiles button opt-in, as Split is: a tile has none of + // the main terminal's touch, IME and soft-keyboard handling. The PRIMARY + // pointer decides, never MobileDetection.isTouchDevice(), which is true on a + // touchscreen laptop too (fine primary pointer: the desktop default stays). + const coarsePrimaryPointer = + typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)')?.matches === true; + return coarsePrimaryPointer ? { showTileGridButton: false } : {}; }, loadAppSettingsFromStorage() { diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index af6960bb..1545f169 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -253,16 +253,65 @@ Object.assign(CodemanApp.prototype, { const wrap = document.querySelector('.terminal-wrap'); wrap?.parentElement?.insertBefore(section, wrap.nextSibling); } + // Once per section (index.html ships it, so not only on create). + if (this._tileFileDropSection !== section) { + this._tileFileDropSection = section; + this._installTileFileDrop(section); + } return section; }, + /** + * A file dragged over the grid. The single view's file drop (image-input.js) + * listens on #terminalContainer, hidden while tiles are open, so nothing + * cancelled a file drag here and the browser opened the file in place of + * Codeman. Anywhere over the grid (a tile, an empty cell, a divider, the + * padding) the drag is cancelled, so the page never navigates; dropped on a + * tile, its images upload to THAT tile's session and their paths are typed + * there, as the single view does for the active one. Bubble phase, files + * only: a tab or tile drag carries none, and its target stops it in the + * capture phase anyway (_acceptTabDrops). + */ + _installTileFileDrop(section) { + const isFileDrag = (e) => { + const types = e.dataTransfer?.types; + return !!types && Array.from(types).includes('Files'); + }; + // The open grid's tile under `target`, or null (an empty cell, a divider, the padding). + const tileAt = (target) => { + const grid = this._tileGrid; + if (!grid?.open || !target) return null; + for (const [id, entry] of grid.tiles) if (entry.el.contains?.(target)) return id; + return null; + }; + section.addEventListener('dragover', (e) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + if (e.dataTransfer && tileAt(e.target)) e.dataTransfer.dropEffect = 'copy'; + }); + section.addEventListener('drop', (e) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + const sessionId = tileAt(e.target); + const files = Array.from(e.dataTransfer?.files || []); + if (!sessionId || files.length === 0) return; + const images = files.filter((f) => String(f?.type || '').startsWith('image/')); + if (images.length === 0) { + this.showToast?.('Only image files are supported', 'error'); + return; + } + this._uploadAndInsertImages?.(images, { sessionId }); + }); + }, + /** * Opens the grid on `ids` (unknown, detached and duplicate ids are skipped; * at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it * adds what is missing and moves focus. `auto: false` makes the focus a human * selection (it acknowledges that session's idle alert). An open split * closes (the two are never open together); `mergeSplit` (default) makes its - * two sessions the first tiles, false opens exactly `ids` (a stored grid). + * two sessions the first tiles, false opens exactly `ids` (a stored grid, a + * group, a Ctrl/Cmd+click: callers that size their own set). * * Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while * its snapshot of the session it shows is still right, and closes its socket. @@ -1015,8 +1064,9 @@ Object.assign(CodemanApp.prototype, { * The tile chord `e` asks for, if it applies right now, else null. The * toggle applies while the grid is open, or where one could open AND the * per-device `showTileGridButton` setting is on (the desktop default; OFF on - * handhelds): with it off the chord is inert and reaches the terminal like - * any unbound key (owner decision 6 in docs/tile-grid-plan.md). An absent key + * handhelds and touch-primary tablets): with it off the chord is inert and + * reaches the terminal like any unbound key (owner decision 6 in + * docs/tile-grid-plan.md). An absent key * resolves through the device defaults exactly as the header button does * (settings-ui.js), so the chord and the button can never disagree. The focus, move, * zoom and remove chords apply only while the grid is open, however it was @@ -1140,10 +1190,14 @@ Object.assign(CodemanApp.prototype, { // replayed fresh (forceReload drops the stale snapshot and nulls // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), // or, if that session is gone, the same fallback as closing the active tab. - // Returns the selection's promise (it settles once the replay is written), - // or undefined for the welcome screen. + // A popped-out session counts as gone in both: selectSession would only + // raise its window and return, leaving the parked terminal's pre-grid + // content on screen under its tab (and saved as its snapshot on the next + // switch). Returns the selection's promise (it settles once the replay is + // written), or undefined for the welcome screen. _selectAfterTileGrid(sessionId) { - if (sessionId && this.sessions.has(sessionId)) { + const usable = (id) => this.sessions.has(id) && !this.detachedSessions?.has(id); + if (sessionId && usable(sessionId)) { return this.selectSession(sessionId, { forceReload: true, auto: true }); } this.activeSessionId = null; @@ -1152,7 +1206,7 @@ Object.assign(CodemanApp.prototype, { } catch { /* Nothing stored. */ } - const next = this.sessionOrder.find((id) => this.sessions.has(id)); + const next = this.sessionOrder.find(usable); if (next) return this.selectSession(next, { auto: true }); this.terminal?.clear(); this.showWelcome(); @@ -1213,11 +1267,14 @@ Object.assign(CodemanApp.prototype, { * Removes one tile; the session keeps running. Its cell becomes empty where * it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus` * moves focus to the neighbouring tile (next in grid order, else previous), - * as the app's choice (`auto`: no idle alert is spent). The last tile + * as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps + * DOM focus where it is (an app-driven removal: a socket the server closed), + * so keystrokes never land in the neighbour's PTY unasked. The last tile * leaving closes the grid: with `refocus` the single view then shows that - * session, without it the caller decides what comes next. + * session (or, popped out, the next one: _selectAfterTileGrid), without it + * the caller decides what comes next. */ - removeTile(sessionId, { refocus = true } = {}) { + removeTile(sessionId, { refocus = true, focus = true } = {}) { const grid = this._tileGrid; const entry = grid?.open ? grid.tiles.get(sessionId) : null; if (!entry) return false; @@ -1243,7 +1300,7 @@ Object.assign(CodemanApp.prototype, { this._applyTileLayout(); this._scheduleTileGridRefit(); this.renderSessionTabs?.(); - if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true }); + if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true, focus }); return true; }, @@ -1331,7 +1388,8 @@ Object.assign(CodemanApp.prototype, { * `accepts(id)` is the target's own rule (a tile takes any session but its * own; an empty cell takes any). A session it does not accept * is held there too, but refused (`dropEffect: 'none'`, no highlight, so no - * drop follows). Any other drag (a file) is left alone. + * drop follows). Any other drag (a file) is left to the grid section's own + * guard (_installTileFileDrop). */ _acceptTabDrops(el, onDrop, { accepts = () => true } = {}) { const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null); @@ -1525,7 +1583,9 @@ Object.assign(CodemanApp.prototype, { const n = Math.max(1, Math.min(this._tileGridCount(), capacity)); const base = this._tileGridOpenSet()?.ids || []; const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId]; - this.openTileGrid(ids, { focusedId: sessionId, auto: false }); + // Exactly these: an open split is already in `base` (tileGridOpenSet seeds + // it), and merging it again went past the count (N+1) and the window. + this.openTileGrid(ids, { focusedId: sessionId, auto: false, mergeSplit: false }); return true; }, @@ -1567,7 +1627,9 @@ Object.assign(CodemanApp.prototype, { this.closeTileGrid({ keepStored: false, reselect: false }); this.activeSessionId = null; } - return this.openTileGrid(ids, { focusedId: focus }); + // Exactly the group: an open split closes without joining it (merged, its + // two pushed group members out and the grid past the window's capacity). + return this.openTileGrid(ids, { focusedId: focus, mergeSplit: false }); }, /** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */ @@ -2036,7 +2098,9 @@ Object.assign(CodemanApp.prototype, { /** * A tile's socket stopped for good. 4009 (the session exited) keeps the tile * with its "session ended" marker; 4003 (refused), 4004 (session gone) and - * 4010 (another socket took over) remove it. + * 4010 (another socket took over) remove it. Nobody here asked for that, so + * the neighbour takes focus without the keyboard (`focus: false`): what the + * user is typing never lands in another session's PTY. */ _onTileExit(sessionId, tile, code) { if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return; @@ -2045,7 +2109,7 @@ Object.assign(CodemanApp.prototype, { this._renderTileOverlay(sessionId); return; } - this.removeTile(sessionId); + this.removeTile(sessionId, { focus: false }); }, /** @@ -2560,8 +2624,9 @@ Object.assign(CodemanApp.prototype, { if (!grid.open) return false; // Only a focus that is gone moves: re-selecting the same tile would hide an // active web tab on every SSE blip (_selectTiledSession hides the web layer), - // which the single view's reconnect never does. - if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true }); + // which the single view's reconnect never does. The app's choice, so DOM + // focus stays put (an open modal or text field keeps the keyboard). + if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true, focus: false }); for (const { tile } of grid.tiles.values()) tile.reconnectNow(); return true; }, @@ -2569,7 +2634,9 @@ Object.assign(CodemanApp.prototype, { // A tiled session deleted (here or elsewhere) loses its tile; if it held focus, // the neighbouring tile takes it (`auto`: the app chose, so no idle alert is -// spent). Done BEFORE the original handler, so activeSessionId no longer names +// spent; `focus: false`: the keyboard stays put, so what the user was typing +// never goes on into the neighbour's PTY, as the single view sends it +// nowhere). Done BEFORE the original handler, so activeSessionId no longer names // the deleted id and its welcome-screen handoff stays out of it. The last tile // closes the grid without a reselect, and the original handler then shows the // welcome screen as in the single view. A close started from this tab @@ -2582,7 +2649,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) { const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id); this.removeTile(data.id, { refocus: false }); if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) { - this._selectTiledSession(neighbor, { auto: true }); + this._selectTiledSession(neighbor, { auto: true, focus: false }); } } return _tileGridOriginalOnSessionDeleted.call(this, data); diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index d39c5956..d796b4a1 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -375,6 +375,10 @@ export function resetGridHarness() { // The CLI catalog the server injects (labels for the harness logos); a test sets its own. delete windowStub.__codemanCliCatalog; section.children = []; + // The grid binds its file-drop guard on the section once per app; an earlier + // test's app must not still be listening there. + section.listeners = {}; + section.captureFlags = {}; main.className = 'main'; // A split a test left open moved .terminal-wrap into its container, with // Pane A's header strip in it. diff --git a/test/tile-grid-close-fallback.test.ts b/test/tile-grid-close-fallback.test.ts index 46eb8479..c20968d0 100644 --- a/test/tile-grid-close-fallback.test.ts +++ b/test/tile-grid-close-fallback.test.ts @@ -22,7 +22,7 @@ * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeTile, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js'; +import { FakeTile, flushFrames, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c']; @@ -148,3 +148,57 @@ describe('a tiled session deleted elsewhere', () => { expect(app.showWelcome).toHaveBeenCalled(); }); }); + +// The handoffs above are the APP's choice: focus moves (activeSessionId, the +// focus paint) but the keyboard does not. Moving DOM focus into the +// neighbour's xterm sent whatever the user was still typing, Enter included, +// into another session's agent; the single view sends it nowhere. Only a +// removal the user made (the x button, Remove Focused Tile) carries the +// keyboard along. +describe('an app-driven refocus never moves the keyboard into another session', () => { + const live = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!; + const focusCalls = () => FakeTile.all.reduce((n, t) => n + t.terminal.focus.mock.calls.length, 0); + /** A grid on IDS focused on s-b, every terminal built, no focus recorded yet. */ + function built() { + const app = makeGridApp(IDS); + app.openTileGrid(IDS, { focusedId: 's-b' }); + flushFrames(); + for (const t of FakeTile.all) t.terminal.focus.mockClear(); + return app; + } + + it('a remote delete of the focused tile hands focus to the neighbour, DOM focus untouched', () => { + const app = built(); + app._onSessionDeleted({ id: 's-b' }); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + expect(app._tileGrid.focusedId).toBe('s-c'); + expect(focusCalls()).toBe(0); + // No deferred focus left to land later either. + expect(app._tileGrid.focusOnConnect ?? null).toBeNull(); + }); + + it.each([4003, 4004, 4010])('a socket the server closed (%i) removes the tile, DOM focus untouched', (code) => { + const app = built(); + app._onTileExit('s-b', live('s-b'), code); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + expect(focusCalls()).toBe(0); + }); + + it('a reconcile that finds the focused session gone moves focus, DOM focus untouched', () => { + const app = built(); + app.sessions.delete('s-b'); + app._reconcileTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-a'); + expect(focusCalls()).toBe(0); + }); + + it('a removal the user made still carries the keyboard to the neighbour', () => { + const app = built(); + app.removeFocusedTile(); + expect(app.activeSessionId).toBe('s-c'); + expect(live('s-c').terminal.focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/tile-grid-file-drop.test.ts b/test/tile-grid-file-drop.test.ts new file mode 100644 index 00000000..a718cb39 --- /dev/null +++ b/test/tile-grid-file-drop.test.ts @@ -0,0 +1,131 @@ +/** + * @fileoverview A file dragged onto the tile grid. + * + * The single view's file drop (image-input.js) listens on #terminalContainer, + * which is hidden while tiles are open. Nothing else cancelled a file drag, + * so dropping a screenshot on a tile made the browser open the file in place + * of Codeman. Now the grid section itself takes every file drag (bubble + * phase): anywhere over it (a tile, an empty cell, a divider, its padding) + * dragover and drop are cancelled, so the page never navigates, and a drop on + * a tile uploads its images to THAT tile's session, the same upload and the + * same "Only image files" toast as the single view. Tab and tile drags are + * the targets' own (_acceptTabDrops, capture phase) and stay untouched. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeEl, makeGridApp, resetGridHarness, section, tileEl, type GridApp } from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; + +function fileEvent(target: FakeEl, files: Array<{ type: string; name?: string }>, types = ['Files']) { + return { + target, + dataTransfer: { types, files, dropEffect: 'none' }, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; +} + +/** A grid on `ids`, s-a focused, uploads stubbed. Five tiles make a 3x2 with one empty cell. */ +function gridApp(ids = IDS): GridApp { + const app = makeGridApp(ids); + app._uploadAndInsertImages = vi.fn(); + app.openTileGrid(ids, { focusedId: 's-a' }); + return app; +} + +const slot = () => section.children.find((el) => el.classList.contains('tile-slot'))!; +const PNG = { type: 'image/png', name: 'shot.png' }; + +beforeEach(() => { + resetGridHarness(); +}); + +describe('a file dropped on a tile', () => { + it("uploads its images to that tile's session (not the focused one), the page staying put", () => { + const app = gridApp(); + // Deep inside the tile, as on xterm's rows or its helper textarea. + const inner = tileEl('s-b').querySelector('.tile-body')!; + const over = fileEvent(inner, [PNG]); + section.dispatch('dragover', over); + expect(over.preventDefault).toHaveBeenCalled(); + expect(over.dataTransfer.dropEffect).toBe('copy'); + + const drop = fileEvent(inner, [PNG, { type: 'image/jpeg' }]); + section.dispatch('drop', drop); + expect(drop.preventDefault).toHaveBeenCalled(); + expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1); + expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, { type: 'image/jpeg' }], { sessionId: 's-b' }); + // A drop is not a selection: focus stays where it was. + expect(app.activeSessionId).toBe('s-a'); + }); + + it('only the images of a mixed drop are uploaded', () => { + const app = gridApp(); + section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG])); + expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG], { sessionId: 's-c' }); + }); + + it('a drop with no image says so, as the single view does, and uploads nothing', () => { + const app = gridApp(); + const drop = fileEvent(tileEl('s-b'), [{ type: 'application/pdf' }]); + section.dispatch('drop', drop); + expect(drop.preventDefault).toHaveBeenCalled(); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenCalledWith('Only image files are supported', 'error'); + }); +}); + +describe('a file dragged anywhere else over the grid', () => { + it('an empty cell, a divider or the padding: cancelled (no navigation), nothing uploaded', () => { + const app = gridApp([...IDS, 's-d', 's-e']); + for (const target of [slot(), section]) { + expect(target).toBeTruthy(); + const over = fileEvent(target, [PNG]); + section.dispatch('dragover', over); + expect(over.preventDefault).toHaveBeenCalled(); + const drop = fileEvent(target, [PNG]); + section.dispatch('drop', drop); + expect(drop.preventDefault).toHaveBeenCalled(); + } + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).not.toHaveBeenCalled(); + }); +}); + +describe('tab and tile drags are left to their targets', () => { + it('a drag that carries no files is not touched by the grid section', () => { + const app = gridApp(); + app.draggedTabId = 's-other'; + const over = fileEvent(section, [], ['text/plain']); + section.dispatch('dragover', over); + section.dispatch('drop', over); + expect(over.preventDefault).not.toHaveBeenCalled(); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + }); + + it("a tab dropped on a tile still replaces it through the tile's own handler", () => { + const app = gridApp(); + app.draggedTabId = 's-other'; + const drop = fileEvent(tileEl('s-b'), [], ['text/plain']); + tileEl('s-b').dispatch('drop', drop); + expect(app._tileGrid.ids).toContain('s-other'); + expect(app._tileGrid.ids).not.toContain('s-b'); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + }); +}); + +describe('the guard is installed once', () => { + it('opening and closing the grid again never stacks listeners', () => { + const app = gridApp(); + app.closeTileGrid({ reselect: false }); + app.openTileGrid(IDS); + app.closeTileGrid({ reselect: false }); + app.openTileGrid(IDS); + expect(section.listeners.dragover).toHaveLength(1); + expect(section.listeners.drop).toHaveLength(1); + // Bubble phase: the tab and tile drop targets stop their own drags in capture. + expect(section.captureFlags.drop).toEqual([false]); + }); +}); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 51840818..ffc28293 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -215,6 +215,14 @@ async function exercise() { harvestAll(app, 'zoomed'); app.zoomTile('s-1'); + // A file that is not an image dropped on a tile. + section.dispatch('drop', { + target: section.children.find((el) => el.dataset.sessionId === 's-1'), + dataTransfer: { types: ['Files'], files: [{ type: 'application/pdf' }] }, + preventDefault: vi.fn(), + }); + harvestAll(app, 'file drop'); + // The toasts of a full grid, by the cap and by the window. app._joinTileGridFromRun('s-7'); app.addSessionToTiles('s-7'); @@ -314,6 +322,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'Could not attach the session', 'This group has no session to show as tiles', 'No sessions to show as tiles', + 'Only image files are supported', 'Split: unavailable while tiles are open', 'Tiles: show several sessions side by side (right-click for how many)', 'Tiles: back to a single session (right-click for how many tiles)', diff --git a/test/tile-grid-per-device-setting.test.ts b/test/tile-grid-per-device-setting.test.ts index eedd5ea5..204ca44a 100644 --- a/test/tile-grid-per-device-setting.test.ts +++ b/test/tile-grid-per-device-setting.test.ts @@ -2,7 +2,8 @@ // Port: none (pure static analysis, runs in CI, no browser/server). // // `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is -// a PER-DEVICE setting (default ON on desktop, OFF on handhelds) with the same three-way rule as +// a PER-DEVICE setting (default ON on desktop, OFF on handhelds and touch-primary +// tablets) with the same three-way rule as // showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the // history): in settings-ui.js's displayKeys merge policy, stripped out of the // object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending @@ -14,7 +15,9 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; +import vm from 'node:vm'; import { JSDOM } from 'jsdom'; +import { makeGridApp } from './mocks/tile-grid-vm.js'; const HERE = fileURLToPath(new URL('.', import.meta.url)); const PUBLIC = join(HERE, '../src/web/public'); @@ -59,6 +62,100 @@ describe('showTileGridButton stays per-device: display key, stripped from the PU }); }); +// The real getDefaultSettings() (settings-ui.js) on each kind of device. A +// touch-primary tablet (iPad, an Android tablet: primary pointer coarse) is +// not a handheld, so it used to take the desktop defaults and get the Tiles +// button ON, although a tile has none of the main terminal's touch, IME and +// soft-keyboard handling. It defaults OFF there (opt-in, like Split); a +// touchscreen laptop (primary pointer fine) keeps the desktop default. +describe('the device default: ON on desktop, OFF on handhelds and touch-primary tablets', () => { + const SOURCE = readFileSync(join(PUBLIC, 'settings-ui.js'), 'utf8'); + type Device = { handheld: boolean; touch: boolean; coarse: boolean }; + function defaultsOn({ handheld, touch, coarse }: Device) { + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + CodemanApp, + VoiceInput: {}, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + console, + MobileDetection: { + isHandheldDevice: () => handheld, + isTouchDevice: () => touch, + getDeviceType: () => (handheld ? 'mobile' : 'desktop'), + }, + window: { + matchMedia: (q: string) => ({ + matches: q === '(pointer: coarse)' ? coarse : q === '(pointer: fine)' && !coarse, + }), + }, + }); + vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' }); + const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record }; + return app.getDefaultSettings(); + } + /** What every reader resolves an absent key to: the button, the App Settings chip and the chord. */ + const resolved = (d: Device) => (defaultsOn(d).showTileGridButton ?? true) === true; + + const DESKTOP = { handheld: false, touch: false, coarse: false }; + const TOUCH_LAPTOP = { handheld: false, touch: true, coarse: false }; + const TABLET = { handheld: false, touch: true, coarse: true }; + const PHONE = { handheld: true, touch: true, coarse: true }; + + it('a desktop and a touchscreen laptop (fine primary pointer): ON', () => { + expect(defaultsOn(DESKTOP).showTileGridButton).toBeUndefined(); + expect(resolved(DESKTOP)).toBe(true); + expect(defaultsOn(TOUCH_LAPTOP).showTileGridButton).toBeUndefined(); + expect(resolved(TOUCH_LAPTOP)).toBe(true); + }); + + it('a touch-primary tablet (coarse primary pointer, not a handheld): OFF, and only that key changes', () => { + expect(defaultsOn(TABLET)).toEqual({ showTileGridButton: false }); + expect(resolved(TABLET)).toBe(false); + }); + + it('a handheld: OFF, as before', () => { + expect(defaultsOn(PHONE).showTileGridButton).toBe(false); + }); + + it('a context with no window at all still answers (the desktop default)', () => { + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + CodemanApp, + VoiceInput: {}, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + console, + MobileDetection: { isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, + }); + vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' }); + const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record }; + expect(app.getDefaultSettings()).toEqual({}); + }); + + it('the Ctrl+Shift+G chord follows the same default: inert on a tablet with nothing stored, live on a touchscreen laptop', () => { + const TOGGLE = { + type: 'keydown', + key: 'G', + code: 'KeyG', + ctrlKey: true, + shiftKey: true, + metaKey: false, + altKey: false, + target: { closest: () => null }, + }; + for (const [device, applies] of [ + [TABLET, false], + [TOUCH_LAPTOP, true], + ] as const) { + const app = makeGridApp(['s-a', 's-b']); + app.loadAppSettingsFromStorage = () => ({}); + app.getDefaultSettings = () => defaultsOn(device); + expect(app.tileShortcutFor(TOGGLE)).toBe(applies ? 'toggle-tile-grid' : null); + } + }); +}); + describe('App Settings search finds Split and Tiles', () => { // The real filter (settings-ui.js) over the real markup: it matches each chip // by its own data-search and its text, so the chips carry their own keywords. diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts index 671accaa..6bee81e6 100644 --- a/test/tile-grid-select-branch.test.ts +++ b/test/tile-grid-select-branch.test.ts @@ -201,6 +201,35 @@ describe('moving focus between tiles', () => { expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); }); + // The last tile popped out closes the grid. The single view must never try + // to show that session: selectSession only raises its window, and the parked + // terminal's stale pre-grid content would stay up under its tab (and be + // saved as its snapshot on the next switch). + it('popping out the LAST tile falls through to the next session, never the popped-out one', () => { + const app = makeGridApp(['s-a']); + app.openTileGrid(['s-a']); + app.$ = () => null; + app.selectSession = vi.fn(); + app._markDetached('s-a', true); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app.selectSession.mock.calls).toEqual([['s-other', { auto: true }]]); + // Nulled before the selection, so the parked buffer is not snapshotted as s-a's. + expect(app.activeSessionId).toBeNull(); + }); + + it('popping out the last tile with every other session popped out too: the welcome screen, the parked terminal cleared', () => { + const app = makeGridApp(['s-a']); + app.detachedSessions.add('s-other'); + app.openTileGrid(['s-a']); + app.$ = () => null; + app.selectSession = vi.fn(); + app._markDetached('s-a', true); + expect(app.selectSession).not.toHaveBeenCalled(); + expect(app.activeSessionId).toBeNull(); + expect(app.terminal.clear).toHaveBeenCalled(); + expect(app.showWelcome).toHaveBeenCalled(); + }); + it('going Home leaves the grid, remembered', () => { const app = openGrid(); app.goHome(); diff --git a/test/tile-grid-split-coexistence.test.ts b/test/tile-grid-split-coexistence.test.ts index cbfb14d8..7b4edcd6 100644 --- a/test/tile-grid-split-coexistence.test.ts +++ b/test/tile-grid-split-coexistence.test.ts @@ -100,6 +100,62 @@ describe('opening the grid over an open split', () => { expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); expect(app.activeSessionId).toBe('s-a'); }); + + // The entry points that size their own set never get the split's two + // prepended on top of it (that went past the group, the count and the + // window's capacity, and auto-zoomed with a "too small" toast). + it("'Open group as tiles' over a split opens exactly the group, the split closed", () => { + const app = makeGridApp([...IDS, 's-d']); + app.tabLayout = { + groups: [ + { + id: 'g', + name: 'G', + refs: [ + { kind: 'session', id: 's-c' }, + { kind: 'session', id: 's-d' }, + ], + }, + ], + }; + const paneB = openSplit(app); + + app.openGroupAsTiles('g'); + + expect(paneB.destroy).toHaveBeenCalledTimes(1); + expect(app._splitPane).toBeNull(); + expect(app._tileGrid.ids).toEqual(['s-c', 's-d']); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('Ctrl/Cmd+click over a split opens the remembered count in total, never one more', () => { + const app = makeGridApp(IDS); + localStore.set('codeman:tile-count', '2'); + openSplit(app); + + app.addSessionToTiles('s-c'); + + expect(app._splitPane).toBeNull(); + // The split's Pane A seeds the set (tileGridOpenSet), the clicked one joins: 2, not 3. + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('Ctrl/Cmd+click over a split with a remembered grid: the remembered grid wins, not the split', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + app.openTileGrid(['s-other', 's-c']); + app.closeTileGrid({ reselect: false }); + app.activeSessionId = 's-a'; + localStore.set('codeman:tile-count', '2'); + openSplit(app); + + app.addSessionToTiles('s-b'); + + expect(app._splitPane).toBeNull(); + expect(app._tileGrid.ids).toEqual(['s-other', 's-b']); + expect(app.activeSessionId).toBe('s-b'); + }); }); describe('while the grid is open', () => {