optimize: terminal spawn lifecycle to eliminate output race condition

Move PTY spawn from context layer to view layer so event subscription
happens before spawn, preventing loss of initial terminal output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-02 13:55:38 +08:00
parent d0e0aad525
commit b2d10fa008
8 changed files with 143 additions and 60 deletions

View File

@@ -18,6 +18,8 @@ export function TerminalPanel() {
<TerminalView
key={tab.id}
terminalId={tab.id}
workingDir={tab.workingDir}
initialCommand={tab.initialCommand}
isActive={tab.id === activeTabId}
isVisible={isOpen}
/>

View File

@@ -1,8 +1,13 @@
"use client"
import { useEffect, useRef } from "react"
import { useEffect, useRef, useState } from "react"
import { subscribe } from "@/lib/platform"
import { terminalWrite, terminalResize } from "@/lib/api"
import {
terminalSpawn,
terminalWrite,
terminalResize,
terminalKill,
} from "@/lib/api"
import type { TerminalEvent } from "@/lib/types"
import type { ITheme } from "@xterm/xterm"
@@ -86,12 +91,16 @@ function getTerminalTheme(container: HTMLDivElement | null): ITheme {
interface TerminalViewProps {
terminalId: string
workingDir: string
initialCommand?: string
isActive: boolean
isVisible: boolean
}
export function TerminalView({
terminalId,
workingDir,
initialCommand,
isActive,
isVisible,
}: TerminalViewProps) {
@@ -101,6 +110,7 @@ export function TerminalView({
const lastResizeRef = useRef<{ cols: number; rows: number } | null>(null)
const isActiveRef = useRef(isActive)
const isVisibleRef = useRef(isVisible)
const [loading, setLoading] = useState(true)
useEffect(() => {
isActiveRef.current = isActive
@@ -167,7 +177,7 @@ export function TerminalView({
}
)
// Set up event listeners BEFORE fit so initial output is captured
// Subscribe to events BEFORE spawning so no initial output is lost
const unlisten = await subscribe<TerminalEvent>(
`terminal://output/${terminalId}`,
(payload) => {
@@ -192,6 +202,27 @@ export function TerminalView({
return
}
// Spawn the terminal AFTER subscribing to events
try {
await terminalSpawn(workingDir, initialCommand, terminalId)
} catch (err) {
term.write(`\r\n\x1b[31m[Failed to start terminal: ${err}]\x1b[0m\r\n`)
} finally {
if (!cancelled) setLoading(false)
}
// If unmounted while spawn was in flight, clean up the spawned PTY
if (cancelled) {
terminalKill(terminalId).catch(() => {})
themeObserver.disconnect()
onDataDisposable.dispose()
onResizeDisposable.dispose()
unlisten()
unlistenExit()
term.dispose()
return
}
const fitIfReady = () => {
const el = containerRef.current
if (!el) return
@@ -237,7 +268,7 @@ export function TerminalView({
cancelled = true
cleanup?.()
}
}, [terminalId])
}, [terminalId, workingDir, initialCommand])
// Refit and focus when becoming active or panel becomes visible
useEffect(() => {
@@ -262,6 +293,32 @@ export function TerminalView({
aria-hidden={!isActive}
>
<div ref={containerRef} className="h-full w-full" />
{loading && isActive && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>Starting terminal...</span>
</div>
</div>
)}
</div>
)
}