Attempt to reconnect to DTD automatically if the connection drops (#9587)

* Attempt to reconnect to DTD automatically if the connection drops

- Repurposes the property editor "ReconnectingOverlay" as a more general "Connecting Overlay" also for the sidebar.
- If a DTD connection drops, it will be retried up to 5 times (and the new connection sent to the ValueNotifier)
- If the connection fails after all retries, a "Retry" button is shown to allow the user to restart connection attempts again
- The periodic check for the connection being alive is now part of DTDManager instead of only the property editor
- DTDManager now also emits state values so consumers can handle each state differently (rather than just connected/not connected)
- Mock editor for Stager apps now has a "Drop Connection" button to similate DTD connection drops
- Stager apps that use the mock editor now use a real DTDManager (since they connect to real DTD) and can simulate slow and failed connections
- Removed the force refresh for reconnect, and instead just made the `_DtdConnectedScreen` use a `key` so that when the connection changes, the widget gets new state
diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart
index 496f860..2c2136a 100644
--- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart
+++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart
@@ -29,8 +29,6 @@
 
   String get gaId => EditorSidebar.id;
 
-  bool get isDtdClosed => _dtd.isClosed;
-
   Future<void> _initialize() async {
     autoDisposeStreamSubscription(
       _dtd.onEvent(CoreDtdServiceConstants.servicesStreamId).listen((data) {
diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart
index 684fae4..bad9c16 100644
--- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart
+++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart
@@ -54,20 +54,13 @@
   String? get fileUri => _editableWidgetData.value?.fileUri;
   EditorRange? get widgetRange => _editableWidgetData.value?.range;
 
-  ValueListenable<bool> get shouldReconnect => _shouldReconnect;
-  final _shouldReconnect = ValueNotifier<bool>(false);
-
   bool get waitingForFirstEvent => _waitingForFirstEvent;
   bool _waitingForFirstEvent = true;
 
   late final Debouncer _requestDebouncer;
 
-  late final Timer _checkConnectionTimer;
-
   static const _requestDebounceDuration = Duration(milliseconds: 600);
 
-  static const _checkConnectionInterval = Duration(minutes: 1);
-
   static const _setPropertiesFilterId = 'set-properties-filter';
 
   @visibleForTesting
@@ -84,9 +77,6 @@
   void init() {
     super.init();
     _requestDebouncer = Debouncer(duration: _requestDebounceDuration);
-    _checkConnectionTimer = _periodicallyCheckConnection(
-      _checkConnectionInterval,
-    );
 
     // Update in response to ActiveLocationChanged events.
     autoDisposeStreamSubscription(
@@ -133,7 +123,6 @@
   @override
   void dispose() {
     _requestDebouncer.dispose();
-    _checkConnectionTimer.cancel();
     super.dispose();
   }
 
@@ -266,16 +255,6 @@
   List<CodeActionCommand> _extractRefactors(CodeActionResult? result) =>
       (result?.actions ?? <CodeActionCommand>[]).toList();
 
-  Timer _periodicallyCheckConnection(Duration interval) {
-    return Timer.periodic(interval, (timer) {
-      final isClosed = editorClient.isDtdClosed;
-      if (isClosed) {
-        _shouldReconnect.value = true;
-        timer.cancel();
-      }
-    });
-  }
-
   bool _filteredOutBySettings(
     EditableProperty property, {
     required Filter filter,
diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart
index fafac06..bf6e889 100644
--- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart
+++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart
@@ -17,7 +17,6 @@
 import '../../../shared/ui/common_widgets.dart';
 import 'property_editor_controller.dart';
 import 'property_editor_view.dart';
-import 'reconnecting_overlay.dart';
 
 /// The side panel for the Property Editor.
 class PropertyEditorPanel extends StatefulWidget {
@@ -106,43 +105,31 @@
 
   @override
   Widget build(BuildContext context) {
-    return ValueListenableBuilder<bool>(
-      valueListenable: widget.controller.shouldReconnect,
-      builder: (context, shouldReconnect, _) {
-        return Stack(
-          children: [
-            Scrollbar(
+    return Scrollbar(
+      controller: scrollController,
+      thumbVisibility: true,
+      child: Column(
+        children: [
+          Expanded(
+            child: SingleChildScrollView(
               controller: scrollController,
-              thumbVisibility: true,
-              child: Column(
-                children: [
-                  Expanded(
-                    child: SingleChildScrollView(
-                      controller: scrollController,
-                      child: Padding(
-                        padding: const EdgeInsets.fromLTRB(
-                          denseSpacing,
-                          defaultSpacing,
-                          defaultSpacing, // Additional right padding for scroll bar.
-                          defaultSpacing,
-                        ),
-                        child: Column(
-                          crossAxisAlignment: CrossAxisAlignment.start,
-                          children: [
-                            PropertyEditorView(controller: widget.controller),
-                          ],
-                        ),
-                      ),
-                    ),
-                  ),
-                  const _PropertyEditorFooter(),
-                ],
+              child: Padding(
+                padding: const EdgeInsets.fromLTRB(
+                  denseSpacing,
+                  defaultSpacing,
+                  defaultSpacing, // Additional right padding for scroll bar.
+                  defaultSpacing,
+                ),
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [PropertyEditorView(controller: widget.controller)],
+                ),
               ),
             ),
-            if (shouldReconnect) const ReconnectingOverlay(),
-          ],
-        );
-      },
+          ),
+          const _PropertyEditorFooter(),
+        ],
+      ),
     );
   }
 }
diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/reconnecting_overlay.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/reconnecting_overlay.dart
index 66f9f33..aac701f 100644
--- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/reconnecting_overlay.dart
+++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/reconnecting_overlay.dart
@@ -2,70 +2,63 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
-import 'dart:async';
-
+import 'package:devtools_app_shared/service.dart';
 import 'package:devtools_app_shared/ui.dart';
 import 'package:flutter/material.dart';
 
+import '../../../shared/globals.dart';
 import '../../../shared/ui/common_widgets.dart';
-import 'utils/utils.dart';
 
-class ReconnectingOverlay extends StatefulWidget {
-  const ReconnectingOverlay({super.key});
+// TODO(dantup): Rename and move this file one level up. Leaving as-is to
+//  make the review/diff simpler.
+
+/// An overlay to show when we are not connected to DTD based on the
+/// [DTDConnectionState] classes.
+class NotConnectedOverlay extends StatefulWidget {
+  const NotConnectedOverlay(this.connectionState, {super.key});
+
+  final DTDConnectionState connectionState;
 
   @override
-  State<ReconnectingOverlay> createState() => _ReconnectingOverlayState();
+  State<NotConnectedOverlay> createState() => _NotConnectedOverlayState();
 }
 
-class _ReconnectingOverlayState extends State<ReconnectingOverlay> {
-  static const _countdownInterval = Duration(seconds: 1);
-  late final Timer _countdownTimer;
-  int _secondsUntilReconnection = 3;
-
-  @override
-  void initState() {
-    super.initState();
-    _countdownTimer = Timer.periodic(_countdownInterval, _onTick);
-  }
-
-  @override
-  void dispose() {
-    _countdownTimer.cancel();
-    super.dispose();
-  }
-
+class _NotConnectedOverlayState extends State<NotConnectedOverlay> {
   @override
   Widget build(BuildContext context) {
+    final connectionState = widget.connectionState;
     final theme = Theme.of(context);
+
+    final showSpinner = connectionState is! ConnectionFailedDTDState;
+    final showReconnectButton = connectionState is ConnectionFailedDTDState;
+    final stateLabel = switch (connectionState) {
+      NotConnectedDTDState() => 'Waiting to connect...',
+      ConnectingDTDState() => 'Connecting...',
+      WaitingToRetryDTDState(seconds: final seconds) =>
+        'Reconnecting in $seconds...',
+      ConnectionFailedDTDState() => 'Connection Failed',
+      // We should never present this widget when connected, but provide a label
+      // for debugging if it happens.
+      ConnectedDTDState() => 'Connected',
+    };
+
     return DevToolsOverlay(
       fullScreen: true,
       content: Column(
         mainAxisAlignment: MainAxisAlignment.center,
         children: [
-          const CircularProgressIndicator(),
-          const SizedBox(height: defaultSpacing),
-          Text(
-            _secondsUntilReconnection > 0
-                ? 'Reconnecting in $_secondsUntilReconnection'
-                : 'Reconnecting...',
-            style: theme.textTheme.headlineMedium,
-          ),
+          if (showSpinner) ...const [
+            CircularProgressIndicator(),
+            SizedBox(height: defaultSpacing),
+          ],
+          Text(stateLabel, style: theme.textTheme.headlineMedium),
+          if (showReconnectButton)
+            ElevatedButton(
+              onPressed: () => dtdManager.reconnect(),
+              child: const Text('Retry'),
+            ),
         ],
       ),
     );
   }
-
-  void _onTick(Timer timer) {
-    setState(() {
-      _secondsUntilReconnection--;
-      if (_secondsUntilReconnection == 0) {
-        timer.cancel();
-        _reconnect();
-      }
-    });
-  }
-
-  void _reconnect() {
-    forceReload();
-  }
 }
diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/utils.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/utils.dart
index c887786..cb824b0 100644
--- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/utils.dart
+++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/utils.dart
@@ -3,7 +3,6 @@
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
 import 'package:flutter/widgets.dart';
-import '_utils_desktop.dart' if (dart.library.js_interop) '_utils_web.dart';
 
 /// Converts a [dartDocText] String into a [Text] widget.
 class DartDocConverter {
@@ -133,10 +132,3 @@
     return result;
   }
 }
-
-/// Workaround to force reload the Property Editor when it disconnects.
-///
-/// See https://github.com/flutter/devtools/issues/9028 for details.
-void forceReload() {
-  reloadIframe();
-}
diff --git a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart
index b1e1bfa..6a1f64a 100644
--- a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart
+++ b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart
@@ -2,12 +2,14 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
+import 'package:devtools_app_shared/service.dart';
 import 'package:dtd/dtd.dart';
 import 'package:flutter/material.dart';
 
 import '../shared/globals.dart';
 import '../shared/ui/common_widgets.dart';
 import 'ide_shared/property_editor/property_editor_panel.dart';
+import 'ide_shared/property_editor/reconnecting_overlay.dart';
 import 'vs_code/flutter_panel.dart';
 
 /// "Screens" that are intended for standalone use only, likely for embedding
@@ -33,26 +35,13 @@
               'newer of the Dart VS Code extension',
         ),
       ),
-      StandaloneScreenType.editorSidebar => ValueListenableBuilder(
-        // TODO(dantup): Add a timeout here so if dtdManager.connection
-        //  doesn't complete after some period we can give some kind of
-        //  useful message.
-        valueListenable: dtdManager.connection,
-        builder: (context, data, _) {
-          return _DtdConnectedScreen(
-            dtd: data,
-            screenProvider: (dtd) => EditorSidebarPanel(dtd),
-          );
-        },
+      StandaloneScreenType.editorSidebar => _DtdConnectedScreen(
+        dtdManager: dtdManager,
+        builder: (dtd) => EditorSidebarPanel(dtd),
       ),
-      StandaloneScreenType.propertyEditor => ValueListenableBuilder(
-        valueListenable: dtdManager.connection,
-        builder: (context, data, _) {
-          return _DtdConnectedScreen(
-            dtd: data,
-            screenProvider: (dtd) => PropertyEditorPanel(dtd),
-          );
-        },
+      StandaloneScreenType.propertyEditor => _DtdConnectedScreen(
+        dtdManager: dtdManager,
+        builder: (dtd) => PropertyEditorPanel(dtd),
       ),
     };
   }
@@ -61,18 +50,45 @@
       values.any((value) => value.name == screenName);
 }
 
-/// Widget that returns a [CenteredCircularProgressIndicator] while it waits for
-/// a [DartToolingDaemon] connection.
+/// Widget that show progress while connecting to [DartToolingDaemon] and then
+/// the result of calling [builder] when a connection is available.
+///
+/// If the DTD connection is dropped, a reconnecting progress will be shown.
 class _DtdConnectedScreen extends StatelessWidget {
-  const _DtdConnectedScreen({required this.dtd, required this.screenProvider});
+  const _DtdConnectedScreen({required this.dtdManager, required this.builder});
 
-  final DartToolingDaemon? dtd;
-  final Widget Function(DartToolingDaemon) screenProvider;
+  final DTDManager dtdManager;
+  final Widget Function(DartToolingDaemon) builder;
 
   @override
   Widget build(BuildContext context) {
-    return dtd == null
-        ? const CenteredCircularProgressIndicator()
-        : screenProvider(dtd!);
+    return ValueListenableBuilder(
+      valueListenable: dtdManager.connectionState,
+      builder: (context, connectionState, child) {
+        return ValueListenableBuilder(
+          valueListenable: dtdManager.connection,
+          builder: (context, connection, _) {
+            return Stack(
+              children: [
+                if (connection != null)
+                  // Use a keyed subtree on the connection, so if the connection
+                  // changes (eg. we reconnect), we reset the state because it's
+                  // not safe to assume the existing state is still valid.
+                  //
+                  // This allows us to still keep rendering the old state under
+                  // the overlay (rather than a blank background) until the
+                  // reconnect occurs.
+                  KeyedSubtree(
+                    key: ValueKey(connection),
+                    child: builder(connection),
+                  ),
+                if (connectionState is! ConnectedDTDState)
+                  NotConnectedOverlay(connectionState),
+              ],
+            );
+          },
+        );
+      },
+    );
   }
 }
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 5e4353d..d5d4a6c 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -15,7 +15,9 @@
 
 ## General updates
 
-TODO: Remove this section if there are not any updates.
+- Dropped connections to DTD will now automatically be retried to improve the
+  experience when your machine is resumed from sleep.
+  [#9587](https://github.com/flutter/devtools/pull/9587)
 
 ## Inspector updates
 
diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart
index 41c7eee..4a18e78 100644
--- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart
+++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart
@@ -21,12 +21,18 @@
 /// carefully to ensure they are not breaking changes to already-shipped
 /// editors.
 class SimulatedEditor {
-  SimulatedEditor(this._dtdUri) {
-    // Set up some default devices.
-    connectDevices();
+  SimulatedEditor._(this._dtdUri);
 
+  /// Creates and connects a simulated editor with some default devices.
+  static Future<SimulatedEditor> connect(Uri dtdUri) async {
+    final editor = SimulatedEditor._(dtdUri);
     // Connect editor automatically at launch.
-    unawaited(connectEditor());
+    await editor.connectEditor();
+
+    // Set up some default devices.
+    editor.connectDevices();
+
+    return editor;
   }
 
   /// The URI of the DTD instance we are connecting/connected to.
@@ -116,12 +122,19 @@
     DTDServiceCallback callback, {
     Map<String, Object?>? capabilities,
   }) async {
-    await _dtd?.registerService(
-      editorServiceName,
-      method.name,
-      callback,
-      capabilities: capabilities,
-    );
+    try {
+      await _dtd?.registerService(
+        editorServiceName,
+        method.name,
+        callback,
+        capabilities: capabilities,
+      );
+    } catch (e) {
+      // Just log but don't fail everything, because this is normal if we try
+      // to connect to a real DTD that already has an editor connected. We
+      // should just fill in any missing services.
+      _logger.add('Failed to register "$method": $e');
+    }
   }
 
   static const _successResponse = {'type': 'Success'};
diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_sidebar.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_sidebar.dart
index 2a331cb..57cbd4f 100644
--- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_sidebar.dart
+++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_sidebar.dart
@@ -5,11 +5,10 @@
 import 'dart:async';
 
 import 'package:devtools_app/devtools_app.dart';
-import 'package:devtools_app/src/standalone_ui/vs_code/flutter_panel.dart';
+import 'package:devtools_app/src/standalone_ui/standalone_screen.dart';
 import 'package:devtools_app_shared/service.dart';
 import 'package:devtools_app_shared/ui.dart';
 import 'package:devtools_app_shared/utils.dart';
-import 'package:devtools_test/devtools_test.dart';
 import 'package:dtd/dtd.dart';
 import 'package:flutter/material.dart';
 import 'package:stager/stager.dart';
@@ -35,7 +34,7 @@
         body: MockEditorWidget(
           editor: editor,
           clientLog: clientLog,
-          child: EditorSidebarPanel(clientDtd),
+          child: StandaloneScreenType.editorSidebar.screen,
         ),
       ),
     );
@@ -46,12 +45,20 @@
 
   @override
   Future<void> setUp() async {
+    final logStream = StreamController<String>();
+    final dtdManager = TestingDTDManager(
+      logStream.sink,
+      // Set this variable to similate a number of failed connections for
+      // testing.
+      failConnectionCount: 3,
+    );
+
     setStagerMode();
     setGlobal(
       DevToolsEnvironmentParameters,
       ExternalDevToolsEnvironmentParameters(),
     );
-    setGlobal(DTDManager, MockDTDManager());
+    setGlobal(DTDManager, dtdManager);
     setGlobal(IdeTheme, IdeTheme());
     setGlobal(PreferencesController, PreferencesController());
 
@@ -60,9 +67,10 @@
     // TODO(dantup): Add a way for the mock editor to set workspace roots so
     //  the extensions parts can work in the sidebar.
     final dtdUri = Uri.parse('ws://127.0.0.1:8500/');
-    final connection = await createLoggedWebSocketChannel(dtdUri);
-    clientLog = connection.log;
-    clientDtd = DartToolingDaemon.fromStreamChannel(connection.channel);
-    editor = SimulatedEditor(dtdUri);
+    clientLog = logStream.stream;
+    editor = await SimulatedEditor.connect(dtdUri);
+
+    // Start connecting to DTD after 1s, so we see the progress indicators.
+    unawaited(dtdManager.connect(dtdUri));
   }
 }
diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/mock_editor_widget.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/mock_editor_widget.dart
index 196401e..08329ba 100644
--- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/mock_editor_widget.dart
+++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/mock_editor_widget.dart
@@ -6,6 +6,7 @@
 import 'dart:collection';
 import 'dart:convert';
 
+import 'package:devtools_app/devtools_app.dart';
 import 'package:devtools_app/src/shared/primitives/list_queue_value_notifier.dart';
 import 'package:devtools_app_shared/ui.dart';
 import 'package:devtools_app_shared/utils.dart';
@@ -258,6 +259,23 @@
                     ),
                     child: const Text('Stop All'),
                   ),
+                  const SizedBox(height: defaultSpacing * 5),
+                  Text('DTD Manager', style: theme.textTheme.headlineMedium),
+                  const SizedBox(height: defaultSpacing),
+                  const Text(
+                    'Use these buttons to simulate actions on the DTD Manager that the sidebar panel is using',
+                  ),
+                  const SizedBox(height: defaultSpacing),
+                  Row(
+                    children: [
+                      const Text('DTD Connection: '),
+                      ElevatedButton(
+                        onPressed: () =>
+                            dtdManager.disconnectImpl(allowReconnect: true),
+                        child: const Text('Drop Connection'),
+                      ),
+                    ],
+                  ),
                 ],
               ),
             ),
diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/property_editor_sidebar.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/property_editor_sidebar.dart
index 6346811..889c170 100644
--- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/property_editor_sidebar.dart
+++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/property_editor_sidebar.dart
@@ -5,12 +5,10 @@
 import 'dart:async';
 
 import 'package:devtools_app/devtools_app.dart';
-import 'package:devtools_app/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart';
+import 'package:devtools_app/src/standalone_ui/standalone_screen.dart';
 import 'package:devtools_app_shared/service.dart';
 import 'package:devtools_app_shared/ui.dart';
 import 'package:devtools_app_shared/utils.dart';
-import 'package:devtools_test/devtools_test.dart';
-import 'package:dtd/dtd.dart';
 import 'package:flutter/material.dart';
 import 'package:stager/stager.dart';
 
@@ -24,9 +22,11 @@
 ///
 /// flutter run -t test/test_infra/scenes/standalone_ui/property_editor_sidebar.stager_app.g.dart -d chrome
 class PropertyEditorSidebarScene extends Scene {
+  late Stream<String> clientLog;
+
   @override
   Widget build(BuildContext context) {
-    return const _PropertyEditorSidebar();
+    return _PropertyEditorSidebar(clientLog);
   }
 
   @override
@@ -34,27 +34,36 @@
 
   @override
   Future<void> setUp() async {
+    final logStream = StreamController<String>();
+    clientLog = logStream.stream;
+    final dtdManager = TestingDTDManager(
+      logStream.sink,
+      // Set this variable to similate a number of failed connections for
+      // testing.
+      failConnectionCount: 3,
+    );
+
     setStagerMode();
     setGlobal(
       DevToolsEnvironmentParameters,
       ExternalDevToolsEnvironmentParameters(),
     );
-    setGlobal(DTDManager, MockDTDManager());
+    setGlobal(DTDManager, dtdManager);
     setGlobal(IdeTheme, IdeTheme());
     setGlobal(PreferencesController, PreferencesController());
   }
 }
 
 class _PropertyEditorSidebar extends StatefulWidget {
-  const _PropertyEditorSidebar();
+  const _PropertyEditorSidebar(this.clientLog);
+
+  final Stream<String> clientLog;
 
   @override
   State<_PropertyEditorSidebar> createState() => _PropertyEditorState();
 }
 
 class _PropertyEditorState extends State<_PropertyEditorSidebar> {
-  Stream<String>? clientLog;
-  DartToolingDaemon? clientDtd;
   SimulatedEditor? editor;
 
   @override
@@ -66,11 +75,11 @@
   Widget build(BuildContext context) {
     return IdeThemedMaterialApp(
       home: Scaffold(
-        body: clientLog != null && clientDtd != null && editor != null
+        body: editor != null
             ? MockEditorWidget(
                 editor: editor!,
-                clientLog: clientLog!,
-                child: PropertyEditorPanel(clientDtd!),
+                clientLog: widget.clientLog,
+                child: StandaloneScreenType.propertyEditor.screen,
               )
             : _DtdUriForm(
                 onSaved: _connectToDtd,
@@ -82,12 +91,12 @@
 
   Future<void> _connectToDtd(String? dtdUri) async {
     if (dtdUri == null) return;
+
     final uri = Uri.parse(dtdUri);
-    final connection = await createLoggedWebSocketChannel(uri);
+    final editor = await SimulatedEditor.connect(uri);
+    unawaited(dtdManager.connect(uri));
     setState(() {
-      clientLog = connection.log;
-      clientDtd = DartToolingDaemon.fromStreamChannel(connection.channel);
-      editor = SimulatedEditor(uri);
+      this.editor = editor;
     });
   }
 }
diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/utils.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/utils.dart
index 1edbd6f..0156a7f 100644
--- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/utils.dart
+++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/utils.dart
@@ -4,6 +4,8 @@
 
 import 'dart:async';
 
+import 'package:devtools_app_shared/service.dart';
+import 'package:dtd/dtd.dart';
 import 'package:stream_channel/stream_channel.dart';
 import 'package:web_socket_channel/web_socket_channel.dart';
 
@@ -11,11 +13,13 @@
 /// in both directions across it.
 typedef LoggedChannel = ({StreamChannel<String> channel, Stream<String> log});
 
-/// Connects to the websocket at [wsUri] and returns a channel along with
-/// a log stream that includes all protocol traffic.
-Future<LoggedChannel> createLoggedWebSocketChannel(Uri wsUri) async {
-  final logController = StreamController<String>();
-
+/// Connects to the websocket at [wsUri] and returns a [StreamSink<String>].
+///
+/// All traffic is logged into [sink].
+Future<StreamChannel<String>> createLoggedWebSocketChannel(
+  Uri wsUri,
+  StreamSink<String> sink,
+) async {
   final rawChannel = WebSocketChannel.connect(wsUri);
   await rawChannel.ready;
   final rawStringChannel = rawChannel.cast<String>();
@@ -24,7 +28,7 @@
   /// traffic with a prefix.
   String Function(String) logTraffic(String prefix) {
     return (String s) {
-      logController.add('$prefix $s'.trim());
+      sink.add('$prefix $s'.trim());
       return s;
     };
   }
@@ -38,8 +42,37 @@
         .pipe(rawStringChannel.sink),
   );
 
-  return (
-    channel: StreamChannel<String>(loggedInput, loggedOutputController.sink),
-    log: logController.stream,
-  );
+  return StreamChannel<String>(loggedInput, loggedOutputController.sink);
+}
+
+/// An implementation of DTD Manager that logs traffic to [logSink] and can
+/// optionally delay or fail connections to DTD for testing.
+class TestingDTDManager extends DTDManager {
+  TestingDTDManager(
+    this.logSink, {
+    this.failConnectionCount = 0,
+    this.connectionDelay = const Duration(seconds: 1),
+  });
+
+  /// The number of connections to fail before connecting.
+  var failConnectionCount = 0;
+
+  /// The delay for each connection attempt.
+  final Duration connectionDelay;
+
+  /// The sink to write protocol traffic to.
+  final StreamSink<String> logSink;
+
+  @override
+  Future<DartToolingDaemon> connectDtdImpl(Uri uri) async {
+    await Future.delayed(connectionDelay);
+
+    if (failConnectionCount > 0) {
+      failConnectionCount--;
+      throw 'Connection failed';
+    }
+
+    final channel = await createLoggedWebSocketChannel(uri, logSink);
+    return DartToolingDaemon.fromStreamChannel(channel);
+  }
 }
diff --git a/packages/devtools_app_shared/lib/service.dart b/packages/devtools_app_shared/lib/service.dart
index 43e8e8d..608adef 100644
--- a/packages/devtools_app_shared/lib/service.dart
+++ b/packages/devtools_app_shared/lib/service.dart
@@ -5,6 +5,7 @@
 export 'src/service/connected_app.dart';
 export 'src/service/constants.dart';
 export 'src/service/dtd_manager.dart';
+export 'src/service/dtd_manager_connection_state.dart';
 export 'src/service/eval_on_dart_library.dart';
 export 'src/service/flutter_version.dart';
 export 'src/service/isolate_manager.dart' hide TestIsolateManager;
diff --git a/packages/devtools_app_shared/lib/src/service/dtd_manager.dart b/packages/devtools_app_shared/lib/src/service/dtd_manager.dart
index e2e9508..183ca53 100644
--- a/packages/devtools_app_shared/lib/src/service/dtd_manager.dart
+++ b/packages/devtools_app_shared/lib/src/service/dtd_manager.dart
@@ -2,10 +2,15 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
+import 'dart:async';
+import 'dart:math' as math;
+
 import 'package:dtd/dtd.dart';
 import 'package:flutter/foundation.dart';
 import 'package:logging/logging.dart';
 
+import 'dtd_manager_connection_state.dart';
+
 final _log = Logger('dtd_manager');
 
 /// Manages a connection to the Dart Tooling Daemon.
@@ -18,39 +23,225 @@
   /// Whether the [DTDManager] is connected to a running instance of the DTD.
   bool get hasConnection => connection.value != null;
 
+  /// The current state of the connection.
+  ValueListenable<DTDConnectionState> get connectionState => _connectionState;
+  final _connectionState =
+      ValueNotifier<DTDConnectionState>(NotConnectedDTDState());
+
   /// The URI of the current DTD connection.
   Uri? get uri => _uri;
   Uri? _uri;
 
-  /// Sets the Dart Tooling Daemon connection to point to [uri].
+  /// Whether or not to automatically reconnect if disconnected.
   ///
-  /// Before connecting to [uri], if a current connection exists, then
-  /// [disconnect] is called to close it.
-  Future<void> connect(
+  /// This will happen by default as long as the disconnect wasn't
+  /// explicitly requested.
+  bool _automaticallyReconnect = true;
+
+  Timer? _periodicConnectionCheck;
+  static const _periodicConnectionCheckInterval = Duration(minutes: 1);
+
+  /// A function that replays the last connection attempt.
+  ///
+  /// This is used by [reconnect] to reconnect to the last server with the same
+  /// settings if the connection was dropped and failed to reconnect within the
+  /// specified retry period.
+  Future<void> Function()? _lastConnectFunc;
+
+  /// A wrapper around connecting to DTD to allow tests to intercept the
+  /// connection.
+  @visibleForTesting
+  Future<DartToolingDaemon> connectDtdImpl(Uri uri) async {
+    // Cancel any previous timer.
+    _periodicConnectionCheck?.cancel();
+
+    final dtd = await DartToolingDaemon.connect(uri);
+
+    // Set up a periodic connection check to detect if the connection has
+    // dropped even if `done` doesn't fire.
+    //
+    // If this happens, just disconnect (without disabling reconnect) so the
+    // done event fires and then the usual handling occurs.
+    _periodicConnectionCheck =
+        Timer.periodic(_periodicConnectionCheckInterval, (timer) async {
+      if (_dtd.isClosed) {
+        _log.warning('The DTD connection has dropped');
+        await disconnectImpl(allowReconnect: true);
+      }
+    });
+
+    return dtd;
+  }
+
+  /// Triggers a reconnect to the last connected URI if the current state is
+  /// [ConnectionFailedDTDState] (and there was a pervious connection).
+  Future<void> reconnect() {
+    final reconnectFunc = _lastConnectFunc;
+    if (_connectionState.value is! ConnectionFailedDTDState ||
+        reconnectFunc == null) {
+      return Future.value();
+    }
+
+    return reconnectFunc();
+  }
+
+  /// Tries to connect to DTD at [uri] with automatic retries and exponential
+  /// backoff.
+  ///
+  /// When a computer sleeps, the WebSocket connection may be dropped and it
+  /// may take some time for a browser to allow network connections without
+  /// ERR_NETWORK_IO_SUSPENDED.
+  Future<DartToolingDaemon> _connectWithRetries(
+    Uri uri, {
+    required int maxRetries,
+  }) async {
+    for (var attempt = 1; attempt <= maxRetries; attempt++) {
+      try {
+        _connectionState.value = ConnectingDTDState();
+        // The await here is important so errors are handled by this catch!
+        return await connectDtdImpl(uri);
+      } catch (e, s) {
+        // On last attempt, fail and propagate the error.
+        if (attempt == maxRetries) {
+          _connectionState.value = ConnectionFailedDTDState();
+          _log.severe('Failed to connect to DTD after $attempt attempts', e, s);
+          rethrow;
+        }
+
+        // Otherwise, retry after a delay.
+        var delay = math.pow(2, attempt - 1).toInt();
+        _log.info(
+          'Failed to connect to DTD on attempt $attempt, '
+          'will retry in ${delay}s',
+        );
+        while (delay > 0) {
+          _connectionState.value = WaitingToRetryDTDState(delay);
+          await Future.delayed(const Duration(seconds: 1));
+          delay--;
+        }
+      }
+    }
+
+    // We can't get here because of the logic above, but the analyzer can't
+    // tell that.
+    _connectionState.value = NotConnectedDTDState();
+    throw StateError('Failed to connect to DTD');
+  }
+
+  /// Connects Dart Tooling Daemon connection to [uri].
+  ///
+  /// Before connecting to [uri], unless [disconnectBeforeConnecting] is
+  /// `false`, will call [disconnect] to disconnect any existing connection.
+  ///
+  /// If the connection fails, will retry with exponential backoff up to
+  /// [maxRetries].
+  Future<void> _connectImpl(
     Uri uri, {
     void Function(Object, StackTrace?)? onError,
+    int maxRetries = 5,
+    bool disconnectBeforeConnecting = true,
   }) async {
-    await disconnect();
+    if (disconnectBeforeConnecting) {
+      await disconnect();
+    }
+    // Enable automatic reconnect on any new connection.
+    _automaticallyReconnect = true;
 
     try {
-      final connection = await DartToolingDaemon.connect(uri);
+      final connection = await _connectWithRetries(uri, maxRetries: maxRetries);
+
       _uri = uri;
       // Set this after setting the value of [_uri] so that [_uri] can be used
       // by any listeners of the [_connection] notifier.
       _connection.value = connection;
+      _connectionState.value = ConnectedDTDState();
       _log.info('Successfully connected to DTD at: $uri');
+
+      // If a connection drops (and we hadn't disabled auto-reconnect, such
+      // as by explicitly calling disconnect/dispose), we should attempt to
+      // reconnect.
+      unawaited(connection.done
+          .then((_) => _reconnectAfterDroppedConnection(uri, onError: onError))
+          .catchError((_) {
+        // TODO(dantup): Create a devtools_app_shared version of safeUnawaited.
+        // https://github.com/flutter/devtools/pull/9587#discussion_r2624306047
+      }));
     } catch (e, st) {
       onError?.call(e, st);
     }
   }
 
+  /// Triggers a reconnect without first disconnecting. This allows existing
+  /// state to be retained in the background while reconnect is in progress so
+  /// that the content the user could previously see is not hidden.
+  Future<void> _reconnectAfterDroppedConnection(
+    Uri uri, {
+    void Function(Object, StackTrace?)? onError,
+  }) async {
+    // Trigger disconnect to ensure we emit a `null` connection to
+    // listeners.
+    await disconnectImpl(allowReconnect: true);
+    if (_automaticallyReconnect) {
+      await _connectImpl(
+        uri,
+        onError: onError,
+        // We've already disconnected above, in a way that doesn't disable
+        // reconnect and does not set connection to null (allowing screens
+        // to remain visible under connection overlays).
+        disconnectBeforeConnecting: false,
+      );
+    }
+  }
+
+  /// Sets the Dart Tooling Daemon connection to point to [uri].
+  ///
+  /// Before connecting to [uri], if a current connection exists, then
+  /// [disconnect] is called to close it.
+  ///
+  /// If the connection fails, will retry with exponential backoff up to
+  /// [maxRetries].
+  Future<void> connect(
+    Uri uri, {
+    void Function(Object, StackTrace?)? onError,
+    int maxRetries = 5,
+  }) {
+    // On explicit connections, we capture the connect function so that we
+    // can call it again if [reconnect()] is called.
+    final connectFunc = _lastConnectFunc =
+        () => _connectImpl(uri, onError: onError, maxRetries: maxRetries);
+    return connectFunc();
+  }
+
   /// Closes and unsets the Dart Tooling Daemon connection, if one is set.
-  Future<void> disconnect() async {
-    if (_connection.value != null) {
-      await _connection.value!.close();
+  Future<void> disconnect() => disconnectImpl();
+
+  /// Closes and unsets the Dart Tooling Daemon connection, if one is set.
+  ///
+  /// [allowReconnect] controls whether reconnection is allowed. This is
+  /// generally false for an explicit disconnect/dispose, but allowed if we
+  /// are called as part of a dropped connection. Reconnecting being allowed
+  /// does not necessarily mean it will happen, because there might have been
+  /// an explicit disconnect (or dispose) call before we got here.
+  @visibleForTesting
+  Future<void> disconnectImpl({bool allowReconnect = false}) async {
+    if (!allowReconnect) {
+      // If we're not allowed to reconnect, disable this. `allowReconnect` being
+      // true does NOT mean we can enable this, because we might get here after
+      // an explicit disconnect.
+      _automaticallyReconnect = false;
+
+      // We only clear the connection if we are explicitly disconnecting. In the
+      // case where the connection just dropped, we leave it so that we can
+      // continue to render a page (usually with an overlay).
+      _connection.value = null;
     }
 
-    _connection.value = null;
+    _periodicConnectionCheck?.cancel();
+    if (_connection.value case final connection?) {
+      await connection.close();
+    }
+
+    _connectionState.value = NotConnectedDTDState();
     _uri = null;
     _workspaceRoots = null;
     _projectRoots = null;
diff --git a/packages/devtools_app_shared/lib/src/service/dtd_manager_connection_state.dart b/packages/devtools_app_shared/lib/src/service/dtd_manager_connection_state.dart
new file mode 100644
index 0000000..0f3d600
--- /dev/null
+++ b/packages/devtools_app_shared/lib/src/service/dtd_manager_connection_state.dart
@@ -0,0 +1,33 @@
+// Copyright 2025 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+/// A class representing the current state of a DTD connection.
+sealed class DTDConnectionState {}
+
+/// DTD is not connected and has not started to connect.
+class NotConnectedDTDState extends DTDConnectionState {}
+
+/// Attempting to connect to DTD.
+class ConnectingDTDState extends DTDConnectionState {}
+
+/// A connection failed and we are waiting for [seconds] before
+/// trying again.
+///
+/// This state is emitted every second during a retry countdown.
+class WaitingToRetryDTDState extends DTDConnectionState {
+  WaitingToRetryDTDState(this.seconds);
+
+  /// The remaining number of seconds to wait.
+  ///
+  /// This value does not update, but the state is emitted for each second of
+  /// a retry countdown.
+  final int seconds;
+}
+
+/// We are connected to DTD.
+class ConnectedDTDState extends DTDConnectionState {}
+
+/// We failed to connect to DTD in the maximum number of retries and are no
+/// longer trying to connect.
+class ConnectionFailedDTDState extends DTDConnectionState {}