diff --git a/src/web/public/app.js b/src/web/public/app.js index 7f496ca0..5e9ccfc7 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1382,7 +1382,6 @@ class CodemanApp { this.closeSessionManager(); this.closeCommandPalette?.(); this.closeTilePicker?.(); - this.closeTileAddMenu?.(); this.closeShortcutOverlay?.(); // Overlay layouts only: below 1024px the sidebar is a modal off-canvas // drawer over the terminal, so Escape must close it. The docked desktop diff --git a/src/web/public/constants.js b/src/web/public/constants.js index c60218bc..58802118 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1780,21 +1780,20 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) { } /** - * The sessions the Tiles picker (and a tile's + menu) offers, in tab order: - * live ones only, never a session popped out to its own window (that window - * owns its PTY size). A session with no PTY attached IS offered: its tile shows - * the Attach overlay. + * The sessions the Tiles picker offers (and the Tiles button opens, case c of + * tileGridOpenSet), in tab order: live ones only, never a session popped out to + * its own window (that window owns its PTY size). A session with no PTY + * attached IS offered: its tile shows the Attach overlay. * * @param {Map} sessions * @param {string[]} sessionOrder * @param {{has(id: string): boolean}} [detachedIds] - * @param {{has(id: string): boolean}} [exclude] - ids not to offer (already tiled) * @returns {Array<{id: string, label: string}>} */ -function buildTilePickerSessions(sessions, sessionOrder, detachedIds, exclude) { +function buildTilePickerSessions(sessions, sessionOrder, detachedIds) { const result = []; for (const id of sessionOrder) { - if (detachedIds?.has?.(id) || exclude?.has?.(id)) continue; + if (detachedIds?.has?.(id)) continue; const session = sessions.get(id); if (!session) continue; result.push({ id, label: session.name || 'Session' }); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index d8d9f535..8a31d88a 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -129,10 +129,6 @@ 'Open tiles': '打开平铺', 'No sessions to show as tiles': '没有可平铺显示的会话', 'This group has no session to show as tiles': '此分组没有可平铺显示的会话', - 'Add a session to the grid': '向平铺网格添加会话', - 'Every open session is already tiled': '所有打开的会话都已平铺', - 'New session in this case': '在此案例中新建会话', - 'This session is not in a case': '此会话不属于任何案例', 'Zoom this tile': '放大此窗格', 'Restore the grid': '恢复平铺网格', 'Remove tile (the session keeps running)': '移除窗格(会话继续运行)', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4b365818..ce2d9b70 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19753,14 +19753,12 @@ body.tile-grid-resizing--row * { } .tile-picker-hint, -.tile-picker-empty, -.tile-add-empty { +.tile-picker-empty { color: var(--text-muted); font-size: 12px; } -.tile-picker-empty, -.tile-add-empty { +.tile-picker-empty { padding: 6px 8px; } diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 3d4e1289..74c457ac 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -296,7 +296,6 @@ Object.assign(CodemanApp.prototype, { this._updateConnectionIndicator?.(); this._updateSplitButtonForTiles(); this._updateTileGridButtonState(); - this.closeTileAddMenu(); // The tabs drop their .in-tiles marker. this.renderSessionTabs?.(); if (reselect) this._selectAfterTileGrid(focusedId); @@ -495,108 +494,6 @@ Object.assign(CodemanApp.prototype, { picker.menu.remove(); }, - /** - * A tile's +: the open sessions not yet tiled, in tab order; picking one - * adds it to the grid and focuses it (a human selection). Disabled once the - * grid holds what the window can fit. - */ - openTileAddMenu(event, fromSessionId = null) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - const grid = this._tileGrid; - if (!grid?.open) return; - const trigger = event?.currentTarget || null; - if (this._tileAddMenu && this._tileAddMenu.trigger === trigger) { - this.closeTileAddMenu(); - return; - } - this.closeTileAddMenu(); - const T = window.CodemanTileGrid; - const limit = this._tileGridLimit(); - const full = grid.ids.length >= limit.capacity; - const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions, grid.tiles); - const menu = document.createElement('div'); - menu.className = 'tab-rail-action-menu tile-add-menu'; - menu.setAttribute('role', 'menu'); - menu.setAttribute('aria-label', 'Add a session to the grid'); - if (candidates.length === 0) { - const empty = document.createElement('div'); - empty.className = 'tile-add-empty'; - empty.textContent = 'Every open session is already tiled'; - menu.appendChild(empty); - } - for (const c of candidates) { - const item = document.createElement('button'); - item.type = 'button'; - item.setAttribute('role', 'menuitem'); - // Only the name is user text: skipping the whole button would keep its - // title (why it is disabled) out of the translator too. - const name = document.createElement('span'); - name.setAttribute('data-i18n-skip', ''); - name.textContent = c.label; - item.appendChild(name); - item.disabled = full; - if (full) item.title = limit.full; - item.addEventListener('click', () => { - this.closeTileAddMenu(); - if (this.addTile(c.id)) this.selectSession(c.id); - }); - menu.appendChild(item); - } - // A new session in the case this tile's session belongs to: the normal Run - // for that case, which then joins the grid like any Run from this tab. - const fromSession = fromSessionId ? this.sessions.get(fromSessionId) : null; - const theCase = fromSession ? this._mobileOverviewCaseFor?.(fromSession.workingDir, this.cases || []) : null; - const create = document.createElement('button'); - create.type = 'button'; - create.className = 'tile-add-new'; - create.setAttribute('role', 'menuitem'); - create.textContent = 'New session in this case'; - create.disabled = full || !theCase; - if (!theCase) create.title = 'This session is not in a case'; - else if (full) create.title = limit.full; - create.addEventListener('click', () => { - this.closeTileAddMenu(); - if (theCase) void this.runInCaseForTiles(theCase.name); - }); - menu.appendChild(create); - document.body.appendChild(menu); - if (trigger?.getBoundingClientRect) { - const rect = trigger.getBoundingClientRect(); - menu.style.position = 'fixed'; - menu.style.top = `${rect.bottom + 4}px`; - menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`; - } - const onOutside = (e) => { - if (menu.contains?.(e.target) || (trigger && trigger.contains?.(e.target))) return; - this.closeTileAddMenu(); - }; - const onKey = (e) => { - if (e.key === 'Escape') this.closeTileAddMenu(); - }; - this._tileAddMenu = { menu, trigger, onOutside, onKey }; - document.addEventListener('pointerdown', onOutside, true); - document.addEventListener('keydown', onKey); - menu.querySelector?.('button:not([disabled])')?.focus?.(); - }, - - /** - * Runs the normal Run (current run mode) in `caseName`, then puts the - * toolbar's case back as it was. The session it creates joins the grid - * through _joinTileGridFromRun. - */ - async runInCaseForTiles(caseName) { - const select = document.getElementById('quickStartCase'); - const previous = select?.value; - const swap = !!select && !!caseName && previous !== caseName; - if (swap) this.selectQuickStartCase?.(caseName, { save: false }); - try { - await this.run?.(); - } finally { - if (swap && previous) this.selectQuickStartCase?.(previous, { save: false }); - } - }, - /** * A session THIS tab's Run just created (session-ui.js * _ensureCreatedSessionVisible, reached only from the Run paths): with the @@ -620,16 +517,6 @@ Object.assign(CodemanApp.prototype, { return this.addTile(sessionId); }, - /** Idempotent, like closeTilePicker. */ - closeTileAddMenu() { - const m = this._tileAddMenu; - if (!m) return; - this._tileAddMenu = null; - document.removeEventListener('pointerdown', m.onOutside, true); - document.removeEventListener('keydown', m.onKey); - m.menu.remove(); - }, - // The Split button cannot act while the grid is open (openSplitPicker and // openSplitPane refuse), so it says so: aria-disabled plus a title, the same // refusal the split already gives for web tabs and the welcome screen. @@ -1207,7 +1094,8 @@ Object.assign(CodemanApp.prototype, { actions.append( button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)), zoomBtn, - button('tile-add', 'Add a session to the grid', '+', (e) => this.openTileAddMenu(e, sessionId)), + // (No + here, owner decision 9: tiles are added from the Tiles button's + // picker, Ctrl/Cmd+click, a dragged tab, a tab group or Run.) // Removes the tile ONLY: the session keeps running. Killing it stays // behind the menu's Close session and its confirm. button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => diff --git a/test/tile-grid-auto-join.test.ts b/test/tile-grid-auto-join.test.ts index 066613bd..9de44700 100644 --- a/test/tile-grid-auto-join.test.ts +++ b/test/tile-grid-auto-join.test.ts @@ -12,25 +12,13 @@ * A tile that joined before its pane existed resends its size once the pid * appears (the server spawned the pane at its own default size). * - * A tile's + also offers "New session in this case": the normal Run for the - * case the tile's session belongs to, the toolbar's case put back afterwards. - * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts); the Run * hook in session-ui.js is pinned at the source. Port: N/A. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - FakeEl, - FakeTile, - body, - bySelector, - makeGridApp, - resetGridHarness, - section, - type GridApp, -} from './mocks/tile-grid-vm.js'; +import { FakeEl, FakeTile, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b']; const addSession = (app: GridApp, id: string, workingDir = '/w') => @@ -137,42 +125,3 @@ describe('a pane that starts after its tile connected (#464)', () => { for (const id of IDS) expect(tileOf(id).paneStarted).not.toHaveBeenCalled(); }); }); - -describe('+ / New session in this case', () => { - function openMenu(app: GridApp, fromId: string) { - app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, fromId); - const menu = body.children.find((c) => c.className.includes('tile-add-menu'))!; - return menu.children.find((c) => c.className === 'tile-add-new')!; - } - - it('runs the normal Run in the tile session case, then puts the toolbar case back', async () => { - const app = makeGridApp(IDS); - app.cases = [{ name: 'proj', path: '/w' }]; - app._mobileOverviewCaseFor = (dir: string, cases: Array<{ name: string; path: string }>) => - cases.find((c) => dir.startsWith(c.path)) ?? null; - const select = new FakeEl(); - select.value = 'other-case'; - bySelector.set('#quickStartCase', select); - const calls: string[] = []; - app.selectQuickStartCase = vi.fn((name: string) => { - calls.push(`case:${name}`); - select.value = name; - }); - app.run = vi.fn(async () => calls.push(`run in ${select.value}`)); - app.openTileGrid(IDS); - const item = openMenu(app, 's-a'); - expect(item.disabled).toBe(false); - item.dispatch('click'); - await vi.waitFor(() => expect(calls).toHaveLength(3)); - expect(calls).toEqual(['case:proj', 'run in proj', 'case:other-case']); - expect(app.selectQuickStartCase).toHaveBeenCalledWith('proj', { save: false }); - }); - - it('is disabled for a session that is not in a case', () => { - const app = makeGridApp(IDS); - app.cases = []; - app._mobileOverviewCaseFor = () => null; - app.openTileGrid(IDS); - expect(openMenu(app, 's-a').disabled).toBe(true); - }); -}); diff --git a/test/tile-grid-cap.test.ts b/test/tile-grid-cap.test.ts index d0e8f85d..7dfe0dfa 100644 --- a/test/tile-grid-cap.test.ts +++ b/test/tile-grid-cap.test.ts @@ -4,8 +4,8 @@ * the headless frame bar. The layout table still covers 7 to 9 (unreachable). * * Every way into the grid stops at the cap even where the window would fit - * nine (the harness window is 2400x1200): opening, adding, a tile's +, a - * session Run makes, Ctrl/Cmd+click, the picker and "Open group as tiles". + * nine (the harness window is 2400x1200): opening, adding, a session Run + * makes, Ctrl/Cmd+click, the picker and "Open group as tiles". * The texts say which limit binds: "at most 6" for the cap, "what this window * fits" for a smaller window. * @@ -63,19 +63,6 @@ describe('the cap', () => { }); describe('every way in stops at the cap', () => { - it("a tile's + offers the rest, disabled, and says why", () => { - const app = fullGrid(); - app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1'); - const menu = body.children.find((c) => c.className.includes('tile-add-menu'))!; - const items = menu.children.filter((c) => ['s-7', 's-8'].includes(c.children[0]?.textContent ?? '')); - expect(items).toHaveLength(2); - for (const item of items) { - expect(item.disabled).toBe(true); - expect(item.title).toBe(AT_MOST); - } - expect(menu.children.find((c) => c.className === 'tile-add-new')!.disabled).toBe(true); - }); - it('a session Run makes opens on its own, with a toast', () => { const app = fullGrid(); expect(app._joinTileGridFromRun('s-7')).toBe(false); diff --git a/test/tile-grid-chrome.test.ts b/test/tile-grid-chrome.test.ts index 650d98fc..96f7e437 100644 --- a/test/tile-grid-chrome.test.ts +++ b/test/tile-grid-chrome.test.ts @@ -97,6 +97,22 @@ describe('the header', () => { }); describe('header buttons', () => { + it('are ⋯ ⤢ × and nothing else: no + (owner decision 9)', () => { + const app = openGrid(); + const tile = section.children.find((el) => el.dataset.sessionId === 's-a') as FakeEl; + const actions = tile.children[0].children.find((c) => c.className === 'tile-actions') as FakeEl; + expect(actions.children.map((b) => b.className)).toEqual([ + 'tile-btn tile-menu', + 'tile-btn tile-zoom', + 'tile-btn tile-remove', + ]); + expect(actions.children.map((b) => b.textContent)).toEqual(['\u22EF', '\u2922', '\u00D7']); + // The + menu and its "New session in this case" went with it. + for (const gone of ['openTileAddMenu', 'closeTileAddMenu', 'runInCaseForTiles']) { + expect(gone in app, gone).toBe(false); + } + }); + it('× removes the tile only: the session keeps running, a neighbour takes focus unacknowledged', () => { const app = openGrid(); app._apiDelete = vi.fn(); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 08258393..1dd5976d 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -5,7 +5,7 @@ * * - Runtime strings are HARVESTED from the real tile code (the shared vm * harness, test/mocks/tile-grid-vm.ts) driven through every state that - * writes text: the picker (cap and window wording), a tile's +, the Attach + * writes text: the picker (cap and window wording), the Attach * overlay (not attached, attaching, exited, ended), zoom, the header * tooltip, the dividers, the empty slot, every toast, the crash-restart * confirm, the Tiles and Split button titles. Each must translate to text @@ -14,7 +14,7 @@ * - Static strings: the shortcut registry's tile entries (overlay and App * Settings list), and index.html run through the real translator in JSDOM * (the Tiles button, the App Settings chips, the Help modal's Tiles rows). - * - User text stays as typed: session names (tile header, picker, + menu) + * - User text stays as typed: session names (tile header, picker) * and group names carry data-i18n-skip, and a session name inside the * confirm passes through the pattern untranslated. * @@ -130,8 +130,6 @@ async function exercise() { let pill = 'idle'; app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } }); app._mobileOverviewStampText = () => '3m'; - app.cases = [{ name: 'proj', path: '/w' }]; - app._mobileOverviewCaseFor = (_dir: string, cases: Array<{ name: string }>) => cases[0] ?? null; app.sessions.get('s-2').pid = null; app.sessions.get('s-3').paneExit = { status: 3 }; app.sessions.get('s-4').paneExit = { signal: 9 }; @@ -183,15 +181,6 @@ async function exercise() { harvestAll(app, 'zoomed'); app.zoomTile('s-1'); - // A tile's + on a full grid, from a session in a case and from one outside. - app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1'); - harvestAll(app, '+ menu, full'); - app.closeTileAddMenu(); - app._mobileOverviewCaseFor = () => null; - app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1'); - harvestAll(app, '+ menu, no case'); - app.closeTileAddMenu(); - // The toasts of a full grid, by the cap and by the window. app._joinTileGridFromRun('s-7'); app.addSessionToTiles('s-7'); @@ -264,7 +253,6 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'Zoom this tile', 'Restore the grid', 'Session actions', - 'Add a session to the grid', 'Remove tile (the session keeps running)', 'idle 3m', 'needs you 3m', @@ -276,8 +264,6 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'The agent exited (signal 9)', 'The session ended', 'It cannot be restarted in place: close it from ⋯ (Close session).', - 'New session in this case', - 'This session is not in a case', 'The grid holds at most 6 tiles: the new session opens on its own', 'The grid already holds what this window fits (4): the new session opens on its own', 'The grid already holds what this window fits (4)', @@ -375,12 +361,12 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => { }); describe('user text in the tile code', () => { - it('session names in a tile header, the picker and the + menu are marked data-i18n-skip', () => { + it('session names in a tile header and the picker are marked data-i18n-skip', () => { // The harvest saw them only inside skipped subtrees. expect(userText.has('Open tiles')).toBe(true); expect([...seen.keys()]).not.toContain('s-1'); const src = read('tile-grid.js'); - expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(3); + expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(2); }); it('the group names of the grouped rail are marked data-i18n-skip', () => { diff --git a/test/tile-grid-picker.test.ts b/test/tile-grid-picker.test.ts index 63235bcc..820f9872 100644 --- a/test/tile-grid-picker.test.ts +++ b/test/tile-grid-picker.test.ts @@ -1,5 +1,5 @@ /** - * @fileoverview The header Tiles button, its picker, and a tile's + menu. + * @fileoverview The header Tiles button and its picker. * * - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker * class) and hard-gated to desktop widths like Split: a JS width check plus a @@ -12,8 +12,8 @@ * opens the grid on the checked ones, focusing the active session if checked. * - Escape closes it, and its close method is idempotent (the global Escape * handler calls every close method). - * - A tile's + lists the open sessions not yet tiled; picking one adds and - * focuses it; a grid that holds what the window fits disables them. + * - A tile has no + (owner decision 9): tiles are added from this picker, + * Ctrl/Cmd+click, a dragged tab, a tab group or Run. * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ @@ -26,7 +26,6 @@ import { bySelector, makeGridApp, resetGridHarness, - section, windowStub, type GridApp, } from './mocks/tile-grid-vm.js'; @@ -184,11 +183,10 @@ describe('the picker', () => { expect(() => app.closeTilePicker()).not.toThrow(); }); - it('the global Escape handler closes the picker and the + menu', () => { + it('the global Escape handler closes the picker', () => { const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); const escape = src.slice(src.indexOf("if (e.key === 'Escape') {"), src.indexOf('Option/Alt session navigation')); expect(escape).toContain('this.closeTilePicker?.();'); - expect(escape).toContain('this.closeTileAddMenu?.();'); }); it('refuses in a narrow window', () => { @@ -198,44 +196,3 @@ describe('the picker', () => { expect(picker()).toBeNull(); }); }); - -describe("a tile's + menu", () => { - const addMenu = () => body.children.find((c) => c.className.includes('tile-add-menu')) ?? null; - const addButtonOf = (id: string) => - (section.children.find((el) => el.dataset.sessionId === id) as FakeEl).children[0].children[2].children.find((b) => - b.className.includes('tile-add') - ) as FakeEl; - - it('lists the open sessions not yet tiled; picking one adds and focuses it', () => { - const app = makeGridApp(IDS); - app.openTileGrid(['s-a', 's-b']); - app.markIdleAlertSeen.mockClear(); - addButtonOf('s-a').dispatch('click', { stopPropagation: vi.fn(), currentTarget: null }); - const items = addMenu()!.children.filter((i) => i.className !== 'tile-add-new'); - expect(items.map((i) => i.children[0].textContent)).toEqual(['s-other', 's-c']); - items[1].dispatch('click'); - expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); - expect(app.activeSessionId).toBe('s-c'); - expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c'); - expect(addMenu()).toBeNull(); - }); - - it('a grid holding what the window fits disables the entries', () => { - const app = makeGridApp(IDS); - app.openTileGrid(['s-a', 's-b']); - const sectionRect = section.getBoundingClientRect; - section.getBoundingClientRect = () => ({ width: 1000, height: 400, top: 0, left: 0, right: 1000, bottom: 400 }); - app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }); - expect(addMenu()!.children.every((i) => i.disabled)).toBe(true); - section.getBoundingClientRect = sectionRect; - }); - - it('closing the grid closes it', () => { - const app = makeGridApp(IDS); - app.selectSession = vi.fn(); - app.openTileGrid(['s-a']); - app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }); - app.closeTileGrid({ reselect: false }); - expect(addMenu()).toBeNull(); - }); -});