4444</template >
4545
4646<script setup lang="ts">
47- import { ref , onMounted , onBeforeUnmount , watch , Ref , computed } from " vue" ;
47+ import {
48+ ref ,
49+ onMounted ,
50+ onBeforeUnmount ,
51+ watch ,
52+ Ref ,
53+ computed ,
54+ type WatchStopHandle ,
55+ } from " vue" ;
4856import { Terminal } from " @xterm/xterm" ;
4957import { FitAddon } from " @xterm/addon-fit" ;
5058import { uid , useQuasar } from " quasar" ;
@@ -63,6 +71,7 @@ interface TerminalWSMessage {
6371 };
6472 error? : string ;
6573}
74+
6675interface ShellOption {
6776 label: string ;
6877 value: string ;
@@ -71,6 +80,7 @@ interface ShellOption {
7180
7281const $q = useQuasar ();
7382const props = defineProps <{ agent_id: string ; agentPlatform: string }>();
83+
7484const loading = ref (false );
7585const customShellPath = ref <string | null >(null );
7686const showCustomShellDialog = ref (false );
@@ -104,63 +114,130 @@ const selectedShell = ref<string>("");
104114
105115let term: Terminal | null = null ;
106116const fit = new FitAddon ();
107- let dataDisposable: { dispose: () => void } | null = null ;
117+
118+ let inputDisposable: { dispose: () => void } | null = null ;
108119let stopResizeObserver: (() => void ) | null = null ;
109- let wsReadyInterval: number | null = null ;
120+ let stopWSDataWatch: WatchStopHandle | null = null ;
121+ let wsReadyInterval: ReturnType <typeof setInterval > | null = null ;
122+
123+ let startRequested = false ;
110124let started = false ;
111125
112- let wsData: Ref <TerminalWSMessage | null >;
113- let wsSend: (msg : string ) => void ;
114- let wsClose: () => void ;
115- let wsStatus: Ref <string >;
126+ let activeSessionId: string | null = null ;
127+
128+ let wsData! : Ref <TerminalWSMessage | null >;
129+ let wsSend! : (msg : string ) => void ;
130+ let wsClose! : () => void ;
131+ let wsStatus! : Ref <string >;
116132
117133const xtermContainer = ref <HTMLElement | null >(null );
118134
119135function waitForLayout(): Promise <void > {
120136 return new Promise ((resolve ) => {
121137 requestAnimationFrame (() => {
122- requestAnimationFrame (() => {
123- resolve ();
124- });
138+ requestAnimationFrame (() => resolve ());
125139 });
126140 });
127141}
128142
129- function initWS(shell : string ) {
130- dataDisposable ?.dispose ();
131- dataDisposable = null ;
143+ function clearWSReadyInterval() {
144+ if (wsReadyInterval ) {
145+ clearInterval (wsReadyInterval );
146+ wsReadyInterval = null ;
147+ }
148+ }
149+
150+ function cleanupCurrentSession({
151+ sendKill = true ,
152+ }: { sendKill? : boolean } = {}) {
153+ clearWSReadyInterval ();
154+
155+ stopWSDataWatch ?.();
156+ stopWSDataWatch = null ;
157+
158+ inputDisposable ?.dispose ();
159+ inputDisposable = null ;
160+
161+ startRequested = false ;
162+ started = false ;
163+
164+ if (sendKill ) {
165+ try {
166+ wsSend ?.(JSON .stringify ({ action: " kill" }));
167+ } catch {}
168+ }
169+
132170 try {
133171 wsClose ?.();
134172 } catch {}
135173
174+ activeSessionId = null ;
175+ }
176+
177+ function markSessionReadyAndResize() {
178+ if (! term || ! startRequested || started ) return ;
179+
180+ started = true ;
181+ loading .value = false ;
182+
183+ void waitForLayout ().then (() => {
184+ if (! term || ! started ) return ;
185+ fit .fit ();
186+ wsSend (
187+ JSON .stringify ({
188+ action: " resize" ,
189+ rows: term .rows ,
190+ cols: term .cols ,
191+ }),
192+ );
193+ });
194+ }
195+
196+ function initWS(shell : string ) {
197+ cleanupCurrentSession ();
198+
199+ const sessionId = uid ();
200+ activeSessionId = sessionId ;
201+
136202 ({
137203 data: wsData ,
138204 send: wsSend ,
139205 close: wsClose ,
140206 status: wsStatus ,
141- } = useTerminalWSConnection (props .agent_id , uid () ) as {
207+ } = useTerminalWSConnection (props .agent_id , sessionId ) as {
142208 data: Ref <TerminalWSMessage | null >;
143209 send: (msg : string ) => void ;
144210 close: () => void ;
145211 status: Ref <string >;
146212 });
147213
148- watch (wsData , (msg ) => {
149- if (! msg ?.action || ! term ) return ;
214+ stopWSDataWatch = watch (wsData , (msg ) => {
215+ if (! msg || ! term ) return ;
216+ if (activeSessionId !== sessionId ) return ;
150217
151218 if (msg .action === " terminal_error" ) {
152219 loading .value = false ;
220+ startRequested = false ;
221+ started = false ;
153222 invalidCustomShell .value = true ;
223+
154224 $q .notify ({
155225 type: " negative" ,
156226 message: msg .error || msg .data ?.error || " Shell path doesn't exist" ,
157227 });
228+
158229 showCustomShellDialog .value = true ;
159230 pendingCustomShell .value = null ;
160231 return ;
161232 }
233+
162234 if (msg .data ?.output ) {
163235 term .write (msg .data .output );
236+
237+ if (! started ) {
238+ markSessionReadyAndResize ();
239+ }
240+
164241 if (pendingCustomShell .value ) {
165242 customShellPath .value = pendingCustomShell .value ;
166243 selectedShell .value = " custom" ;
@@ -169,31 +246,41 @@ function initWS(shell: string) {
169246 invalidCustomShell .value = false ;
170247 }
171248 }
249+
172250 if (msg .data ?.done ) {
251+ started = false ;
252+ startRequested = false ;
253+ loading .value = false ;
173254 term .write (" \r\n [Session Ended]\r\n " );
174255 }
175256 });
176257
177- dataDisposable = term ! .onData ((d ) => {
258+ inputDisposable = term ! .onData ((d ) => {
178259 if (! started ) return ;
260+ if (activeSessionId !== sessionId ) return ;
261+
179262 wsSend (JSON .stringify ({ action: " input" , data: d }));
180263 });
181264
182- const interval = setInterval (async () => {
265+ wsReadyInterval = setInterval (async () => {
266+ if (activeSessionId !== sessionId ) {
267+ clearWSReadyInterval ();
268+ return ;
269+ }
270+
183271 if (wsStatus .value === " OPEN" && term ) {
272+ clearWSReadyInterval ();
273+
184274 await waitForLayout ();
275+ if (! term || activeSessionId !== sessionId ) return ;
276+
185277 fit .fit ();
278+
279+ startRequested = true ;
280+ started = false ;
281+ loading .value = true ;
282+
186283 wsSend (JSON .stringify ({ action: " start" , shell }));
187- wsSend (
188- JSON .stringify ({
189- action: " resize" ,
190- rows: term .rows ,
191- cols: term .cols ,
192- }),
193- );
194- started = true ;
195- loading .value = false ;
196- clearInterval (interval );
197284 }
198285 }, 50 );
199286}
@@ -211,31 +298,39 @@ function handleCustomEdit() {
211298
212299async function onShellChange(newShell : string ) {
213300 if (! term ) return ;
301+
214302 if (newShell === " custom" ) {
215303 if (selectedShell .value !== " custom" ) {
216304 lastSelectedShell .value = selectedShell .value ;
217305 }
306+
218307 if (! customShellPath .value || invalidCustomShell .value ) {
219308 showCustomShellDialog .value = true ;
220309 customShellInput .value = " " ;
221310 return ;
222311 }
312+
223313 newShell = customShellPath .value ;
224314 }
315+
225316 loading .value = true ;
317+ startRequested = false ;
226318 started = false ;
227319 term .reset ();
228320 fit .fit ();
229321 initWS (newShell );
230322}
231323
232324function startCustomShell() {
233- if (! customShellInput .value ) return ;
325+ if (! customShellInput .value || ! term ) return ;
326+
234327 loading .value = true ;
328+ startRequested = false ;
235329 started = false ;
236330 invalidCustomShell .value = false ;
237331 pendingCustomShell .value = customShellInput .value ;
238- term ?.reset ();
332+
333+ term .reset ();
239334 fit .fit ();
240335 initWS (customShellInput .value );
241336}
@@ -259,27 +354,25 @@ const resizeWindow = useDebounceFn(async () => {
259354 if (! term || ! started ) return ;
260355
261356 await waitForLayout ();
357+ if (! term || ! started ) return ;
358+
262359 fit .fit ();
263360 wsSend (
264- JSON .stringify ({ action: " resize" , rows: term .rows , cols: term .cols }),
361+ JSON .stringify ({
362+ action: " resize" ,
363+ rows: term .rows ,
364+ cols: term .cols ,
365+ }),
265366 );
266367}, 200 );
267368
268369function disconnect() {
269- if ( wsReadyInterval ) clearInterval ( wsReadyInterval );
270- wsReadyInterval = null ;
370+ clearWSReadyInterval ( );
371+
271372 stopResizeObserver ?.();
272373 stopResizeObserver = null ;
273374
274- dataDisposable ?.dispose ();
275- dataDisposable = null ;
276-
277- started = false ;
278-
279- try {
280- wsSend ?.(JSON .stringify ({ action: " kill" }));
281- } catch {}
282- wsClose ?.();
375+ cleanupCurrentSession ();
283376
284377 term ?.dispose ();
285378 term = null ;
@@ -289,6 +382,7 @@ function cancelCustomShell() {
289382 showCustomShellDialog .value = false ;
290383 selectedShell .value = lastSelectedShell .value ;
291384 loading .value = true ;
385+ startRequested = false ;
292386 started = false ;
293387 term ?.reset ();
294388 fit .fit ();
@@ -297,18 +391,22 @@ function cancelCustomShell() {
297391
298392const BUILT_IN_SHELLS = [" cmd" , " powershell" , " bash" ] as const ;
299393type BuiltInShell = (typeof BUILT_IN_SHELLS )[number ];
394+
300395const isBuiltInShell = (shell : string ): shell is BuiltInShell => {
301396 return (BUILT_IN_SHELLS as readonly string []).includes (shell );
302397};
303398
304399onMounted (async () => {
305- setupXTerm ();
400+ await setupXTerm ();
401+
306402 const { stop } = useResizeObserver (xtermContainer , resizeWindow );
307403 stopResizeObserver = stop ;
404+
308405 const data = await fetchAgentShell (props .agent_id );
309406 if (data ) {
310407 const { default_shell, effective_default_shell } = data ;
311408 const isWindows = props .agentPlatform === " windows" ;
409+
312410 if (default_shell === " custom" ) {
313411 if (isBuiltInShell (effective_default_shell )) {
314412 selectedShell .value = effective_default_shell ;
@@ -324,15 +422,16 @@ onMounted(async () => {
324422 } else {
325423 selectedShell .value = effective_default_shell ;
326424 lastSelectedShell .value = effective_default_shell ;
327-
328425 invalidCustomShell .value = false ;
329426 customShellPath .value = null ;
330427 }
331428 }
429+
332430 const shellToStart =
333431 selectedShell .value === " custom"
334432 ? customShellPath .value !
335433 : selectedShell .value ;
434+
336435 initWS (shellToStart );
337436});
338437
0 commit comments