Fix disconnect reconnect (#9878)

* reconnect to VM service after disconnect without app restart

* Add release note

* Apply code review suggestions

* Apply code review suggestions

* Format disconnect_observer.dart

* Update disconnect observer golden images for Reconnect button

* Remove redundant argument in initVmService call

* fix issue
diff --git a/packages/devtools_app/lib/src/framework/framework_core.dart b/packages/devtools_app/lib/src/framework/framework_core.dart
index 28bb4d4..7d48749 100644
--- a/packages/devtools_app/lib/src/framework/framework_core.dart
+++ b/packages/devtools_app/lib/src/framework/framework_core.dart
@@ -136,6 +136,7 @@
     final uri = normalizeVmServiceUri(serviceUriAsString);
     if (uri != null) {
       vmServiceInitializationInProgress = true;
+      _lastServiceUriAsString = serviceUriAsString;
       final finishedCompleter = Completer<void>();
 
       try {
@@ -187,6 +188,32 @@
   static void _defaultErrorReporter(String title, Object error) {
     notificationService.pushError('$title, $error', isReportable: false);
   }
+
+  /// The URI of the last VM service connection, used for reconnection.
+  static String? _lastServiceUriAsString;
+
+  /// Attempts to reconnect to the last known VM service.
+  ///
+  /// Returns true if reconnection was successful.
+  static Future<bool> reconnectVmService() async {
+    if (vmServiceInitializationInProgress) {
+      _log.warning(
+        'Reconnection attempt ignored: initialization already in progress.',
+      );
+      return false;
+    }
+    final lastUri = _lastServiceUriAsString;
+    if (lastUri == null) return false;
+
+    _log.info('Attempting to reconnect to VM service at: $lastUri');
+    final success = await initVmService(serviceUriAsString: lastUri);
+    if (success) {
+      _log.info('VM service reconnection successful');
+    } else {
+      _log.warning('VM service reconnection failed');
+    }
+    return success;
+  }
 }
 
 Future<void> _initDTDConnection() async {
diff --git a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart
index bf415e4..8a46105 100644
--- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart
+++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart
@@ -16,6 +16,7 @@
 import '../../shared/framework/routing.dart';
 import '../../shared/globals.dart';
 import '../../shared/primitives/query_parameters.dart';
+import '../framework_core.dart';
 
 class DisconnectObserver extends StatefulWidget {
   const DisconnectObserver({
@@ -34,9 +35,10 @@
 class DisconnectObserverState extends State<DisconnectObserver>
     with AutoDisposeMixin {
   OverlayEntry? currentDisconnectedOverlay;
-
   late ConnectedState currentConnectionState;
 
+  bool _isReconnecting = false;
+
   @override
   void initState() {
     super.initState();
@@ -52,6 +54,7 @@
       if (currentConnectionState.connected &&
           currentDisconnectedOverlay != null) {
         setState(() {
+          _isReconnecting = false;
           hideDisconnectedOverlay();
         });
       } else if (!currentConnectionState.connected) {
@@ -95,6 +98,38 @@
     currentDisconnectedOverlay = null;
   }
 
+  Future<void> _attemptReconnect() async {
+    if (_isReconnecting) return;
+    _isReconnecting = true;
+    currentDisconnectedOverlay?.markNeedsBuild();
+
+    bool success = false;
+    try {
+      success = await FrameworkCore.reconnectVmService().timeout(
+        const Duration(seconds: 5),
+      );
+    } catch (_) {
+      success = false;
+    }
+
+    if (mounted) {
+      _isReconnecting = false;
+      if (success) {
+        final uri = serviceConnection.serviceManager.serviceUri;
+        if (uri != null) {
+          unawaited(
+            widget.routerDelegate.updateArgsIfChanged({
+              DevToolsQueryParams.vmServiceUriKey: uri,
+            }),
+          );
+        }
+        hideDisconnectedOverlay();
+      } else {
+        currentDisconnectedOverlay?.markNeedsBuild();
+      }
+    }
+  }
+
   Future<void> _reviewHistory() async {
     assert(offlineDataController.offlineDataJson.isNotEmpty);
 
@@ -124,16 +159,29 @@
             child: Column(
               children: [
                 const Spacer(),
-                Text('Disconnected', style: theme.textTheme.headlineMedium),
-                const SizedBox(height: defaultSpacing),
-                if (!isEmbedded())
-                  ConnectToNewAppButton(
-                    routerDelegate: widget.routerDelegate,
-                    onPressed: hideDisconnectedOverlay,
-                    gaScreen: gac.devToolsMain,
-                  )
-                else
-                  const Text('Run a new debug session to reconnect.'),
+                if (_isReconnecting) ...[
+                  const CircularProgressIndicator(),
+                  const SizedBox(height: defaultSpacing),
+                  Text(
+                    'Reconnecting...',
+                    style: theme.textTheme.headlineMedium,
+                  ),
+                ] else ...[
+                  Text('Disconnected', style: theme.textTheme.headlineMedium),
+                  const SizedBox(height: defaultSpacing),
+                  ElevatedButton(
+                    onPressed: _attemptReconnect,
+                    child: const Text('Reconnect'),
+                  ),
+                  if (!isEmbedded()) ...[
+                    const SizedBox(height: denseSpacing),
+                    ConnectToNewAppButton(
+                      routerDelegate: widget.routerDelegate,
+                      onPressed: hideDisconnectedOverlay,
+                      gaScreen: gac.devToolsMain,
+                    ),
+                  ],
+                ],
                 const Spacer(),
                 if (offlineDataController.offlineDataJson.isNotEmpty) ...[
                   ElevatedButton(
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 14cf2ab..16954fd 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -15,6 +15,8 @@
 
 ## General updates
 
+* Fixed an issue where DevTools could get stuck in a disconnected state (e.g., after a Mac goes to sleep) by adding a manual "Reconnect" button to the disconnected screen. -
+  [#9838](https://github.com/flutter/devtools/issues/9838)
 * Resolve several memory leaks. - [#9857](https://github.com/flutter/devtools/pull/9857)
 * Fixed a bug where highlighted search matches in tables were unreadable in dark
   mode because the highlight color had become fully opaque. -
diff --git a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart
index 1678744..1c186ad 100644
--- a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart
+++ b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart
@@ -67,9 +67,10 @@
         find.byType(ConnectToNewAppButton),
         showingOverlay && !isEmbedded() ? findsOneWidget : findsNothing,
       );
+      // The Reconnect button should be shown in all modes when disconnected.
       expect(
-        find.text('Run a new debug session to reconnect.'),
-        showingOverlay && isEmbedded() ? findsOneWidget : findsNothing,
+        find.text('Reconnect'),
+        showingOverlay ? findsOneWidget : findsNothing,
       );
       expect(
         find.text('Review recent data (offline)'),
@@ -170,5 +171,58 @@
     // TODO(kenz): test navigation that occurs by clicking on buttons. This will
     // require either modifying the test wrappers to take a set of routes or
     // writing an integration test for this user journey.
+
+    group('reconnect button', () {
+      testWidgets('shows Reconnect button in embedded mode', (
+        WidgetTester tester,
+      ) async {
+        setGlobal(IdeTheme, IdeTheme(embedMode: EmbedMode.embedOne));
+        await pumpDisconnectObserver(tester);
+        verifyObserverState(tester, connected: true, showingOverlay: false);
+
+        // Trigger a disconnect.
+        fakeServiceConnectionManager.serviceManager.setConnectedState(false);
+        await tester.pumpAndSettle();
+        verifyObserverState(tester, connected: false, showingOverlay: true);
+
+        // Verify the Reconnect button is present in embedded mode.
+        expect(find.text('Reconnect'), findsOneWidget);
+        // ConnectToNewAppButton should NOT be shown in embedded mode.
+        expect(find.byType(ConnectToNewAppButton), findsNothing);
+      });
+
+      testWidgets('shows Reconnect button in non-embedded mode', (
+        WidgetTester tester,
+      ) async {
+        await pumpDisconnectObserver(tester);
+        verifyObserverState(tester, connected: true, showingOverlay: false);
+
+        // Trigger a disconnect.
+        fakeServiceConnectionManager.serviceManager.setConnectedState(false);
+        await tester.pumpAndSettle();
+        verifyObserverState(tester, connected: false, showingOverlay: true);
+
+        // Both Reconnect and ConnectToNewAppButton should be shown.
+        expect(find.text('Reconnect'), findsOneWidget);
+        expect(find.byType(ConnectToNewAppButton), findsOneWidget);
+      });
+
+      testWidgets('hides overlay when reconnection succeeds', (
+        WidgetTester tester,
+      ) async {
+        await pumpDisconnectObserver(tester);
+        verifyObserverState(tester, connected: true, showingOverlay: false);
+
+        // Trigger a disconnect.
+        fakeServiceConnectionManager.serviceManager.setConnectedState(false);
+        await tester.pumpAndSettle();
+        verifyObserverState(tester, connected: false, showingOverlay: true);
+
+        // Simulate a successful reconnection by setting connected state.
+        fakeServiceConnectionManager.serviceManager.setConnectedState(true);
+        await tester.pumpAndSettle();
+        verifyObserverState(tester, connected: true, showingOverlay: false);
+      });
+    });
   });
 }
diff --git a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png
index 0bdf4f1..3b55ee8 100644
--- a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png
+++ b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png
index 7d39dd2..1c97e3e 100644
--- a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png
+++ b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png
Binary files differ