diff --git a/web/src/routes/sessions/terminal.test.tsx b/web/src/routes/sessions/terminal.test.tsx index 2f294342..4497873b 100644 --- a/web/src/routes/sessions/terminal.test.tsx +++ b/web/src/routes/sessions/terminal.test.tsx @@ -1,9 +1,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { I18nProvider } from '@/lib/i18n-context' import TerminalPage from './terminal' const writeMock = vi.fn() +const goBackMock = vi.fn() +const connectMock = vi.fn() +const resizeMock = vi.fn() +const disconnectMock = vi.fn() +const onOutputMock = vi.fn() +let onExitHandler: ((code: number | null, signal: string | null) => void) | null = null + +const onExitRegister = (handler: (code: number | null, signal: string | null) => void) => { + onExitHandler = handler +} + +const terminalSocketState = { + state: { status: 'connected' as const }, + connect: connectMock, + write: writeMock, + resize: resizeMock, + disconnect: disconnectMock, + onOutput: onOutputMock, + onExit: onExitRegister +} vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ sessionId: 'session-1' }) @@ -18,7 +38,7 @@ vi.mock('@/lib/app-context', () => ({ })) vi.mock('@/hooks/useAppGoBack', () => ({ - useAppGoBack: () => vi.fn() + useAppGoBack: () => goBackMock })) vi.mock('@/hooks/queries/useSession', () => ({ @@ -32,15 +52,7 @@ vi.mock('@/hooks/queries/useSession', () => ({ })) vi.mock('@/hooks/useTerminalSocket', () => ({ - useTerminalSocket: () => ({ - state: { status: 'connected' as const }, - connect: vi.fn(), - write: writeMock, - resize: vi.fn(), - disconnect: vi.fn(), - onOutput: vi.fn(), - onExit: vi.fn() - }) + useTerminalSocket: () => terminalSocketState })) vi.mock('@/hooks/useLongPress', () => ({ @@ -64,6 +76,7 @@ function renderWithProviders() { describe('TerminalPage paste behavior', () => { beforeEach(() => { vi.clearAllMocks() + onExitHandler = null }) it('does not open manual paste dialog when clipboard text is empty', async () => { @@ -98,3 +111,29 @@ describe('TerminalPage paste behavior', () => { expect(await screen.findByText('Paste input')).toBeInTheDocument() }) }) + +describe('TerminalPage exit behavior', () => { + beforeEach(() => { + vi.clearAllMocks() + onExitHandler = null + }) + + it('navigates back to chat shortly after the terminal exits', async () => { + renderWithProviders() + + await waitFor(() => { + expect(onExitHandler).not.toBeNull() + }) + + await act(async () => { + onExitHandler?.(0, null) + }) + + await waitFor( + () => { + expect(goBackMock).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) +}) diff --git a/web/src/routes/sessions/terminal.tsx b/web/src/routes/sessions/terminal.tsx index 26e04ab7..dd0a0b4f 100644 --- a/web/src/routes/sessions/terminal.tsx +++ b/web/src/routes/sessions/terminal.tsx @@ -93,6 +93,8 @@ function shouldResetModifiers(sequence: string, state: ModifierState): boolean { return state.ctrl || state.alt } +const EXIT_NAVIGATION_DELAY_MS = 700 + const QUICK_INPUT_ROWS: QuickInput[][] = [ [ { label: 'Esc', sequence: '\u001b', description: 'Escape' }, @@ -193,6 +195,7 @@ export default function TerminalPage() { const connectOnceRef = useRef(false) const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null) const modifierStateRef = useRef({ ctrl: false, alt: false }) + const exitNavTimerRef = useRef | null>(null) const [exitInfo, setExitInfo] = useState<{ code: number | null; signal: string | null } | null>(null) const [ctrlActive, setCtrlActive] = useState(false) const [altActive, setAltActive] = useState(false) @@ -224,8 +227,15 @@ export default function TerminalPage() { onExit((code, signal) => { setExitInfo({ code, signal }) terminalRef.current?.write(`\r\n[process exited${code !== null ? ` with code ${code}` : ''}]`) + if (exitNavTimerRef.current) { + clearTimeout(exitNavTimerRef.current) + } + exitNavTimerRef.current = setTimeout(() => { + exitNavTimerRef.current = null + goBack() + }, EXIT_NAVIGATION_DELAY_MS) }) - }, [onExit]) + }, [onExit, goBack]) useEffect(() => { modifierStateRef.current = { ctrl: ctrlActive, alt: altActive } @@ -292,6 +302,10 @@ export default function TerminalPage() { useEffect(() => { connectOnceRef.current = false setExitInfo(null) + if (exitNavTimerRef.current) { + clearTimeout(exitNavTimerRef.current) + exitNavTimerRef.current = null + } disconnect() }, [sessionId, disconnect]) @@ -299,6 +313,10 @@ export default function TerminalPage() { return () => { inputDisposableRef.current?.dispose() connectOnceRef.current = false + if (exitNavTimerRef.current) { + clearTimeout(exitNavTimerRef.current) + exitNavTimerRef.current = null + } disconnect() } }, [disconnect]) @@ -313,6 +331,10 @@ export default function TerminalPage() { useEffect(() => { if (terminalState.status === 'connecting' || terminalState.status === 'connected') { setExitInfo(null) + if (exitNavTimerRef.current) { + clearTimeout(exitNavTimerRef.current) + exitNavTimerRef.current = null + } } }, [terminalState.status])