mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(terminal): prevent infinite reconnect loop on Windows hosts (#336)
This commit is contained in:
@@ -97,7 +97,7 @@ export class TerminalManager {
|
||||
|
||||
create(terminalId: string, cols: number, rows: number): void {
|
||||
if (process.platform === 'win32') {
|
||||
this.emitError(terminalId, 'Terminal is not supported on Windows.')
|
||||
this.emitError(terminalId, 'Remote terminal is not supported on Windows yet.')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -97,7 +97,9 @@ Yes. Open any session and use the chat interface to send messages directly to th
|
||||
|
||||
### Can I access a terminal remotely?
|
||||
|
||||
Yes. Open a session in the web app and tap the Terminal tab for a remote shell.
|
||||
Yes, on Linux and macOS hosts. Open a session in the web app and tap the Terminal tab for a remote shell.
|
||||
|
||||
Windows hosts do not support the remote Terminal yet because the Bun PTY API used by HAPI is currently POSIX-only.
|
||||
|
||||
### How do I use voice control?
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { StoredSession } from '../../../store'
|
||||
import type { CliSocketWithData } from '../../socketTypes'
|
||||
import { TerminalRegistry } from '../../terminalRegistry'
|
||||
import { registerTerminalHandlers } from './terminalHandlers'
|
||||
|
||||
type EmittedEvent = {
|
||||
event: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
class FakeSocket {
|
||||
readonly id: string
|
||||
readonly data: Record<string, unknown> = {}
|
||||
readonly emitted: EmittedEvent[] = []
|
||||
private readonly handlers = new Map<string, (...args: unknown[]) => void>()
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
on(event: string, handler: (...args: unknown[]) => void): this {
|
||||
this.handlers.set(event, handler)
|
||||
return this
|
||||
}
|
||||
|
||||
emit(event: string, data: unknown): boolean {
|
||||
this.emitted.push({ event, data })
|
||||
return true
|
||||
}
|
||||
|
||||
trigger(event: string, data?: unknown): void {
|
||||
const handler = this.handlers.get(event)
|
||||
if (!handler) {
|
||||
return
|
||||
}
|
||||
if (typeof data === 'undefined') {
|
||||
handler()
|
||||
return
|
||||
}
|
||||
handler(data)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNamespace {
|
||||
readonly sockets = new Map<string, FakeSocket>()
|
||||
}
|
||||
|
||||
function lastEmit(socket: FakeSocket, event: string): EmittedEvent | undefined {
|
||||
return [...socket.emitted].reverse().find((entry) => entry.event === event)
|
||||
}
|
||||
|
||||
describe('cli terminal handlers', () => {
|
||||
it('removes stale registry entries after terminal errors', () => {
|
||||
const cliSocket = new FakeSocket('cli-socket')
|
||||
const terminalSocket = new FakeSocket('terminal-socket')
|
||||
const terminalNamespace = new FakeNamespace()
|
||||
const terminalRegistry = new TerminalRegistry({ idleTimeoutMs: 0 })
|
||||
|
||||
terminalNamespace.sockets.set(terminalSocket.id, terminalSocket)
|
||||
terminalRegistry.register('terminal-1', 'session-1', terminalSocket.id, cliSocket.id)
|
||||
|
||||
registerTerminalHandlers(cliSocket as unknown as CliSocketWithData, {
|
||||
terminalRegistry,
|
||||
terminalNamespace: terminalNamespace as never,
|
||||
resolveSessionAccess: () => ({ ok: true, value: {} as StoredSession }),
|
||||
emitAccessError: () => {
|
||||
throw new Error('Unexpected access error')
|
||||
}
|
||||
})
|
||||
|
||||
cliSocket.trigger('terminal:error', {
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
message: 'Remote terminal is not supported on Windows yet.'
|
||||
})
|
||||
|
||||
expect(terminalRegistry.get('terminal-1')).toBeNull()
|
||||
expect(lastEmit(terminalSocket, 'terminal:error')?.data).toEqual({
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
message: 'Remote terminal is not supported on Windows yet.'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -93,7 +93,22 @@ export function registerTerminalHandlers(socket: CliSocketWithData, deps: Termin
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
forwardTerminalEvent('terminal:error', parsed.data)
|
||||
|
||||
const entry = terminalRegistry.get(parsed.data.terminalId)
|
||||
if (!entry || entry.sessionId !== parsed.data.sessionId || entry.cliSocketId !== socket.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionAccess = resolveSessionAccess(parsed.data.sessionId)
|
||||
if (!sessionAccess.ok) {
|
||||
terminalRegistry.remove(parsed.data.terminalId)
|
||||
emitAccessError('session', parsed.data.sessionId, sessionAccess.reason)
|
||||
return
|
||||
}
|
||||
|
||||
const terminalSocket = terminalNamespace.sockets.get(entry.socketId)
|
||||
terminalRegistry.remove(parsed.data.terminalId)
|
||||
terminalSocket?.emit('terminal:error', parsed.data)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,7 @@ export function ComposerButtons(props: {
|
||||
onSettingsToggle: () => void
|
||||
showTerminalButton: boolean
|
||||
terminalDisabled: boolean
|
||||
terminalLabel: string
|
||||
onTerminal: () => void
|
||||
showAbortButton: boolean
|
||||
abortDisabled: boolean
|
||||
@@ -350,8 +351,8 @@ export function ComposerButtons(props: {
|
||||
{props.showTerminalButton ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('composer.terminal')}
|
||||
title={t('composer.terminal')}
|
||||
aria-label={props.terminalLabel}
|
||||
title={props.terminalLabel}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={props.onTerminal}
|
||||
disabled={props.terminalDisabled}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function HappyComposer(props: {
|
||||
onModelChange?: (model: string | null) => void
|
||||
onSwitchToRemote?: () => void
|
||||
onTerminal?: () => void
|
||||
terminalUnsupported?: boolean
|
||||
autocompletePrefixes?: string[]
|
||||
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
|
||||
// Voice assistant props
|
||||
@@ -80,6 +81,7 @@ export function HappyComposer(props: {
|
||||
onModelChange,
|
||||
onSwitchToRemote,
|
||||
onTerminal,
|
||||
terminalUnsupported = false,
|
||||
autocompletePrefixes = ['@', '/', '$'],
|
||||
autocompleteSuggestions = defaultSuggestionHandler,
|
||||
voiceStatus = 'disconnected',
|
||||
@@ -215,7 +217,9 @@ export function HappyComposer(props: {
|
||||
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
|
||||
const switchDisabled = controlsDisabled || isSwitching || !controlledByUser
|
||||
const showSwitchButton = Boolean(controlledByUser && onSwitchToRemote)
|
||||
const showTerminalButton = Boolean(onTerminal)
|
||||
const showTerminalButton = Boolean(onTerminal || terminalUnsupported)
|
||||
const terminalDisabled = controlsDisabled || terminalUnsupported
|
||||
const terminalLabel = terminalUnsupported ? t('terminal.unsupportedWindows') : t('composer.terminal')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAborting) return
|
||||
@@ -639,7 +643,8 @@ export function HappyComposer(props: {
|
||||
showSettingsButton={showSettingsButton}
|
||||
onSettingsToggle={handleSettingsToggle}
|
||||
showTerminalButton={showTerminalButton}
|
||||
terminalDisabled={controlsDisabled}
|
||||
terminalDisabled={terminalDisabled}
|
||||
terminalLabel={terminalLabel}
|
||||
onTerminal={onTerminal ?? (() => {})}
|
||||
showAbortButton={showAbortButton}
|
||||
abortDisabled={abortDisabled}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
|
||||
import { useVoiceOptional } from '@/lib/voice-context'
|
||||
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
||||
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
|
||||
|
||||
export function SessionChat(props: {
|
||||
api: ApiClient
|
||||
@@ -42,6 +43,7 @@ export function SessionChat(props: {
|
||||
const { haptic } = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
const sessionInactive = !props.session.active
|
||||
const terminalSupported = isRemoteTerminalSupported(props.session.metadata)
|
||||
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
||||
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
|
||||
const [forceScrollToken, setForceScrollToken] = useState(0)
|
||||
@@ -345,7 +347,8 @@ export function SessionChat(props: {
|
||||
onPermissionModeChange={handlePermissionModeChange}
|
||||
onModelChange={handleModelChange}
|
||||
onSwitchToRemote={handleSwitchToRemote}
|
||||
onTerminal={props.session.active ? handleViewTerminal : undefined}
|
||||
onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined}
|
||||
terminalUnsupported={props.session.active && !terminalSupported}
|
||||
autocompleteSuggestions={props.autocompleteSuggestions}
|
||||
voiceStatus={voice?.status}
|
||||
voiceMicMuted={voice?.micMuted}
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
'terminal.commandArgs': 'Command args',
|
||||
'terminal.stdout': 'Stdout',
|
||||
'terminal.stderr': 'Stderr',
|
||||
'terminal.unsupportedWindows': 'Remote terminal is unavailable on Windows hosts.',
|
||||
'terminal.paste.fallbackTitle': 'Paste input',
|
||||
'terminal.paste.fallbackDescription': 'Clipboard read is unavailable. Paste your text below.',
|
||||
'terminal.paste.placeholder': 'Paste terminal input here…',
|
||||
|
||||
@@ -144,6 +144,7 @@ export default {
|
||||
'terminal.commandArgs': '命令参数',
|
||||
'terminal.stdout': '标准输出',
|
||||
'terminal.stderr': '标准错误',
|
||||
'terminal.unsupportedWindows': 'Windows 主机暂不支持远程终端。',
|
||||
'terminal.paste.fallbackTitle': '粘贴输入',
|
||||
'terminal.paste.fallbackDescription': '无法读取剪贴板,请在下方粘贴文本。',
|
||||
'terminal.paste.placeholder': '在此粘贴终端输入…',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from '@/lib/use-translation'
|
||||
import { TerminalView } from '@/components/Terminal/TerminalView'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -184,6 +185,7 @@ export default function TerminalPage() {
|
||||
const { api, token, baseUrl } = useAppContext()
|
||||
const goBack = useAppGoBack()
|
||||
const { session } = useSession(api, sessionId)
|
||||
const terminalSupported = isRemoteTerminalSupported(session?.metadata)
|
||||
const terminalId = useMemo(() => {
|
||||
if (typeof crypto?.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
@@ -226,7 +228,6 @@ export default function TerminalPage() {
|
||||
onExit((code, signal) => {
|
||||
setExitInfo({ code, signal })
|
||||
terminalRef.current?.write(`\r\n[process exited${code !== null ? ` with code ${code}` : ''}]`)
|
||||
connectOnceRef.current = false
|
||||
})
|
||||
}, [onExit])
|
||||
|
||||
@@ -264,7 +265,7 @@ export default function TerminalPage() {
|
||||
const handleResize = useCallback(
|
||||
(cols: number, rows: number) => {
|
||||
lastSizeRef.current = { cols, rows }
|
||||
if (!session?.active) {
|
||||
if (!session?.active || !terminalSupported) {
|
||||
return
|
||||
}
|
||||
if (!connectOnceRef.current) {
|
||||
@@ -274,11 +275,11 @@ export default function TerminalPage() {
|
||||
resize(cols, rows)
|
||||
}
|
||||
},
|
||||
[session?.active, connect, resize]
|
||||
[session?.active, terminalSupported, connect, resize]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.active) {
|
||||
if (!session?.active || !terminalSupported) {
|
||||
return
|
||||
}
|
||||
if (connectOnceRef.current) {
|
||||
@@ -290,7 +291,7 @@ export default function TerminalPage() {
|
||||
}
|
||||
connectOnceRef.current = true
|
||||
connect(size.cols, size.rows)
|
||||
}, [session?.active, connect])
|
||||
}, [session?.active, terminalSupported, connect])
|
||||
|
||||
useEffect(() => {
|
||||
connectOnceRef.current = false
|
||||
@@ -307,17 +308,13 @@ export default function TerminalPage() {
|
||||
}, [disconnect])
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.active === false) {
|
||||
if (session?.active === false || !terminalSupported) {
|
||||
disconnect()
|
||||
connectOnceRef.current = false
|
||||
}
|
||||
}, [session?.active, disconnect])
|
||||
}, [session?.active, terminalSupported, disconnect])
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalState.status === 'error') {
|
||||
connectOnceRef.current = false
|
||||
return
|
||||
}
|
||||
if (terminalState.status === 'connecting' || terminalState.status === 'connected') {
|
||||
setExitInfo(null)
|
||||
}
|
||||
@@ -405,7 +402,11 @@ export default function TerminalPage() {
|
||||
|
||||
const subtitle = session.metadata?.path ?? sessionId
|
||||
const status = terminalState.status
|
||||
const errorMessage = terminalState.status === 'error' ? terminalState.error : null
|
||||
const errorMessage = !terminalSupported
|
||||
? t('terminal.unsupportedWindows')
|
||||
: terminalState.status === 'error'
|
||||
? terminalState.error
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -453,7 +454,13 @@ export default function TerminalPage() {
|
||||
|
||||
<div className="flex-1 overflow-hidden bg-[var(--app-bg)]">
|
||||
<div className="mx-auto h-full w-full max-w-content p-3">
|
||||
<TerminalView onMount={handleTerminalMount} onResize={handleResize} className="h-full w-full" />
|
||||
{terminalSupported ? (
|
||||
<TerminalView onMount={handleTerminalMount} onResize={handleResize} className="h-full w-full" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-4 text-sm text-[var(--app-hint)]">
|
||||
{t('terminal.unsupportedWindows')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isRemoteTerminalSupported, isWindowsHostOs } from './terminalSupport'
|
||||
|
||||
describe('terminal support helpers', () => {
|
||||
it('detects Windows session hosts as unsupported', () => {
|
||||
expect(isWindowsHostOs('win32')).toBe(true)
|
||||
expect(isRemoteTerminalSupported({ os: 'win32', path: '', host: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps remote terminal enabled for non-Windows or unknown hosts', () => {
|
||||
expect(isWindowsHostOs('linux')).toBe(false)
|
||||
expect(isRemoteTerminalSupported({ os: 'linux', path: '', host: '' })).toBe(true)
|
||||
expect(isRemoteTerminalSupported(null)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SessionMetadataSummary } from '@/types/api'
|
||||
|
||||
export function isWindowsHostOs(os: string | null | undefined): boolean {
|
||||
return typeof os === 'string' && os.toLowerCase() === 'win32'
|
||||
}
|
||||
|
||||
export function isRemoteTerminalSupported(metadata: SessionMetadataSummary | null | undefined): boolean {
|
||||
return !isWindowsHostOs(metadata?.os)
|
||||
}
|
||||
Reference in New Issue
Block a user