From 3473a88d67f3b338e67e238ee3b2ac7bfbcfde76 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:56:50 +0100 Subject: [PATCH] feat(web): auto-return to chat when remote terminal exits (#857) When the shell exited inside the remote terminal view, the page kept showing a banner ("Terminal exited with code 0.") and left the user stranded with no obvious next step. On mobile this is awkward, and it does not match the muscle memory from native terminal emulators where typing `exit` closes the tab/window. Schedule a goBack() shortly after `terminal:exit` fires so the user briefly sees the exit info, then returns to the session chat (same destination as the existing back arrow via useAppGoBack). The auto-close timer is cleared on unmount, on sessionId change, and when the socket reconnects after a transient drop so a stale exit event cannot navigate away from a freshly reconnected terminal. Closes #856 Co-authored-by: Cursor --- web/src/routes/sessions/terminal.test.tsx | 61 +++++++++++++++++++---- web/src/routes/sessions/terminal.tsx | 24 ++++++++- 2 files changed, 73 insertions(+), 12 deletions(-) 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])