diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index 02bbcfa..f3564ef 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -61,6 +61,7 @@ class _TerminalPageState extends ConsumerState String? _pendingHistorySeed; bool _receivedSocketFrame = false; bool _historySeeded = false; + bool _shouldReconnectOnResume = false; @override void initState() { @@ -116,12 +117,21 @@ class _TerminalPageState extends ConsumerState @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state != AppLifecycleState.resumed) { + if (state == AppLifecycleState.resumed) { + if (_shouldReconnectOnResume) { + _shouldReconnectOnResume = false; + _diagnosticLog.add('app.lifecycle.resumed', widget.session.sessionId); + unawaited(_coordinator.reconnectNow()); + } return; } - _diagnosticLog.add('app.lifecycle.resumed', widget.session.sessionId); - unawaited(_coordinator.reconnectNow()); + if (state == AppLifecycleState.hidden || + state == AppLifecycleState.paused) { + _shouldReconnectOnResume = true; + _diagnosticLog.add('app.lifecycle.suspended', state.name); + unawaited(_coordinator.suspendForBackground()); + } } Future _openSiblingTerminal() async { diff --git a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart index e25b5dd..bb0cfdb 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart @@ -195,6 +195,20 @@ class TerminalSessionCoordinator extends ChangeNotifier { await start(isReconnect: true); } + Future suspendForBackground() async { + _cancelPendingReconnect(); + + if (_isDisposed) { + return; + } + + controller.markDisconnected(); + _connectionStatus = 'App inactive. Reconnecting when active...'; + diagnosticLog?.add('socket.suspend', session.sessionId); + notifyListeners(); + await _closeActiveSession(); + } + Future close() async { _isDisposed = true; _cancelPendingReconnect(); diff --git a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart index b7245fc..676c710 100644 --- a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart @@ -125,6 +125,33 @@ void main() { }, ); + test('suspendForBackground closes the active socket session', () async { + final controller = TerminalInteractionController(); + final apiClient = _FakeAgentApiClient(); + final sessionFactory = _FakeTerminalSessionFactory(); + final session = Session( + sessionId: 'abc', + name: 'codex-main', + status: 'idle', + ); + final coordinator = TerminalSessionCoordinator( + controller: controller, + apiClient: apiClient, + session: session, + sessionFactory: sessionFactory.create, + onFrame: (_) {}, + viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), + ); + + await coordinator.start(); + final socketSession = sessionFactory.createdSessions.single; + + await coordinator.suspendForBackground(); + + expect(socketSession.disposeCount, 1); + expect(controller.connectionState, TerminalConnectionState.disconnected); + }); + test( 'incoming frames while browsing history flag pending live output', () async { @@ -224,6 +251,7 @@ class _FakeTerminalSocketSession extends TerminalSocketSession { final bool autoConnect; final resizeCalls = >[]; + int disposeCount = 0; Completer _connectCompleter = Completer(); void Function(String frame)? _onFrame; void Function()? _onDisconnected; @@ -259,6 +287,11 @@ class _FakeTerminalSocketSession extends TerminalSocketSession { void disconnect() { _onDisconnected?.call(); } + + @override + Future dispose() async { + disposeCount += 1; + } } class _FakeAgentSocketClient extends AgentSocketClient { diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index 552e46c..ddc0fc0 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -608,6 +608,36 @@ void main() { expect(find.text('codex-main'), findsOneWidget); }); + testWidgets( + 'terminal page does not reconnect after an inactive-only interruption', + (tester) async { + final transportFactory = _QueuedTerminalSocketTransportFactory(); + + await _pumpApp( + tester, + projectRepository: _FakeProjectRepository(), + sessionRepository: _FakeSessionRepository(), + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + await _openProjectTerminal(tester); + + expect(transportFactory.createCount, 1); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + expect(transportFactory.createCount, 1); + expect(find.text('codex-main'), findsOneWidget); + }, + ); + testWidgets('terminal page can open another terminal for the same project', ( tester, ) async {