diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs
new file mode 100644
index 00000000..7b470188
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs
@@ -0,0 +1,82 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+
+///
+/// Builds the Instance Picker's area/instance forest from a site's flat area and
+/// instance lists. Pure — no Blazor, no DI, no I/O — so the shape is unit-testable
+/// on its own and the picker component only has to render it.
+///
+public static class InstanceTreeBuilder
+{
+ ///
+ /// Nests the supplied areas by and hangs each area's
+ /// instances beneath it, mirroring the Topology page's ordering: at every level the
+ /// child areas come first (sorted by name, ordinal-ignore-case), then that level's
+ /// instances (sorted by unique name, ordinal-ignore-case). Instances with no area sit
+ /// at the root after the root areas. An area whose parent id points at an area that is
+ /// not in is treated as a root, so a stale row can never hide
+ /// a subtree.
+ ///
+ /// The site's areas (flat, linked by ); null is treated as empty.
+ /// The site's instances; null is treated as empty.
+ ///
+ /// Optional case-insensitive substring matched against .
+ /// When set, only matching instances are kept along with their ancestor areas; areas left
+ /// with nothing beneath them are dropped. Null or whitespace means no filter.
+ ///
+ /// The root rows of the forest, never null.
+ public static IReadOnlyList Build(
+ IReadOnlyList? areas,
+ IReadOnlyList? instances,
+ string? filter)
+ {
+ areas ??= [];
+ instances ??= [];
+
+ var needle = filter?.Trim();
+ var hasFilter = !string.IsNullOrEmpty(needle);
+
+ var knownAreaIds = areas.Select(a => a.Id).ToHashSet();
+
+ // Child areas by effective parent id — null for a root or an orphaned area.
+ var areasByParent = areas
+ .OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
+ .ToLookup(a => a.ParentAreaId is int p && knownAreaIds.Contains(p) ? p : (int?)null);
+
+ var instancesByArea = instances
+ .Where(i => !hasFilter || i.UniqueName.Contains(needle!, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
+ .ToLookup(i => i.AreaId);
+
+ return BuildLevel(parentId: null, areasByParent, instancesByArea, hasFilter, ancestors: []);
+ }
+
+ private static List BuildLevel(
+ int? parentId,
+ ILookup areasByParent,
+ ILookup instancesByArea,
+ bool hasFilter,
+ HashSet ancestors)
+ {
+ var level = new List();
+
+ foreach (var area in areasByParent[parentId])
+ {
+ // Defensive: a cycle in ParentAreaId must never recurse forever.
+ if (!ancestors.Add(area.Id)) continue;
+
+ var node = new InstanceTreeNode($"a:{area.Id}", area.Name, area, Instance: null);
+ node.Children.AddRange(BuildLevel(area.Id, areasByParent, instancesByArea, hasFilter, ancestors));
+ ancestors.Remove(area.Id);
+
+ // With a filter an area survives only if something matched beneath it.
+ if (!hasFilter || node.HasChildren) level.Add(node);
+ }
+
+ level.AddRange(instancesByArea[parentId].Select(i =>
+ new InstanceTreeNode($"i:{i.Id}", i.UniqueName, Area: null, Instance: i)));
+
+ return level;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs
new file mode 100644
index 00000000..e882fa96
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs
@@ -0,0 +1,23 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+
+///
+/// One row of the Instance Picker tree: an area branch or an instance leaf.
+/// Keys are a:{areaId} for areas and i:{instanceId} for instances.
+///
+/// Stable tree key — a:{areaId} or i:{instanceId}.
+/// Text rendered for this row.
+/// The area this node represents, or null for an instance leaf.
+/// The instance this node represents, or null for an area branch.
+public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)
+{
+ /// Gets the child rows nested under this node (child areas first, then instances).
+ public List Children { get; } = new();
+
+ /// Gets a value indicating whether this node is a selectable instance leaf.
+ public bool IsInstance => Instance is not null;
+
+ /// Gets a value indicating whether this node has any child rows.
+ public bool HasChildren => Children.Count > 0;
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs
new file mode 100644
index 00000000..8e012661
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs
@@ -0,0 +1,143 @@
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
+
+///
+/// Unit tests for — the pure area/instance forest
+/// behind the Instance Picker combobox (design 2026-09-08).
+///
+public class InstanceTreeBuilderTests
+{
+ private static Area MakeArea(int id, string name, int? parentAreaId = null) =>
+ new(name) { Id = id, SiteId = 1, ParentAreaId = parentAreaId };
+
+ private static Instance MakeInstance(int id, string uniqueName, int? areaId = null) =>
+ new(uniqueName) { Id = id, SiteId = 1, AreaId = areaId };
+
+ [Fact]
+ public void Build_NestsAreasByParent_AndPutsRootInstancesAfterRootAreas()
+ {
+ var line1 = MakeArea(1, "Line1");
+ var cellA = MakeArea(2, "Cell A", parentAreaId: 1);
+ var p1 = MakeInstance(10, "P1", areaId: 2);
+ var zulu = MakeInstance(11, "Zulu");
+ var alpha = MakeInstance(12, "Alpha");
+
+ var roots = InstanceTreeBuilder.Build([line1, cellA], [p1, zulu, alpha], filter: null);
+
+ Assert.Equal(new[] { "a:1", "i:12", "i:11" }, roots.Select(n => n.Key));
+
+ var areaNode = roots[0];
+ Assert.Equal("Line1", areaNode.Label);
+ Assert.False(areaNode.IsInstance);
+ Assert.Same(line1, areaNode.Area);
+ Assert.Null(areaNode.Instance);
+ Assert.True(areaNode.HasChildren);
+
+ var cellNode = Assert.Single(areaNode.Children);
+ Assert.Equal("a:2", cellNode.Key);
+ Assert.Equal("Cell A", cellNode.Label);
+ Assert.Same(cellA, cellNode.Area);
+
+ var p1Node = Assert.Single(cellNode.Children);
+ Assert.Equal("i:10", p1Node.Key);
+ Assert.Equal("P1", p1Node.Label);
+ Assert.True(p1Node.IsInstance);
+ Assert.Same(p1, p1Node.Instance);
+ Assert.Null(p1Node.Area);
+ Assert.False(p1Node.HasChildren);
+
+ Assert.Equal("Alpha", roots[1].Label);
+ Assert.Same(alpha, roots[1].Instance);
+ Assert.Equal("Zulu", roots[2].Label);
+ Assert.Same(zulu, roots[2].Instance);
+ }
+
+ [Fact]
+ public void Build_SortsAreasAndInstancesCaseInsensitively()
+ {
+ var beta = MakeArea(1, "beta");
+ var alphaArea = MakeArea(2, "Alpha");
+ var zebra = MakeInstance(10, "zebra");
+ var apple = MakeInstance(11, "Apple");
+
+ var roots = InstanceTreeBuilder.Build([beta, alphaArea], [zebra, apple], filter: null);
+
+ Assert.Equal(new[] { "Alpha", "beta", "Apple", "zebra" }, roots.Select(n => n.Label));
+ }
+
+ [Fact]
+ public void Build_OrphanedArea_BecomesARoot()
+ {
+ // ParentAreaId points at an area that is not in the supplied list.
+ var orphan = MakeArea(5, "Orphan", parentAreaId: 99);
+ var child = MakeInstance(10, "OrphanChild", areaId: 5);
+
+ var roots = InstanceTreeBuilder.Build([orphan], [child], filter: null);
+
+ var root = Assert.Single(roots);
+ Assert.Equal("a:5", root.Key);
+ var leaf = Assert.Single(root.Children);
+ Assert.Equal("i:10", leaf.Key);
+ }
+
+ [Fact]
+ public void Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches()
+ {
+ var line1 = MakeArea(1, "Line1");
+ var cellA = MakeArea(2, "Cell A", parentAreaId: 1);
+ var emptyArea = MakeArea(3, "Line2");
+ var p1 = MakeInstance(10, "P1", areaId: 2);
+ var zulu = MakeInstance(11, "Zulu");
+ var alpha = MakeInstance(12, "Alpha");
+
+ var roots = InstanceTreeBuilder.Build([line1, cellA, emptyArea], [p1, zulu, alpha], filter: "p1");
+
+ var root = Assert.Single(roots);
+ Assert.Equal("a:1", root.Key);
+ var cellNode = Assert.Single(root.Children);
+ Assert.Equal("a:2", cellNode.Key);
+ var leaf = Assert.Single(cellNode.Children);
+ Assert.Equal("i:10", leaf.Key);
+ }
+
+ [Fact]
+ public void Build_Filter_WhitespaceMeansNoFilter()
+ {
+ var line1 = MakeArea(1, "Line1");
+ var p1 = MakeInstance(10, "P1", areaId: 1);
+ var zulu = MakeInstance(11, "Zulu");
+
+ var roots = InstanceTreeBuilder.Build([line1], [p1, zulu], filter: " ");
+
+ Assert.Equal(new[] { "a:1", "i:11" }, roots.Select(n => n.Key));
+ Assert.Single(roots[0].Children);
+ }
+
+ [Fact]
+ public void Build_NullOrEmptyInputs_ReturnEmpty()
+ {
+ Assert.Empty(InstanceTreeBuilder.Build(null, null, filter: null));
+ Assert.Empty(InstanceTreeBuilder.Build([], [], filter: null));
+ Assert.Empty(InstanceTreeBuilder.Build(null, [], filter: "anything"));
+
+ var orphanInstances = InstanceTreeBuilder.Build(null, [MakeInstance(1, "Solo")], filter: null);
+ Assert.Equal("i:1", Assert.Single(orphanInstances).Key);
+
+ var areasOnly = InstanceTreeBuilder.Build([MakeArea(1, "Line1")], null, filter: null);
+ Assert.Equal("a:1", Assert.Single(areasOnly).Key);
+ }
+
+ [Fact]
+ public void Build_AreaWithNoInstancesAndNoFilter_IsStillShown()
+ {
+ var empty = MakeArea(1, "Empty");
+
+ var roots = InstanceTreeBuilder.Build([empty], [], filter: null);
+
+ var root = Assert.Single(roots);
+ Assert.Equal("a:1", root.Key);
+ Assert.False(root.HasChildren);
+ }
+}