fix(terminal): reconcile tabs with server sessions and keep shown terminals alive

The tab list lived only in per-tab sessionStorage, so a new browser
tab, another device, or cleared storage showed an empty terminal
sidebar while PTYs kept running server-side, and orphans leaked until
the idle sweep. Add GET /api/terminal/sessions and adopt unknown
server sessions into the local tab projection (additive only; a failed
listing changes nothing).

The idle sweep also reaped terminals in background tabs because only
the active tab holds a WebSocket attachment. Add POST
/api/terminal/touch and have open clients periodically refresh
activity for every session their tabs reference.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 14:30:00 +03:00
parent 0918bee566
commit 2f27f0ec4b
9 changed files with 249 additions and 2 deletions
@@ -179,6 +179,32 @@ describe('terminal runtime', () => {
} finally { await harness.runtime.shutdown(); }
});
it('lists sessions scoped to a working directory and refreshes activity via touch', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-a', cwd: '/repo' } }, createResponse());
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-b', cwd: '/other' } }, createResponse());
const all = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, all);
expect(all.body.sessions.map((s) => s.sessionId).sort()).toEqual(['term-a', 'term-b']);
const scoped = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
expect(scoped.body.sessions).toEqual([
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) },
]);
const touch = createResponse();
harness.routes.post.get('/api/terminal/touch')({ body: { sessionIds: ['term-a', 'missing', 42] } }, touch);
expect(touch.body).toEqual({ touched: 1 });
const malformed = createResponse();
harness.routes.post.get('/api/terminal/touch')({ body: {} }, malformed);
expect(malformed.body).toEqual({ touched: 0 });
} finally { await harness.runtime.shutdown(); }
});
it('strips AppImage ARGV0 from PTY child environments', async () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';