[A11y] Add semantics tree UI (#9982)

tracking issue: https://github.com/flutter/devtools/issues/9893 
Added the tree UI 
<img width="1341" height="906" alt="Screenshot 2026-08-27 at 16 59 03" src="https://github.com/user-attachments/assets/23cb465d-48c7-4f79-a916-8524f5b3d6a8" />

### General checklist

### Issues checklist

### Tests checklist

### AI-tooling checklist

    * [ ] I read the [AI contributions guidelines] and agree to follow them.
    * [ ] I reviewed all AI-generated code before opening this PR.
    * [ ] I understand and am able to discuss the code in this PR.
    * [ ] I have verifed the accuracy of any AI-generated text included in the PR description.
    * [ ] I commit to verifying the accuracy of any AI-generated code or text that I upload in response to review comments.

### Feature-change checklist 

    * [ ] I added the `release-notes-not-required` label or left a comment requesting the label be added.
    * [ ] I added an entry to `packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md`.
    * [ ] I included before/after screenshots and/or a GIF demo of the new UI to my PR description.
    * [ ] I ran the DevTools app locally to manually verify my changes.

![build.yaml badge]

If you need help, consider asking for help on [Discord].

[`contributions-welcome`]: https://github.com/flutter/devtools/issues?q=state%3Aopen%20label%3Acontributions-welcome
[`good-first-issue`]: https://github.com/flutter/devtools/issues?q=state%3Aopen%20label%3Agood-first-issue
[build.yaml badge]: https://github.com/flutter/devtools/actions/workflows/build.yaml/badge.svg
diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart
index 8088054..5fd07a4 100644
--- a/packages/devtools_app/lib/devtools_app.dart
+++ b/packages/devtools_app/lib/devtools_app.dart
@@ -12,6 +12,7 @@
 export 'src/framework/scaffold/status_line.dart';
 export 'src/screens/accessibility/accessibility_controller.dart';
 export 'src/screens/accessibility/accessibility_screen.dart';
+export 'src/screens/accessibility/semantics_node_model.dart';
 export 'src/screens/app_size/app_size_controller.dart';
 export 'src/screens/app_size/app_size_screen.dart';
 export 'src/screens/debugger/breakpoint_manager.dart';
diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
index 1fd71a3..573756a 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
@@ -7,11 +7,17 @@
 import 'package:devtools_app_shared/service.dart';
 import 'package:devtools_app_shared/utils.dart';
 import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart' show ScrollController;
+import 'package:logging/logging.dart';
 
 import '../../service/service_extensions.dart' as extensions;
+import '../../service/service_registrations.dart' as registrations;
 import '../../shared/framework/screen.dart';
 import '../../shared/framework/screen_controllers.dart';
 import '../../shared/globals.dart';
+import 'semantics_node_model.dart';
+
+final _log = Logger('accessibility_controller');
 
 /// Modes for brightness override in the accessibility controls.
 enum BrightnessOverride {
@@ -40,6 +46,7 @@
   void init() {
     super.init();
     _initServiceExtensionStates();
+    _initSemanticsTree();
   }
 
   void _initListeners() {
@@ -50,6 +57,37 @@
     addAutoDisposeListener(highContrast, _onHighContrastChanged);
   }
 
+  void _initSemanticsTree() {
+    if (serviceConnection.serviceManager.isolateManager.mainIsolate.value !=
+        null) {
+      unawaited(_autoLoadSemanticsTreeIfNeeded());
+    }
+    addAutoDisposeListener(
+      serviceConnection.serviceManager.isolateManager.mainIsolate,
+      () {
+        if (serviceConnection.serviceManager.isolateManager.mainIsolate.value !=
+            null) {
+          // Clear stale data from a previous isolate so the guard in
+          // _autoLoadSemanticsTreeIfNeeded doesn't skip the new load.
+          semanticsRoots.value = [];
+          semanticsTreeError.value = null;
+          unawaited(_autoLoadSemanticsTreeIfNeeded());
+        } else {
+          semanticsRoots.value = [];
+          semanticsTreeError.value = null;
+        }
+      },
+    );
+  }
+
+  Future<void> _autoLoadSemanticsTreeIfNeeded() async {
+    if (semanticsRoots.value.isEmpty &&
+        semanticsTreeError.value == null &&
+        !semanticsTreeLoading.value) {
+      await loadSemanticsTree();
+    }
+  }
+
   void _initServiceExtensionStates() {
     final state = serviceConnection.serviceManager.serviceExtensionManager
         .getServiceExtensionState(extensions.brightnessMode.extension);
@@ -109,13 +147,144 @@
   final screenReader = ValueNotifier<bool>(false);
   final highContrast = ValueNotifier<bool>(false);
 
+  final semanticsRoots = ValueNotifier<List<SemanticsNodeModel>>([]);
+  final semanticsTreeLoading = ValueNotifier<bool>(false);
+  final semanticsTreeError = ValueNotifier<String?>(null);
+  final treeScrollController = ScrollController();
+
+  Future<void> loadSemanticsTree() async {
+    if (semanticsTreeLoading.value) return;
+
+    final mainIsolate =
+        serviceConnection.serviceManager.isolateManager.mainIsolate.value;
+    if (mainIsolate == null) {
+      semanticsTreeError.value =
+          'Failed to load semantics tree: no connected application.';
+      return;
+    }
+
+    semanticsTreeLoading.value = true;
+    semanticsTreeError.value = null;
+
+    try {
+      await serviceConnection.serviceManager.callServiceExtensionOnMainIsolate(
+        registrations.enableSemantics,
+        args: {'enabled': 'true'},
+      );
+
+      final response = await serviceConnection.serviceManager
+          .callServiceExtensionOnMainIsolate(registrations.getSemanticsTree);
+
+      final json = response.json;
+      if (json != null && json.containsKey('error')) {
+        throw Exception(json['error']);
+      }
+
+      final rawData = json?['data'];
+      if (rawData == null) {
+        throw Exception(
+          'Empty semantics tree returned from service extension.',
+        );
+      }
+
+      final roots = <SemanticsNodeModel>[];
+      if (rawData is Map<String, dynamic>) {
+        if (rawData.isNotEmpty) {
+          final rootId = rawData.containsKey('0')
+              ? '0'
+              : rawData.keys.first.toString();
+          roots.add(_buildTreeFromNodesMap(rootId, rawData, <String>{}));
+        }
+      }
+
+      if (roots.isEmpty) {
+        throw Exception('No semantics nodes found in response.');
+      }
+
+      for (final root in roots) {
+        root.expandCascading();
+      }
+      semanticsRoots.value = roots;
+      semanticsTreeError.value = null;
+    } catch (e, st) {
+      _log.warning('Error loading semantics tree: $e', e, st);
+      semanticsRoots.value = [];
+      semanticsTreeError.value = 'Failed to load semantics tree: $e';
+    } finally {
+      if (!disposed) {
+        semanticsTreeLoading.value = false;
+      }
+    }
+  }
+
+  SemanticsNodeModel _buildTreeFromNodesMap(
+    String nodeId,
+    Map<String, dynamic> nodesMap,
+    Set<String> visited,
+  ) {
+    if (!visited.add(nodeId)) {
+      return SemanticsNodeModel(id: nodeId);
+    }
+
+    final json =
+        (nodesMap[nodeId] as Map<String, dynamic>?) ??
+        <String, dynamic>{'id': nodeId};
+    final node = _parseSemanticsNode(json);
+
+    final childIds =
+        (json['childrenInTraversalOrder'] as List?)
+            ?.map((e) => e.toString())
+            .toList() ??
+        (json['childrenInHitTestOrder'] as List?)
+            ?.map((e) => e.toString())
+            .toList() ??
+        const <String>[];
+
+    for (final childId in childIds) {
+      if (nodesMap.containsKey(childId)) {
+        final childNode = _buildTreeFromNodesMap(childId, nodesMap, visited);
+        node.addChild(childNode);
+      }
+    }
+
+    return node;
+  }
+
+  SemanticsNodeModel _parseSemanticsNode(Map<String, dynamic> json) {
+    final rawFlags = json['flags'] as List<Object?>?;
+    final flags = SemanticsNodeModel.parseFlags(rawFlags);
+
+    return SemanticsNodeModel(
+      id: json['id']?.toString() ?? '',
+      label: json['label']?.toString() ?? '',
+      flags: flags,
+      widgetName: json['widgetName']?.toString() ?? '',
+    );
+  }
+
   @override
   void dispose() {
+    unawaited(_disposeSemanticsOnApp());
     brightness.dispose();
     textScale.dispose();
     boldText.dispose();
     screenReader.dispose();
     highContrast.dispose();
+    semanticsRoots.dispose();
+    semanticsTreeLoading.dispose();
+    semanticsTreeError.dispose();
+    treeScrollController.dispose();
     super.dispose();
   }
+
+  Future<void> _disposeSemanticsOnApp() async {
+    try {
+      if (serviceConnection.serviceManager.connectedState.value.connected) {
+        await serviceConnection.serviceManager
+            .callServiceExtensionOnMainIsolate(registrations.disposeSemantics);
+      }
+    } catch (_) {
+      // Ignore errors if the app or isolate connection is already closed.
+    }
+  }
 }
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
new file mode 100644
index 0000000..2ee7209
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
@@ -0,0 +1,56 @@
+// Copyright 2026 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.
+
+/// @docImport 'package:flutter/semantics.dart';
+library;
+
+import 'dart:ui' show SemanticsFlag;
+
+import '../../shared/primitives/trees.dart';
+
+/// Represents a node in the accessibility semantics tree.
+class SemanticsNodeModel extends TreeNode<SemanticsNodeModel> {
+  SemanticsNodeModel({
+    required this.id,
+    this.label = '',
+    this.flags = const <SemanticsFlag>{},
+    this.widgetName = '',
+  });
+
+  /// The semantics node identifier, as provided by the Flutter framework.
+  final String id;
+
+  /// The user-visible label announced by screen readers (maps to [SemanticsData.label]).
+  final String label;
+
+  /// Semantic flags active on this node.
+  final Set<SemanticsFlag> flags;
+
+  /// The name of the Flutter widget that produced this node, if available.
+  final String widgetName;
+
+  /// Mapping from flag name strings to [SemanticsFlag] instances.
+  static final _flagByName = <String, SemanticsFlag>{
+    for (final flag in SemanticsFlag.values) flag.name: flag,
+  };
+
+  /// Parses a list of flag name strings into a set of [SemanticsFlag]s.
+  static Set<SemanticsFlag> parseFlags(List<Object?>? rawFlags) {
+    if (rawFlags == null) return const <SemanticsFlag>{};
+    return rawFlags
+        .map((e) => _flagByName[e?.toString()])
+        .whereType<SemanticsFlag>()
+        .toSet();
+  }
+
+  @override
+  SemanticsNodeModel shallowCopy() {
+    return SemanticsNodeModel(
+      id: id,
+      label: label,
+      flags: flags,
+      widgetName: widgetName,
+    );
+  }
+}
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
index 8081874..9b97007 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
@@ -2,10 +2,17 @@
 // 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:ui' show SemanticsFlag;
+
 import 'package:devtools_app_shared/ui.dart';
 import 'package:flutter/material.dart';
 
+import '../../shared/analytics/constants.dart' as gac;
+import '../../shared/globals.dart';
 import '../../shared/ui/common_widgets.dart';
+import '../../shared/ui/tree_view.dart';
+import 'accessibility_controller.dart';
+import 'semantics_node_model.dart';
 
 /// A pane that displays the semantics tree of the connected app.
 class AccessibilitySemanticsTreePane extends StatelessWidget {
@@ -13,17 +20,216 @@
 
   @override
   Widget build(BuildContext context) {
-    return const DevToolsAreaPane(
-      header: AreaPaneHeader(
-        title: Text('Semantics Tree'),
-        includeTopBorder: false,
-        roundedTopBorder: false,
+    final controller = screenControllers.lookup<AccessibilityController>();
+    return ValueListenableBuilder<List<SemanticsNodeModel>>(
+      valueListenable: controller.semanticsRoots,
+      builder: (context, roots, _) {
+        return DevToolsAreaPane(
+          header: AreaPaneHeader(
+            title: const Text('Semantics Tree'),
+            includeTopBorder: false,
+            roundedTopBorder: false,
+            actions: [
+              if (roots.isNotEmpty)
+                RefreshButton(
+                  iconOnly: true,
+                  tooltip: 'Refresh Semantics Tree',
+                  gaScreen: gac.accessibility,
+                  gaSelection: gac.refresh,
+                  onPressed: controller.loadSemanticsTree,
+                ),
+            ],
+          ),
+          child: ValueListenableBuilder<bool>(
+            valueListenable: controller.semanticsTreeLoading,
+            builder: (context, loading, _) {
+              if (loading) {
+                return const CenteredCircularProgressIndicator();
+              }
+              return ValueListenableBuilder<String?>(
+                valueListenable: controller.semanticsTreeError,
+                builder: (context, error, _) {
+                  if (error != null) {
+                    return _SemanticsTreeErrorState(
+                      errorMessage: error,
+                      onRetry: controller.loadSemanticsTree,
+                    );
+                  }
+                  if (roots.isEmpty) {
+                    return _SemanticsTreeEmptyState(
+                      onLoad: controller.loadSemanticsTree,
+                    );
+                  }
+                  return _SemanticsTreeContent(controller: controller);
+                },
+              );
+            },
+          ),
+        );
+      },
+    );
+  }
+}
+
+class _SemanticsTreeEmptyState extends StatelessWidget {
+  const _SemanticsTreeEmptyState({required this.onLoad});
+
+  final VoidCallback onLoad;
+
+  @override
+  Widget build(BuildContext context) {
+    return Center(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          const CenteredMessage(
+            message:
+                'No semantics tree loaded. Inspect the accessibility hierarchy of the connected app.',
+          ),
+          const SizedBox(height: defaultSpacing),
+          DevToolsButton(
+            onPressed: onLoad,
+            icon: Icons.account_tree_outlined,
+            label: 'Load Semantics Tree',
+            elevated: true,
+          ),
+        ],
       ),
-      child: CenteredMessage(
-        message:
-            'Accessibility semantics tree placeholder.\n'
-            '// TODO(hannah-hyj): Implement semantics tree view and details explorer.',
+    );
+  }
+}
+
+class _SemanticsTreeErrorState extends StatelessWidget {
+  const _SemanticsTreeErrorState({
+    required this.errorMessage,
+    required this.onRetry,
+  });
+
+  final String errorMessage;
+  final VoidCallback onRetry;
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    return Center(
+      child: Padding(
+        padding: const EdgeInsets.all(defaultSpacing),
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            SelectableText(
+              errorMessage,
+              style: theme.regularTextStyle.copyWith(
+                color: theme.colorScheme.error,
+              ),
+              textAlign: TextAlign.center,
+            ),
+            const SizedBox(height: defaultSpacing),
+            DevToolsButton(
+              onPressed: onRetry,
+              icon: Icons.refresh,
+              label: 'Try Again',
+              elevated: true,
+            ),
+          ],
+        ),
       ),
     );
   }
 }
+
+class _SemanticsTreeContent extends StatelessWidget {
+  const _SemanticsTreeContent({required this.controller});
+
+  final AccessibilityController controller;
+
+  static IconData _iconForNode(SemanticsNodeModel node) {
+    if (node.flags.contains(SemanticsFlag.isButton)) {
+      return Icons.smart_button_rounded;
+    }
+    if (node.flags.contains(SemanticsFlag.isTextField)) {
+      return Icons.text_fields_rounded;
+    }
+    if (node.flags.contains(SemanticsFlag.isHeader)) {
+      return Icons.title_rounded;
+    }
+    if (node.flags.contains(SemanticsFlag.isSlider)) {
+      return Icons.linear_scale_rounded;
+    }
+    if (node.flags.contains(SemanticsFlag.hasCheckedState)) {
+      return Icons.check_box_outlined;
+    }
+    return Icons.widgets_outlined;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    final colorScheme = theme.colorScheme;
+
+    return TreeView<SemanticsNodeModel>(
+      dataRootsListenable: controller.semanticsRoots,
+      scrollController: controller.treeScrollController,
+      includeScrollbar: true,
+      dataDisplayProvider: (node, onPressed) {
+        return InkWell(
+          onTap: onPressed,
+          child: Padding(
+            padding: const EdgeInsets.symmetric(horizontal: denseSpacing),
+            child: Row(
+              children: [
+                Icon(
+                  _iconForNode(node),
+                  size: defaultIconSize,
+                  color: colorScheme.onSurface.withValues(alpha: 0.7),
+                ),
+                const SizedBox(width: denseSpacing),
+                Text(
+                  'SemanticsNode #${node.id}',
+                  maxLines: 1,
+                  style: theme.fixedFontStyle,
+                ),
+                if (node.label.isNotEmpty) ...[
+                  const SizedBox(width: denseSpacing),
+                  Flexible(
+                    child: Text(
+                      '"${node.label}"',
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      style: theme.subtleTextStyle.copyWith(
+                        fontStyle: FontStyle.italic,
+                        color: colorScheme.onSurface.withValues(alpha: 0.6),
+                      ),
+                    ),
+                  ),
+                ],
+                if (node.widgetName.isNotEmpty) ...[
+                  const SizedBox(width: denseSpacing),
+                  Container(
+                    padding: const EdgeInsets.symmetric(
+                      horizontal: densePadding,
+                    ),
+                    decoration: BoxDecoration(
+                      color: colorScheme.primaryContainer.withValues(
+                        alpha: 0.2,
+                      ),
+                      borderRadius: BorderRadius.circular(4),
+                    ),
+                    child: Text(
+                      node.widgetName,
+                      maxLines: 1,
+                      style: theme.subtleTextStyle.copyWith(
+                        color: colorScheme.primary,
+                        fontSize: smallFontSize,
+                      ),
+                    ),
+                  ),
+                ],
+              ],
+            ),
+          ),
+        );
+      },
+    );
+  }
+}
diff --git a/packages/devtools_app/lib/src/service/service_registrations.dart b/packages/devtools_app/lib/src/service/service_registrations.dart
index 2f6c0bc..958e071 100644
--- a/packages/devtools_app/lib/src/service/service_registrations.dart
+++ b/packages/devtools_app/lib/src/service/service_registrations.dart
@@ -60,3 +60,12 @@
 /// Service extension that returns whether or not the Impeller rendering engine
 /// is being used (if false, the app is using SKIA).
 const isImpellerEnabled = 'ext.ui.window.impellerEnabled';
+
+/// Service extension to enable or disable semantics on the connected app.
+const enableSemantics = 'ext.flutter.accessibility.enableSemantics';
+
+/// Service extension to fetch the accessibility semantics tree from the connected app.
+const getSemanticsTree = 'ext.flutter.accessibility.getSemanticsTree';
+
+/// Service extension to dispose semantics on the connected app.
+const disposeSemantics = 'ext.flutter.accessibility.disposeSemantics';
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
index 1363f9d..06f3eb8 100644
--- a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
@@ -5,11 +5,17 @@
 @TestOn('vm')
 library;
 
+import 'dart:ui' show SemanticsFlag;
+
 import 'package:devtools_app/devtools_app.dart';
+import 'package:devtools_app/src/service/service_registrations.dart'
+    as registrations;
 import 'package:devtools_app_shared/utils.dart';
 import 'package:devtools_test/devtools_test.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:mockito/mockito.dart';
+import 'package:vm_service/vm_service.dart';
 
 void main() {
   group('AccessibilityController', () {
@@ -24,6 +30,19 @@
         fakeServiceConnection.serviceManager.connectedApp!.isProfileBuildNow,
       ).thenReturn(false);
 
+      fakeServiceConnection
+              .serviceManager
+              .serviceExtensionResponses[registrations.enableSemantics] =
+          Response.parse({})!;
+      fakeServiceConnection
+              .serviceManager
+              .serviceExtensionResponses[registrations.getSemanticsTree] =
+          Response.parse({
+            'data': {
+              '0': {'id': '0', 'label': 'Root'},
+            },
+          })!;
+
       setGlobal(NotificationService, NotificationService());
       setGlobal(
         DevToolsEnvironmentParameters,
@@ -36,7 +55,17 @@
     });
 
     test('initial state', () {
-      expect(controller.brightness.value, BrightnessOverride.system);
+      final uninitializedController = AccessibilityController();
+      expect(
+        uninitializedController.brightness.value,
+        BrightnessOverride.system,
+      );
+      expect(uninitializedController.textScale.value, 1.0);
+      expect(uninitializedController.boldText.value, isFalse);
+      expect(uninitializedController.screenReader.value, isFalse);
+      expect(uninitializedController.highContrast.value, isFalse);
+      expect(uninitializedController.semanticsRoots.value, isEmpty);
+      expect(uninitializedController.semanticsTreeLoading.value, isFalse);
     });
 
     test(
@@ -114,5 +143,305 @@
         expect(systemState.enabled, isFalse);
       },
     );
+
+    test('SemanticsNodeModel properties and shallowCopy', () {
+      final child = SemanticsNodeModel(
+        id: '1',
+        label: 'Child Node',
+        flags: {SemanticsFlag.isButton, SemanticsFlag.hasCheckedState},
+        widgetName: 'ElevatedButton',
+      );
+      final parent = SemanticsNodeModel(
+        id: '0',
+        label: 'Parent Node',
+        flags: {SemanticsFlag.isHeader},
+        widgetName: 'Column',
+      )..addChild(child);
+
+      expect(parent.children, hasLength(1));
+      expect(parent.children.first.id, equals('1'));
+
+      final copy = child.shallowCopy();
+      expect(copy.id, equals('1'));
+      expect(copy.label, equals('Child Node'));
+      expect(
+        copy.flags,
+        equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}),
+      );
+      expect(copy.widgetName, equals('ElevatedButton'));
+      expect(copy.children, isEmpty);
+    });
+
+    test(
+      'loadSemanticsTree sets error state when no main isolate connected',
+      () async {
+        final fakeServiceConnection = _NullIsolateServiceConnectionManager();
+        setGlobal(ServiceConnectionManager, fakeServiceConnection);
+
+        final testController = AccessibilityController();
+        expect(testController.semanticsTreeError.value, isNull);
+        await testController.loadSemanticsTree();
+        expect(
+          testController.semanticsTreeError.value,
+          equals('Failed to load semantics tree: no connected application.'),
+        );
+        expect(testController.semanticsTreeLoading.value, isFalse);
+        expect(testController.semanticsRoots.value, isEmpty);
+      },
+    );
+
+    test(
+      'loadSemanticsTree sets error state when service extension returns error',
+      () async {
+        final fakeServiceManager =
+            serviceConnection.serviceManager as FakeServiceManager;
+        fakeServiceManager.serviceExtensionResponses[registrations
+            .enableSemantics] = Response.parse(
+          {},
+        )!;
+        fakeServiceManager.serviceExtensionResponses[registrations
+            .getSemanticsTree] = Response.parse({
+          'error': 'Semantics not enabled.',
+        })!;
+
+        final testController = AccessibilityController();
+        await testController.loadSemanticsTree();
+
+        expect(
+          testController.semanticsTreeError.value,
+          equals(
+            'Failed to load semantics tree: Exception: Semantics not enabled.',
+          ),
+        );
+        expect(testController.semanticsRoots.value, isEmpty);
+      },
+    );
+
+    test(
+      'loadSemanticsTree parses full SemanticsNode.toJson format with multiple nodes',
+      () async {
+        final fakeServiceManager =
+            serviceConnection.serviceManager as FakeServiceManager;
+        fakeServiceManager.serviceExtensionResponses[registrations
+            .enableSemantics] = Response.parse(
+          {},
+        )!;
+        fakeServiceManager.serviceExtensionResponses[registrations
+            .getSemanticsTree] = Response.parse({
+          'data': {
+            '0': {
+              'id': 0,
+              'label': 'Root View',
+              'value': 'Main Screen',
+              'hint': '',
+              'tooltip': '',
+              'increasedValue': '',
+              'decreasedValue': '',
+              'flags': ['hasEnabledState', 'isEnabled'],
+              'actions': [],
+              'rect': {
+                'left': 0.0,
+                'top': 0.0,
+                'width': 390.0,
+                'height': 844.0,
+              },
+              'transform': [
+                1.0,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                1.0,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                1.0,
+                0.0,
+                0.0,
+                0.0,
+                0.0,
+                1.0,
+              ],
+              'childrenInTraversalOrder': [1, 2],
+              'childrenInHitTestOrder': [2, 1],
+            },
+            '1': {
+              'id': 1,
+              'label': 'Settings Header',
+              'flags': ['isHeader'],
+              'actions': [],
+              'rect': {
+                'left': 16.0,
+                'top': 40.0,
+                'width': 358.0,
+                'height': 32.0,
+              },
+            },
+            '2': {
+              'id': 2,
+              'label': 'Search Input',
+              'value': 'Flutter',
+              'hint': 'Enter search query',
+              'tooltip': 'Search field',
+              'flags': ['isTextField'],
+              'actions': ['tap', 'setSelection'],
+              'rect': {
+                'left': 16.0,
+                'top': 88.0,
+                'width': 358.0,
+                'height': 48.0,
+              },
+              'childrenInTraversalOrder': [3],
+              'childrenInHitTestOrder': [3],
+            },
+            '3': {
+              'id': 3,
+              'label': 'Clear Text',
+              'tooltip': 'Clear input content',
+              'flags': ['isButton', 'hasCheckedState'],
+              'actions': ['tap'],
+              'rect': {
+                'left': 330.0,
+                'top': 96.0,
+                'width': 32.0,
+                'height': 32.0,
+              },
+            },
+          },
+        })!;
+
+        final testController = AccessibilityController();
+        await testController.loadSemanticsTree();
+
+        expect(testController.semanticsRoots.value, hasLength(1));
+        final root = testController.semanticsRoots.value.first;
+        expect(root.id, equals('0'));
+        expect(root.label, equals('Root View'));
+        expect(
+          root.flags,
+          equals({SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled}),
+        );
+        expect(root.children, hasLength(2));
+
+        // Node 1: Settings Header
+        final headerNode = root.children[0];
+        expect(headerNode.id, equals('1'));
+        expect(headerNode.label, equals('Settings Header'));
+        expect(headerNode.flags, equals({SemanticsFlag.isHeader}));
+        expect(headerNode.children, isEmpty);
+
+        // Node 2: Search Input
+        final searchNode = root.children[1];
+        expect(searchNode.id, equals('2'));
+        expect(searchNode.label, equals('Search Input'));
+        expect(searchNode.flags, equals({SemanticsFlag.isTextField}));
+        expect(searchNode.children, hasLength(1));
+
+        // Node 3: Clear Text Button (child of Node 2)
+        final clearButtonNode = searchNode.children.first;
+        expect(clearButtonNode.id, equals('3'));
+        expect(clearButtonNode.label, equals('Clear Text'));
+        expect(
+          clearButtonNode.flags,
+          equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}),
+        );
+        expect(clearButtonNode.children, isEmpty);
+      },
+    );
+
+    test(
+      'loadSemanticsTree parses flat nodes map and builds child hierarchy',
+      () async {
+        final fakeServiceManager =
+            serviceConnection.serviceManager as FakeServiceManager;
+        fakeServiceManager.serviceExtensionResponses[registrations
+            .enableSemantics] = Response.parse(
+          {},
+        )!;
+        fakeServiceManager.serviceExtensionResponses[registrations
+            .getSemanticsTree] = Response.parse({
+          'data': {
+            '0': {
+              'id': 0,
+              'label': 'Root',
+              'childrenInTraversalOrder': [1],
+            },
+            '1': {
+              'id': 1,
+              'label': 'Child full',
+              'flags': ['isButton'],
+            },
+          },
+        })!;
+
+        final testController = AccessibilityController();
+        await testController.loadSemanticsTree();
+
+        expect(testController.semanticsRoots.value, hasLength(1));
+        final root = testController.semanticsRoots.value.first;
+        expect(root.id, equals('0'));
+        expect(root.label, equals('Root'));
+        expect(root.children, hasLength(1));
+        expect(root.children.first.id, equals('1'));
+        expect(root.children.first.label, equals('Child full'));
+        expect(root.children.first.flags, equals({SemanticsFlag.isButton}));
+      },
+    );
+
+    test('dispose calls disposeSemantics', () async {
+      final recordingServiceConnection = _RecordingServiceConnectionManager();
+      setGlobal(ServiceConnectionManager, recordingServiceConnection);
+
+      final testController = AccessibilityController();
+      testController.dispose();
+      await Future<void>.delayed(Duration.zero);
+
+      final calls =
+          (recordingServiceConnection.serviceManager
+                  as _RecordingServiceManager)
+              .recordedCalls;
+      expect(
+        calls.any((call) => call.$1 == registrations.disposeSemantics),
+        isTrue,
+      );
+    });
   });
 }
+
+class _RecordingServiceConnectionManager extends FakeServiceConnectionManager {
+  @override
+  late final serviceManager = _RecordingServiceManager();
+}
+
+// ignore: subtype_of_sealed_class, fake for testing.
+class _RecordingServiceManager extends FakeServiceManager {
+  final recordedCalls = <(String, Map<String, dynamic>?)>[];
+
+  @override
+  Future<Response> callServiceExtensionOnMainIsolate(
+    String method, {
+    Map<String, dynamic>? args,
+  }) async {
+    recordedCalls.add((method, args));
+    return serviceExtensionResponses[method] ?? Response.parse({})!;
+  }
+}
+
+class _NullIsolateServiceConnectionManager
+    extends FakeServiceConnectionManager {
+  @override
+  late final serviceManager = _NullIsolateServiceManager();
+}
+
+// ignore: subtype_of_sealed_class, fake for testing.
+class _NullIsolateServiceManager extends FakeServiceManager {
+  @override
+  late final isolateManager = _NullIsolateManager();
+}
+
+base class _NullIsolateManager extends FakeIsolateManager {
+  @override
+  ValueListenable<IsolateRef?> get mainIsolate =>
+      ValueNotifier<IsolateRef?>(null);
+}
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
index ba5e53e..337d24e 100644
--- a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
@@ -5,6 +5,8 @@
 @TestOn('vm')
 library;
 
+import 'dart:ui' show SemanticsFlag;
+
 import 'package:devtools_app/devtools_app.dart';
 import 'package:devtools_app_shared/ui.dart';
 import 'package:devtools_app_shared/utils.dart';
@@ -181,5 +183,128 @@
         expect(controller.highContrast.value, isTrue);
       },
     );
+
+    testWidgetsWithWindowSize(
+      'renders semantics tree when nodes are loaded',
+      windowSize,
+      (WidgetTester tester) async {
+        final rootNode = SemanticsNodeModel(
+          id: '0',
+          label: 'Root Node',
+          flags: {SemanticsFlag.isHeader},
+          widgetName: 'HeaderWidget',
+        );
+        controller.semanticsRoots.value = [rootNode];
+
+        await pumpAccessibilityScreen(tester);
+        await tester.pumpAndSettle();
+
+        expect(find.text('SemanticsNode #0'), findsAtLeastNWidgets(1));
+        expect(find.text('"Root Node"'), findsAtLeastNWidgets(1));
+        expect(find.text('HeaderWidget'), findsOneWidget);
+      },
+    );
+
+    testWidgetsWithWindowSize(
+      'renders semantics tree error state when error occurs',
+      windowSize,
+      (WidgetTester tester) async {
+        controller.semanticsTreeError.value =
+            'Failed to load semantics tree: network error.';
+
+        await pumpAccessibilityScreen(tester);
+        await tester.pumpAndSettle();
+
+        expect(
+          find.text('Failed to load semantics tree: network error.'),
+          findsOneWidget,
+        );
+        expect(find.text('Try Again'), findsOneWidget);
+      },
+    );
+
+    testWidgetsWithWindowSize(
+      'renders parent and child nodes in semantics tree',
+      windowSize,
+      (WidgetTester tester) async {
+        final childNode = SemanticsNodeModel(
+          id: '1',
+          label: 'Child Node',
+          flags: {SemanticsFlag.isButton},
+          widgetName: 'ElevatedButton',
+        );
+        final rootNode = SemanticsNodeModel(
+          id: '0',
+          label: 'Root Node',
+          widgetName: 'Column',
+        )..addChild(childNode);
+
+        rootNode.expandCascading();
+        controller.semanticsRoots.value = [rootNode];
+
+        await pumpAccessibilityScreen(tester);
+        await tester.pumpAndSettle();
+
+        expect(find.text('SemanticsNode #0'), findsOneWidget);
+        expect(find.text('SemanticsNode #1'), findsOneWidget);
+        expect(find.text('"Child Node"'), findsOneWidget);
+        expect(find.text('ElevatedButton'), findsOneWidget);
+      },
+    );
+
+    testWidgetsWithWindowSize(
+      'renders empty state with Load Semantics Tree button when no roots',
+      windowSize,
+      (WidgetTester tester) async {
+        controller.semanticsRoots.value = [];
+        await pumpAccessibilityScreen(tester);
+        controller.semanticsTreeError.value = null;
+        await tester.pumpAndSettle();
+
+        expect(
+          find.text(
+            'No semantics tree loaded. Inspect the accessibility hierarchy of the connected app.',
+          ),
+          findsOneWidget,
+        );
+        expect(
+          find.widgetWithText(DevToolsButton, 'Load Semantics Tree'),
+          findsOneWidget,
+        );
+        expect(find.byType(RefreshButton), findsNothing);
+      },
+    );
+
+    testWidgetsWithWindowSize(
+      'renders refresh button when roots are loaded',
+      windowSize,
+      (WidgetTester tester) async {
+        final rootNode = SemanticsNodeModel(id: '0', label: 'Root Node');
+        controller.semanticsRoots.value = [rootNode];
+
+        await pumpAccessibilityScreen(tester);
+        await tester.pumpAndSettle();
+
+        expect(find.byType(RefreshButton), findsOneWidget);
+      },
+    );
+
+    testWidgetsWithWindowSize(
+      'renders appropriate icon for node with hasCheckedState flag',
+      windowSize,
+      (WidgetTester tester) async {
+        final node = SemanticsNodeModel(
+          id: '0',
+          label: 'Checkbox Node',
+          flags: {SemanticsFlag.hasCheckedState},
+        );
+        controller.semanticsRoots.value = [node];
+
+        await pumpAccessibilityScreen(tester);
+        await tester.pumpAndSettle();
+
+        expect(find.byIcon(Icons.check_box_outlined), findsOneWidget);
+      },
+    );
   });
 }