1- import { useEffect , useRef , useCallback } from "react" ;
1+ import { useEffect , useRef , useCallback , useState } from "react" ;
22import { Terminal } from "@xterm/xterm" ;
33import { FitAddon } from "@xterm/addon-fit" ;
44import { WebLinksAddon } from "@xterm/addon-web-links" ;
@@ -7,31 +7,52 @@ import apiClient from "../../api/client";
77import type { TerminalSession } from "../../stores/terminalStore" ;
88import { useTerminalStore } from "../../stores/terminalStore" ;
99import { useTerminalThemeStore } from "../../stores/terminalThemeStore" ;
10+ import type { TerminalError } from "./terminalErrors" ;
11+ import TerminalErrorBanner from "./TerminalErrorBanner" ;
12+ import {
13+ WS_KIND ,
14+ HTTP_CONNECT_ERROR ,
15+ WS_CLOSE_ERROR ,
16+ WS_NETWORK_ERROR ,
17+ parseMessage ,
18+ resolveError ,
19+ } from "./terminalErrors" ;
1020
1121interface TerminalInstanceProps {
1222 session : TerminalSession ;
1323 visible : boolean ;
1424}
1525
16- export default function TerminalInstance ( { session, visible } : TerminalInstanceProps ) {
26+ export default function TerminalInstance ( {
27+ session,
28+ visible,
29+ } : TerminalInstanceProps ) {
1730 const containerRef = useRef < HTMLDivElement > ( null ) ;
1831 const termRef = useRef < Terminal | null > ( null ) ;
1932 const fitRef = useRef < FitAddon | null > ( null ) ;
2033 const wsRef = useRef < WebSocket | null > ( null ) ;
2134 const observerRef = useRef < ResizeObserver | null > ( null ) ;
2235 const prevVisibleRef = useRef ( visible ) ;
36+ const [ error , setError ] = useState < TerminalError | null > ( null ) ;
2337
2438 const { theme, fontFamilyWithFallback, fontSize } = useTerminalThemeStore ( ) ;
2539
26- const updateStatus = useCallback ( ( s : "connecting" | "connected" | "disconnected" ) => {
27- useTerminalStore . getState ( ) . setConnectionStatus ( session . id , s ) ;
28- } , [ session . id ] ) ;
40+ const updateStatus = useCallback (
41+ ( s : "connecting" | "connected" | "disconnected" ) => {
42+ useTerminalStore . getState ( ) . setConnectionStatus ( session . id , s ) ;
43+ } ,
44+ [ session . id ] ,
45+ ) ;
2946
3047 // Connect on mount, cleanup on unmount
3148 useEffect ( ( ) => {
3249 let cancelled = false ;
33- const { theme : initTheme , fontFamilyWithFallback : initFont , fontSize : initSize } =
34- useTerminalThemeStore . getState ( ) ;
50+ let lastError = false ;
51+ const {
52+ theme : initTheme ,
53+ fontFamilyWithFallback : initFont ,
54+ fontSize : initSize ,
55+ } = useTerminalThemeStore . getState ( ) ;
3556
3657 async function connect ( ) {
3758 updateStatus ( "connecting" ) ;
@@ -45,7 +66,9 @@ export default function TerminalInstance({ session, visible }: TerminalInstanceP
4566 } ) ;
4667 token = res . data . token ;
4768 } catch {
69+ if ( cancelled ) return ;
4870 updateStatus ( "disconnected" ) ;
71+ setError ( HTTP_CONNECT_ERROR ) ;
4972 return ;
5073 }
5174
@@ -88,34 +111,49 @@ export default function TerminalInstance({ session, visible }: TerminalInstanceP
88111 } ;
89112
90113 ws . onmessage = async ( event ) => {
114+ if ( cancelled ) return ;
91115 if ( event . data instanceof Blob ) {
92116 term . write ( await event . data . text ( ) ) ;
93117 } else {
94- term . write ( event . data ) ;
118+ const msg = parseMessage ( event . data ) ;
119+ if ( msg ?. kind === WS_KIND . ERROR ) {
120+ lastError = true ;
121+ updateStatus ( "disconnected" ) ;
122+ setError ( resolveError ( msg . data , session . deviceUid ) ) ;
123+ } else if ( ! lastError ) {
124+ term . write ( event . data ) ;
125+ }
95126 }
96127 } ;
97128
98129 ws . onclose = ( ) => {
99130 if ( cancelled ) return ;
100131 updateStatus ( "disconnected" ) ;
101- term . write ( "\r\n\x1b[1;31mDisconnected.\x1b[0m\r\n" ) ;
132+ if ( ! lastError ) {
133+ setError ( WS_CLOSE_ERROR ) ;
134+ }
102135 } ;
103136
104137 ws . onerror = ( ) => {
105138 if ( cancelled ) return ;
139+ lastError = true ;
106140 updateStatus ( "disconnected" ) ;
107- term . write ( "\r\n\x1b[1;31mConnection error.\x1b[0m\r\n" ) ;
141+ setError ( WS_NETWORK_ERROR ) ;
108142 } ;
109143
110144 term . onData ( ( data ) => {
111145 if ( ws . readyState === WebSocket . OPEN ) {
112- ws . send ( JSON . stringify ( { kind : 1 , data : data . slice ( 0 , 4096 ) } ) ) ;
146+ ws . send (
147+ JSON . stringify ( { kind : WS_KIND . INPUT , data : data . slice ( 0 , 4096 ) } ) ,
148+ ) ;
113149 }
114150 } ) ;
115151
116152 term . onResize ( ( { cols, rows } ) => {
117153 if ( ws . readyState === WebSocket . OPEN ) {
118- ws . send ( JSON . stringify ( { kind : 2 , data : { cols, rows } } ) ) ;
154+ ws . send (
155+ JSON . stringify ( { kind : WS_KIND . RESIZE , data : { cols, rows } } ) ,
156+ ) ;
119157 }
120158 } ) ;
121159
@@ -135,15 +173,18 @@ export default function TerminalInstance({ session, visible }: TerminalInstanceP
135173 observerRef . current ?. disconnect ( ) ;
136174 observerRef . current = null ;
137175 if ( wsRef . current ) {
176+ wsRef . current . onopen = null ;
138177 wsRef . current . onclose = null ;
178+ wsRef . current . onerror = null ;
179+ wsRef . current . onmessage = null ;
139180 wsRef . current . close ( ) ;
140181 wsRef . current = null ;
141182 }
142183 termRef . current ?. dispose ( ) ;
143184 termRef . current = null ;
144185 fitRef . current = null ;
145186 } ;
146- // eslint-disable-next-line react-hooks/exhaustive-deps
187+ // eslint-disable-next-line react-hooks/exhaustive-deps
147188 } , [ session . id ] ) ;
148189
149190 // Live theme/font updates
@@ -167,20 +208,35 @@ export default function TerminalInstance({ session, visible }: TerminalInstanceP
167208 fitRef . current ?. fit ( ) ;
168209 } , [ fontSize ] ) ;
169210
211+ // Hide cursor on error
212+ useEffect ( ( ) => {
213+ const term = termRef . current ;
214+ if ( ! term || error === null ) return ;
215+ term . options . cursorBlink = false ;
216+ term . blur ( ) ;
217+ term . write ( "\x1b[?25l" ) ; // CSI sequence to hide cursor
218+ } , [ error ] ) ;
219+
170220 // Handle visibility changes (minimize/restore)
171221 useEffect ( ( ) => {
172- if ( ! prevVisibleRef . current && visible ) {
222+ if ( ! prevVisibleRef . current && visible && error === null ) {
173223 requestAnimationFrame ( ( ) => {
174224 fitRef . current ?. fit ( ) ;
175225 termRef . current ?. focus ( ) ;
176226 } ) ;
177227 }
178228 prevVisibleRef . current = visible ;
179- } , [ visible ] ) ;
229+ } , [ visible , error ] ) ;
180230
181231 return (
182- < div className = "flex-1 overflow-hidden" >
183- < div ref = { containerRef } className = "w-full h-full" />
232+ < div className = "relative flex-1 flex flex-col overflow-hidden" >
233+ { error !== null && (
234+ < TerminalErrorBanner error = { error } sessionId = { session . id } />
235+ ) }
236+ < div
237+ ref = { containerRef }
238+ className = { `flex-1 ${ error !== null ? "opacity-30 pointer-events-none" : "" } ` }
239+ />
184240 </ div >
185241 ) ;
186242}
0 commit comments