fix(terminal): prevent infinite reconnect loop on Windows hosts (#336)

This commit is contained in:
lifu963
2026-03-21 21:45:40 +08:00
committed by GitHub
parent ae39fc5e77
commit 895654ddf6
12 changed files with 165 additions and 21 deletions
@@ -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}
+4 -1
View File
@@ -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}
+1
View File
@@ -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…',
+1
View File
@@ -144,6 +144,7 @@ export default {
'terminal.commandArgs': '命令参数',
'terminal.stdout': '标准输出',
'terminal.stderr': '标准错误',
'terminal.unsupportedWindows': 'Windows 主机暂不支持远程终端。',
'terminal.paste.fallbackTitle': '粘贴输入',
'terminal.paste.fallbackDescription': '无法读取剪贴板,请在下方粘贴文本。',
'terminal.paste.placeholder': '在此粘贴终端输入…',
+20 -13
View File
@@ -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>
+15
View File
@@ -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)
})
})
+9
View File
@@ -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)
}