From 9b7b683311253489b59264e5802a0993f5759f7e Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Sat, 19 Sep 2026 15:14:23 +0200 Subject: [PATCH] feat(web): let humans discard finished sessions Sidebar groups sessions into Running and Finished sections with per-item actions: Kill for running sessions, Remove for finished ones, plus a "Clear finished" bulk action. Killing keeps the session and its buffer for log access; removing discards both. Session removal stays a human-only action in the web UI, so the PTY tools keep the existing cleanup flag but document it as deprecated. A new session_removed WebSocket event is broadcast on cleanup/clear so every open client drops the row immediately instead of waiting for the 10s polling sync. Also fixes a build trap: the client styles lived in an inline
diff --git a/src/web/client/main.tsx b/src/web/client/main.tsx index 49ea2735..f5a3f7b1 100644 --- a/src/web/client/main.tsx +++ b/src/web/client/main.tsx @@ -1,5 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import './index.css' import { App } from './components/app.tsx' import { ErrorBoundary } from './components/error-boundary.tsx' diff --git a/src/web/server/callback-manager.ts b/src/web/server/callback-manager.ts index e9a8040b..0994a1d5 100644 --- a/src/web/server/callback-manager.ts +++ b/src/web/server/callback-manager.ts @@ -1,17 +1,24 @@ import { registerRawOutputCallback, + registerSessionRemovedCallback, registerSessionUpdateCallback, removeRawOutputCallback, + removeSessionRemovedCallback, removeSessionUpdateCallback, } from '../../plugin/pty/manager' import type { PTYSessionInfo } from '../../plugin/pty/types' -import type { WSMessageServerSessionUpdate, WSMessageServerRawData } from '../shared/types' +import type { + WSMessageServerSessionRemoved, + WSMessageServerSessionUpdate, + WSMessageServerRawData, +} from '../shared/types' export class CallbackManager implements Disposable { constructor(private server: Bun.Server) { this.server = server registerSessionUpdateCallback(this.sessionUpdateCallback) registerRawOutputCallback(this.rawOutputCallback) + registerSessionRemovedCallback(this.sessionRemovedCallback) } private sessionUpdateCallback = (session: PTYSessionInfo): void => { @@ -19,6 +26,11 @@ export class CallbackManager implements Disposable { this.server.publish('sessions:update', JSON.stringify(message)) } + private sessionRemovedCallback = (sessionId: string): void => { + const message: WSMessageServerSessionRemoved = { type: 'session_removed', sessionId } + this.server.publish('sessions:update', JSON.stringify(message)) + } + private rawOutputCallback = (session: PTYSessionInfo, rawData: string): void => { const message: WSMessageServerRawData = { type: 'raw_data', session, rawData } this.server.publish(`session:${session.id}`, JSON.stringify(message)) @@ -27,5 +39,6 @@ export class CallbackManager implements Disposable { [Symbol.dispose]() { removeSessionUpdateCallback(this.sessionUpdateCallback) removeRawOutputCallback(this.rawOutputCallback) + removeSessionRemovedCallback(this.sessionRemovedCallback) } } diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts index aaa795b5..2759834e 100644 --- a/src/web/shared/types.ts +++ b/src/web/shared/types.ts @@ -60,6 +60,7 @@ export interface WSMessageServer { | 'readRawResponse' | 'session_list' | 'session_update' + | 'session_removed' | 'error' } @@ -95,6 +96,15 @@ export interface WSMessageServerSessionUpdate extends WSMessageServer { session: PTYSessionInfo } +/** + * Emitted when a session is permanently removed (buffer freed and dropped from + * the manager), e.g. when a human discards a finished session in the web UI. + */ +export interface WSMessageServerSessionRemoved extends WSMessageServer { + type: 'session_removed' + sessionId: string +} + export interface WSMessageServerError extends WSMessageServer { type: 'error' error: CustomError diff --git a/test/e2e/ui/app.pw.ts b/test/e2e/ui/app.pw.ts index 2518fbaf..b28aa7ca 100644 --- a/test/e2e/ui/app.pw.ts +++ b/test/e2e/ui/app.pw.ts @@ -269,4 +269,48 @@ extendedTest.describe('App Component', () => { expect(count).toBeGreaterThan(0) }) }) + + extendedTest.describe('Session Discarding', () => { + extendedTest( + 'removes a finished session from the sidebar (human-only)', + async ({ page, api }) => { + // Prevent autoselect so the sidebar state stays predictable + await page.evaluate(() => { + localStorage.setItem('skip-autoselect', 'true') + }) + + await api.sessions.create({ + command: 'echo', + args: ['finished'], + description: 'Finished session to discard', + }) + + // Wait until the session has actually exited + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + const sessions = await api.sessions.list() + const target = sessions.find((s) => s.description === 'Finished session to discard') + if (target && target.status !== 'running') { + break + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + await page.reload() + + const sessionRow = page.locator('.session-row:has-text("Finished session to discard")') + await expect(sessionRow).toBeVisible({ timeout: 5000 }) + + // The confirmation dialog must be accepted for the removal to proceed + page.on('dialog', (dialog) => dialog.accept()) + await sessionRow.locator('.session-action-remove').click() + + // The row disappears immediately via the session_removed broadcast + await expect(sessionRow).toHaveCount(0, { timeout: 5000 }) + + const remaining = await api.sessions.list() + expect(remaining.some((s) => s.description === 'Finished session to discard')).toBe(false) + } + ) + }) }) diff --git a/test/utils.ts b/test/utils.ts index 4da56491..c89cb483 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -2,6 +2,7 @@ import { OpencodeClient } from '@opencode-ai/sdk' import { initManager, manager, + sessionRemovedCallbacks, sessionUpdateCallbacks, rawOutputCallbacks, } from '../src/plugin/pty/manager' @@ -11,6 +12,7 @@ import type { WSMessageServerSubscribedSession, WSMessageServerUnsubscribedSession, WSMessageServerSessionUpdate, + WSMessageServerSessionRemoved, WSMessageServerRawData, WSMessageServerReadRawResponse, WSMessageServerSessionList, @@ -34,6 +36,8 @@ export class ManagedTestClient implements Disposable { > = [] public readonly sessionUpdateCallbacks: Array<(message: WSMessageServerSessionUpdate) => void> = [] + public readonly sessionRemovedCallbacks: Array<(message: WSMessageServerSessionRemoved) => void> = + [] public readonly rawDataCallbacks: Array<(message: WSMessageServerRawData) => void> = [] public readonly readRawResponseCallbacks: Array< (message: WSMessageServerReadRawResponse) => void @@ -65,6 +69,11 @@ export class ManagedTestClient implements Disposable { callback(message as WSMessageServerSessionUpdate) }) break + case 'session_removed': + this.sessionRemovedCallbacks.forEach((callback) => { + callback(message as WSMessageServerSessionRemoved) + }) + break case 'raw_data': this.rawDataCallbacks.forEach((callback) => { callback(message as WSMessageServerRawData) @@ -171,5 +180,6 @@ export class ManagedTestServer implements Disposable { manager.clearAllSessions() sessionUpdateCallbacks.length = 0 rawOutputCallbacks.length = 0 + sessionRemovedCallbacks.length = 0 } } diff --git a/test/web-server.test.ts b/test/web-server.test.ts index 991bed31..4238fd66 100644 --- a/test/web-server.test.ts +++ b/test/web-server.test.ts @@ -238,6 +238,50 @@ describe('Web Server', () => { await sessionExitedPromise }, 1000) + it('should retain a killed session when no cleanup is requested', async () => { + const session = manager.spawn({ + command: 'cat', + args: [], + description: 'Retained after kill', + parentSessionId: 'test', + }) + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}`, + { method: 'DELETE' } + ) + expect(response.status).toBe(200) + + const listResponse = await fetch(`${managedTestServer.server.server.url}/api/sessions`) + const sessions = (await listResponse.json()) as PTYSessionInfo[] + expect(sessions.some((s) => s.id === session.id)).toBe(true) + }, 1000) + + it('should discard a session via the cleanup endpoint', async () => { + const session = manager.spawn({ + command: 'cat', + args: [], + description: 'Discarded session', + parentSessionId: 'test', + }) + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}/cleanup`, + { method: 'DELETE' } + ) + expect(response.status).toBe(200) + expect((await response.json()).success).toBe(true) + + const listResponse = await fetch(`${managedTestServer.server.server.url}/api/sessions`) + const sessions = (await listResponse.json()) as PTYSessionInfo[] + expect(sessions.some((s) => s.id === session.id)).toBe(false) + + const getResponse = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}` + ) + expect(getResponse.status).toBe(404) + }, 1000) + it('should return session output', async () => { const title = crypto.randomUUID() const sessionExitedPromise = new Promise((resolve) => { diff --git a/test/websocket.test.ts b/test/websocket.test.ts index 19fd713e..dee34877 100644 --- a/test/websocket.test.ts +++ b/test/websocket.test.ts @@ -4,6 +4,7 @@ import type { CustomError, WSMessageServerError, WSMessageServerSessionList, + WSMessageServerSessionRemoved, WSMessageServerSessionUpdate, WSMessageServerSubscribedSession, WSMessageServerUnsubscribedSession, @@ -170,6 +171,31 @@ describe('WebSocket Functionality', () => { await sessionListPromise }, 1000) + it('should broadcast session_removed when a session is discarded', async () => { + await using managedTestClient = await ManagedTestClient.create( + managedTestServer.server.getWsUrl() + ) + const session = manager.spawn({ + command: 'echo', + args: ['done'], + description: 'Session to discard', + parentSessionId: managedTestServer.sessionId, + }) + + const removedPromise = new Promise((res) => { + managedTestClient.sessionRemovedCallbacks.push((message) => { + if (message.sessionId === session.id) { + res(message) + } + }) + }) + + manager.kill(session.id, true) + + const removed = await removedPromise + expect(removed.sessionId).toBe(session.id) + }, 1000) + it('should handle invalid message format', async () => { await using managedTestClient = await ManagedTestClient.create( managedTestServer.server.getWsUrl()