diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 3aec9677..7ac60f9b 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19527,6 +19527,42 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--accent, #4a9eff) 60%, transparent); } +/* The Attach overlay: a tile whose session has no PTY attached, or whose + agent exited. Absolute over the body, so the body and its xterm keep their + size. */ +.tile-attach { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + background: color-mix(in srgb, var(--term-bg, #161b23) 88%, transparent); + font-size: 13px; + color: var(--text-muted); +} + +.tile-attach[hidden] { + display: none; +} + +.tile-attach-btn { + padding: 4px 14px; + font: inherit; + color: var(--accent-ink, #fff); + background: var(--accent, #4a9eff); + border: none; + border-radius: 4px; + cursor: pointer; +} + +.tile-attach-btn:disabled { + opacity: 0.5; + cursor: progress; +} + /* Quiet "loading" state while the tile's capture waits in the grid's one load queue (or runs). */ .tile.tile--loading .tile-body::after { diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 3db67e4a..ff940757 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -448,19 +448,12 @@ Object.assign(CodemanApp.prototype, { if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); }); this._tileGridSection().appendChild(el); - const tile = new window.TerminalTile(sessionId, body, { - mode: session.mode, - fontSettings: this.loadAppSettingsFromStorage?.() || {}, - detachedSessions: this.detachedSessions, - scheduleLoad: (t, kind, run) => this._tileLoadQueue().schedule(t, kind, run), - scrollback: window.CodemanTileGrid.TILE_SCROLLBACK, - fontSize: this._tileGridFontSize(), - boundedLoad: true, - onExit: (code) => this._onTileExit(sessionId, tile, code), - }); + const tile = this._newTerminalTile(sessionId, body); grid.tiles.set(sessionId, { tile, el, + body, + overlay: null, header: header.el, dot: header.dot, name: header.name, @@ -472,6 +465,144 @@ Object.assign(CodemanApp.prototype, { return true; }, + /** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */ + _newTerminalTile(sessionId, body) { + const session = this.sessions.get(sessionId); + const tile = new window.TerminalTile(sessionId, body, { + mode: session?.mode, + fontSettings: this.loadAppSettingsFromStorage?.() || {}, + detachedSessions: this.detachedSessions, + scheduleLoad: (t, kind, run) => this._tileLoadQueue().schedule(t, kind, run), + scrollback: window.CodemanTileGrid.TILE_SCROLLBACK, + fontSize: this._tileGridFontSize(), + boundedLoad: true, + onExit: (code) => this._onTileExit(sessionId, tile, code), + }); + return tile; + }, + + /** + * Replaces a tile's TerminalTile with a fresh one in the same place (after + * Attach: a tile whose socket stopped for good cannot reconnect, and a fresh + * one loads the new pane from scratch). Keeps the keyboard if it had it. + */ + _remountTile(sessionId) { + const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null; + if (!entry) return; + const hadKeyboard = this._focusedTile === entry.tile; + this._tileGrid.queue?.drop(entry.tile); + entry.tile.destroy(); + entry.tile = this._newTerminalTile(sessionId, entry.body); + this._connectTile(sessionId); + if (hadKeyboard) this._noteFocusedTile(entry.tile); + }, + + /** + * What the tile's body should say instead of a terminal, or '' for none: a + * session with no PTY attached (pid null), an agent that exited in a live + * pane (paneExit), or a socket the server closed because the session exited + * (4009). Attach was just pressed: nothing, while the server catches up. + */ + _tileAttachReason(sessionId, tile) { + const session = this.sessions.get(sessionId); + if (!session) return ''; + const pending = this._tileAttachPending?.get(sessionId); + if (pending && Date.now() - pending < 15000) return ''; + const exited = typeof paneExitLabel === 'function' ? paneExitLabel(session.paneExit) : ''; + if (exited) return `The agent ${exited}`; + if (session.pid === null) return 'Not attached'; + if (tile?._stoppedCode === 4009) return 'The session ended'; + return ''; + }, + + /** + * The Attach overlay over a tile's body (absolute, so the body and its xterm + * keep their size): why there is no terminal, and an Attach button. + */ + _renderTileOverlay(sessionId) { + const entry = this._tileGrid?.tiles.get(sessionId); + if (!entry?.body) return; + const session = this.sessions.get(sessionId); + if (session && session.pid !== null && !session.paneExit) this._tileAttachPending?.delete(sessionId); + const reason = this._tileAttachReason(sessionId, entry.tile); + const busy = !!this._tileAttachInFlight?.has(sessionId); + if (!reason && !busy) { + if (entry.overlay) entry.overlay.hidden = true; + return; + } + if (!entry.overlay) { + const overlay = document.createElement('div'); + overlay.className = 'tile-attach'; + const text = document.createElement('span'); + text.className = 'tile-attach-text'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'tile-attach-btn'; + btn.textContent = 'Attach'; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + void this.attachTileSession(sessionId); + }); + overlay.append(text, btn); + entry.body.appendChild(overlay); + entry.overlay = overlay; + entry.overlayText = text; + entry.overlayBtn = btn; + } + entry.overlay.hidden = false; + const text = busy ? 'Attaching\u2026' : reason; + if (entry.overlayText.textContent !== text) entry.overlayText.textContent = text; + entry.overlayBtn.disabled = busy; + }, + + /** + * Attach: starts the session's CLI in its pane, exactly as the single view's + * automatic re-attach does: `POST /interactive` (or `/shell` for a shell) + * with NO body, at most one in flight per session (the route has no guard of + * its own). A session whose PTY-exit breaker tripped goes through the same + * confirm the single view asks before `clearBreaker: true`; nothing automatic + * ever sends that. On success the tile is remounted onto the new pane. + */ + async attachTileSession(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return false; + this._tileAttachInFlight ||= new Set(); + if (this._tileAttachInFlight.has(sessionId)) return false; + let url = `/api/sessions/${sessionId}/${session.mode === 'shell' ? 'shell' : 'interactive'}`; + let init = { method: 'POST' }; + if (session.respawnBlocked) { + const label = session.name || 'Session'; + if (!window.confirm(`${label} was stopped after crashing repeatedly. Restart it?`)) return false; + url = `/api/sessions/${sessionId}/interactive`; + init = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clearBreaker: true }), + }; + } + this._tileAttachInFlight.add(sessionId); + this._renderTileOverlay(sessionId); + let ok = false; + try { + const res = await fetch(url, init); + ok = !!res?.ok; + } catch { + ok = false; + } finally { + this._tileAttachInFlight.delete(sessionId); + } + if (ok) { + if (init.body) session.respawnBlocked = false; + session.status = 'busy'; + (this._tileAttachPending ||= new Map()).set(sessionId, Date.now()); + this._remountTile(sessionId); + } else { + this.showToast?.('Could not attach the session', 'error'); + } + this._renderTileOverlay(sessionId); + return ok; + }, + /** * `● name ......... ⋯ ×`: the status dot (the six-state classifier the tab * rows and both home screens share), the session name (double-click @@ -548,6 +679,7 @@ Object.assign(CodemanApp.prototype, { if (entry.name.textContent !== name) entry.name.textContent = name; // A permission prompt is visible across the room. entry.el.classList.toggle('tile--needs', state === 'needs'); + this._renderTileOverlay(sessionId); }, /** Every tile's header (after a tab render, i.e. any session change). */ @@ -620,7 +752,11 @@ Object.assign(CodemanApp.prototype, { */ _onTileExit(sessionId, tile, code) { if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return; - if (code === 4009) return; + // The session exited: the tile stays, with the Attach overlay over it. + if (code === 4009) { + this._renderTileOverlay(sessionId); + return; + } this.removeTile(sessionId, { refocus: true, auto: true }); }, diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index ddd77458..400ccbff 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -49,6 +49,8 @@ export class FakeEl { this.parentElement = null; } title = ''; + hidden = false; + disabled = false; textContent = ''; value = ''; type = ''; @@ -148,6 +150,8 @@ main.appendChild(section); /** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */ export const bySelector = new Map(); export const body = new FakeEl(); +/** The context's `fetch`; a test sets what it answers. */ +export const fetchSpy = vi.fn(async (..._args: unknown[]) => ({ ok: true, json: async () => ({}) })); /** `document.addEventListener`, so a test can find a listener the app installed. */ export const documentAddEventListener = vi.fn(); export const localStore = new Map(); @@ -188,7 +192,7 @@ const context = vm.createContext({ requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb), HTMLCanvasElement: class HTMLCanvasElement {}, WebSocket: { OPEN: 1 }, - fetch: vi.fn(), + fetch: (...args: unknown[]) => fetchSpy(...args), navigator: { onLine: true }, location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, history: { replaceState: vi.fn(), state: null }, diff --git a/test/tile-grid-attach.test.ts b/test/tile-grid-attach.test.ts new file mode 100644 index 00000000..14aa078a --- /dev/null +++ b/test/tile-grid-attach.test.ts @@ -0,0 +1,182 @@ +/** + * @fileoverview A tile whose session is not attached shows an Attach overlay. + * + * Three ways a tile has no live terminal: the session has no PTY attached + * (`pid === null`, e.g. restored after a server restart), the agent exited in a + * live pane (`paneExit`), or the server closed the tile's socket because the + * session exited (close code 4009). The tile body then shows why, with an + * Attach button, over the terminal (never resizing it). + * + * Attach is exactly the single view's automatic re-attach: `POST /interactive` + * (or `/shell` for a shell) with NO body, at most one in flight per session + * (the route has no in-flight guard of its own). A tripped PTY-exit breaker + * (`respawnBlocked`) goes through the same confirm before `clearBreaker: true`, + * and nothing automatic ever sends that. On success the tile is remounted onto + * the new pane (a socket stopped for good cannot reconnect). + * + * 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 { + FakeEl, + FakeTile, + fetchSpy, + makeGridApp, + resetGridHarness, + section, + windowStub, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; +const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; +const overlayOf = (id: string) => tileEl(id).children[1].children.find((c) => c.className === 'tile-attach') ?? null; +const visible = (id: string) => !!overlayOf(id) && !overlayOf(id)!.hidden; +const textOf = (id: string) => overlayOf(id)!.children[0].textContent; +const attachButton = (id: string) => overlayOf(id)!.children[1]; +const tilesFor = (id: string) => FakeTile.all.filter((t) => t.sessionId === id); + +function gridWith(setup: (app: GridApp) => void = () => {}): GridApp { + const app = makeGridApp(IDS); + setup(app); + app.openTileGrid(IDS); + return app; +} + +async function settle() { + for (let i = 0; i < 10; i++) await Promise.resolve(); +} + +beforeEach(() => { + resetGridHarness(); + fetchSpy.mockReset(); + fetchSpy.mockImplementation(async () => ({ ok: true, json: async () => ({}) })); + windowStub.confirm = vi.fn(() => true); +}); + +describe('when the overlay shows', () => { + it('a session with no PTY attached', () => { + gridWith((app) => (app.sessions.get('s-b').pid = null)); + expect(visible('s-b')).toBe(true); + expect(textOf('s-b')).toBe('Not attached'); + expect(attachButton('s-b').textContent).toBe('Attach'); + expect(visible('s-a')).toBe(false); + }); + + it('an agent that exited in a live pane', () => { + gridWith((app) => (app.sessions.get('s-c').paneExit = { status: 2 })); + expect(visible('s-c')).toBe(true); + expect(textOf('s-c')).toBe('The agent exited (2)'); + }); + + it('a socket closed because the session exited (4009) keeps the tile and shows it', () => { + const app = gridWith(); + const tile = tilesFor('s-b')[0]; + tile._stoppedCode = 4009; + tile.onExit?.(4009); + expect(app._tileGrid.ids).toContain('s-b'); + expect(visible('s-b')).toBe(true); + expect(textOf('s-b')).toBe('The session ended'); + }); + + it('a live session shows none, and the overlay leaves once the session is back', () => { + const app = gridWith((a) => (a.sessions.get('s-b').pid = null)); + app.sessions.get('s-b').pid = 4242; + app._renderTileChrome(); + expect(visible('s-b')).toBe(false); + }); + + it('sits over the body, never inside the header (the body keeps its size)', () => { + gridWith((app) => (app.sessions.get('s-b').pid = null)); + expect(tileEl('s-b').children[1].className).toBe('tile-body'); + expect(overlayOf('s-b')?.parentElement).toBe(tileEl('s-b').children[1]); + }); +}); + +describe('Attach', () => { + it('POSTs /interactive with NO body, then remounts the tile onto the new pane', async () => { + const app = gridWith((a) => (a.sessions.get('s-b').pid = null)); + const before = tilesFor('s-b')[0]; + attachButton('s-b').dispatch('click', { stopPropagation: vi.fn() }); + await settle(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-b/interactive', { method: 'POST' }); + expect(before.destroy).toHaveBeenCalledTimes(1); + const after = tilesFor('s-b')[1]; + expect(after.connect).toHaveBeenCalledTimes(1); + expect(app._tileFor('s-b')).toBe(after); + // The server has not reported the new pid yet: no overlay meanwhile. + expect(visible('s-b')).toBe(false); + }); + + it('a shell session attaches through /shell', async () => { + const app = makeGridApp(IDS); + app.sessions.get('s-c').mode = 'shell'; + app.sessions.get('s-c').pid = null; + app.openTileGrid(IDS); + await app.attachTileSession('s-c'); + expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-c/shell', { method: 'POST' }); + }); + + it('at most one attach in flight per session', async () => { + const app = gridWith((a) => (a.sessions.get('s-b').pid = null)); + let release: (v: unknown) => void = () => {}; + fetchSpy.mockImplementation(() => new Promise((r) => (release = r))); + const first = app.attachTileSession('s-b'); + attachButton('s-b').dispatch('click', { stopPropagation: vi.fn() }); + void app.attachTileSession('s-b'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(textOf('s-b')).toBe('Attaching…'); + expect(attachButton('s-b').disabled).toBe(true); + release({ ok: true }); + await first; + }); + + it('a failed attach keeps the overlay and says so', async () => { + const app = gridWith((a) => (a.sessions.get('s-b').pid = null)); + fetchSpy.mockImplementation(async () => ({ ok: false, json: async () => ({}) })); + expect(await app.attachTileSession('s-b')).toBe(false); + expect(app.showToast).toHaveBeenCalledWith('Could not attach the session', 'error'); + expect(visible('s-b')).toBe(true); + expect(textOf('s-b')).toBe('Not attached'); + expect(tilesFor('s-b')).toHaveLength(1); + }); +}); + +describe('the PTY-exit breaker', () => { + function tripped() { + return gridWith((a) => { + a.sessions.get('s-b').pid = null; + a.sessions.get('s-b').respawnBlocked = true; + }); + } + + it('asks first, and only a yes sends clearBreaker', async () => { + const app = tripped(); + await app.attachTileSession('s-b'); + expect(windowStub.confirm).toHaveBeenCalledWith('s-b was stopped after crashing repeatedly. Restart it?'); + expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-b/interactive', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clearBreaker: true }), + }); + expect(app.sessions.get('s-b').respawnBlocked).toBe(false); + }); + + it('a no sends nothing at all', async () => { + const app = tripped(); + windowStub.confirm = vi.fn(() => false); + await app.attachTileSession('s-b'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(visible('s-b')).toBe(true); + }); + + it('nothing automatic attaches: opening the grid and refreshing headers POST nothing', () => { + const app = tripped(); + app._renderTileChrome(); + app._reconcileTileGrid(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(windowStub.confirm).not.toHaveBeenCalled(); + }); +});