feat(tiles): Attach overlay for a tile whose session is not attached or has exited

A tile has no live terminal when its session has no PTY attached (pid null,
e.g. restored after a server restart), when the agent exited in a live pane
(paneExit), or when the server closed the tile's socket because the session
exited (4009, which used to leave only the "session ended" marker). Its body
now says which, with an Attach button, in an overlay laid over the terminal
so the body and its xterm keep their size.

Attach is the single view's own re-attach: POST /interactive (or /shell for a
shell) with NO body, at most one in flight per session, since the route has
no in-flight guard of its own. A tripped PTY-exit breaker goes through the
same confirm before clearBreaker: true, and nothing automatic ever sends it.
On success the tile is remounted onto the new pane (a socket stopped for good
cannot reconnect), keeping the keyboard if it had it; the overlay stays away
while the server catches up, and a failed attach says so and keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 17:51:47 +02:00
parent 6fedbd1b09
commit de1b48a63f
4 changed files with 370 additions and 12 deletions
+36
View File
@@ -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); 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 /* Quiet "loading" state while the tile's capture waits in the grid's one load
queue (or runs). */ queue (or runs). */
.tile.tile--loading .tile-body::after { .tile.tile--loading .tile-body::after {
+147 -11
View File
@@ -448,19 +448,12 @@ Object.assign(CodemanApp.prototype, {
if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId);
}); });
this._tileGridSection().appendChild(el); this._tileGridSection().appendChild(el);
const tile = new window.TerminalTile(sessionId, body, { const tile = this._newTerminalTile(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),
});
grid.tiles.set(sessionId, { grid.tiles.set(sessionId, {
tile, tile,
el, el,
body,
overlay: null,
header: header.el, header: header.el,
dot: header.dot, dot: header.dot,
name: header.name, name: header.name,
@@ -472,6 +465,144 @@ Object.assign(CodemanApp.prototype, {
return true; 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 * `● name ......... ⋯ ×`: the status dot (the six-state classifier the tab
* rows and both home screens share), the session name (double-click * 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; if (entry.name.textContent !== name) entry.name.textContent = name;
// A permission prompt is visible across the room. // A permission prompt is visible across the room.
entry.el.classList.toggle('tile--needs', state === 'needs'); entry.el.classList.toggle('tile--needs', state === 'needs');
this._renderTileOverlay(sessionId);
}, },
/** Every tile's header (after a tab render, i.e. any session change). */ /** 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) { _onTileExit(sessionId, tile, code) {
if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return; 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 }); this.removeTile(sessionId, { refocus: true, auto: true });
}, },
+5 -1
View File
@@ -49,6 +49,8 @@ export class FakeEl {
this.parentElement = null; this.parentElement = null;
} }
title = ''; title = '';
hidden = false;
disabled = false;
textContent = ''; textContent = '';
value = ''; value = '';
type = ''; type = '';
@@ -148,6 +150,8 @@ main.appendChild(section);
/** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */ /** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */
export const bySelector = new Map<string, FakeEl>(); export const bySelector = new Map<string, FakeEl>();
export const body = new FakeEl(); 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. */ /** `document.addEventListener`, so a test can find a listener the app installed. */
export const documentAddEventListener = vi.fn(); export const documentAddEventListener = vi.fn();
export const localStore = new Map<string, string>(); export const localStore = new Map<string, string>();
@@ -188,7 +192,7 @@ const context = vm.createContext({
requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb), requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb),
HTMLCanvasElement: class HTMLCanvasElement {}, HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 }, WebSocket: { OPEN: 1 },
fetch: vi.fn(), fetch: (...args: unknown[]) => fetchSpy(...args),
navigator: { onLine: true }, navigator: { onLine: true },
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
history: { replaceState: vi.fn(), state: null }, history: { replaceState: vi.fn(), state: null },
+182
View File
@@ -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();
});
});