• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

apowers313 / aiforge / 22173138776

19 Feb 2026 07:50AM UTC coverage: 81.939% (+0.9%) from 81.026%
22173138776

push

github

apowers313
fix: status indicators when shell is deselected, new shell death bugs

2162 of 2510 branches covered (86.14%)

Branch coverage included in aggregate %.

50 of 175 new or added lines in 10 files covered. (28.57%)

214 existing lines in 10 files now uncovered.

10269 of 12661 relevant lines covered (81.11%)

27.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.92
/src/client/hooks/useTerminalSession.ts
1
/**
2
 * useTerminalSession - React hook for terminal session management with explicit state machine
3
 *
4
 * Replaces useTerminal with a cleaner state machine model using the new session.* protocol.
5
 */
6
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
1✔
7
import { useWebSocket } from './useWebSocket.js';
1✔
8
import { useUIStore } from '@client/stores/uiStore';
1✔
9
import { log } from '@client/services/logger';
1✔
10
import {
1✔
11
  generateRequestId,
12
  isSessionOpenedMessage,
13
  isSessionErrorMessage,
14
  isSessionOutputMessage,
15
  isSessionClosedMessage,
16
  isShellActivityMessage,
17
  type Shell,
18
} from '@shared/types/index.js';
19

20
/**
21
 * Grace period in ms to suppress activity recording after session open or resize.
22
 * Prevents false positives from terminal redraws triggered by resize/reconnect.
23
 */
24
const ACTIVITY_SUPPRESSION_MS = 1500;
1✔
25

26
const sessionLog = log.terminal;
1✔
27

28
/**
29
 * Session state union type
30
 */
31
export type SessionState =
32
  | { status: 'closed' }
33
  | { status: 'opening'; requestId: string }
34
  | { status: 'open'; shell: Shell; scrollback: string }
35
  | { status: 'reconnecting'; attempt: number; maxAttempts: number }
36
  | { status: 'error'; code: string; message: string; retryable: boolean };
37

38
/**
39
 * Options for useTerminalSession hook
40
 */
41
export interface UseTerminalSessionOptions {
42
  /**
43
   * Callback when output is received (not scrollback)
44
   */
45
  onOutput?: (data: string) => void;
46

47
  /**
48
   * Auto-open session on mount
49
   * @default true
50
   */
51
  autoOpen?: boolean;
52

53
  /**
54
   * Maximum reconnect attempts
55
   * @default 5
56
   */
57
  maxReconnectAttempts?: number;
58

59
  /**
60
   * WebSocket URL (defaults to current host)
61
   */
62
  wsUrl?: string;
63
}
64

65
/**
66
 * Return type for useTerminalSession hook
67
 */
68
export interface UseTerminalSessionReturn {
69
  state: SessionState;
70
  open: () => void;
71
  close: () => void;
72
  write: (data: string) => void;
73
  resize: (cols: number, rows: number) => void;
74
  retry: () => void;
75
}
76

77
/**
78
 * React hook for managing terminal sessions with explicit state machine
79
 */
80
export function useTerminalSession(
1✔
81
  shellId: string,
99✔
82
  options: UseTerminalSessionOptions = {},
99✔
83
): UseTerminalSessionReturn {
99✔
84
  // Determine WebSocket protocol based on page protocol
85
  const wsProtocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
99!
86
  const defaultWsUrl = `${wsProtocol}//${typeof window !== 'undefined' ? window.location.host : 'localhost:9000'}/ws/terminal`;
99!
87

88
  const {
99✔
89
    onOutput,
99✔
90
    autoOpen = true,
99✔
91
    maxReconnectAttempts = 5,
99✔
92
    wsUrl = defaultWsUrl,
99✔
93
  } = options;
99✔
94

95
  // State machine state
96
  const [state, setState] = useState<SessionState>({ status: 'closed' });
99✔
97

98
  // Track the current shellId to detect changes
99
  const currentShellIdRef = useRef(shellId);
99✔
100
  const previousShellIdRef = useRef<string | null>(null);
99✔
101

102
  // Track pending request ID for correlation
103
  const pendingRequestIdRef = useRef<string | null>(null);
99✔
104

105
  // Track reconnect attempts
106
  const reconnectAttemptRef = useRef(0);
99✔
107

108
  // Track if we've auto-opened to prevent duplicates
109
  const hasAutoOpenedRef = useRef(false);
99✔
110

111
  // Track if session is active for cleanup (avoid stale closure in unmount effect)
112
  const isSessionActiveRef = useRef(false);
99✔
113

114
  // Track activity suppression window to avoid false positives from resize/reconnect redraws
115
  const activitySuppressedUntilRef = useRef(0);
99✔
116

117
  // Store callbacks in refs to avoid effect dependencies
118
  const onOutputRef = useRef(onOutput);
99✔
119
  useEffect(() => {
99✔
120
    onOutputRef.current = onOutput;
18✔
121
  }, [onOutput]);
99✔
122

123
  // Get activity recorder from UI store
124
  const recordShellActivity = useUIStore((state) => state.recordShellActivity);
99✔
125

126
  // Handle incoming messages
127
  const handleMessage = useCallback((message: unknown) => {
99✔
128
    // shell.activity broadcasts are handled by useShellActivityTracker at the app level.
129
    // Skip them here to avoid duplicate processing.
130
    if (isShellActivityMessage(message)) {
17!
NEW
131
      return;
×
NEW
132
    }
×
133

134
    // Handle session.opened
135
    if (isSessionOpenedMessage(message)) {
17✔
136
      if (message.shellId !== currentShellIdRef.current) {
13!
UNCOV
137
        return;
×
UNCOV
138
      }
×
139

140
      // Check if this response matches our pending request
141
      if (pendingRequestIdRef.current && message.requestId === pendingRequestIdRef.current) {
13✔
142
        sessionLog.info({ shellId: message.shellId }, 'Session opened successfully');
13✔
143
        pendingRequestIdRef.current = null;
13✔
144
        reconnectAttemptRef.current = 0;
13✔
145

146
        setState({
13✔
147
          status: 'open',
13✔
148
          shell: message.shell,
13✔
149
          scrollback: message.scrollback,
13✔
150
        });
13✔
151
      }
13✔
152
      return;
13✔
153
    }
13✔
154

155
    // Handle session.error
156
    if (isSessionErrorMessage(message)) {
17✔
157
      if (message.shellId !== currentShellIdRef.current) {
2!
UNCOV
158
        return;
×
UNCOV
159
      }
×
160

161
      // Check if this response matches our pending request
162
      if (message.requestId && pendingRequestIdRef.current && message.requestId === pendingRequestIdRef.current) {
2✔
163
        sessionLog.error({ shellId: message.shellId, code: message.code }, 'Session error');
2✔
164
        pendingRequestIdRef.current = null;
2✔
165

166
        setState({
2✔
167
          status: 'error',
2✔
168
          code: message.code,
2✔
169
          message: message.message,
2✔
170
          retryable: message.retryable,
2✔
171
        });
2✔
172
      }
2✔
173
      return;
2✔
174
    }
2✔
175

176
    // Handle session.output
177
    if (isSessionOutputMessage(message)) {
17✔
178
      if (message.shellId !== currentShellIdRef.current) {
1!
UNCOV
179
        return;
×
UNCOV
180
      }
×
181

182
      sessionLog.debug({ bytes: message.data.length, isScrollback: message.isScrollback }, 'Session output received');
1✔
183

184
      // Only call onOutput for non-scrollback data
185
      // Scrollback is provided in the session.opened message and stored in state
186
      if (!message.isScrollback) {
1✔
187
        onOutputRef.current?.(message.data);
1✔
188
        // Only record activity if outside the suppression window
189
        // (avoids false positive from resize/reconnect-triggered redraws)
190
        if (Date.now() >= activitySuppressedUntilRef.current) {
1!
NEW
191
          recordShellActivity(message.shellId);
×
NEW
192
        }
×
193
      }
1✔
194
      return;
1✔
195
    }
1✔
196

197
    // Handle session.closed
198
    if (isSessionClosedMessage(message)) {
1✔
199
      if (message.shellId !== currentShellIdRef.current) {
1!
UNCOV
200
        return;
×
UNCOV
201
      }
×
202

203
      sessionLog.info({ shellId: message.shellId, reason: message.reason }, 'Session closed');
1✔
204

205
      if (message.reason === 'requested') {
1✔
206
        setState({ status: 'closed' });
1✔
207
      } else {
1!
208
        // Unexpected close - could transition to error or reconnecting
UNCOV
209
        setState({
×
UNCOV
210
          status: 'error',
×
UNCOV
211
          code: 'CONNECTION_LOST',
×
UNCOV
212
          message: `Session closed: ${message.reason}`,
×
UNCOV
213
          retryable: true,
×
UNCOV
214
        });
×
215
      }
×
216
      return;
1✔
217
    }
1✔
218
  }, [recordShellActivity]);
99✔
219

220
  // Handle WebSocket disconnect
221
  const handleClose = useCallback(() => {
99✔
222
    setState((currentState) => {
21✔
223
      // If we were in the middle of opening, the in-flight request will never
224
      // complete. Treat it as transient connection loss and enter reconnecting.
225
      if (currentState.status === 'opening') {
5!
NEW
226
        pendingRequestIdRef.current = null;
×
NEW
227
        reconnectAttemptRef.current = 1;
×
NEW
228
        return {
×
NEW
229
          status: 'reconnecting',
×
NEW
230
          attempt: 1,
×
NEW
231
          maxAttempts: maxReconnectAttempts,
×
NEW
232
        };
×
NEW
233
      }
×
234

235
      // Only transition to reconnecting if we were in an open state
236
      if (currentState.status === 'open') {
5✔
237
        reconnectAttemptRef.current = 1;
3✔
238
        return {
3✔
239
          status: 'reconnecting',
3✔
240
          attempt: 1,
3✔
241
          maxAttempts: maxReconnectAttempts,
3✔
242
        };
3✔
243
      }
3✔
244

245
      // If already reconnecting, increment attempt counter
246
      if (currentState.status === 'reconnecting') {
2✔
247
        reconnectAttemptRef.current++;
2✔
248
        const newAttempt = reconnectAttemptRef.current;
2✔
249

250
        if (newAttempt > maxReconnectAttempts) {
2✔
251
          return {
1✔
252
            status: 'error',
1✔
253
            code: 'CONNECTION_LOST',
1✔
254
            message: 'Max reconnect attempts exceeded',
1✔
255
            retryable: true,
1✔
256
          };
1✔
257
        }
1✔
258

259
        return {
1✔
260
          ...currentState,
1✔
261
          attempt: newAttempt,
1✔
262
        };
1✔
263
      }
1!
264

265
      // If we're in a retryable error state, re-enter reconnecting so the
266
      // auto-reconnect effect can re-attempt session establishment.
267
      if (currentState.status === 'error' && currentState.retryable) {
5!
NEW
268
        reconnectAttemptRef.current = 1;
×
NEW
269
        return {
×
NEW
270
          status: 'reconnecting',
×
NEW
271
          attempt: 1,
×
NEW
272
          maxAttempts: maxReconnectAttempts,
×
NEW
273
        };
×
NEW
274
      }
×
275

UNCOV
276
      return currentState;
×
277
    });
21✔
278
  }, [maxReconnectAttempts]);
99✔
279

280
  // Handle max retries reached from WebSocket
281
  const handleMaxRetries = useCallback(() => {
99✔
UNCOV
282
    setState((currentState) => {
×
UNCOV
283
      if (currentState.status === 'reconnecting') {
×
UNCOV
284
        return {
×
UNCOV
285
          status: 'error',
×
UNCOV
286
          code: 'CONNECTION_LOST',
×
UNCOV
287
          message: 'Unable to reconnect after multiple attempts',
×
UNCOV
288
          retryable: true,
×
UNCOV
289
        };
×
UNCOV
290
      }
×
UNCOV
291
      return currentState;
×
UNCOV
292
    });
×
293
  }, []);
99✔
294

295
  // Handle WebSocket reconnect (connection restored)
296
  const handleOpen = useCallback(() => {
99✔
297
    setState((currentState) => {
18✔
298
      // If we were reconnecting and the connection is restored, try to re-open session
299
      if (currentState.status === 'reconnecting') {
18✔
300
        // The session will be re-opened via the effect that watches ws.isConnected
301
        return currentState;
1✔
302
      }
1✔
303
      return currentState;
17✔
304
    });
18✔
305
  }, []);
99✔
306

307
  // Memoize reconnect options
308
  const reconnectOptions = useMemo(() => ({
99✔
309
    onMaxRetriesReached: handleMaxRetries,
18✔
310
  }), [handleMaxRetries]);
99✔
311

312
  // WebSocket connection
313
  const ws = useWebSocket(wsUrl, {
99✔
314
    onMessage: handleMessage,
99✔
315
    onOpen: handleOpen,
99✔
316
    onClose: handleClose,
99✔
317
    reconnectOptions,
99✔
318
    waitForHealth: true,
99✔
319
  });
99✔
320

321
  // Store ws.send in a ref to avoid effect dependencies
322
  const wsSendRef = useRef(ws.send);
99✔
323
  useEffect(() => {
99✔
324
    wsSendRef.current = ws.send;
18✔
325
  }, [ws.send]);
99✔
326

327
  // Open session action
328
  const open = useCallback(() => {
99✔
329
    if (state.status === 'opening' || state.status === 'open') {
20!
UNCOV
330
      return;
×
UNCOV
331
    }
×
332

333
    const requestId = generateRequestId();
20✔
334
    pendingRequestIdRef.current = requestId;
20✔
335

336
    sessionLog.info({ shellId: currentShellIdRef.current, requestId }, 'Opening session');
20✔
337

338
    // Suppress activity recording during the post-open grace period
339
    // to avoid false positives from scrollback replay and initial resize redraws
340
    activitySuppressedUntilRef.current = Date.now() + ACTIVITY_SUPPRESSION_MS;
20✔
341

342
    wsSendRef.current({
20✔
343
      type: 'session.open',
20✔
344
      shellId: currentShellIdRef.current,
20✔
345
      requestId,
20✔
346
    });
20✔
347

348
    setState({ status: 'opening', requestId });
20✔
349
  }, [state.status]);
99✔
350

351
  // Close session action
352
  const close = useCallback(() => {
99✔
353
    if (state.status === 'closed') {
1!
UNCOV
354
      return;
×
UNCOV
355
    }
×
356

357
    sessionLog.info({ shellId: currentShellIdRef.current }, 'Closing session');
1✔
358

359
    wsSendRef.current({
1✔
360
      type: 'session.close',
1✔
361
      shellId: currentShellIdRef.current,
1✔
362
    });
1✔
363

364
    // Don't immediately transition - wait for session.closed response
365
    // But for cleanup purposes, we can optimistically close
366
  }, [state.status]);
99✔
367

368
  // Write input action
369
  const write = useCallback((data: string) => {
99✔
370
    if (state.status !== 'open') {
2!
UNCOV
371
      sessionLog.warn({ status: state.status }, 'Cannot write: session not open');
×
UNCOV
372
      return;
×
UNCOV
373
    }
×
374

375
    wsSendRef.current({
2✔
376
      type: 'session.input',
2✔
377
      shellId: currentShellIdRef.current,
2✔
378
      data,
2✔
379
    });
2✔
380

381
    // Record activity for user input
382
    recordShellActivity(currentShellIdRef.current);
2✔
383
  }, [state.status, recordShellActivity]);
99✔
384

385
  // Resize action
386
  const resize = useCallback((cols: number, rows: number) => {
99✔
387
    if (state.status !== 'open') {
1!
388
      return;
×
UNCOV
389
    }
×
390

391
    // Suppress activity recording during the post-resize grace period
392
    // to avoid false positives from terminal redraws triggered by SIGWINCH
393
    activitySuppressedUntilRef.current = Date.now() + ACTIVITY_SUPPRESSION_MS;
1✔
394

395
    wsSendRef.current({
1✔
396
      type: 'session.resize',
1✔
397
      shellId: currentShellIdRef.current,
1✔
398
      cols,
1✔
399
      rows,
1✔
400
    });
1✔
401
  }, [state.status]);
99✔
402

403
  // Retry action
404
  const retry = useCallback(() => {
99✔
405
    if (state.status !== 'error' || !(state as { retryable?: boolean }).retryable) {
1!
UNCOV
406
      return;
×
UNCOV
407
    }
×
408

409
    sessionLog.info({ shellId: currentShellIdRef.current }, 'Retrying session');
1✔
410
    open();
1✔
411
  }, [state, open]);
99✔
412

413
  // Handle shellId changes
414
  useEffect(() => {
99✔
415
    if (shellId !== currentShellIdRef.current) {
19✔
416
      sessionLog.debug({ oldShellId: currentShellIdRef.current, newShellId: shellId }, 'Shell ID changed');
1✔
417

418
      // Store previous shell ID before updating
419
      previousShellIdRef.current = currentShellIdRef.current;
1✔
420
      currentShellIdRef.current = shellId;
1✔
421

422
      // Reset state for new shell
423
      setState({ status: 'closed' });
1✔
424
      pendingRequestIdRef.current = null;
1✔
425
      hasAutoOpenedRef.current = false;
1✔
426
    }
1✔
427
  }, [shellId]);
99✔
428

429
  // Send close for previous shell when switching
430
  useEffect(() => {
99✔
431
    if (previousShellIdRef.current && previousShellIdRef.current !== shellId) {
19✔
432
      sessionLog.info({ shellId: previousShellIdRef.current }, 'Closing previous session');
1✔
433
      wsSendRef.current({
1✔
434
        type: 'session.close',
1✔
435
        shellId: previousShellIdRef.current,
1✔
436
      });
1✔
437
      previousShellIdRef.current = null;
1✔
438
    }
1✔
439
  }, [shellId]);
99✔
440

441
  // Auto-open and reconnect handling
442
  useEffect(() => {
99✔
443
    if (!ws.isConnected) {
77✔
444
      return;
22✔
445
    }
22✔
446

447
    // Auto-open on initial connection
448
    if (autoOpen && state.status === 'closed' && !hasAutoOpenedRef.current) {
77✔
449
      hasAutoOpenedRef.current = true;
1✔
450
      open();
1✔
451
      return;
1✔
452
    }
1✔
453

454
    // Re-open on reconnect
455
    if (state.status === 'reconnecting') {
77✔
456
      open();
1✔
457
    }
1✔
458
  }, [ws.isConnected, autoOpen, state.status, open]);
99✔
459

460
  // Track session active status for cleanup (avoid stale closure)
461
  useEffect(() => {
99✔
462
    isSessionActiveRef.current = state.status === 'open' || state.status === 'opening';
59✔
463
  }, [state.status]);
99✔
464

465
  // Cleanup on unmount - use ref to avoid stale closure
466
  useEffect(() => {
99✔
467
    return (): void => {
18✔
468
      if (isSessionActiveRef.current) {
18✔
469
        sessionLog.info({ shellId: currentShellIdRef.current }, 'Closing session on unmount');
13✔
470
        wsSendRef.current({
13✔
471
          type: 'session.close',
13✔
472
          shellId: currentShellIdRef.current,
13✔
473
        });
13✔
474
      }
13✔
475
    };
18✔
476
  }, []);
99✔
477

478
  return {
99✔
479
    state,
99✔
480
    open,
99✔
481
    close,
99✔
482
    write,
99✔
483
    resize,
99✔
484
    retry,
99✔
485
  };
99✔
486
}
99✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc