Merge changes I1d0c46ce,I126a9ef5 into main

* changes:
  ui: add support for saving/restoring sets of tracks by name
  ui: switch pinned tracks save/restore feature to use Zod
diff --git a/src/trace_processor/perfetto_sql/stdlib/wattson/curves/w_dsu_dependence.sql b/src/trace_processor/perfetto_sql/stdlib/wattson/curves/w_dsu_dependence.sql
index dbaf4ac..9435c09 100644
--- a/src/trace_processor/perfetto_sql/stdlib/wattson/curves/w_dsu_dependence.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/wattson/curves/w_dsu_dependence.sql
@@ -55,6 +55,22 @@
   base.freq_7 = lut7.freq_khz AND
   base.idle_7 = lut7.idle;
 
+-- Get nominal devfreq_dsu counter, OR use a dummy one for Pixel 9 VM traces
+-- The VM doesn't have a DSU, so the placeholder value of FMin is put in. The
+-- DSU frequency is a prerequisite for power estimation on Pixel 9.
+CREATE PERFETTO TABLE _dsu_frequency AS
+SELECT * from linux_devfreq_dsu_counter
+UNION ALL
+SELECT
+ 0 as id,
+ trace_start() as ts,
+ trace_end() - trace_start() as dur,
+ 610000 as dsu_freq
+-- Only add this for traces from a VM on Pixel 9 where DSU values aren't present
+WHERE (SELECT str_value FROM metadata WHERE name = 'android_guest_soc_model')
+  IN (SELECT device FROM _use_devfreq)
+  AND (SELECT COUNT(*) FROM linux_devfreq_dsu_counter) = 0;
+
 CREATE PERFETTO TABLE _w_dsu_dependence AS
 SELECT
   c.ts, c.dur,
@@ -80,10 +96,10 @@
 FROM _interval_intersect!(
   (
     _ii_subquery!(_cpu_curves),
-    _ii_subquery!(linux_devfreq_dsu_counter)
+    _ii_subquery!(_dsu_frequency)
   ),
   ()
 ) ii
 JOIN _cpu_curves AS c ON c._auto_id = id_0
-JOIN linux_devfreq_dsu_counter AS d on d._auto_id = id_1;
+JOIN _dsu_frequency AS d on d._auto_id = id_1;
 
diff --git a/test/data/wattson_tk4_vm.pb.sha256 b/test/data/wattson_tk4_vm.pb.sha256
new file mode 100644
index 0000000..52b09dd
--- /dev/null
+++ b/test/data/wattson_tk4_vm.pb.sha256
@@ -0,0 +1 @@
+12beb14bc06e28ecbdfc618d423aeea2badd449aeb9ccc65a457aa6c05ebf2be
\ No newline at end of file
diff --git a/test/trace_processor/diff_tests/stdlib/wattson/tests.py b/test/trace_processor/diff_tests/stdlib/wattson/tests.py
index b5c9cae..5b20333 100644
--- a/test/trace_processor/diff_tests/stdlib/wattson/tests.py
+++ b/test/trace_processor/diff_tests/stdlib/wattson/tests.py
@@ -452,3 +452,30 @@
             452415394221,69579176303,13654,13361,11651,9609,1
             564873995228,135118729231,45223,37594,22798,20132,1
             """))
+
+  # Tests traces from VM that have incomplete CPU tracks
+  def test_wattson_missing_cpus_on_guest(self):
+    return DiffTestBlueprint(
+        trace=DataPath('wattson_tk4_vm.pb'),
+        query=("""
+            INCLUDE PERFETTO MODULE wattson.curves.estimates;
+               SELECT
+                 ts, dur, cpu0_mw, cpu1_mw, cpu2_mw, cpu3_mw, cpu4_mw, cpu5_mw,
+                 cpu6_mw
+               FROM _system_state_mw
+               WHERE ts > 25150000000
+               LIMIT 10
+            """),
+        out=Csv("""
+            "ts","dur","cpu0_mw","cpu1_mw","cpu2_mw","cpu3_mw","cpu4_mw","cpu5_mw","cpu6_mw"
+            25150029000,1080,0.000000,0.000000,0.000000,0.000000,70.050000,83.260000,0.000000
+            25150030640,42920,0.000000,0.000000,0.000000,0.000000,70.050000,70.050000,0.000000
+            25150073560,99800,0.000000,0.000000,0.000000,0.000000,70.050000,0.000000,0.000000
+            25150173360,28240,176.280000,0.000000,0.000000,0.000000,70.050000,0.000000,0.000000
+            25150201600,6480,176.280000,0.000000,0.000000,176.280000,70.050000,0.000000,0.000000
+            25150208080,29840,176.280000,0.000000,0.000000,176.280000,70.050000,70.050000,0.000000
+            25150237920,129800,0.000000,0.000000,0.000000,176.280000,70.050000,70.050000,0.000000
+            25150367720,37480,0.000000,0.000000,0.000000,176.280000,70.050000,0.000000,0.000000
+            25150405200,15120,0.000000,176.280000,0.000000,176.280000,70.050000,0.000000,0.000000
+            25150420320,15920,0.000000,176.280000,0.000000,0.000000,70.050000,0.000000,0.000000
+            """))
diff --git a/ui/src/assets/explore_page.scss b/ui/src/assets/explore_page.scss
new file mode 100644
index 0000000..c31b288
--- /dev/null
+++ b/ui/src/assets/explore_page.scss
@@ -0,0 +1,16 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+.explore-page {
+  overflow: auto;
+}
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 3e04c42..cb4387d 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -30,6 +30,7 @@
 @import "viz_page";
 @import "widgets_page";
 @import "plugins_page";
+@import "explore_page";
 
 // Widgets - keep these sorted (they should NOT have any inter-dependencies)
 @import "widgets/anchor";
diff --git a/ui/src/frontend/flow_events_renderer.ts b/ui/src/frontend/flow_events_renderer.ts
index 28ee34b..e221954 100644
--- a/ui/src/frontend/flow_events_renderer.ts
+++ b/ui/src/frontend/flow_events_renderer.ts
@@ -18,7 +18,7 @@
 import {Flow} from '../core/flow_types';
 import {RenderedPanelInfo} from './panel_container';
 import {TimeScale} from '../base/time_scale';
-import {TrackNode} from '../public/workspace';
+import {TrackNode, TrackNodeContainer} from '../public/workspace';
 import {TraceImpl} from '../core/trace_impl';
 
 const TRACK_GROUP_CONNECTION_OFFSET = 5;
@@ -57,6 +57,7 @@
   ctx: CanvasRenderingContext2D,
   size: Size2D,
   panels: ReadonlyArray<RenderedPanelInfo>,
+  tracks: TrackNodeContainer,
 ): void {
   const timescale = new TimeScale(trace.timeline.visibleWindow, {
     left: 0,
@@ -74,7 +75,7 @@
   // the tree to find containing groups.
 
   const sqlTrackIdToTrack = new Map<number, TrackNode>();
-  trace.workspace.flatTracks.forEach((track) =>
+  tracks.flatTracks.forEach((track) =>
     track.uri
       ? trace.tracks
           .getTrack(track.uri)
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index 9509c1d..7bb2af7 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -24,7 +24,7 @@
 import {TimeScale} from '../base/time_scale';
 import {featureFlags} from '../core/feature_flags';
 import {raf} from '../core/raf_scheduler';
-import {TrackNode} from '../public/workspace';
+import {TrackNode, TrackNodeContainer} from '../public/workspace';
 import {TRACK_BORDER_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
 import {renderFlows} from './flow_events_renderer';
 import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper';
@@ -323,44 +323,58 @@
         m(PanelContainer, {
           trace: attrs.trace,
           className: 'pinned-panel-container',
-          panels: attrs.trace.workspace.pinnedTracks.map((trackNode) => {
-            if (trackNode.uri) {
-              const tr = attrs.trace.tracks.getTrackRenderer(trackNode.uri);
-              return new TrackPanel({
-                trace: attrs.trace,
-                reorderable: true,
-                node: trackNode,
-                trackRenderer: tr,
-                revealOnCreate: true,
-                indentationLevel: 0,
-                topOffsetPx: 0,
-              });
-            } else {
-              return new TrackPanel({
-                trace: attrs.trace,
-                node: trackNode,
-                revealOnCreate: true,
-                indentationLevel: 0,
-                topOffsetPx: 0,
-              });
-            }
-          }),
+          panels: AppImpl.instance.isLoadingTrace
+            ? []
+            : attrs.trace.workspace.pinnedTracks.map((trackNode) => {
+                if (trackNode.uri) {
+                  const tr = attrs.trace.tracks.getTrackRenderer(trackNode.uri);
+                  return new TrackPanel({
+                    trace: attrs.trace,
+                    reorderable: true,
+                    node: trackNode,
+                    trackRenderer: tr,
+                    revealOnCreate: true,
+                    indentationLevel: 0,
+                    topOffsetPx: 0,
+                  });
+                } else {
+                  return new TrackPanel({
+                    trace: attrs.trace,
+                    node: trackNode,
+                    revealOnCreate: true,
+                    indentationLevel: 0,
+                    topOffsetPx: 0,
+                  });
+                }
+              }),
           renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size),
           renderOverlay: (ctx, size, panels) =>
-            renderOverlay(attrs.trace, ctx, size, panels),
+            renderOverlay(
+              attrs.trace,
+              ctx,
+              size,
+              panels,
+              attrs.trace.workspace.pinnedRoot,
+            ),
           selectedYRange: this.getYRange('pinned-panel-container'),
         }),
         m(PanelContainer, {
           trace: attrs.trace,
           className: 'scrolling-panel-container',
-          panels: scrollingPanels,
+          panels: AppImpl.instance.isLoadingTrace ? [] : scrollingPanels,
           onPanelStackResize: (width) => {
             const timelineWidth = width - TRACK_SHELL_WIDTH;
             this.timelineWidthPx = timelineWidth;
           },
           renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size),
           renderOverlay: (ctx, size, panels) =>
-            renderOverlay(attrs.trace, ctx, size, panels),
+            renderOverlay(
+              attrs.trace,
+              ctx,
+              size,
+              panels,
+              attrs.trace.workspace,
+            ),
           selectedYRange: this.getYRange('scrolling-panel-container'),
         }),
       ),
@@ -411,6 +425,7 @@
   ctx: CanvasRenderingContext2D,
   canvasSize: Size2D,
   panels: ReadonlyArray<RenderedPanelInfo>,
+  trackContainer: TrackNodeContainer,
 ): void {
   const size = {
     width: canvasSize.width - TRACK_SHELL_WIDTH,
@@ -422,7 +437,7 @@
   canvasClip(ctx, 0, 0, size.width, size.height);
 
   // TODO(primiano): plumb the TraceImpl obj throughout the viwer page.
-  renderFlows(trace, ctx, size, panels);
+  renderFlows(trace, ctx, size, panels, trackContainer);
 
   const timewindow = trace.timeline.visibleWindow;
   const timescale = new TimeScale(timewindow, {left: 0, right: size.width});
diff --git a/ui/src/plugins/dev.perfetto.AndroidDmabuf/index.ts b/ui/src/plugins/dev.perfetto.AndroidDmabuf/index.ts
index a946cc2..b9d0919 100644
--- a/ui/src/plugins/dev.perfetto.AndroidDmabuf/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidDmabuf/index.ts
@@ -17,13 +17,10 @@
   SqlDataSource,
 } from '../../public/lib/tracks/query_counter_track';
 import {PerfettoPlugin} from '../../public/plugin';
-import {
-  getOrCreateGroupForProcess,
-  getOrCreateGroupForThread,
-} from '../../public/standard_groups';
 import {Trace} from '../../public/trace';
 import {TrackNode} from '../../public/workspace';
 import {NUM_NULL} from '../../trace_processor/query_result';
+import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 
 async function registerAllocsTrack(
   ctx: Trace,
@@ -44,6 +41,8 @@
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.AndroidDmabuf';
+  static readonly dependencies = [ProcessThreadGroupsPlugin];
+
   async onTraceLoad(ctx: Trace): Promise<void> {
     const e = ctx.engine;
     await e.query(`INCLUDE PERFETTO MODULE android.memory.dmabuf`);
@@ -66,9 +65,10 @@
                  WHERE upid = ${it.upid}`,
         };
         await registerAllocsTrack(ctx, uri, config);
-        getOrCreateGroupForProcess(ctx.workspace, it.upid).addChildInOrder(
-          new TrackNode({uri, title: 'dmabuf allocs'}),
-        );
+        ctx.plugins
+          .getPlugin(ProcessThreadGroupsPlugin)
+          .getGroupForProcess(it.upid)
+          ?.addChildInOrder(new TrackNode({uri, title: 'dmabuf allocs'}));
       } else if (it.utid != null) {
         const uri = `/android_process_dmabuf_utid_${it.utid}`;
         const config: SqlDataSource = {
@@ -76,9 +76,10 @@
                  WHERE utid = ${it.utid}`,
         };
         await registerAllocsTrack(ctx, uri, config);
-        getOrCreateGroupForThread(ctx.workspace, it.utid).addChildInOrder(
-          new TrackNode({uri, title: 'dmabuf allocs'}),
-        );
+        ctx.plugins
+          .getPlugin(ProcessThreadGroupsPlugin)
+          .getGroupForThread(it.utid)
+          ?.addChildInOrder(new TrackNode({uri, title: 'dmabuf allocs'}));
       }
     }
   }
diff --git a/ui/src/plugins/dev.perfetto.AsyncSlices/index.ts b/ui/src/plugins/dev.perfetto.AsyncSlices/index.ts
index ecd8dab..e5940cd 100644
--- a/ui/src/plugins/dev.perfetto.AsyncSlices/index.ts
+++ b/ui/src/plugins/dev.perfetto.AsyncSlices/index.ts
@@ -20,19 +20,18 @@
 import {getThreadUriPrefix, getTrackName} from '../../public/utils';
 import {NUM, NUM_NULL, STR, STR_NULL} from '../../trace_processor/query_result';
 import {AsyncSliceTrack} from './async_slice_track';
-import {
-  getOrCreateGroupForProcess,
-  getOrCreateGroupForThread,
-} from '../../public/standard_groups';
 import {exists} from '../../base/utils';
 import {assertExists, assertTrue} from '../../base/logging';
 import {SliceSelectionAggregator} from './slice_selection_aggregator';
 import {sqlTableRegistry} from '../../frontend/widgets/sql/table/sql_table_registry';
 import {getSliceTable} from './table';
 import {extensions} from '../../public/lib/extensions';
+import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.AsyncSlices';
+  static readonly dependencies = [ProcessThreadGroupsPlugin];
+
   async onTraceLoad(ctx: Trace): Promise<void> {
     const trackIdsToUris = new Map<number, string>();
 
@@ -298,8 +297,10 @@
       if (parent !== false && parent !== undefined) {
         parent.trackNode.addChildInOrder(t.trackNode);
       } else {
-        const processGroup = getOrCreateGroupForProcess(ctx.workspace, t.upid);
-        processGroup.addChildInOrder(t.trackNode);
+        const processGroup = ctx.plugins
+          .getPlugin(ProcessThreadGroupsPlugin)
+          .getGroupForProcess(t.upid);
+        processGroup?.addChildInOrder(t.trackNode);
       }
     });
   }
@@ -399,8 +400,10 @@
       if (parent !== false && parent !== undefined) {
         parent.trackNode.addChildInOrder(t.trackNode);
       } else {
-        const group = getOrCreateGroupForThread(ctx.workspace, t.utid);
-        group.addChildInOrder(t.trackNode);
+        const group = ctx.plugins
+          .getPlugin(ProcessThreadGroupsPlugin)
+          .getGroupForThread(t.utid);
+        group?.addChildInOrder(t.trackNode);
       }
     });
   }
diff --git a/ui/src/plugins/dev.perfetto.Counter/index.ts b/ui/src/plugins/dev.perfetto.Counter/index.ts
index ef063ca..47a8f61 100644
--- a/ui/src/plugins/dev.perfetto.Counter/index.ts
+++ b/ui/src/plugins/dev.perfetto.Counter/index.ts
@@ -27,11 +27,8 @@
 import {TraceProcessorCounterTrack} from './trace_processor_counter_track';
 import {exists} from '../../base/utils';
 import {TrackNode} from '../../public/workspace';
-import {
-  getOrCreateGroupForProcess,
-  getOrCreateGroupForThread,
-} from '../../public/standard_groups';
 import {CounterSelectionAggregator} from './counter_selection_aggregator';
+import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 
 const NETWORK_TRACK_REGEX = new RegExp('^.* (Received|Transmitted)( KB)?$');
 const ENTITY_RESIDENCY_REGEX = new RegExp('^Entity residency:');
@@ -108,6 +105,8 @@
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.Counter';
+  static readonly dependencies = [ProcessThreadGroupsPlugin];
+
   async onTraceLoad(ctx: Trace): Promise<void> {
     await this.addCounterTracks(ctx);
     await this.addGpuFrequencyTracks(ctx);
@@ -313,9 +312,11 @@
           name,
         ),
       });
-      const group = getOrCreateGroupForThread(ctx.workspace, utid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForThread(utid);
       const track = new TrackNode({uri, title: name, sortOrder: 30});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
   }
 
@@ -371,9 +372,11 @@
           name,
         ),
       });
-      const group = getOrCreateGroupForProcess(ctx.workspace, upid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForProcess(upid);
       const track = new TrackNode({uri, title: name, sortOrder: 20});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
   }
 
diff --git a/ui/src/plugins/dev.perfetto.CpuProfile/index.ts b/ui/src/plugins/dev.perfetto.CpuProfile/index.ts
index e33e341..05718df 100644
--- a/ui/src/plugins/dev.perfetto.CpuProfile/index.ts
+++ b/ui/src/plugins/dev.perfetto.CpuProfile/index.ts
@@ -19,11 +19,13 @@
 import {CpuProfileTrack} from './cpu_profile_track';
 import {getThreadUriPrefix} from '../../public/utils';
 import {exists} from '../../base/utils';
-import {getOrCreateGroupForThread} from '../../public/standard_groups';
 import {TrackNode} from '../../public/workspace';
+import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.CpuProfile';
+  static readonly dependencies = [ProcessThreadGroupsPlugin];
+
   async onTraceLoad(ctx: Trace): Promise<void> {
     const result = await ctx.engine.query(`
       with thread_cpu_sample as (
@@ -62,9 +64,11 @@
         },
         track: new CpuProfileTrack(ctx, uri, utid),
       });
-      const group = getOrCreateGroupForThread(ctx.workspace, utid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForThread(utid);
       const track = new TrackNode({uri, title, sortOrder: -40});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
   }
 }
diff --git a/ui/src/plugins/dev.perfetto.Frames/index.ts b/ui/src/plugins/dev.perfetto.Frames/index.ts
index 8d3abec..d9df86c 100644
--- a/ui/src/plugins/dev.perfetto.Frames/index.ts
+++ b/ui/src/plugins/dev.perfetto.Frames/index.ts
@@ -18,16 +18,18 @@
 } from '../../public/track_kinds';
 import {Trace} from '../../public/trace';
 import {PerfettoPlugin} from '../../public/plugin';
-import {getOrCreateGroupForProcess} from '../../public/standard_groups';
 import {getTrackName} from '../../public/utils';
 import {TrackNode} from '../../public/workspace';
 import {NUM, NUM_NULL, STR, STR_NULL} from '../../trace_processor/query_result';
 import {ActualFramesTrack} from './actual_frames_track';
 import {ExpectedFramesTrack} from './expected_frames_track';
 import {FrameSelectionAggregator} from './frame_selection_aggregator';
+import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.Frames';
+  static readonly dependencies = [ProcessThreadGroupsPlugin];
+
   async onTraceLoad(ctx: Trace): Promise<void> {
     this.addExpectedFrames(ctx);
     this.addActualFrames(ctx);
@@ -88,9 +90,11 @@
           kind: EXPECTED_FRAMES_SLICE_TRACK_KIND,
         },
       });
-      const group = getOrCreateGroupForProcess(ctx.workspace, upid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForProcess(upid);
       const track = new TrackNode({uri, title, sortOrder: -50});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
   }
 
@@ -151,9 +155,11 @@
           kind: ACTUAL_FRAMES_SLICE_TRACK_KIND,
         },
       });
-      const group = getOrCreateGroupForProcess(ctx.workspace, upid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForProcess(upid);
       const track = new TrackNode({uri, title, sortOrder: -50});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
   }
 }
diff --git a/ui/src/plugins/dev.perfetto.HeapProfile/index.ts b/ui/src/plugins/dev.perfetto.HeapProfile/index.ts
index 2e0591f..fcd79a3 100644
--- a/ui/src/plugins/dev.perfetto.HeapProfile/index.ts
+++ b/ui/src/plugins/dev.perfetto.HeapProfile/index.ts
@@ -17,9 +17,9 @@
 import {PerfettoPlugin} from '../../public/plugin';
 import {LONG, NUM, STR} from '../../trace_processor/query_result';
 import {HeapProfileTrack} from './heap_profile_track';
-import {getOrCreateGroupForProcess} from '../../public/standard_groups';
 import {TrackNode} from '../../public/workspace';
 import {createPerfettoTable} from '../../trace_processor/sql_utils';
+import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 
 function getUriForTrack(upid: number): string {
   return `/process_${upid}/heap_profile`;
@@ -27,6 +27,8 @@
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.HeapProfile';
+  static readonly dependencies = [ProcessThreadGroupsPlugin];
+
   async onTraceLoad(ctx: Trace): Promise<void> {
     const it = await ctx.engine.query(`
       select value from stats
@@ -94,9 +96,11 @@
         },
         track: new HeapProfileTrack(ctx, uri, tableName, upid, incomplete),
       });
-      const group = getOrCreateGroupForProcess(ctx.workspace, upid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForProcess(upid);
       const track = new TrackNode({uri, title, sortOrder: -30});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
 
     ctx.addEventListener('traceready', async () => {
diff --git a/ui/src/plugins/dev.perfetto.PerfSamplesProfile/index.ts b/ui/src/plugins/dev.perfetto.PerfSamplesProfile/index.ts
index 04e8b13..1488376 100644
--- a/ui/src/plugins/dev.perfetto.PerfSamplesProfile/index.ts
+++ b/ui/src/plugins/dev.perfetto.PerfSamplesProfile/index.ts
@@ -23,11 +23,8 @@
   ThreadPerfSamplesProfileTrack,
 } from './perf_samples_profile_track';
 import {getThreadUriPrefix} from '../../public/utils';
-import {
-  getOrCreateGroupForProcess,
-  getOrCreateGroupForThread,
-} from '../../public/standard_groups';
 import {TrackNode} from '../../public/workspace';
+import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 
 export interface Data extends TrackData {
   tsStarts: BigInt64Array;
@@ -39,6 +36,8 @@
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.PerfSamplesProfile';
+  static readonly dependencies = [ProcessThreadGroupsPlugin];
+
   async onTraceLoad(ctx: Trace): Promise<void> {
     const pResult = await ctx.engine.query(`
       select distinct upid
@@ -59,9 +58,11 @@
         },
         track: new ProcessPerfSamplesProfileTrack(ctx, uri, upid),
       });
-      const group = getOrCreateGroupForProcess(ctx.workspace, upid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForProcess(upid);
       const track = new TrackNode({uri, title, sortOrder: -40});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
     const tResult = await ctx.engine.query(`
       select distinct
@@ -99,9 +100,11 @@
         },
         track: new ThreadPerfSamplesProfileTrack(ctx, uri, utid),
       });
-      const group = getOrCreateGroupForThread(ctx.workspace, utid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForThread(utid);
       const track = new TrackNode({uri, title, sortOrder: -50});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
 
     ctx.addEventListener('traceready', async () => {
diff --git a/ui/src/plugins/dev.perfetto.ProcessThreadGroups/index.ts b/ui/src/plugins/dev.perfetto.ProcessThreadGroups/index.ts
index 60aa11c..eb720c9 100644
--- a/ui/src/plugins/dev.perfetto.ProcessThreadGroups/index.ts
+++ b/ui/src/plugins/dev.perfetto.ProcessThreadGroups/index.ts
@@ -14,10 +14,6 @@
 
 import {Trace} from '../../public/trace';
 import {PerfettoPlugin} from '../../public/plugin';
-import {
-  getOrCreateGroupForProcess,
-  getOrCreateGroupForThread,
-} from '../../public/standard_groups';
 import {TrackNode} from '../../public/workspace';
 import {NUM, STR, STR_NULL} from '../../trace_processor/query_result';
 
@@ -41,24 +37,35 @@
 // including the kernel groups, sorting, and adding summary tracks.
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.ProcessThreadGroups';
-  async onTraceLoad(ctx: Trace): Promise<void> {
-    const processGroups = new Map<number, TrackNode>();
-    const threadGroups = new Map<number, TrackNode>();
 
+  private readonly processGroups = new Map<number, TrackNode>();
+  private readonly threadGroups = new Map<number, TrackNode>();
+
+  constructor(private readonly ctx: Trace) {}
+
+  getGroupForProcess(upid: number): TrackNode | undefined {
+    return this.processGroups.get(upid);
+  }
+
+  getGroupForThread(utid: number): TrackNode | undefined {
+    return this.threadGroups.get(utid);
+  }
+
+  async onTraceLoad(ctx: Trace): Promise<void> {
     // Pre-group all kernel "threads" (actually processes) if this is a linux
     // system trace. Below, addProcessTrackGroups will skip them due to an
     // existing group uuid, and addThreadStateTracks will fill in the
     // per-thread tracks. Quirk: since all threads will appear to be
     // TrackKindPriority.MAIN_THREAD, any process-level tracks will end up
     // pushed to the bottom of the group in the UI.
-    await this.addKernelThreadGrouping(ctx, threadGroups);
+    await this.addKernelThreadGrouping();
 
     // Create the per-process track groups. Note that this won't necessarily
     // create a track per process. If a process has been completely idle and has
     // no sched events, no track group will be emitted.
     // Will populate this.addTrackGroupActions
-    await this.addProcessGroups(ctx, processGroups, threadGroups);
-    await this.addThreadGroups(ctx, processGroups, threadGroups);
+    await this.addProcessGroups();
+    await this.addThreadGroups();
 
     ctx.addEventListener('traceready', () => {
       // If, by the time the trace has finished loading, some of the process or
@@ -68,15 +75,12 @@
           g.remove();
         }
       };
-      processGroups.forEach(removeIfEmpty);
-      threadGroups.forEach(removeIfEmpty);
+      this.processGroups.forEach(removeIfEmpty);
+      this.threadGroups.forEach(removeIfEmpty);
     });
   }
 
-  private async addKernelThreadGrouping(
-    ctx: Trace,
-    threadGroups: Map<number, TrackNode>,
-  ): Promise<void> {
+  private async addKernelThreadGrouping(): Promise<void> {
     // Identify kernel threads if this is a linux system trace, and sufficient
     // process information is available. Kernel threads are identified by being
     // children of kthreadd (always pid 2).
@@ -86,7 +90,7 @@
     // which has pid 0 but appears as a distinct process (with its own comm) on
     // each cpu. It'd make sense to exclude its thread state track, but still
     // put process-scoped tracks in this group.
-    const result = await ctx.engine.query(`
+    const result = await this.ctx.engine.query(`
       select
         t.utid, p.upid, (case p.pid when 2 then 1 else 0 end) isKthreadd
       from
@@ -123,28 +127,27 @@
       sortOrder: 50,
       isSummary: true,
     });
-    ctx.workspace.addChildInOrder(kernelThreadsGroup);
+    this.ctx.workspace.addChildInOrder(kernelThreadsGroup);
 
     // Set the group for all kernel threads (including kthreadd itself).
     for (; it.valid(); it.next()) {
       const {utid} = it;
 
-      const threadGroup = getOrCreateGroupForThread(ctx.workspace, utid);
-      threadGroup.headless = true;
+      const threadGroup = new TrackNode({
+        uri: `thread${utid}`,
+        title: `Thread ${utid}`,
+        isSummary: true,
+        headless: true,
+      });
       kernelThreadsGroup.addChildInOrder(threadGroup);
-
-      threadGroups.set(utid, threadGroup);
+      this.threadGroups.set(utid, threadGroup);
     }
   }
 
   // Adds top level groups for processes and thread that don't belong to a
   // process.
-  private async addProcessGroups(
-    ctx: Trace,
-    processGroups: Map<number, TrackNode>,
-    threadGroups: Map<number, TrackNode>,
-  ): Promise<void> {
-    const result = await ctx.engine.query(`
+  private async addProcessGroups(): Promise<void> {
+    const result = await this.ctx.engine.query(`
       with processGroups as (
         select
           upid,
@@ -231,7 +234,7 @@
 
       if (kind === 'process') {
         // Ignore kernel process groups
-        if (processGroups.has(uid)) {
+        if (this.processGroups.has(uid)) {
           continue;
         }
 
@@ -247,41 +250,41 @@
         }
 
         const displayName = getProcessDisplayName(name ?? undefined, id);
-        const group = getOrCreateGroupForProcess(ctx.workspace, uid);
-        group.title = displayName;
-        group.uri = `/process_${uid}`; // Summary track URI
-        group.sortOrder = 50;
+        const group = new TrackNode({
+          uri: `/process_${uid}`,
+          title: displayName,
+          isSummary: true,
+          sortOrder: 50,
+        });
 
         // Re-insert the child node to sort it
-        ctx.workspace.addChildInOrder(group);
-        processGroups.set(uid, group);
+        this.ctx.workspace.addChildInOrder(group);
+        this.processGroups.set(uid, group);
       } else {
         // Ignore kernel process groups
-        if (threadGroups.has(uid)) {
+        if (this.threadGroups.has(uid)) {
           continue;
         }
 
         const displayName = getThreadDisplayName(name ?? undefined, id);
-        const group = getOrCreateGroupForThread(ctx.workspace, uid);
-        group.title = displayName;
-        group.uri = `/thread_${uid}`; // Summary track URI
-        group.sortOrder = 50;
+        const group = new TrackNode({
+          uri: `/thread_${uid}`,
+          title: displayName,
+          isSummary: true,
+          sortOrder: 50,
+        });
 
         // Re-insert the child node to sort it
-        ctx.workspace.addChildInOrder(group);
-        threadGroups.set(uid, group);
+        this.ctx.workspace.addChildInOrder(group);
+        this.threadGroups.set(uid, group);
       }
     }
   }
 
   // Create all the nested & headless thread groups that live inside existing
   // process groups.
-  private async addThreadGroups(
-    ctx: Trace,
-    processGroups: Map<number, TrackNode>,
-    threadGroups: Map<number, TrackNode>,
-  ): Promise<void> {
-    const result = await ctx.engine.query(`
+  private async addThreadGroups(): Promise<void> {
+    const result = await this.ctx.engine.query(`
       with threadGroups as (
         select
           utid,
@@ -329,15 +332,18 @@
       const {utid, tid, upid, threadName} = it;
 
       // Ignore kernel thread groups
-      if (threadGroups.has(utid)) {
+      if (this.threadGroups.has(utid)) {
         continue;
       }
 
-      const group = getOrCreateGroupForThread(ctx.workspace, utid);
-      group.title = getThreadDisplayName(threadName ?? undefined, tid);
-      threadGroups.set(utid, group);
-      group.headless = true;
-      processGroups.get(upid)?.addChildInOrder(group);
+      const group = new TrackNode({
+        uri: `/thread_${utid}`,
+        title: getThreadDisplayName(threadName ?? undefined, tid),
+        isSummary: true,
+        headless: true,
+      });
+      this.threadGroups.set(utid, group);
+      this.processGroups.get(upid)?.addChildInOrder(group);
     }
   }
 }
diff --git a/ui/src/plugins/dev.perfetto.ThreadState/index.ts b/ui/src/plugins/dev.perfetto.ThreadState/index.ts
index 155b35f..d7c2363 100644
--- a/ui/src/plugins/dev.perfetto.ThreadState/index.ts
+++ b/ui/src/plugins/dev.perfetto.ThreadState/index.ts
@@ -22,9 +22,9 @@
 import {getThreadStateTable} from './table';
 import {sqlTableRegistry} from '../../frontend/widgets/sql/table/sql_table_registry';
 import {TrackNode} from '../../public/workspace';
-import {getOrCreateGroupForThread} from '../../public/standard_groups';
 import {ThreadStateSelectionAggregator} from './thread_state_selection_aggregator';
 import {extensions} from '../../public/lib/extensions';
+import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 
 function uriForThreadStateTrack(upid: number | null, utid: number): string {
   return `${getThreadUriPrefix(upid, utid)}_state`;
@@ -32,6 +32,8 @@
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.ThreadState';
+  static readonly dependencies = [ProcessThreadGroupsPlugin];
+
   async onTraceLoad(ctx: Trace): Promise<void> {
     const {engine} = ctx;
 
@@ -87,9 +89,11 @@
         track: new ThreadStateTrack(ctx, uri, utid),
       });
 
-      const group = getOrCreateGroupForThread(ctx.workspace, utid);
+      const group = ctx.plugins
+        .getPlugin(ProcessThreadGroupsPlugin)
+        .getGroupForThread(utid);
       const track = new TrackNode({uri, title, sortOrder: 10});
-      group.addChildInOrder(track);
+      group?.addChildInOrder(track);
     }
 
     sqlTableRegistry['thread_state'] = getThreadStateTable();
diff --git a/ui/src/public/standard_groups.ts b/ui/src/public/standard_groups.ts
index 61b844a..2bc7570 100644
--- a/ui/src/public/standard_groups.ts
+++ b/ui/src/public/standard_groups.ts
@@ -15,40 +15,6 @@
 import {TrackNode, TrackNodeArgs, Workspace} from './workspace';
 
 /**
- * Gets or creates a group for a given process given the normal grouping
- * conventions.
- *
- * @param workspace - The workspace to search for the group on.
- * @param upid - The upid of teh process to find.
- */
-export function getOrCreateGroupForProcess(
-  workspace: Workspace,
-  upid: number,
-): TrackNode {
-  return getOrCreateGroup(workspace, `process${upid}`, {
-    title: `Process ${upid}`,
-    isSummary: true,
-  });
-}
-
-/**
- * Gets or creates a group for a given thread given the normal grouping
- * conventions.
- *
- * @param workspace - The workspace to search for the group on.
- * @param utid - The utid of the thread to find.
- */
-export function getOrCreateGroupForThread(
-  workspace: Workspace,
-  utid: number,
-): TrackNode {
-  return getOrCreateGroup(workspace, `thread${utid}`, {
-    title: `Thread ${utid}`,
-    isSummary: true,
-  });
-}
-
-/**
  * Gets or creates a group for user interaction
  *
  * @param workspace - The workspace on which to create the group.
diff --git a/ui/src/public/workspace.ts b/ui/src/public/workspace.ts
index a2bab1f..c6766b9 100644
--- a/ui/src/public/workspace.ts
+++ b/ui/src/public/workspace.ts
@@ -472,7 +472,7 @@
   onchange: (w: Workspace) => void = () => {};
 
   // Dummy node to contain the pinned tracks
-  private pinnedRoot = new TrackNode();
+  public readonly pinnedRoot = new TrackNode();
 
   get pinnedTracks(): ReadonlyArray<TrackNode> {
     return this.pinnedRoot.children;