diff --git a/src/web/public/app.js b/src/web/public/app.js index cda1a41f..6b1e59ae 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1471,7 +1471,9 @@ class CodemanApp { return false; } this._retireUrlSession(); - this.selectSession(id, { auto: true }); + // Following a link is navigation: a tiled id focuses its tile, any other + // leaves the tile grid for the single view (the grid is remembered). + this.selectSession(id, { auto: true, leaveTiles: true }); return true; } @@ -1610,6 +1612,9 @@ class CodemanApp { * Full re-renders re-apply the class from this.detachedSessions. */ _markDetached(id, on) { if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id); + // A popped-out session's window owns its PTY size now, so it leaves the + // tile grid (one place per session in this browser tab). + if (on && this._tileGrid?.has(id)) this.removeTile(id, { refocus: true, auto: true }); const container = this.$('sessionTabs'); const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`); if (tab) tab.classList.toggle('detached', on); @@ -7973,6 +7978,20 @@ class CodemanApp { if (userInitiated) this.markIdleAlertSeen(sessionId); return; } + // Tile grid open (tile-grid.js): a tiled session is FOCUSED in its tile and + // never loaded into the parked main terminal. Decision 1: only a USER- + // initiated pick of a session that is not tiled (or an explicit + // `leaveTiles`, a followed link) leaves the grid for the single view, the + // grid remembered for one-click return. An app-driven pick (`auto`) never + // collapses it. + if (this._tileGrid?.open) { + if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options); + if (options?.auto === true && !options?.leaveTiles) return; + this.closeTileGrid({ keepStored: true, reselect: false }); + // The parked terminal still holds what it showed before the grid opened; + // with no active id, the switch below snapshots none of it. + this.activeSessionId = null; + } if (this.activeSessionId === sessionId && forceReload) { this.terminalBufferCache?.delete(sessionId); this._xtermSnapshots?.delete(sessionId); @@ -8828,12 +8847,24 @@ class CodemanApp { // next session or dumped you on the home screen, depending on which path // won the race (both outcomes measured on one build, 2026-08-17). const wasActive = this.activeSessionId === sessionId; + // Tile grid open: the fallback is the NEIGHBOURING TILE, never the first + // sessionOrder entry (often not tiled, which would collapse the grid). + // Captured here for the same reason as wasActive: the SSE delete can remove + // the tile while the request is still in flight. + const grid = this._tileGrid; + const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null; this._closingSessions.add(sessionId); try { await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`); this._cleanupSessionData(sessionId); + // The last tile leaving closes the grid (no reselect): the pick below runs. + if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false }); - if (wasActive) { + if (wasActive && grid?.open) { + // `auto`: the app chose this tile because the previous one went away. + const target = grid.has(tileNeighborId) ? tileNeighborId : grid.ids[0]; + this._selectTiledSession(target, { auto: true }); + } else if (wasActive) { this.activeSessionId = null; try { localStorage.removeItem('codeman-active-session'); } catch {} // Next tab in the user's own order, skipping ids the cleanup has not @@ -8918,6 +8949,12 @@ class CodemanApp { } nextSession() { + // With the tile grid open, Ctrl+Tab and Alt+] cycle through the tiles. + if (this._tileGrid?.open) { + const id = window.CodemanTileGrid.cycleTile(this._tileGrid.ids, this.activeSessionId, 1); + if (id) this.selectSession(id); + return; + } if (this.sessionOrder.length <= 1) return; const currentIndex = this.sessionOrder.indexOf(this.activeSessionId); @@ -8926,6 +8963,11 @@ class CodemanApp { } prevSession() { + if (this._tileGrid?.open) { + const id = window.CodemanTileGrid.cycleTile(this._tileGrid.ids, this.activeSessionId, -1); + if (id) this.selectSession(id); + return; + } if (this.sessionOrder.length <= 1) return; const currentIndex = this.sessionOrder.indexOf(this.activeSessionId); @@ -8941,6 +8983,8 @@ class CodemanApp { // Going Home is choosing something else, so a `#session=` link still // waiting for its session must not take the screen later. this._retireUrlSession(); + // Home is a choice to leave the grid too; it is remembered for Tiles. + this.closeTileGrid?.({ keepStored: true, reselect: false }); // Deselect active session and show welcome screen this.activeSessionId = null; try { localStorage.removeItem('codeman-active-session'); } catch {} @@ -8991,6 +9035,8 @@ class CodemanApp { try { await this._apiDelete('/api/sessions'); + // Every tiled session is gone: nothing left to remember or reselect. + this.closeTileGrid?.({ keepStored: false, reselect: false }); this.sessions.clear(); this.terminalBuffers.clear(); this.terminalBufferCache.clear(); diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index adf15291..bc96e631 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -309,6 +309,15 @@ Object.assign(CodemanApp.prototype, { const body = document.createElement('div'); body.className = 'tile-body'; el.appendChild(body); + // Pressing a tile is a human selection: it focuses the tile and + // acknowledges its idle alert (the already-focused tile hits + // selectSession's early return, which acknowledges too). pointerdown, not + // click, so focus moves before the press reaches xterm, and never + // preventDefault: xterm's own mousedown focuses its textarea and starts + // selections. + el.addEventListener('pointerdown', () => { + if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); + }); this._tileGridSection().appendChild(el); const tile = new window.TerminalTile(sessionId, body, { mode: session.mode, @@ -479,3 +488,24 @@ Object.assign(CodemanApp.prototype, { return true; }, }); + +// 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 +// 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 +// (closeSession, in _closingSessions) owns its own follow-up: only the tile goes. +const _tileGridOriginalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted; +CodemanApp.prototype._onSessionDeleted = function (data) { + const grid = this._tileGrid; + if (grid?.has(data.id)) { + const wasFocused = grid.focusedId === data.id; + 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 }); + } + } + return _tileGridOriginalOnSessionDeleted.call(this, data); +}; diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts new file mode 100644 index 00000000..bb85244a --- /dev/null +++ b/test/mocks/tile-grid-vm.ts @@ -0,0 +1,240 @@ +/** + * @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real + * app around it: constants.js + app.js + terminal-ui.js + tile-grid.js in one + * context, a small fake DOM (just what the grid touches) and a fake + * TerminalTile that records what the grid asks of it. + * + * `makeGridApp()` returns an app instance with everything around the grid that + * a test does not exercise stubbed as `vi.fn()`; a test overrides what it needs + * (or deletes a stub to run the real method from the prototype). + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { vi } from 'vitest'; + +/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */ +export class FakeEl { + id = ''; + className = ''; + dataset: Record = {}; + style: Record = {}; + children: FakeEl[] = []; + parentElement: FakeEl | null = null; + attrs: Record = {}; + listeners: Record void>> = {}; + classList = { + add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)), + remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)), + toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)), + contains: (n: string) => this.className.split(/\s+/).includes(n), + }; + _setClass(name: string, on: boolean) { + const set = new Set(this.className.split(/\s+/).filter(Boolean)); + if (on) set.add(name); + else set.delete(name); + this.className = [...set].join(' '); + return on; + } + appendChild(child: FakeEl) { + child.remove(); + child.parentElement = this; + this.children.push(child); + return child; + } + insertBefore(child: FakeEl, ref: FakeEl | null) { + child.remove(); + child.parentElement = this; + const i = ref ? this.children.indexOf(ref) : -1; + if (i === -1) this.children.push(child); + else this.children.splice(i, 0, child); + return child; + } + get nextSibling() { + const siblings = this.parentElement?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + remove() { + if (!this.parentElement) return; + const siblings = this.parentElement.children; + siblings.splice(siblings.indexOf(this), 1); + this.parentElement = null; + } + setAttribute(k: string, v: string) { + this.attrs[k] = v; + } + getAttribute(k: string) { + return this.attrs[k] ?? null; + } + addEventListener(type: string, fn: (ev: unknown) => void) { + (this.listeners[type] ||= []).push(fn); + } + removeEventListener() {} + dispatch(type: string, ev: unknown = {}) { + for (const fn of this.listeners[type] ?? []) fn(ev); + } + getBoundingClientRect() { + return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 }; + } + querySelector() { + return null; + } +} + +/** A TerminalTile stand-in: records what the grid asks of it. */ +export class FakeTile { + static all: FakeTile[] = []; + _wsReady = false; + _stoppedCode: number | null = null; + _destroyed = false; + fontSize: number | null; + terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record }; + connect = vi.fn(async () => {}); + reconnectNow = vi.fn(); + fit = vi.fn(); + destroy = vi.fn(() => { + this._destroyed = true; + }); + onExit: ((code: number) => void) | null; + constructor( + public sessionId: string, + public mountEl: FakeEl, + public opts: Record + ) { + this.fontSize = (opts.fontSize as number) ?? null; + this.onExit = (opts.onExit as (code: number) => void) ?? null; + FakeTile.all.push(this); + } +} + +export const main = new FakeEl(); +main.className = 'main'; +const wrap = new FakeEl(); +wrap.className = 'terminal-wrap'; +main.appendChild(wrap); +export const section = new FakeEl(); +section.id = 'tileGrid'; +section.className = 'tile-grid'; +main.appendChild(section); + +export const localStore = new Map(); +/** What the code under test deferred with requestIdleCallback; a test runs them. */ +export const idleCallbacks: Array<() => void> = []; +export const windowStub: Record = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + CodemanBase: { base: '' }, + innerWidth: 2400, + innerHeight: 1200, +}; + +const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8'); +const context = vm.createContext({ + console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + performance: { now: () => 100_000 }, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), + requestAnimationFrame: vi.fn(), + requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: vi.fn(), + navigator: { onLine: true }, + location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, + history: { replaceState: vi.fn(), state: null }, + document: { + addEventListener: vi.fn(), + documentElement: { dataset: {} }, + createElement: () => new FakeEl(), + getElementById: (id: string) => (id === 'tileGrid' ? section : null), + querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null), + querySelectorAll: () => [], + }, + localStorage: { + getItem: (k: string) => localStore.get(k) ?? null, + setItem: (k: string, v: string) => localStore.set(k, String(v)), + removeItem: (k: string) => localStore.delete(k), + }, + window: windowStub, + VoiceInput: { cleanup: vi.fn() }, + MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, +}); +vm.runInContext( + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` + + 'globalThis.__CodemanApp = CodemanApp;', + context +); +windowStub.TerminalTile = FakeTile; +windowStub.TileLoadQueue = class { + schedule(_t: unknown, _k: string, run: () => Promise) { + return run(); + } + drop() {} +}; + +export const CodemanApp = (context as unknown as { __CodemanApp: { prototype: Record } }).__CodemanApp; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GridApp = Record; + +/** Stubbed around the grid: panels, tabs, the main terminal's own select path. */ +const STUBS = [ + '_cleanupPreviousSession', + 'hideWelcome', + 'showWelcome', + 'markIdleAlertSeen', + 'renderSessionTabs', + 'renderRalphStatePanel', + 'renderProjectInsightsPanel', + '_updateActiveTabImmediate', + '_refreshSessionPanels', + '_updateSseSubscription', + '_updateConnectionIndicator', + '_activateFileBrowserSession', + '_hideWebviewLayer', + 'closeSessionSidebarOnHandheld', + 'updateAttachmentHistoryBadge', + 'refreshHostWakeBanner', + '_connectWs', + 'sendResize', + 'showToast', + '_cleanupSessionData', + 'stopSystemStatsPolling', +]; + +/** + * An app with sessions `ids` plus one more (`s-other`, FIRST in sessionOrder and + * never tiled), `s-a` active, nothing open. + */ +export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp { + const app = Object.create(CodemanApp.prototype) as GridApp; + app.sessions = new Map( + [...ids, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }]) + ); + app.sessionOrder = ['s-other', ...ids]; + app.detachedSessions = new Set(); + app.isSoloWindow = false; + app.activeSessionId = ids[0]; + app._selectGeneration = 0; + app._xtermSnapshots = new Map(); + app.terminalBufferCache = new Map(); + app._pendingDeliveries = new Map(); + app._closingSessions = new Set(); + app.pendingHooks = new Map(); + app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } }; + for (const name of STUBS) app[name] = vi.fn(); + app.loadAppSettingsFromStorage = () => ({}); + return app; +} + +/** Resets the shared fake DOM and tile registry between tests. */ +export function resetGridHarness() { + FakeTile.all = []; + idleCallbacks.length = 0; + localStore.clear(); + windowStub.innerWidth = 2400; + section.children = []; + main.className = 'main'; +} diff --git a/test/session-select-ack-gate.test.ts b/test/session-select-ack-gate.test.ts index 904b5de9..cc3a73af 100644 --- a/test/session-select-ack-gate.test.ts +++ b/test/session-select-ack-gate.test.ts @@ -110,6 +110,60 @@ describe('selectSession acknowledgement gate', () => { }); }); + describe('with the tile grid open (tile-grid.js)', () => { + // A plain stand-in for the grid: the branch reads only `open` and `has(id)`. + function withGrid(app: ReturnType, tiled: string[]) { + const a = app as Record; + a._tileGrid = { open: true, has: (id: string) => tiled.includes(id) }; + a._selectTiledSession = vi.fn(); + a.closeTileGrid = vi.fn(); + a._connectWs = vi.fn(); + return a; + } + + it('a tiled session goes to its tile, never the main terminal, and the tile decides the ack', async () => { + const app = withGrid(makeApp('other'), [SID]); + await (app.selectSession as (id: string) => Promise)(SID); + expect(app._selectTiledSession).toHaveBeenCalledWith(SID, {}); + expect(app._cleanupPreviousSession).not.toHaveBeenCalled(); + expect(app._connectWs).not.toHaveBeenCalled(); + // The branch returns before the main path's own acknowledgement line. + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('passes `auto` through, so an app-driven focus stays unacknowledged', async () => { + const app = withGrid(makeApp('other'), [SID]); + await (app.selectSession as (id: string, o: object) => Promise)(SID, { auto: true }); + expect(app._selectTiledSession).toHaveBeenCalledWith(SID, { auto: true }); + }); + + it('an `auto` selection of a session that is not tiled leaves the grid open and acknowledges nothing', async () => { + const app = withGrid(makeApp('other'), []); + await (app.selectSession as (id: string, o: object) => Promise)(SID, { auto: true }); + expect(app.closeTileGrid).not.toHaveBeenCalled(); + expect(app._cleanupPreviousSession).not.toHaveBeenCalled(); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('a user-initiated one leaves the grid (remembered) and switches normally, acknowledging', async () => { + const app = withGrid(makeApp('other'), []); + await (app.selectSession as (id: string) => Promise)(SID).catch(() => {}); + expect(app.closeTileGrid).toHaveBeenCalledWith({ keepStored: true, reselect: false }); + expect(app._cleanupPreviousSession).toHaveBeenCalled(); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID); + }); + + it('`leaveTiles` leaves it too, without acknowledging an `auto` pick', async () => { + const app = withGrid(makeApp('other'), []); + await (app.selectSession as (id: string, o: object) => Promise)(SID, { + auto: true, + leaveTiles: true, + }).catch(() => {}); + expect(app.closeTileGrid).toHaveBeenCalledTimes(1); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + }); + describe('the call sites the app drives itself', () => { // Source guard: these call sites are the reason the flag exists. If a refactor // moves or reformats them, fail loudly rather than silently going back to @@ -119,7 +173,7 @@ describe('selectSession acknowledgement gate', () => { ['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'], ['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'], ['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'], - ['a #session= link from another page', 'this.selectSession(id, { auto: true });'], + ['a #session= link from another page', 'this.selectSession(id, { auto: true, leaveTiles: true });'], ])('%s passes auto: true', (_label, call) => { expect(APP_SOURCE).toContain(call); }); diff --git a/test/tile-grid-close-fallback.test.ts b/test/tile-grid-close-fallback.test.ts new file mode 100644 index 00000000..46eb8479 --- /dev/null +++ b/test/tile-grid-close-fallback.test.ts @@ -0,0 +1,150 @@ +/** + * @fileoverview Closing or deleting a tiled session keeps the grid open and + * moves focus to the NEIGHBOURING tile. + * + * `closeSession()` normally falls back to the first remaining `sessionOrder` + * entry with `auto: true`, and that entry is often NOT tiled: an app-driven + * pick that, with the grid open, would be refused (auto never collapses the + * grid) and leave nothing focused. So the fallback is grid-aware, and it lives + * IN closeSession: the delete broadcast routinely lands while the request is in + * flight, and the delete handlers skip ids in `_closingSessions`. The neighbour + * is captured BEFORE the await, like `wasActive`, because that broadcast may + * already have removed the tile. + * + * - closing the focused tile: next tile in grid order, else the previous one; + * `s-other` is FIRST in sessionOrder and never tiled, so the old pick would + * have collapsed the grid; + * - the same with the broadcast arriving mid-request and after it; + * - closing the last tile closes the grid and falls back to the normal pick; + * - a tiled session deleted ELSEWHERE: its tile goes, a neighbour takes focus + * with `auto` (no idle alert spent); the last one leaves the welcome screen. + * + * 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'; + +const IDS = ['s-a', 's-b', 's-c']; + +/** A grid on `ids` focused on `focus`, with the DELETE request held open until `finish()`. */ +function setup(ids = IDS, focus = ids[0]) { + const app = makeGridApp(ids); + app.openTileGrid(ids, { focusedId: focus }); + app.selectSession = vi.fn(); + app.markIdleAlertSeen.mockClear(); + let finish: () => void = () => {}; + app._apiDelete = vi.fn(() => new Promise((r) => (finish = r))); + // The real cleanup touches a lot of panels; what the fallback reads is the session list. + app._cleanupSessionData = vi.fn((id: string) => { + app.sessions.delete(id); + app.sessionOrder = app.sessionOrder.filter((s: string) => s !== id); + }); + return { app: app as GridApp, finish: () => finish() }; +} + +async function settle() { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + +beforeEach(() => { + resetGridHarness(); +}); + +describe('closeSession on the focused tile', () => { + it('keeps the grid open and focuses the next tile, never the untiled first sessionOrder entry', async () => { + const { app, finish } = setup(IDS, 's-a'); + const closing = app.closeSession('s-a'); + finish(); + await closing; + + expect(app._tilesOwnTerminal()).toBe(true); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app.selectSession).not.toHaveBeenCalled(); + // The app chose the neighbour: no idle alert spent. + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('the last tile in grid order hands focus back to the previous one', async () => { + const { app, finish } = setup(IDS, 's-c'); + const closing = app.closeSession('s-c'); + finish(); + await closing; + expect(app.activeSessionId).toBe('s-b'); + }); + + it('the delete broadcast arriving DURING the request changes nothing about the outcome', async () => { + const { app, finish } = setup(IDS, 's-b'); + const closing = app.closeSession('s-b'); + await settle(); + app._onSessionDeleted({ id: 's-b' }); + // Only the tile went; closeSession owns the follow-up (as the split's + // wrapper does for ids in _closingSessions), so focus has not moved yet. + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app.showWelcome).not.toHaveBeenCalled(); + finish(); + await closing; + + expect(app._tilesOwnTerminal()).toBe(true); + expect(app.activeSessionId).toBe('s-c'); + expect(app.showWelcome).not.toHaveBeenCalled(); + }); + + it('the delete broadcast arriving AFTER the request is a no-op for the grid', async () => { + const { app, finish } = setup(IDS, 's-a'); + const closing = app.closeSession('s-a'); + finish(); + await closing; + app._onSessionDeleted({ id: 's-a' }); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + }); + + it('closing the LAST tile closes the grid and falls back to the normal pick', async () => { + const { app, finish } = setup(['s-a'], 's-a'); + const closing = app.closeSession('s-a'); + finish(); + await closing; + + expect(app._tilesOwnTerminal()).toBe(false); + expect(FakeTile.all[0].destroy).toHaveBeenCalledTimes(1); + expect(app.selectSession).toHaveBeenCalledWith('s-other', { auto: true }); + }); + + it('closing a tile that is NOT focused removes it and leaves focus alone', async () => { + const { app, finish } = setup(IDS, 's-a'); + const closing = app.closeSession('s-c'); + finish(); + await closing; + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + expect(app.activeSessionId).toBe('s-a'); + }); +}); + +describe('a tiled session deleted elsewhere', () => { + it('removes its tile and moves focus to the neighbour with `auto`', () => { + const { app } = setup(IDS, 's-b'); + app._onSessionDeleted({ id: 's-b' }); + + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + expect(app.showWelcome).not.toHaveBeenCalled(); + }); + + it('a deleted tile that was not focused just goes', () => { + const { app } = setup(IDS, 's-a'); + app._onSessionDeleted({ id: 's-c' }); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + expect(app.activeSessionId).toBe('s-a'); + }); + + it('the last tile deleted closes the grid and lands on the welcome screen, as in the single view', () => { + const { app } = setup(['s-a'], 's-a'); + app._onSessionDeleted({ id: 's-a' }); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app.activeSessionId).toBeNull(); + expect(app.showWelcome).toHaveBeenCalled(); + }); +}); diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts new file mode 100644 index 00000000..611785f7 --- /dev/null +++ b/test/tile-grid-select-branch.test.ts @@ -0,0 +1,176 @@ +/** + * @fileoverview The tile branch of `selectSession`, and focus moving between tiles. + * + * With the tile grid open the main terminal is parked, so a selection must + * never reach the half of selectSession that cleans it up, replays into it or + * reconnects its socket. A TILED session is focused in its tile + * (`_selectTiledSession`: an `activeSessionId` change plus `xterm.focus()`, the + * same panel refresh as a normal switch). Decision 1: a USER-initiated pick of + * a session that is NOT tiled leaves the grid for the single view (the grid is + * remembered), and so does an explicit `leaveTiles` (a followed link); an + * app-driven pick (`auto: true`) never collapses the grid. + * + * Focus and alert rules: only a human selection acknowledges an idle alert. + * Pressing a tile, Ctrl+Tab / Alt+[ ] (which cycle through the tiles) and a tab + * click are human; the delete fallback and a popped-out tile are the app's. + * + * Real code: constants.js + app.js + terminal-ui.js + tile-grid.js (the shared + * vm harness in test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeTile, idleCallbacks, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; + +/** A grid open on IDS (focus s-a), with selectSession REAL and the main terminal's select path tripwired. */ +function openGrid(): GridApp { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + delete app.selectSession; // the prototype's, for real + app._cleanupPreviousSession.mockClear(); + app.markIdleAlertSeen.mockClear(); + // Anything past the tile branch: the main terminal's own select path. + app._shouldFocusTerminalForTabSwitch = vi.fn(() => false); + app._setTerminalLoadState = vi.fn(() => { + throw new Error('the main terminal select path ran'); + }); + return app; +} + +beforeEach(() => { + resetGridHarness(); +}); + +describe('selectSession with the grid open', () => { + it('focuses a tiled session in its tile and never touches the main terminal', async () => { + const app = openGrid(); + await app.selectSession('s-b'); + + expect(app.activeSessionId).toBe('s-b'); + expect(app._tileGrid.focusedId).toBe('s-b'); + expect(app._cleanupPreviousSession).not.toHaveBeenCalled(); + expect(app._connectWs).not.toHaveBeenCalled(); + expect(app.sendResize).not.toHaveBeenCalled(); + expect(FakeTile.all.find((t) => t.sessionId === 's-b')?.terminal.focus).toHaveBeenCalled(); + expect(app._focusedPane().sessionId).toBe('s-b'); + }); + + it('a user-initiated focus acknowledges the idle alert; an `auto` one does not', async () => { + const app = openGrid(); + await app.selectSession('s-b'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b'); + + app.markIdleAlertSeen.mockClear(); + await app.selectSession('s-c', { auto: true }); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('runs the same deferred panel refresh as a normal switch, tagged with its own generation', async () => { + const app = openGrid(); + idleCallbacks.length = 0; + await app.selectSession('s-b'); + const genB = app._selectGeneration; + expect(app._refreshSessionPanels).not.toHaveBeenCalled(); // deferred + for (const cb of idleCallbacks.splice(0)) cb(); + expect(app._refreshSessionPanels).toHaveBeenCalledWith('s-b', genB); + }); + + it('an `auto` selection of a session that is NOT tiled leaves the grid open and changes nothing', async () => { + const app = openGrid(); + await app.selectSession('s-other', { auto: true }); + + expect(app._tilesOwnTerminal()).toBe(true); + expect(app.activeSessionId).toBe('s-a'); + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true); + }); + + it('a user-initiated selection of a session that is NOT tiled leaves the grid, remembered', async () => { + const app = openGrid(); + let activeAtCleanup: unknown = 'unset'; + app._cleanupPreviousSession = vi.fn(() => { + activeAtCleanup = app.activeSessionId; + }); + app._setTerminalLoadState = vi.fn(); + app._clearTerminalLoadState = vi.fn(); + app._renderHistoryTruncationBanner = vi.fn(); + app._isStaleSelect = vi.fn(() => true); // stop right after the switch itself + await app.selectSession('s-other').catch(() => {}); + + expect(app._tilesOwnTerminal()).toBe(false); + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true); + expect(app._tileGridRemembered).toEqual({ ids: IDS, focusedId: 's-a' }); + // The parked terminal's stale content must not be saved as s-a's snapshot. + expect(activeAtCleanup).toBeNull(); + expect(app.activeSessionId).toBe('s-other'); + }); + + it('`leaveTiles` makes even an `auto` selection leave the grid (a followed link is navigation)', async () => { + const app = openGrid(); + app._setTerminalLoadState = vi.fn(); + app._clearTerminalLoadState = vi.fn(); + app._renderHistoryTruncationBanner = vi.fn(); + app._isStaleSelect = vi.fn(() => true); + await app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {}); + expect(app._tilesOwnTerminal()).toBe(false); + }); + + it('a followed `#session=` link passes leaveTiles, and a tiled one just focuses its tile', async () => { + const app = openGrid(); + app._urlSessionId = 's-c'; + app._selectUrlSession(); + await Promise.resolve(); + expect(app._tilesOwnTerminal()).toBe(true); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); +}); + +describe('moving focus between tiles', () => { + it('pressing a tile is a human selection of its session', async () => { + const app = openGrid(); + app.selectSession = vi.fn(); + const tileB = section.children.find((el) => el.dataset.sessionId === 's-b'); + tileB?.dispatch('pointerdown'); + expect(app.selectSession).toHaveBeenCalledWith('s-b'); + }); + + it('Ctrl+Tab / Alt+] and Alt+[ cycle through the tiles (wrapping), user-initiated, and only the tiles', () => { + const app = openGrid(); + app.selectSession = vi.fn(); + // From the last tile, the next one wraps to the first tile; the tab order + // would have wrapped to s-other (first in sessionOrder, not tiled). + app.activeSessionId = 's-c'; + app.nextSession(); + expect(app.selectSession.mock.calls).toEqual([['s-a']]); + app.selectSession.mockClear(); + app.activeSessionId = 's-a'; + app.prevSession(); + expect(app.selectSession.mock.calls).toEqual([['s-c']]); + }); + + it('without the grid they walk the tab order as before', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + app.nextSession(); + expect(app.selectSession).toHaveBeenLastCalledWith('s-b'); + }); + + it('a tiled session popped out to its own window leaves the grid; a neighbour takes focus', () => { + const app = openGrid(); + app.$ = () => null; + app._markDetached('s-a', true); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('going Home leaves the grid, remembered', () => { + const app = openGrid(); + app.goHome(); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app._tileGridRemembered?.ids).toEqual(IDS); + expect(app.activeSessionId).toBeNull(); + expect(app.showWelcome).toHaveBeenCalled(); + }); +}); diff --git a/test/url-session-fragment.test.ts b/test/url-session-fragment.test.ts index 40e8ec6a..12e348ff 100644 --- a/test/url-session-fragment.test.ts +++ b/test/url-session-fragment.test.ts @@ -136,7 +136,7 @@ describe('dashboard handling of a #session= link', () => { const app = make(['a']); app._urlSessionId = 'a'; expect(app._selectUrlSession()).toBe(true); - expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true }); + expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true, leaveTiles: true }); expect(app._urlSessionId).toBeNull(); }); @@ -149,7 +149,7 @@ describe('dashboard handling of a #session= link', () => { app._onSessionCreated({ id: 'other', name: 'other' }); expect(app.selectSession).not.toHaveBeenCalled(); app._onSessionCreated({ id: 'new', name: 'new' }); - expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true }); + expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true, leaveTiles: true }); expect(app._urlSessionId).toBeNull(); }); @@ -234,7 +234,7 @@ describe('a #session= link that is still waiting', () => { app._selectUrlSession(); vi.advanceTimersByTime(waitMs - 1); app._onSessionCreated({ id: 'new', name: 'new' }); - expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true }); + expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true, leaveTiles: true }); expect(app._urlSessionWaitTimer).toBeNull(); vi.advanceTimersByTime(waitMs); expect(app.showToast).not.toHaveBeenCalled();