mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user