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
+98 -1
View File
@@ -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<string, unknown> };
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<string, unknown> };
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.