fix(tiles): file drops, focus handoffs, pop-out fallback, split merge, tablet default

- A file dragged onto the tile grid navigated the browser away: the single
  view's drop handler sits on #terminalContainer, hidden while tiles are
  open. The #tileGrid section now cancels every file dragover and drop
  (bubble phase, so tab and tile drags stay with _acceptTabDrops), and a
  drop on a tile uploads its images to THAT tile's session through
  _uploadAndInsertImages, with the same "Only image files are supported"
  toast as image-input.js (now in the zh-CN table).
- App-driven refocus no longer moves DOM focus into another session's
  xterm: a remote delete of the focused tile, _reconcileTileGrid and a
  socket closed with 4003/4004/4010 (_onTileExit) pass focus: false.
  removeTile gains a focus option; user-initiated removes keep focusing.
- Popping out the last tile left the parked terminal's stale content under
  the popped-out tab (and snapshotted it on the next switch).
  _selectAfterTileGrid treats a detached session as unusable for both the
  focused id and the fallback.
- "Open group as tiles" and Ctrl/Cmd+click with the grid closed pass
  mergeSplit: false, so an open split no longer adds its two sessions on
  top of a set already sized to the group, the count and the window.
- Touch-primary devices (primary pointer coarse: iPad, Android tablets)
  default the Tiles button OFF in getDefaultSettings(); touchscreen
  laptops (fine primary pointer) keep the desktop default ON. The button,
  the App Settings chip and the Ctrl+Shift+G gate all resolve an absent key
  through these defaults, so they agree. CLAUDE.md and the invariants doc
  say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 08:44:52 +02:00
parent f855b5d274
commit 18c8b5c280
12 changed files with 481 additions and 25 deletions
+55 -1
View File
@@ -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);
});
});