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 <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-10 13:56:50 +08:00
committed by GitHub
co-authored by Cursor
parent e2625b8b47
commit 3473a88d67
2 changed files with 73 additions and 12 deletions
+50 -11
View File
@@ -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 }
)
})
})
+23 -1
View File
@@ -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<ModifierState>({ ctrl: false, alt: false })
const exitNavTimerRef = useRef<ReturnType<typeof setTimeout> | 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])