diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor
new file mode 100644
index 00000000..214fda1c
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor
@@ -0,0 +1,154 @@
+@* Lazy tree component with per-node text filter. Driver-agnostic — consumes
+ IBrowserSessionService for root/expand. Selected node is bound back to parent
+ via OnNodeSelected EventCallback. *@
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
+@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
+@inject IBrowserSessionService BrowserService
+
+
+ @if (_loading)
+ {
+
Loading…
+ }
+ else if (_error is not null)
+ {
+
@_error
+ }
+ else if (_roots is null || _roots.Count == 0)
+ {
+
No nodes.
+ }
+ else
+ {
+ @foreach (var n in _roots) { @RenderNode(n, 0) }
+ }
+
+
+@code {
+ /// The browse-session token returned by IBrowserSessionService.OpenAsync.
+ [Parameter, EditorRequired] public Guid SessionToken { get; set; }
+
+ /// The currently-selected node's NodeId, for visual selection highlighting.
+ [Parameter] public string SelectedNodeId { get; set; } = "";
+
+ /// Fired when the user clicks a leaf (or any node — caller decides what to do with it).
+ [Parameter] public EventCallback OnNodeSelected { get; set; }
+
+ private bool _loading = true;
+ private string? _error;
+ private List? _roots;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadRootAsync();
+ }
+
+ private async Task LoadRootAsync()
+ {
+ try
+ {
+ var roots = await BrowserService.RootAsync(SessionToken, default);
+ _roots = roots.Select(n => new TreeItem(n)).ToList();
+ }
+ catch (Exception ex) { _error = ex.Message; }
+ finally { _loading = false; }
+ }
+
+ private async Task ToggleAsync(TreeItem item)
+ {
+ item.Expanded = !item.Expanded;
+ if (item.Expanded && !item.Loaded) await ExpandAsync(item);
+ }
+
+ private async Task ExpandAsync(TreeItem item)
+ {
+ if (item.Loaded || item.Loading) return;
+ item.Loading = true; StateHasChanged();
+ try
+ {
+ var kids = await BrowserService.ExpandAsync(SessionToken, item.Node.NodeId, default);
+ item.Children = kids.Select(k => new TreeItem(k)).ToList();
+ item.Loaded = true;
+ }
+ catch (Exception ex) { item.Error = ex.Message; }
+ finally { item.Loading = false; StateHasChanged(); }
+ }
+
+ private async Task SelectAsync(TreeItem item)
+ {
+ SelectedNodeId = item.Node.NodeId;
+ await OnNodeSelected.InvokeAsync(item.Node);
+ }
+
+ private RenderFragment RenderNode(TreeItem item, int depth) => __builder =>
+ {
+ var indent = $"padding-left:{depth * 18}px";
+ var selectedCls = SelectedNodeId == item.Node.NodeId ? "bg-primary-subtle" : "";
+
+ @if (item.Expanded && item.Loading)
+ {
+
+ Loading…
+
+ }
+ else if (item.Expanded && item.Error is not null)
+ {
+
+ @item.Error
+
+ }
+ else if (item.Expanded && item.Loaded && item.Children is { Count: > 0 })
+ {
+
+ @foreach (var c in FilterChildren(item))
+ {
+ @RenderNode(c, depth + 1)
+ }
+ }
+ };
+
+ private static IEnumerable FilterChildren(TreeItem item)
+ {
+ if (item.Children is null) yield break;
+ var f = item.Filter?.Trim();
+ foreach (var c in item.Children)
+ {
+ if (string.IsNullOrEmpty(f)) { yield return c; continue; }
+ if (c.Node.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase) ||
+ c.Node.NodeId.Contains(f, StringComparison.OrdinalIgnoreCase))
+ yield return c;
+ }
+ }
+
+ private sealed class TreeItem(BrowseNode node)
+ {
+ public BrowseNode Node { get; } = node;
+ public bool Expanded { get; set; }
+ public bool Loaded { get; set; }
+ public bool Loading { get; set; }
+ public string? Error { get; set; }
+ public List? Children { get; set; }
+ public string? Filter { get; set; }
+ }
+}