diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 9b2bf1b4..63a49be8 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -573,6 +573,10 @@ Object.assign(CodemanApp.prototype, { } if (session?.id) this._onSessionCreated(session); + // A session this tab's Run created joins an open tile grid (tile-grid.js), + // so Run's selectSession() below focuses its tile instead of leaving the + // grid. Only here: sessions created elsewhere arrive by session:created. + this._joinTileGridFromRun?.(sessionId); // session:created normally uses the debounced renderer. The direct POST path // needs the tab in the DOM before selectSession() marks it active. this._renderSessionTabsImmediate?.(); diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 678cf8ab..f826d430 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -465,7 +465,7 @@ Object.assign(CodemanApp.prototype, { * adds it to the grid and focuses it (a human selection). Disabled once the * grid holds what the window can fit. */ - openTileAddMenu(event) { + openTileAddMenu(event, fromSessionId = null) { event?.preventDefault?.(); event?.stopPropagation?.(); const grid = this._tileGrid; @@ -504,6 +504,23 @@ Object.assign(CodemanApp.prototype, { }); 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 = `The grid already holds what this window fits (${capacity})`; + 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(); @@ -524,6 +541,47 @@ Object.assign(CodemanApp.prototype, { 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 + * grid open it joins the next free slot, and Run's own selectSession then + * focuses it through the tile branch. Sessions created elsewhere (agents, + * other devices, cron) arrive only by session:created and never join. A grid + * already holding what the window fits does not take it; Run's selection + * then shows it in the single view (decision 1), and a hint says why. + */ + _joinTileGridFromRun(sessionId) { + const grid = this._tileGrid; + if (!grid?.open || grid.tiles.has(sessionId) || !this.sessions.has(sessionId)) return false; + const T = window.CodemanTileGrid; + const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX)); + if (grid.ids.length >= capacity) { + this.showToast?.(`The grid holds what this window fits (${capacity}): the new session opens on its own`, 'info'); + return false; + } + // Run starts the session right after creating it: no Attach overlay + // meanwhile for a pane that is about to exist. + (this._tileAttachPending ||= new Map()).set(sessionId, Date.now()); + return this.addTile(sessionId); + }, + /** Idempotent, like closeTilePicker. */ closeTileAddMenu() { const m = this._tileAddMenu; @@ -1087,7 +1145,7 @@ 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)), + button('tile-add', 'Add a session to the grid', '+', (e) => this.openTileAddMenu(e, sessionId)), // 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/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 96b0d0b4..63e9843a 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -219,7 +219,7 @@ const context = vm.createContext({ removeEventListener: documentRemoveEventListener, documentElement: { dataset: {} }, createElement: () => new FakeEl(), - getElementById: (id: string) => (id === 'tileGrid' ? section : null), + getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)), body, querySelector: (sel: string) => bySelector.get(sel) ?? (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null), diff --git a/test/tile-grid-auto-join.test.ts b/test/tile-grid-auto-join.test.ts new file mode 100644 index 00000000..ea328c98 --- /dev/null +++ b/test/tile-grid-auto-join.test.ts @@ -0,0 +1,135 @@ +/** + * @fileoverview New sessions started from THIS tab's Run join the open grid. + * + * Every Run path calls `_ensureCreatedSessionVisible` for each session it + * created, then `selectSession(firstId)` (a human selection). With the grid open, + * `_joinTileGridFromRun` (called from there) adds each new session to the next + * free slot, so that selection focuses its tile instead of leaving the grid. + * Sessions created elsewhere (agents, other devices, cron) arrive only by + * `session:created` and never join. A grid already holding what the window + * fits does not take it: Run's selection then shows it alone, with a hint. + * + * 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, + body, + bySelector, + 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') => + app.sessions.set(id, { id, name: id, mode: 'claude', pid: null, workingDir }); + +beforeEach(() => { + resetGridHarness(); +}); + +describe('Run from this tab', () => { + it('the Run hook is where every Run path makes its new session visible', () => { + const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + const helper = src.slice(src.indexOf('async _ensureCreatedSessionVisible('), src.indexOf('async run() {')); + expect(helper).toContain('this._joinTileGridFromRun?.(sessionId);'); + }); + + it('with the grid open, the new session joins the next free slot; Run then focuses its tile', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + addSession(app, 's-new'); + expect(app._joinTileGridFromRun('s-new')).toBe(true); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-new']); + // What Run does next: a human selection of the session it created. + app.selectSession('s-new'); + expect(app._tilesOwnTerminal()).toBe(true); + expect(app.activeSessionId).toBe('s-new'); + }); + + it('no Attach overlay flashes on the new tile while Run starts its pane', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + addSession(app, 's-new'); + app._joinTileGridFromRun('s-new'); + const tile = section.children.find((el) => el.dataset.sessionId === 's-new') as FakeEl; + const overlay = tile.children[1].children.find((c) => c.className === 'tile-attach'); + expect(!overlay || overlay.hidden).toBe(true); + }); + + it('with the grid closed, nothing joins', () => { + const app = makeGridApp(IDS); + addSession(app, 's-new'); + expect(app._joinTileGridFromRun('s-new')).toBe(false); + expect(app._tileGrid?.open ?? false).toBe(false); + }); + + it('a grid that already holds what the window fits does not take it, and says so', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + addSession(app, 's-new'); + section.getBoundingClientRect = () => ({ width: 1000, height: 400, top: 0, left: 0, right: 1000, bottom: 400 }); + expect(app._joinTileGridFromRun('s-new')).toBe(false); + delete (section as unknown as Record).getBoundingClientRect; + expect(app._tileGrid.ids).toEqual(IDS); + expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('opens on its own'), 'info'); + }); + + it('a session created ELSEWHERE (session:created) never joins', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.renderSessionTabs = vi.fn(); + app.saveSessionOrder = vi.fn(); + app.updateCost = vi.fn(); + app._onSessionCreated({ id: 's-agent', name: 's-agent', mode: 'claude', pid: 1, workingDir: '/w' }); + expect(app.sessions.has('s-agent')).toBe(true); + expect(app._tileGrid.ids).toEqual(IDS); + }); +}); + +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-picker.test.ts b/test/tile-grid-picker.test.ts index f31db637..fbd7cca9 100644 --- a/test/tile-grid-picker.test.ts +++ b/test/tile-grid-picker.test.ts @@ -211,7 +211,7 @@ describe("a tile's + menu", () => { app.openTileGrid(['s-a', 's-b']); app.markIdleAlertSeen.mockClear(); addButtonOf('s-a').dispatch('click', { stopPropagation: vi.fn(), currentTarget: null }); - const items = addMenu()!.children; + const items = addMenu()!.children.filter((i) => i.className !== 'tile-add-new'); expect(items.map((i) => i.textContent)).toEqual(['s-other', 's-c']); items[1].dispatch('click'); expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);