Merge "docs: note that iid 0 is not a valid iid" into main
diff --git a/include/perfetto/public/protos/common/builtin_clock.pzc.h b/include/perfetto/public/protos/common/builtin_clock.pzc.h
index 51218e4..a7fa7e3 100644
--- a/include/perfetto/public/protos/common/builtin_clock.pzc.h
+++ b/include/perfetto/public/protos/common/builtin_clock.pzc.h
@@ -33,6 +33,7 @@
     PERFETTO_PB_ENUM_ENTRY(perfetto_protos_BUILTIN_CLOCK_MONOTONIC_COARSE) = 4,
     PERFETTO_PB_ENUM_ENTRY(perfetto_protos_BUILTIN_CLOCK_MONOTONIC_RAW) = 5,
     PERFETTO_PB_ENUM_ENTRY(perfetto_protos_BUILTIN_CLOCK_BOOTTIME) = 6,
+    PERFETTO_PB_ENUM_ENTRY(perfetto_protos_BUILTIN_CLOCK_TSC) = 9,
     PERFETTO_PB_ENUM_ENTRY(perfetto_protos_BUILTIN_CLOCK_MAX_ID) = 63,
 };
 
diff --git a/include/perfetto/public/protos/config/data_source_config.pzc.h b/include/perfetto/public/protos/config/data_source_config.pzc.h
index ed7632b..5a78ca5 100644
--- a/include/perfetto/public/protos/config/data_source_config.pzc.h
+++ b/include/perfetto/public/protos/config/data_source_config.pzc.h
@@ -43,6 +43,7 @@
 PERFETTO_PB_MSG_DECL(perfetto_protos_NetworkPacketTraceConfig);
 PERFETTO_PB_MSG_DECL(perfetto_protos_PackagesListConfig);
 PERFETTO_PB_MSG_DECL(perfetto_protos_PerfEventConfig);
+PERFETTO_PB_MSG_DECL(perfetto_protos_PixelModemConfig);
 PERFETTO_PB_MSG_DECL(perfetto_protos_ProcessStatsConfig);
 PERFETTO_PB_MSG_DECL(perfetto_protos_ProtoLogConfig);
 PERFETTO_PB_MSG_DECL(perfetto_protos_StatsdTracingConfig);
@@ -244,6 +245,11 @@
                   android_input_event_config,
                   128);
 PERFETTO_PB_FIELD(perfetto_protos_DataSourceConfig,
+                  MSG,
+                  perfetto_protos_PixelModemConfig,
+                  pixel_modem_config,
+                  129);
+PERFETTO_PB_FIELD(perfetto_protos_DataSourceConfig,
                   STRING,
                   const char*,
                   legacy_config,
diff --git a/include/perfetto/public/protos/config/trace_config.pzc.h b/include/perfetto/public/protos/config/trace_config.pzc.h
index 76492e3..cea2610 100644
--- a/include/perfetto/public/protos/config/trace_config.pzc.h
+++ b/include/perfetto/public/protos/config/trace_config.pzc.h
@@ -36,6 +36,7 @@
 PERFETTO_PB_MSG_DECL(perfetto_protos_TraceConfig_IncidentReportConfig);
 PERFETTO_PB_MSG_DECL(perfetto_protos_TraceConfig_IncrementalStateConfig);
 PERFETTO_PB_MSG_DECL(perfetto_protos_TraceConfig_ProducerConfig);
+PERFETTO_PB_MSG_DECL(perfetto_protos_TraceConfig_SessionSemaphore);
 PERFETTO_PB_MSG_DECL(perfetto_protos_TraceConfig_StatsdMetadata);
 PERFETTO_PB_MSG_DECL(perfetto_protos_TraceConfig_TraceFilter);
 PERFETTO_PB_MSG_DECL(perfetto_protos_TraceConfig_TraceFilter_StringFilterChain);
@@ -275,6 +276,23 @@
                   perfetto_protos_TraceConfig_CmdTraceStartDelay,
                   cmd_trace_start_delay,
                   35);
+PERFETTO_PB_FIELD(perfetto_protos_TraceConfig,
+                  MSG,
+                  perfetto_protos_TraceConfig_SessionSemaphore,
+                  session_semaphores,
+                  39);
+
+PERFETTO_PB_MSG(perfetto_protos_TraceConfig_SessionSemaphore);
+PERFETTO_PB_FIELD(perfetto_protos_TraceConfig_SessionSemaphore,
+                  STRING,
+                  const char*,
+                  name,
+                  1);
+PERFETTO_PB_FIELD(perfetto_protos_TraceConfig_SessionSemaphore,
+                  VARINT,
+                  uint64_t,
+                  max_other_session_count,
+                  2);
 
 PERFETTO_PB_MSG(perfetto_protos_TraceConfig_CmdTraceStartDelay);
 PERFETTO_PB_FIELD(perfetto_protos_TraceConfig_CmdTraceStartDelay,
diff --git a/include/perfetto/public/protos/trace/clock_snapshot.pzc.h b/include/perfetto/public/protos/trace/clock_snapshot.pzc.h
new file mode 100644
index 0000000..cc00030
--- /dev/null
+++ b/include/perfetto/public/protos/trace/clock_snapshot.pzc.h
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2023 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.
+ */
+
+// Autogenerated by the ProtoZero C compiler plugin.
+// Invoked by tools/gen_c_protos
+// DO NOT EDIT.
+#ifndef INCLUDE_PERFETTO_PUBLIC_PROTOS_TRACE_CLOCK_SNAPSHOT_PZC_H_
+#define INCLUDE_PERFETTO_PUBLIC_PROTOS_TRACE_CLOCK_SNAPSHOT_PZC_H_
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#include "perfetto/public/pb_macros.h"
+#include "perfetto/public/protos/common/builtin_clock.pzc.h"
+
+PERFETTO_PB_MSG_DECL(perfetto_protos_ClockSnapshot_Clock);
+
+PERFETTO_PB_ENUM_IN_MSG(perfetto_protos_ClockSnapshot_Clock, BuiltinClocks){
+    PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_ClockSnapshot_Clock,
+                                  UNKNOWN) = 0,
+    PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_ClockSnapshot_Clock,
+                                  REALTIME) = 1,
+    PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_ClockSnapshot_Clock,
+                                  REALTIME_COARSE) = 2,
+    PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_ClockSnapshot_Clock,
+                                  MONOTONIC) = 3,
+    PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_ClockSnapshot_Clock,
+                                  MONOTONIC_COARSE) = 4,
+    PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_ClockSnapshot_Clock,
+                                  MONOTONIC_RAW) = 5,
+    PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_ClockSnapshot_Clock,
+                                  BOOTTIME) = 6,
+    PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_ClockSnapshot_Clock,
+                                  BUILTIN_CLOCK_MAX_ID) = 63,
+};
+
+PERFETTO_PB_MSG(perfetto_protos_ClockSnapshot);
+PERFETTO_PB_FIELD(perfetto_protos_ClockSnapshot,
+                  MSG,
+                  perfetto_protos_ClockSnapshot_Clock,
+                  clocks,
+                  1);
+PERFETTO_PB_FIELD(perfetto_protos_ClockSnapshot,
+                  VARINT,
+                  enum perfetto_protos_BuiltinClock,
+                  primary_trace_clock,
+                  2);
+
+PERFETTO_PB_MSG(perfetto_protos_ClockSnapshot_Clock);
+PERFETTO_PB_FIELD(perfetto_protos_ClockSnapshot_Clock,
+                  VARINT,
+                  uint32_t,
+                  clock_id,
+                  1);
+PERFETTO_PB_FIELD(perfetto_protos_ClockSnapshot_Clock,
+                  VARINT,
+                  uint64_t,
+                  timestamp,
+                  2);
+PERFETTO_PB_FIELD(perfetto_protos_ClockSnapshot_Clock,
+                  VARINT,
+                  bool,
+                  is_incremental,
+                  3);
+PERFETTO_PB_FIELD(perfetto_protos_ClockSnapshot_Clock,
+                  VARINT,
+                  uint64_t,
+                  unit_multiplier_ns,
+                  4);
+
+#endif  // INCLUDE_PERFETTO_PUBLIC_PROTOS_TRACE_CLOCK_SNAPSHOT_PZC_H_
diff --git a/include/perfetto/public/protos/trace/interned_data/interned_data.pzc.h b/include/perfetto/public/protos/trace/interned_data/interned_data.pzc.h
index 71e26fa..d2c5a1b 100644
--- a/include/perfetto/public/protos/trace/interned_data/interned_data.pzc.h
+++ b/include/perfetto/public/protos/trace/interned_data/interned_data.pzc.h
@@ -193,5 +193,25 @@
                   perfetto_protos_InternedString,
                   protolog_stacktrace,
                   37);
+PERFETTO_PB_FIELD(perfetto_protos_InternedData,
+                  MSG,
+                  perfetto_protos_InternedString,
+                  viewcapture_package_name,
+                  38);
+PERFETTO_PB_FIELD(perfetto_protos_InternedData,
+                  MSG,
+                  perfetto_protos_InternedString,
+                  viewcapture_window_name,
+                  39);
+PERFETTO_PB_FIELD(perfetto_protos_InternedData,
+                  MSG,
+                  perfetto_protos_InternedString,
+                  viewcapture_view_id,
+                  40);
+PERFETTO_PB_FIELD(perfetto_protos_InternedData,
+                  MSG,
+                  perfetto_protos_InternedString,
+                  viewcapture_class_name,
+                  41);
 
 #endif  // INCLUDE_PERFETTO_PUBLIC_PROTOS_TRACE_INTERNED_DATA_INTERNED_DATA_PZC_H_
diff --git a/include/perfetto/public/protos/trace/trace_packet.pzc.h b/include/perfetto/public/protos/trace/trace_packet.pzc.h
index d83ab18..0151821 100644
--- a/include/perfetto/public/protos/trace/trace_packet.pzc.h
+++ b/include/perfetto/public/protos/trace/trace_packet.pzc.h
@@ -29,13 +29,13 @@
 PERFETTO_PB_MSG_DECL(perfetto_protos_AndroidCameraSessionStats);
 PERFETTO_PB_MSG_DECL(perfetto_protos_AndroidEnergyEstimationBreakdown);
 PERFETTO_PB_MSG_DECL(perfetto_protos_AndroidGameInterventionList);
-PERFETTO_PB_MSG_DECL(perfetto_protos_AndroidInputEvent);
 PERFETTO_PB_MSG_DECL(perfetto_protos_AndroidLogPacket);
 PERFETTO_PB_MSG_DECL(perfetto_protos_AndroidSystemProperty);
 PERFETTO_PB_MSG_DECL(perfetto_protos_BatteryCounters);
 PERFETTO_PB_MSG_DECL(perfetto_protos_ChromeBenchmarkMetadata);
 PERFETTO_PB_MSG_DECL(perfetto_protos_ChromeEventBundle);
 PERFETTO_PB_MSG_DECL(perfetto_protos_ChromeMetadataPacket);
+PERFETTO_PB_MSG_DECL(perfetto_protos_ChromeTrigger);
 PERFETTO_PB_MSG_DECL(perfetto_protos_ClockSnapshot);
 PERFETTO_PB_MSG_DECL(perfetto_protos_CpuInfo);
 PERFETTO_PB_MSG_DECL(perfetto_protos_DeobfuscationMapping);
@@ -62,6 +62,8 @@
 PERFETTO_PB_MSG_DECL(perfetto_protos_PackagesList);
 PERFETTO_PB_MSG_DECL(perfetto_protos_PerfSample);
 PERFETTO_PB_MSG_DECL(perfetto_protos_PerfettoMetatrace);
+PERFETTO_PB_MSG_DECL(perfetto_protos_PixelModemEvents);
+PERFETTO_PB_MSG_DECL(perfetto_protos_PixelModemTokenDatabase);
 PERFETTO_PB_MSG_DECL(perfetto_protos_PowerRails);
 PERFETTO_PB_MSG_DECL(perfetto_protos_ProcessDescriptor);
 PERFETTO_PB_MSG_DECL(perfetto_protos_ProcessStats);
@@ -101,6 +103,7 @@
 PERFETTO_PB_MSG_DECL(perfetto_protos_V8WasmCode);
 PERFETTO_PB_MSG_DECL(perfetto_protos_VulkanApiEvent);
 PERFETTO_PB_MSG_DECL(perfetto_protos_VulkanMemoryEvent);
+PERFETTO_PB_MSG_DECL(perfetto_protos_WinscopeExtensions);
 
 PERFETTO_PB_ENUM_IN_MSG(perfetto_protos_TracePacket, SequenceFlags){
     PERFETTO_PB_ENUM_IN_MSG_ENTRY(perfetto_protos_TracePacket,
@@ -215,6 +218,11 @@
                   46);
 PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
                   MSG,
+                  perfetto_protos_ChromeTrigger,
+                  chrome_trigger,
+                  109);
+PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
+                  MSG,
                   perfetto_protos_PackagesList,
                   packages_list,
                   47);
@@ -455,6 +463,11 @@
                   105);
 PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
                   MSG,
+                  perfetto_protos_WinscopeExtensions,
+                  winscope_extensions,
+                  112);
+PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
+                  MSG,
                   perfetto_protos_EtwTraceEventBundle,
                   etw_events,
                   95);
@@ -485,16 +498,21 @@
                   103);
 PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
                   MSG,
-                  perfetto_protos_AndroidInputEvent,
-                  android_input_event,
-                  106);
-PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
-                  MSG,
                   perfetto_protos_RemoteClockSync,
                   remote_clock_sync,
                   107);
 PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
                   MSG,
+                  perfetto_protos_PixelModemEvents,
+                  pixel_modem_events,
+                  110);
+PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
+                  MSG,
+                  perfetto_protos_PixelModemTokenDatabase,
+                  pixel_modem_token_database,
+                  111);
+PERFETTO_PB_FIELD(perfetto_protos_TracePacket,
+                  MSG,
                   perfetto_protos_TestEvent,
                   for_testing,
                   900);
diff --git a/include/perfetto/public/protos/trace/track_event/track_descriptor.pzc.h b/include/perfetto/public/protos/trace/track_event/track_descriptor.pzc.h
index ee3d978..7f0a5ad 100644
--- a/include/perfetto/public/protos/trace/track_event/track_descriptor.pzc.h
+++ b/include/perfetto/public/protos/trace/track_event/track_descriptor.pzc.h
@@ -44,6 +44,11 @@
                   name,
                   2);
 PERFETTO_PB_FIELD(perfetto_protos_TrackDescriptor,
+                  STRING,
+                  const char*,
+                  static_name,
+                  10);
+PERFETTO_PB_FIELD(perfetto_protos_TrackDescriptor,
                   MSG,
                   perfetto_protos_ProcessDescriptor,
                   process,
diff --git a/include/perfetto/public/protos/trace/track_event/track_event.pzc.h b/include/perfetto/public/protos/trace/track_event/track_event.pzc.h
index 2ee81f1..e9e3247 100644
--- a/include/perfetto/public/protos/trace/track_event/track_event.pzc.h
+++ b/include/perfetto/public/protos/trace/track_event/track_event.pzc.h
@@ -41,6 +41,7 @@
 PERFETTO_PB_MSG_DECL(perfetto_protos_ChromeWindowHandleEventInfo);
 PERFETTO_PB_MSG_DECL(perfetto_protos_DebugAnnotation);
 PERFETTO_PB_MSG_DECL(perfetto_protos_LogMessage);
+PERFETTO_PB_MSG_DECL(perfetto_protos_PixelModemEventInsight);
 PERFETTO_PB_MSG_DECL(perfetto_protos_Screenshot);
 PERFETTO_PB_MSG_DECL(perfetto_protos_SourceLocation);
 PERFETTO_PB_MSG_DECL(perfetto_protos_TaskExecution);
@@ -252,6 +253,11 @@
                   50);
 PERFETTO_PB_FIELD(perfetto_protos_TrackEvent,
                   MSG,
+                  perfetto_protos_PixelModemEventInsight,
+                  pixel_modem_event_insight,
+                  51);
+PERFETTO_PB_FIELD(perfetto_protos_TrackEvent,
+                  MSG,
                   perfetto_protos_SourceLocation,
                   source_location,
                   33);
diff --git a/python/tools/check_imports.py b/python/tools/check_imports.py
index a114982..7f732a8 100755
--- a/python/tools/check_imports.py
+++ b/python/tools/check_imports.py
@@ -227,6 +227,21 @@
         r'/core/.*',
         'instead plugins should depend on the API exposed at ui/src/public.',
     ),
+    NoDirectDep(
+        r"/frontend/.*",
+        r"/core_plugins/.*",
+        "core code should not depend on plugins.",
+    ),
+    NoDirectDep(
+        r"/core/.*",
+        r"/core_plugins/.*",
+        "core code should not depend on plugins.",
+    ),
+    NoDirectDep(
+        r"/base/.*",
+        r"/core_plugins/.*",
+        "core code should not depend on plugins.",
+    ),
     #NoDirectDep(
     #    r'/tracks/.*',
     #    r'/core/.*',
diff --git a/src/traceconv/trace_to_firefox.cc b/src/traceconv/trace_to_firefox.cc
index 251400d..976cf75 100644
--- a/src/traceconv/trace_to_firefox.cc
+++ b/src/traceconv/trace_to_firefox.cc
@@ -33,8 +33,8 @@
 void ExportFirefoxProfile(trace_processor::TraceProcessor& tp,
                           std::ostream* output) {
   auto it = tp.ExecuteQuery(R"(
-      INCLUDE PERFETTO MODULE export.firefox;
-      SELECT CAST(export_firefox_profile() AS BLOB);
+      INCLUDE PERFETTO MODULE export.to_firefox_profile;
+      SELECT CAST(export_to_firefox_profile() AS BLOB);
     )");
   PERFETTO_CHECK(it.Next());
 
diff --git a/tools/gen_c_protos b/tools/gen_c_protos
index 370e59a..cefc5e3 100755
--- a/tools/gen_c_protos
+++ b/tools/gen_c_protos
@@ -30,6 +30,7 @@
       'protos/perfetto/config/data_source_config.proto',
       'protos/perfetto/config/trace_config.proto',
       'protos/perfetto/config/track_event/track_event_config.proto',
+      'protos/perfetto/trace/clock_snapshot.proto',
       'protos/perfetto/trace/interned_data/interned_data.proto',
       'protos/perfetto/trace/test_event.proto',
       'protos/perfetto/trace/trace.proto',
diff --git a/ui/PRESUBMIT.py b/ui/PRESUBMIT.py
index 5eeab58..d4a90cc 100644
--- a/ui/PRESUBMIT.py
+++ b/ui/PRESUBMIT.py
@@ -109,7 +109,10 @@
   cmd = [prettier_path, '--check'] + paths
   if subprocess.call(cmd):
     s = ' '.join(cmd)
-    return [output_api.PresubmitError(f"prettier errors. Run: $ {s}")]
+    return [
+        output_api.PresubmitError(f"""Prettier errors. To fix, run:
+{prettier_path} -w {'_'.join(paths)}""")
+    ]
   return []
 
 
diff --git a/ui/src/base/disposable.ts b/ui/src/base/disposable.ts
index 15b0346..f251c05 100644
--- a/ui/src/base/disposable.ts
+++ b/ui/src/base/disposable.ts
@@ -18,60 +18,29 @@
   dispose(): void;
 }
 
-// Perform some operation using a disposable object guaranteeing it is disposed
-// of after the operation completes.
-// This can be replaced by the native "using" when Typescript 5.2 lands.
-// See: https://www.totaltypescript.com/typescript-5-2-new-keyword-using
-// Usage:
-//   using(createDisposable(), (x) => {doSomethingWith(x)});
-export function using<T extends Disposable>(x: T, func?: (x: T) => void) {
-  try {
-    func && func(x);
-  } finally {
-    x.dispose();
-  }
-}
-
-export class DisposableCallback implements Disposable {
-  private callback?: () => void;
-
-  constructor(callback: () => void) {
-    this.callback = callback;
-  }
-
-  static from(callback: () => void): Disposable {
-    return new DisposableCallback(callback);
-  }
-
-  dispose() {
-    if (this.callback) {
-      this.callback();
-      this.callback = undefined;
-    }
-  }
-}
-
-export class NullDisposable implements Disposable {
-  dispose() {}
+export interface AsyncDisposable {
+  disposeAsync(): Promise<void>;
 }
 
 // A collection of Disposables.
 // Disposables can be added one by one, (e.g. during the lifecycle of a
 // component) then can all be disposed at once (e.g. when the component
 // is destroyed). Resources are disposed LIFO.
-export class Trash implements Disposable {
+export class DisposableStack implements Disposable {
   private resources: Disposable[];
 
   constructor() {
     this.resources = [];
   }
 
-  add(d: Disposable) {
+  use(d: Disposable) {
     this.resources.push(d);
   }
 
-  addCallback(callback: () => void) {
-    this.add(DisposableCallback.from(callback));
+  defer(onDispose: () => void) {
+    this.use({
+      dispose: onDispose,
+    });
   }
 
   dispose() {
@@ -84,3 +53,27 @@
     }
   }
 }
+
+export class AsyncDisposableStack implements AsyncDisposable {
+  private resources: AsyncDisposable[] = [];
+
+  use(d: AsyncDisposable) {
+    this.resources.push(d);
+  }
+
+  defer(onDispose: () => Promise<void>) {
+    this.use({
+      disposeAsync: onDispose,
+    });
+  }
+
+  async disposeAsync(): Promise<void> {
+    while (true) {
+      const d = this.resources.pop();
+      if (d === undefined) {
+        break;
+      }
+      await d.disposeAsync();
+    }
+  }
+}
diff --git a/ui/src/base/disposable_unittest.ts b/ui/src/base/disposable_unittest.ts
index 7774ad8..7a3dd84 100644
--- a/ui/src/base/disposable_unittest.ts
+++ b/ui/src/base/disposable_unittest.ts
@@ -12,15 +12,36 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {DisposableCallback, Trash} from './disposable';
+import {AsyncDisposableStack, DisposableStack} from './disposable';
 
-test('trash', () => {
+test('DisposableStack', () => {
   const order: number[] = [];
-  const trash = new Trash();
-  trash.add(DisposableCallback.from(() => order.push(3)));
-  trash.add(DisposableCallback.from(() => order.push(2)));
-  trash.add(DisposableCallback.from(() => order.push(1)));
+  const trash = new DisposableStack();
+  trash.use({dispose: () => order.push(3)});
+  trash.use({dispose: () => order.push(2)});
+  trash.defer(() => order.push(1));
   expect(order).toEqual([]);
   trash.dispose();
   expect(order).toEqual([1, 2, 3]);
 });
+
+test('AsyncDisposableStack', async () => {
+  const order: number[] = [];
+  const trash = new AsyncDisposableStack();
+  trash.use({
+    disposeAsync: async () => {
+      order.push(3);
+    },
+  });
+  trash.use({
+    disposeAsync: async () => {
+      order.push(2);
+    },
+  });
+  trash.defer(async () => {
+    order.push(1);
+  });
+  expect(order).toEqual([]);
+  await trash.disposeAsync();
+  expect(order).toEqual([1, 2, 3]);
+});
diff --git a/ui/src/base/hotkeys.ts b/ui/src/base/hotkeys.ts
index f224aa4..80672be 100644
--- a/ui/src/base/hotkeys.ts
+++ b/ui/src/base/hotkeys.ts
@@ -97,8 +97,8 @@
   | 'Ctrl+'
   | 'Alt+'
   | 'Mod+Shift+'
-  | 'Mod+Alt'
-  | 'Mod+Shift+Alt'
+  | 'Mod+Alt+'
+  | 'Mod+Shift+Alt+'
   | 'Ctrl+Shift+'
   | 'Ctrl+Alt'
   | 'Ctrl+Shift+Alt';
diff --git a/ui/src/base/store_unittest.ts b/ui/src/base/store_unittest.ts
index f90cde2..8500c17 100644
--- a/ui/src/base/store_unittest.ts
+++ b/ui/src/base/store_unittest.ts
@@ -14,7 +14,6 @@
 
 import {Draft} from 'immer';
 
-import {using} from './disposable';
 import {createStore} from './store';
 
 interface Bar {
@@ -145,7 +144,7 @@
     const callback = jest.fn();
 
     // Subscribe then immediately unsubscribe
-    using(store.subscribe(callback));
+    store.subscribe(callback).dispose();
 
     // Make an arbitrary edit
     store.edit((draft) => {
@@ -236,7 +235,7 @@
     const callback = jest.fn();
 
     // Subscribe then immediately unsubscribe
-    using(subStore.subscribe(callback));
+    subStore.subscribe(callback).dispose();
 
     // Make an arbitrary edit
     subStore.edit((draft) => {
diff --git a/ui/src/base/utils.ts b/ui/src/base/utils.ts
index d3c5f4b..c6f3069 100644
--- a/ui/src/base/utils.ts
+++ b/ui/src/base/utils.ts
@@ -24,3 +24,6 @@
 export type Result<T, E = {}> =
   | {success: true; result: T}
   | {success: false; error: E};
+
+// Generic "optional" type
+export type Optional<T> = T | undefined;
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 0083843..50a67fd 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -30,7 +30,6 @@
   tableColumnEquals,
   toggleEnabled,
 } from '../frontend/pivot_table_types';
-import {PrimaryTrackSortKey} from '../public/index';
 
 import {
   computeIntervals,
@@ -53,6 +52,7 @@
   OmniboxState,
   PendingDeeplinkState,
   PivotTableResult,
+  PrimaryTrackSortKey,
   ProfileType,
   RecordingTarget,
   SCROLLING_TRACK_GROUP,
@@ -73,7 +73,6 @@
   labels?: string[];
   trackSortKey: TrackSortKey;
   trackGroup?: string;
-  params?: unknown;
   closeable?: boolean;
 }
 
@@ -215,7 +214,6 @@
         trackGroup: track.trackGroup,
         labels: track.labels,
         uri: track.uri,
-        params: track.params,
         closeable: track.closeable,
       };
       if (track.trackGroup === SCROLLING_TRACK_GROUP) {
@@ -552,22 +550,6 @@
     }
   },
 
-  selectCounter(
-    state: StateDraft,
-    args: {leftTs: time; rightTs: time; id: number; trackKey: string},
-  ): void {
-    state.selection = {
-      kind: 'legacy',
-      legacySelection: {
-        kind: 'COUNTER',
-        leftTs: args.leftTs,
-        rightTs: args.rightTs,
-        id: args.id,
-        trackKey: args.trackKey,
-      },
-    };
-  },
-
   selectHeapProfile(
     state: StateDraft,
     args: {id: number; upid: number; ts: time; type: ProfileType},
diff --git a/ui/src/common/actions_unittest.ts b/ui/src/common/actions_unittest.ts
index 468755e..af68b07 100644
--- a/ui/src/common/actions_unittest.ts
+++ b/ui/src/common/actions_unittest.ts
@@ -18,7 +18,6 @@
 import {PrimaryTrackSortKey} from '../public';
 import {HEAP_PROFILE_TRACK_KIND} from '../core_plugins/heap_profile';
 import {PROCESS_SCHEDULING_TRACK_KIND} from '../core_plugins/process_summary/process_scheduling_track';
-import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
 
 import {StateActions} from './actions';
 import {createEmptyState} from './empty_state';
@@ -29,7 +28,10 @@
   TraceUrlSource,
   TrackSortKey,
 } from './state';
-import {THREAD_SLICE_TRACK_KIND} from '../core_plugins/thread_slice/thread_slice_track';
+import {
+  THREAD_SLICE_TRACK_KIND,
+  THREAD_STATE_TRACK_KIND,
+} from '../core/track_kinds';
 
 function fakeTrack(
   state: State,
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index 648bf06..a0ab84e 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -14,7 +14,7 @@
 
 import {v4 as uuidv4} from 'uuid';
 
-import {Disposable, Trash} from '../base/disposable';
+import {Disposable, DisposableStack} from '../base/disposable';
 import {Registry} from '../base/registry';
 import {Span, duration, time} from '../base/time';
 import {TraceContext, globals} from '../frontend/globals';
@@ -52,7 +52,7 @@
 // plugins.
 // The PluginContext exists for the whole duration a plugin is active.
 export class PluginContextImpl implements PluginContext, Disposable {
-  private trash = new Trash();
+  private trash = new DisposableStack();
   private alive = true;
 
   readonly sidebar = {
@@ -80,7 +80,7 @@
     if (!this.alive) return;
 
     const disposable = globals.commandManager.registerCommand(cmd);
-    this.trash.add(disposable);
+    this.trash.use(disposable);
   }
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -101,13 +101,13 @@
 // The PluginContextTrace exists for the whole duration a plugin is active AND a
 // trace is loaded.
 class PluginContextTraceImpl implements PluginContextTrace, Disposable {
-  private trash = new Trash();
+  private trash = new DisposableStack();
   private alive = true;
   readonly engine: Engine;
 
   constructor(private ctx: PluginContext, engine: EngineBase) {
     const engineProxy = engine.getProxy(ctx.pluginId);
-    this.trash.add(engineProxy);
+    this.trash.use(engineProxy);
     this.engine = engineProxy;
   }
 
@@ -116,7 +116,7 @@
     if (!this.alive) return;
 
     const dispose = globals.commandManager.registerCommand(cmd);
-    this.trash.add(dispose);
+    this.trash.use(dispose);
   }
 
   registerTrack(trackDesc: TrackDescriptor): void {
@@ -124,7 +124,7 @@
     if (!this.alive) return;
 
     const dispose = globals.trackManager.registerTrack(trackDesc);
-    this.trash.add(dispose);
+    this.trash.use(dispose);
   }
 
   addDefaultTrack(track: TrackRef): void {
@@ -132,7 +132,7 @@
     if (!this.alive) return;
 
     const dispose = globals.trackManager.addPotentialTrack(track);
-    this.trash.add(dispose);
+    this.trash.use(dispose);
   }
 
   registerStaticTrack(track: TrackDescriptor & TrackRef): void {
@@ -149,12 +149,12 @@
     if (!this.alive) return;
 
     const unregister = globals.tabManager.registerTab(desc);
-    this.trash.add(unregister);
+    this.trash.use(unregister);
   }
 
   addDefaultTab(uri: string): void {
     const remove = globals.tabManager.addDefaultTab(uri);
-    this.trash.add(remove);
+    this.trash.use(remove);
   }
 
   registerDetailsPanel(detailsPanel: LegacyDetailsPanel): void {
@@ -162,7 +162,7 @@
 
     const tabMan = globals.tabManager;
     const unregister = tabMan.registerLegacyDetailsPanel(detailsPanel);
-    this.trash.add(unregister);
+    this.trash.use(unregister);
   }
 
   get sidebar() {
@@ -189,14 +189,13 @@
 
   readonly timeline = {
     // Add a new track to the timeline, returning its key.
-    addTrack(uri: string, displayName: string, params?: unknown): string {
+    addTrack(uri: string, displayName: string): string {
       const trackKey = uuidv4();
       globals.dispatch(
         Actions.addTrack({
           key: trackKey,
           uri,
           name: displayName,
-          params,
           trackSortKey: PrimaryTrackSortKey.ORDINARY_TRACK,
           trackGroup: SCROLLING_TRACK_GROUP,
         }),
@@ -315,7 +314,6 @@
         return {
           displayName: trackState.name,
           uri: trackState.uri,
-          params: trackState.params,
           key: trackState.key,
           groupName: group?.name,
           isPinned: pinnedTracks.includes(trackState.key),
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index 4a53a5d..23233c6 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -20,7 +20,6 @@
   PivotTree,
   TableColumn,
 } from '../frontend/pivot_table_types';
-import {PrimaryTrackSortKey} from '../public/index';
 
 import {
   selectionToLegacySelection,
@@ -33,7 +32,6 @@
   SelectionKind,
   NoteSelection,
   SliceSelection,
-  CounterSelection,
   HeapProfileSelection,
   PerfSamplesSelection,
   LegacySelection,
@@ -43,6 +41,36 @@
   CpuProfileSampleSelection,
 } from '../core/selection_manager';
 
+// Tracks within track groups (usually corresponding to processes) are sorted.
+// As we want to group all tracks related to a given thread together, we use
+// two keys:
+// - Primary key corresponds to a priority of a track block (all tracks related
+//   to a given thread or a single track if it's not thread-associated).
+// - Secondary key corresponds to a priority of a given thread-associated track
+//   within its thread track block.
+// Each track will have a sort key, which either a primary sort key
+// (for non-thread tracks) or a tid and secondary sort key (mapping of tid to
+// primary sort key is done independently).
+export enum PrimaryTrackSortKey {
+  DEBUG_TRACK,
+  NULL_TRACK,
+  PROCESS_SCHEDULING_TRACK,
+  PROCESS_SUMMARY_TRACK,
+  EXPECTED_FRAMES_SLICE_TRACK,
+  ACTUAL_FRAMES_SLICE_TRACK,
+  PERF_SAMPLES_PROFILE_TRACK,
+  HEAP_PROFILE_TRACK,
+  MAIN_THREAD,
+  RENDER_THREAD,
+  GPU_COMPLETION_THREAD,
+  CHROME_IO_THREAD,
+  CHROME_COMPOSITOR_THREAD,
+  ORDINARY_THREAD,
+  COUNTER_TRACK,
+  ASYNC_SLICE_TRACK,
+  ORDINARY_TRACK,
+}
+
 /**
  * A plain js object, holding objects of type |Class| keyed by string id.
  * We use this instead of using |Map| object since it is simpler and faster to
@@ -152,7 +180,8 @@
 // 58. Remove area map.
 // 59. Deprecate old area selection type.
 // 60. Deprecate old note selection type.
-export const STATE_VERSION = 60;
+// 61. Remove params/state from TrackState.
+export const STATE_VERSION = 61;
 
 export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
 
@@ -237,8 +266,6 @@
   labels?: string[];
   trackSortKey: TrackSortKey;
   trackGroup?: string;
-  params?: unknown;
-  state?: unknown;
   closeable?: boolean;
 }
 
diff --git a/ui/src/common/tab_registry.ts b/ui/src/common/tab_registry.ts
index fd9cc65..0df6284 100644
--- a/ui/src/common/tab_registry.ts
+++ b/ui/src/common/tab_registry.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Disposable, DisposableCallback} from '../base/disposable';
+import {Disposable} from '../base/disposable';
 import {DetailsPanel, LegacyDetailsPanel, TabDescriptor} from '../public';
 
 export interface ResolvedTab {
@@ -41,30 +41,30 @@
 
   registerTab(desc: TabDescriptor): Disposable {
     this._registry.set(desc.uri, desc);
-    return new DisposableCallback(() => {
-      this._registry.delete(desc.uri);
-    });
+    return {
+      dispose: () => this._registry.delete(desc.uri),
+    };
   }
 
   addDefaultTab(uri: string): Disposable {
     this._defaultTabs.add(uri);
-    return new DisposableCallback(() => {
-      this._defaultTabs.delete(uri);
-    });
+    return {
+      dispose: () => this._defaultTabs.delete(uri),
+    };
   }
 
   registerLegacyDetailsPanel(section: LegacyDetailsPanel): Disposable {
     this._legacyDetailsPanelRegistry.add(section);
-    return new DisposableCallback(() => {
-      this._legacyDetailsPanelRegistry.delete(section);
-    });
+    return {
+      dispose: () => this._legacyDetailsPanelRegistry.delete(section),
+    };
   }
 
   registerDetailsPanel(section: DetailsPanel): Disposable {
     this._detailsPanelRegistry.add(section);
-    return new DisposableCallback(() => {
-      this._detailsPanelRegistry.delete(section);
-    });
+    return {
+      dispose: () => this._detailsPanelRegistry.delete(section),
+    };
   }
 
   resolveTab(uri: string): TabDescriptor | undefined {
diff --git a/ui/src/common/track_cache.ts b/ui/src/common/track_cache.ts
index 5d05a13..08ec2ac 100644
--- a/ui/src/common/track_cache.ts
+++ b/ui/src/common/track_cache.ts
@@ -12,18 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Disposable, DisposableCallback} from '../base/disposable';
+import {Disposable} from '../base/disposable';
 import {exists} from '../base/utils';
 import {Registry} from '../base/registry';
 import {Store} from '../base/store';
 import {PanelSize} from '../frontend/panel';
-import {
-  Migrate,
-  Track,
-  TrackContext,
-  TrackDescriptor,
-  TrackRef,
-} from '../public';
+import {Track, TrackContext, TrackDescriptor, TrackRef} from '../public';
 
 import {ObjectByKey, State, TrackState} from './state';
 
@@ -82,9 +76,9 @@
 
   addPotentialTrack(track: TrackRef): Disposable {
     this.defaultTracks.add(track);
-    return new DisposableCallback(() => {
-      this.defaultTracks.delete(track);
-    });
+    return {
+      dispose: () => this.defaultTracks.delete(track),
+    };
   }
 
   findPotentialTracks(): TrackRef[] {
@@ -103,11 +97,7 @@
 
   // Creates a new track using |uri| and |params| or retrieves a cached track if
   // |key| exists in the cache.
-  resolveTrack(
-    key: string,
-    trackDesc: TrackDescriptor,
-    params?: unknown,
-  ): TrackCacheEntry {
+  resolveTrack(key: string, trackDesc: TrackDescriptor): TrackCacheEntry {
     // Search for a cached version of this track,
     const cached = this.currentTracks.get(key);
 
@@ -126,11 +116,6 @@
       // Cached track doesn't exist or is out of date, create a new one.
       const trackContext: TrackContext = {
         trackKey: key,
-        mountStore: <T>(migrate: Migrate<T>) => {
-          const path = ['tracks', key, 'state'];
-          return this.store.createSubStore(path, migrate);
-        },
-        params,
       };
       const track = trackDesc.trackFactory(trackContext);
       const entry = new TrackFSM(track, trackDesc, trackContext);
diff --git a/ui/src/controller/aggregation/cpu_aggregation_controller.ts b/ui/src/controller/aggregation/cpu_aggregation_controller.ts
index 6b9083e..d3fcd88 100644
--- a/ui/src/controller/aggregation/cpu_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/cpu_aggregation_controller.ts
@@ -15,9 +15,9 @@
 import {exists} from '../../base/utils';
 import {ColumnDef} from '../../common/aggregation_data';
 import {Area, Sorting} from '../../common/state';
+import {CPU_SLICE_TRACK_KIND} from '../../core/track_kinds';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {CPU_SLICE_TRACK_KIND} from '../../core_plugins/cpu_slices';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts b/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
index 03f29f2..578c874 100644
--- a/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
@@ -17,7 +17,7 @@
 import {Area, Sorting} from '../../common/state';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {CPU_SLICE_TRACK_KIND} from '../../core_plugins/cpu_slices';
+import {CPU_SLICE_TRACK_KIND} from '../../core/track_kinds';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/aggregation/frame_aggregation_controller.ts b/ui/src/controller/aggregation/frame_aggregation_controller.ts
index 100e47b..d905ede 100644
--- a/ui/src/controller/aggregation/frame_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/frame_aggregation_controller.ts
@@ -14,9 +14,9 @@
 
 import {ColumnDef} from '../../common/aggregation_data';
 import {Area, Sorting} from '../../common/state';
+import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../../core/track_kinds';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../../core_plugins/frames';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/aggregation/slice_aggregation_controller.ts b/ui/src/controller/aggregation/slice_aggregation_controller.ts
index 89cdfb1..def6765 100644
--- a/ui/src/controller/aggregation/slice_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/slice_aggregation_controller.ts
@@ -16,10 +16,12 @@
 import {Area, Sorting} from '../../common/state';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {ASYNC_SLICE_TRACK_KIND} from '../../core_plugins/async_slices';
-import {THREAD_SLICE_TRACK_KIND} from '../../core_plugins/thread_slice/thread_slice_track';
 
 import {AggregationController} from './aggregation_controller';
+import {
+  ASYNC_SLICE_TRACK_KIND,
+  THREAD_SLICE_TRACK_KIND,
+} from '../../core/track_kinds';
 
 export function getSelectedTrackKeys(area: Area): number[] {
   const selectedTrackKeys: number[] = [];
diff --git a/ui/src/controller/aggregation/thread_aggregation_controller.ts b/ui/src/controller/aggregation/thread_aggregation_controller.ts
index 98e5c77..64eedeb 100644
--- a/ui/src/controller/aggregation/thread_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/thread_aggregation_controller.ts
@@ -16,10 +16,10 @@
 import {ColumnDef, ThreadStateExtra} from '../../common/aggregation_data';
 import {Area, Sorting} from '../../common/state';
 import {translateState} from '../../common/thread_state';
+import {THREAD_STATE_TRACK_KIND} from '../../core/track_kinds';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
 import {NUM, NUM_NULL, STR_NULL} from '../../trace_processor/query_result';
-import {THREAD_STATE_TRACK_KIND} from '../../core_plugins/thread_state';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/flow_events_controller.ts b/ui/src/controller/flow_events_controller.ts
index 0501014..8769c00 100644
--- a/ui/src/controller/flow_events_controller.ts
+++ b/ui/src/controller/flow_events_controller.ts
@@ -20,11 +20,13 @@
 import {asSliceSqlId} from '../frontend/sql_types';
 import {Engine} from '../trace_processor/engine';
 import {LONG, NUM, STR_NULL} from '../trace_processor/query_result';
-import {THREAD_SLICE_TRACK_KIND} from '../core_plugins/thread_slice/thread_slice_track';
-import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../core_plugins/frames';
 
 import {Controller} from './controller';
 import {Monitor} from '../base/monitor';
+import {
+  ACTUAL_FRAMES_SLICE_TRACK_KIND,
+  THREAD_SLICE_TRACK_KIND,
+} from '../core/track_kinds';
 
 export interface FlowEventsControllerArgs {
   engine: Engine;
diff --git a/ui/src/controller/search_controller.ts b/ui/src/controller/search_controller.ts
index bd6a3b1..21892e7 100644
--- a/ui/src/controller/search_controller.ts
+++ b/ui/src/controller/search_controller.ts
@@ -21,12 +21,12 @@
   SearchSummary,
 } from '../common/search_data';
 import {OmniboxState} from '../common/state';
+import {CPU_SLICE_TRACK_KIND} from '../core/track_kinds';
 import {globals} from '../frontend/globals';
 import {publishSearch, publishSearchResult} from '../frontend/publish';
 import {Engine} from '../trace_processor/engine';
 import {LONG, NUM, STR} from '../trace_processor/query_result';
 import {escapeSearchQuery} from '../trace_processor/query_utils';
-import {CPU_SLICE_TRACK_KIND} from '../core_plugins/cpu_slices';
 
 import {Controller} from './controller';
 
diff --git a/ui/src/controller/selection_controller.ts b/ui/src/controller/selection_controller.ts
index 6a75fa6..b52b73f 100644
--- a/ui/src/controller/selection_controller.ts
+++ b/ui/src/controller/selection_controller.ts
@@ -20,14 +20,9 @@
   ThreadSliceSelection,
   getLegacySelection,
 } from '../common/state';
+import {THREAD_SLICE_TRACK_KIND} from '../core/track_kinds';
+import {globals, SliceDetails, ThreadStateDetails} from '../frontend/globals';
 import {
-  CounterDetails,
-  globals,
-  SliceDetails,
-  ThreadStateDetails,
-} from '../frontend/globals';
-import {
-  publishCounterDetails,
   publishSliceDetails,
   publishThreadStateDetails,
 } from '../frontend/publish';
@@ -41,7 +36,6 @@
   STR_NULL,
   timeFromSql,
 } from '../trace_processor/query_result';
-import {THREAD_SLICE_TRACK_KIND} from '../core_plugins/thread_slice/thread_slice_track';
 
 import {Controller} from './controller';
 
@@ -77,7 +71,6 @@
 
     const selectWithId: SelectionKind[] = [
       'SLICE',
-      'COUNTER',
       'SCHED_SLICE',
       'HEAP_PROFILE',
       'THREAD_STATE',
@@ -97,21 +90,7 @@
 
     if (selectedId === undefined) return;
 
-    if (selection.kind === 'COUNTER') {
-      this.counterDetails(
-        selection.leftTs,
-        selection.rightTs,
-        selection.id,
-      ).then((results) => {
-        if (
-          results !== undefined &&
-          selection.kind === selectedKind &&
-          selection.id === selectedId
-        ) {
-          publishCounterDetails(results);
-        }
-      });
-    } else if (selection.kind === 'SCHED_SLICE') {
+    if (selection.kind === 'SCHED_SLICE') {
       this.schedSliceDetails(selectedId as number);
     } else if (selection.kind === 'THREAD_STATE') {
       this.threadStateDetails(selection.id);
@@ -424,35 +403,6 @@
     }
   }
 
-  async counterDetails(
-    ts: time,
-    rightTs: time,
-    id: number,
-  ): Promise<CounterDetails> {
-    const counter = await this.args.engine.query(
-      `SELECT value, track_id as trackId FROM counter WHERE id = ${id}`,
-    );
-    const row = counter.iter({
-      value: NUM,
-      trackId: NUM,
-    });
-    const value = row.value;
-    const trackId = row.trackId;
-    // Finding previous value. If there isn't previous one, it will return 0 for
-    // ts and value.
-    const previous = await this.args.engine.query(`SELECT
-          MAX(ts),
-          IFNULL(value, 0) as value
-        FROM counter WHERE ts < ${ts} and track_id = ${trackId}`);
-    const previousValue = previous.firstRow({value: NUM}).value;
-    const endTs = rightTs !== -1n ? rightTs : globals.traceContext.end;
-    const delta = value - previousValue;
-    const duration = endTs - ts;
-    const trackKey = globals.trackManager.trackKeyByTrackId.get(trackId);
-    const name = trackKey ? globals.state.tracks[trackKey].name : undefined;
-    return {startTime: ts, value, delta, duration, name};
-  }
-
   async schedulingDetails(ts: time, utid: number) {
     // Find the ts of the first wakeup before the current slice.
     const wakeResult = await this.args.engine.query(`
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index 065f690..dea50d7 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -95,7 +95,8 @@
   TraceStream,
 } from '../core/trace_stream';
 import {decideTracks} from './track_decider';
-import {FlamegraphCache, profileType} from '../frontend/flamegraph_panel';
+import {profileType} from '../frontend/flamegraph_panel';
+import {FlamegraphCache} from '../core/flamegraph_cache';
 
 type States = 'init' | 'loading_trace' | 'ready';
 
diff --git a/ui/src/controller/track_decider.ts b/ui/src/controller/track_decider.ts
index 36fb251..fb5114a 100644
--- a/ui/src/controller/track_decider.ts
+++ b/ui/src/controller/track_decider.ts
@@ -29,20 +29,20 @@
 import {getTrackName} from '../public/utils';
 import {Engine, EngineBase} from '../trace_processor/engine';
 import {NUM, NUM_NULL, STR, STR_NULL} from '../trace_processor/query_result';
-import {ASYNC_SLICE_TRACK_KIND} from '../core_plugins/async_slices';
 import {
   ENABLE_SCROLL_JANK_PLUGIN_V2,
   getScrollJankTracks,
 } from '../core_plugins/chrome_scroll_jank';
 import {decideTracks as scrollJankDecideTracks} from '../core_plugins/chrome_scroll_jank/chrome_tasks_scroll_jank_track';
 import {COUNTER_TRACK_KIND} from '../core_plugins/counter';
+import {decideTracks as screenshotDecideTracks} from '../core_plugins/screenshots';
 import {
   ACTUAL_FRAMES_SLICE_TRACK_KIND,
+  ASYNC_SLICE_TRACK_KIND,
   EXPECTED_FRAMES_SLICE_TRACK_KIND,
-} from '../core_plugins/frames';
-import {decideTracks as screenshotDecideTracks} from '../core_plugins/screenshots';
-import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
-import {THREAD_SLICE_TRACK_KIND} from '../core_plugins/thread_slice/thread_slice_track';
+  THREAD_SLICE_TRACK_KIND,
+  THREAD_STATE_TRACK_KIND,
+} from '../core/track_kinds';
 
 const MEM_DMA_COUNTER_NAME = 'mem.dma_heap';
 const MEM_DMA = 'mem.dma_buffer';
@@ -1400,7 +1400,6 @@
         // 'sort keys' which the user can use to choose a sort order.
         trackSortKey: info.sortKey ?? PrimaryTrackSortKey.ORDINARY_TRACK,
         trackGroup: groupUuid,
-        params: info.params,
       });
     }
   }
diff --git a/ui/src/core/default_plugins.ts b/ui/src/core/default_plugins.ts
index 983f37e..56a6b19 100644
--- a/ui/src/core/default_plugins.ts
+++ b/ui/src/core/default_plugins.ts
@@ -45,7 +45,7 @@
   'perfetto.CpuProfile',
   'perfetto.CpuSlices',
   'perfetto.CriticalUserInteraction',
-  'perfetto.DebugSlices',
+  'perfetto.DebugTracks',
   'perfetto.Flows',
   'perfetto.Frames',
   'perfetto.FtraceRaw',
@@ -56,7 +56,6 @@
   'perfetto.Sched',
   'perfetto.Screenshots',
   'perfetto.ThreadState',
-  'perfetto.VisualisedArgs',
   'org.kernel.LinuxKernelDevices',
   'perfetto.TrackUtils',
   'com.google.PixelMemory',
diff --git a/ui/src/core/flamegraph_cache.ts b/ui/src/core/flamegraph_cache.ts
new file mode 100644
index 0000000..d0f7dad
--- /dev/null
+++ b/ui/src/core/flamegraph_cache.ts
@@ -0,0 +1,52 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size 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.
+
+import {Engine} from '../trace_processor/engine';
+
+export class FlamegraphCache {
+  private cache: Map<string, string>;
+  private prefix: string;
+  private tableId: number;
+  private cacheSizeLimit: number;
+
+  constructor(prefix: string) {
+    this.cache = new Map<string, string>();
+    this.prefix = prefix;
+    this.tableId = 0;
+    this.cacheSizeLimit = 10;
+  }
+
+  async getTableName(engine: Engine, query: string): Promise<string> {
+    let tableName = this.cache.get(query);
+    if (tableName === undefined) {
+      // TODO(hjd): This should be LRU.
+      if (this.cache.size > this.cacheSizeLimit) {
+        for (const name of this.cache.values()) {
+          await engine.query(`drop table ${name}`);
+        }
+        this.cache.clear();
+      }
+      tableName = `${this.prefix}_${this.tableId++}`;
+      await engine.query(
+        `create temp table if not exists ${tableName} as ${query}`,
+      );
+      this.cache.set(query, tableName);
+    }
+    return tableName;
+  }
+
+  hasQuery(query: string): boolean {
+    return this.cache.get(query) !== undefined;
+  }
+}
diff --git a/ui/src/core/selection_manager.ts b/ui/src/core/selection_manager.ts
index b954380..5764f51 100644
--- a/ui/src/core/selection_manager.ts
+++ b/ui/src/core/selection_manager.ts
@@ -32,13 +32,6 @@
   id: number;
 }
 
-export interface CounterSelection {
-  kind: 'COUNTER';
-  leftTs: time;
-  rightTs: time;
-  id: number;
-}
-
 export interface HeapProfileSelection {
   kind: 'HEAP_PROFILE';
   id: number;
@@ -92,7 +85,6 @@
 
 export type LegacySelection = (
   | SliceSelection
-  | CounterSelection
   | HeapProfileSelection
   | CpuProfileSampleSelection
   | ThreadSliceSelection
@@ -112,7 +104,7 @@
 export interface SingleSelection {
   kind: 'single';
   trackKey: string;
-  eventId: string;
+  eventId: number;
 }
 
 export interface AreaSelection {
@@ -225,7 +217,7 @@
 
   setEvent(
     trackKey: string,
-    eventId: string,
+    eventId: number,
     legacySelection?: LegacySelection,
   ) {
     this.clear();
@@ -234,7 +226,7 @@
 
   addEvent(
     trackKey: string,
-    eventId: string,
+    eventId: number,
     legacySelection?: LegacySelection,
   ) {
     this.addSelection({
diff --git a/ui/src/core/track_kinds.ts b/ui/src/core/track_kinds.ts
new file mode 100644
index 0000000..bd36d6c
--- /dev/null
+++ b/ui/src/core/track_kinds.ts
@@ -0,0 +1,24 @@
+// 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.
+
+// This file contains a list of well known (to the core) track kinds.
+// This file exists purely to keep legacy systems in place without introducing a
+// ton of circular imports.
+export const CPU_SLICE_TRACK_KIND = 'CpuSliceTrack';
+export const THREAD_STATE_TRACK_KIND = 'ThreadStateTrack';
+export const THREAD_SLICE_TRACK_KIND = 'ThreadSliceTrack';
+export const EXPECTED_FRAMES_SLICE_TRACK_KIND = 'ExpectedFramesSliceTrack';
+export const ACTUAL_FRAMES_SLICE_TRACK_KIND = 'ActualFramesSliceTrack';
+export const ASYNC_SLICE_TRACK_KIND = 'AsyncSliceTrack';
+export const PERF_SAMPLES_PROFILE_TRACK_KIND = 'PerfSamplesProfileTrack';
diff --git a/ui/src/core_plugins/annotation/index.ts b/ui/src/core_plugins/annotation/index.ts
index d2f1627..4b29cd9 100644
--- a/ui/src/core_plugins/annotation/index.ts
+++ b/ui/src/core_plugins/annotation/index.ts
@@ -13,13 +13,11 @@
 // limitations under the License.
 
 import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
-import {
-  ThreadSliceTrack,
-  THREAD_SLICE_TRACK_KIND,
-} from '../thread_slice/thread_slice_track';
+import {ThreadSliceTrack} from '../../frontend/thread_slice_track';
 import {NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
 import {COUNTER_TRACK_KIND} from '../counter';
 import {TraceProcessorCounterTrack} from '../counter/trace_processor_counter_track';
+import {THREAD_SLICE_TRACK_KIND} from '../../public';
 
 class AnnotationPlugin implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
diff --git a/ui/src/core_plugins/async_slices/index.ts b/ui/src/core_plugins/async_slices/index.ts
index b01cb38..530f3c7 100644
--- a/ui/src/core_plugins/async_slices/index.ts
+++ b/ui/src/core_plugins/async_slices/index.ts
@@ -12,14 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {ASYNC_SLICE_TRACK_KIND} from '../../public';
 import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
 import {getTrackName} from '../../public/utils';
 import {NUM, NUM_NULL, STR, STR_NULL} from '../../trace_processor/query_result';
 
 import {AsyncSliceTrack} from './async_slice_track';
 
-export const ASYNC_SLICE_TRACK_KIND = 'AsyncSliceTrack';
-
 class AsyncSlicePlugin implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     await this.addGlobalAsyncTracks(ctx);
diff --git a/ui/src/core_plugins/counter/counter_details_panel.ts b/ui/src/core_plugins/counter/counter_details_panel.ts
new file mode 100644
index 0000000..0ee28bd
--- /dev/null
+++ b/ui/src/core_plugins/counter/counter_details_panel.ts
@@ -0,0 +1,165 @@
+// 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.
+
+import {AsyncLimiter} from '../../base/async_limiter';
+import {Time, duration, time} from '../../base/time';
+import {raf} from '../../core/raf_scheduler';
+import {
+  Engine,
+  LONG,
+  LONG_NULL,
+  NUM,
+  NUM_NULL,
+  TrackSelectionDetailsPanel,
+} from '../../public';
+import m from 'mithril';
+import {DetailsShell} from '../../widgets/details_shell';
+import {GridLayout} from '../../widgets/grid_layout';
+import {Section} from '../../widgets/section';
+import {Tree, TreeNode} from '../../widgets/tree';
+import {Timestamp} from '../../frontend/widgets/timestamp';
+import {DurationWidget} from '../../frontend/widgets/duration';
+
+interface CounterDetails {
+  // The "left" timestamp of the counter sample T(N)
+  ts: time;
+
+  // The delta between this sample and the next one's timestamps T(N+1) - T(N)
+  duration: duration;
+
+  // The value of the counter sample F(N)
+  value: number;
+
+  // The delta between this sample's value and the previous one F(N) - F(N-1)
+  delta: number;
+}
+
+export class CounterDetailsPanel implements TrackSelectionDetailsPanel {
+  private readonly queryLimiter = new AsyncLimiter();
+  private readonly engine: Engine;
+  private readonly trackId: number;
+  private readonly rootTable: string;
+  private readonly trackName: string;
+  private id?: number;
+  private counterDetails?: CounterDetails;
+
+  constructor(
+    engine: Engine,
+    trackId: number,
+    trackName: string,
+    rootTable = 'counter',
+  ) {
+    this.engine = engine;
+    this.trackId = trackId;
+    this.trackName = trackName;
+    this.rootTable = rootTable;
+  }
+
+  render(id: number): m.Children {
+    if (id !== this.id) {
+      this.id = id;
+      this.queryLimiter.schedule(async () => {
+        this.counterDetails = await loadCounterDetails(
+          this.engine,
+          this.trackId,
+          id,
+          this.rootTable,
+        );
+        raf.scheduleFullRedraw();
+      });
+    }
+
+    return this.renderView();
+  }
+
+  private renderView() {
+    const counterInfo = this.counterDetails;
+    if (counterInfo) {
+      return m(
+        DetailsShell,
+        {title: 'Counter', description: `${this.trackName}`},
+        m(
+          GridLayout,
+          m(
+            Section,
+            {title: 'Properties'},
+            m(
+              Tree,
+              m(TreeNode, {left: 'Name', right: `${this.trackName}`}),
+              m(TreeNode, {
+                left: 'Start time',
+                right: m(Timestamp, {ts: counterInfo.ts}),
+              }),
+              m(TreeNode, {
+                left: 'Value',
+                right: `${counterInfo.value.toLocaleString()}`,
+              }),
+              m(TreeNode, {
+                left: 'Delta',
+                right: `${counterInfo.delta.toLocaleString()}`,
+              }),
+              m(TreeNode, {
+                left: 'Duration',
+                right: m(DurationWidget, {dur: counterInfo.duration}),
+              }),
+            ),
+          ),
+        ),
+      );
+    } else {
+      return m(DetailsShell, {title: 'Counter', description: 'Loading...'});
+    }
+  }
+
+  isLoading(): boolean {
+    return this.counterDetails === undefined;
+  }
+}
+
+async function loadCounterDetails(
+  engine: Engine,
+  trackId: number,
+  id: number,
+  rootTable: string,
+): Promise<CounterDetails> {
+  const query = `
+    WITH CTE AS (
+      SELECT
+        id,
+        ts as leftTs,
+        value,
+        LAG(value) OVER (ORDER BY ts) AS prevValue,
+        LEAD(ts) OVER (ORDER BY ts) AS rightTs
+      FROM ${rootTable}
+      WHERE track_id = ${trackId}
+    )
+    SELECT * FROM CTE WHERE id = ${id}
+  `;
+
+  const counter = await engine.query(query);
+  const row = counter.iter({
+    value: NUM,
+    prevValue: NUM_NULL,
+    leftTs: LONG,
+    rightTs: LONG_NULL,
+  });
+  const value = row.value;
+  const leftTs = Time.fromRaw(row.leftTs);
+  const rightTs = row.rightTs !== null ? Time.fromRaw(row.rightTs) : leftTs;
+  const prevValue = row.prevValue !== null ? row.prevValue : value;
+
+  const delta = value - prevValue;
+  const duration = rightTs - leftTs;
+  return {ts: leftTs, value, delta, duration};
+}
diff --git a/ui/src/core_plugins/counter/index.ts b/ui/src/core_plugins/counter/index.ts
index 40865fa..2af0836 100644
--- a/ui/src/core_plugins/counter/index.ts
+++ b/ui/src/core_plugins/counter/index.ts
@@ -12,9 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import m from 'mithril';
-
-import {CounterDetailsPanel} from '../../frontend/counter_panel';
 import {
   NUM_NULL,
   STR_NULL,
@@ -25,10 +22,15 @@
   PluginDescriptor,
   PrimaryTrackSortKey,
   STR,
+  LONG,
+  Engine,
 } from '../../public';
 import {getTrackName} from '../../public/utils';
 import {CounterOptions} from '../../frontend/base_counter_track';
 import {TraceProcessorCounterTrack} from './trace_processor_counter_track';
+import {CounterDetailsPanel} from './counter_details_panel';
+import {Time, duration, time} from '../../base/time';
+import {Optional} from '../../base/utils';
 
 export const COUNTER_TRACK_KIND = 'CounterTrack';
 
@@ -105,6 +107,34 @@
   return options;
 }
 
+async function getCounterEventBounds(
+  engine: Engine,
+  trackId: number,
+  id: number,
+): Promise<Optional<{ts: time; dur: duration}>> {
+  const query = `
+    WITH CTE AS (
+      SELECT
+        id,
+        ts as leftTs,
+        LEAD(ts) OVER (ORDER BY ts) AS rightTs
+      FROM counter
+      WHERE track_id = ${trackId}
+    )
+    SELECT * FROM CTE WHERE id = ${id}
+  `;
+
+  const counter = await engine.query(query);
+  const row = counter.iter({
+    leftTs: LONG,
+    rightTs: LONG_NULL,
+  });
+  const leftTs = Time.fromRaw(row.leftTs);
+  const rightTs = row.rightTs !== null ? Time.fromRaw(row.rightTs) : leftTs;
+  const duration = rightTs - leftTs;
+  return {ts: leftTs, dur: duration};
+}
+
 class CounterPlugin implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     await this.addCounterTracks(ctx);
@@ -113,16 +143,6 @@
     await this.addCpuPerfCounterTracks(ctx);
     await this.addThreadCounterTracks(ctx);
     await this.addProcessCounterTracks(ctx);
-
-    ctx.registerDetailsPanel({
-      render: (sel) => {
-        if (sel.kind === 'COUNTER') {
-          return m(CounterDetailsPanel);
-        } else {
-          return undefined;
-        }
-      },
-    });
   }
 
   private async addCounterTracks(ctx: PluginContextTrace) {
@@ -170,6 +190,10 @@
           });
         },
         sortKey: PrimaryTrackSortKey.COUNTER_TRACK,
+        detailsPanel: new CounterDetailsPanel(ctx.engine, trackId, displayName),
+        getEventBounds: async (id) => {
+          return await getCounterEventBounds(ctx.engine, trackId, id);
+        },
       });
     }
   }
@@ -229,6 +253,10 @@
             options: getDefaultCounterOptions(name),
           });
         },
+        detailsPanel: new CounterDetailsPanel(ctx.engine, trackId, name),
+        getEventBounds: async (id) => {
+          return await getCounterEventBounds(ctx.engine, trackId, id);
+        },
       });
     }
   }
@@ -288,6 +316,10 @@
             options: getDefaultCounterOptions(name),
           });
         },
+        detailsPanel: new CounterDetailsPanel(ctx.engine, trackId, name),
+        getEventBounds: async (id) => {
+          return await getCounterEventBounds(ctx.engine, trackId, id);
+        },
       });
     }
   }
@@ -338,6 +370,10 @@
             options: getDefaultCounterOptions(name),
           });
         },
+        detailsPanel: new CounterDetailsPanel(ctx.engine, trackId, name),
+        getEventBounds: async (id) => {
+          return await getCounterEventBounds(ctx.engine, trackId, id);
+        },
       });
     }
   }
@@ -373,6 +409,10 @@
               options: getDefaultCounterOptions(name),
             });
           },
+          detailsPanel: new CounterDetailsPanel(ctx.engine, trackId, name),
+          getEventBounds: async (id) => {
+            return await getCounterEventBounds(ctx.engine, trackId, id);
+          },
         });
       }
     }
diff --git a/ui/src/core_plugins/counter/trace_processor_counter_track.ts b/ui/src/core_plugins/counter/trace_processor_counter_track.ts
index b47a03d..6a97273 100644
--- a/ui/src/core_plugins/counter/trace_processor_counter_track.ts
+++ b/ui/src/core_plugins/counter/trace_processor_counter_track.ts
@@ -12,8 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Time} from '../../base/time';
-import {Actions} from '../../common/actions';
 import {globals} from '../../frontend/globals';
 import {LONG, LONG_NULL, NUM} from '../../public';
 import {
@@ -80,23 +78,8 @@
       if (!it.valid()) {
         return;
       }
-      const trackKey = this.trackKey;
       const id = it.id;
-      const leftTs = Time.fromRaw(it.leftTs);
-
-      // TODO(stevegolton): Don't try to guess times and durations here, make it
-      // obvious to the user that this counter sample has no duration as it's
-      // the last one in the series
-      const rightTs = Time.fromRaw(it.rightTs ?? leftTs);
-
-      globals.makeSelection(
-        Actions.selectCounter({
-          leftTs,
-          rightTs,
-          id,
-          trackKey,
-        }),
-      );
+      globals.selectSingleEvent(this.trackKey, id);
     });
 
     return true;
diff --git a/ui/src/core_plugins/cpu_slices/index.ts b/ui/src/core_plugins/cpu_slices/index.ts
index a862732..51ac18b 100644
--- a/ui/src/core_plugins/cpu_slices/index.ts
+++ b/ui/src/core_plugins/cpu_slices/index.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {CPU_SLICE_TRACK_KIND} from '../../public';
 import {SliceDetailsPanel} from '../../frontend/slice_details_panel';
 import {
   Engine,
@@ -22,8 +23,6 @@
 import {NUM, STR_NULL} from '../../trace_processor/query_result';
 import {CpuSliceTrack} from './cpu_slice_track';
 
-export const CPU_SLICE_TRACK_KIND = 'CpuSliceTrack';
-
 class CpuSlices implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     const cpus = ctx.trace.cpus;
diff --git a/ui/src/core_plugins/debug/counter_track.ts b/ui/src/core_plugins/debug/counter_track.ts
deleted file mode 100644
index 5c0022d..0000000
--- a/ui/src/core_plugins/debug/counter_track.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2023 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.
-
-import m from 'mithril';
-
-import {BaseCounterTrack} from '../../frontend/base_counter_track';
-import {TrackContext} from '../../public';
-import {Engine} from '../../trace_processor/engine';
-import {CounterDebugTrackConfig} from '../../frontend/debug_tracks';
-import {Disposable, DisposableCallback} from '../../base/disposable';
-import {uuidv4Sql} from '../../base/uuid';
-
-export class DebugCounterTrack extends BaseCounterTrack {
-  private config: CounterDebugTrackConfig;
-  private sqlTableName: string;
-
-  constructor(engine: Engine, ctx: TrackContext) {
-    super({
-      engine,
-      trackKey: ctx.trackKey,
-    });
-
-    // TODO(stevegolton): Validate params before type asserting.
-    // TODO(stevegolton): Avoid just pushing this config up for some base
-    // class to use. Be more explicit.
-    this.config = ctx.params as CounterDebugTrackConfig;
-    this.sqlTableName = `__debug_counter_${uuidv4Sql(this.trackKey)}`;
-  }
-
-  async onInit(): Promise<Disposable> {
-    await this.createTrackTable();
-    return new DisposableCallback(() => {
-      this.dropTrackTable();
-    });
-  }
-
-  getTrackShellButtons(): m.Children {
-    return this.getCounterContextMenu();
-  }
-
-  getSqlSource(): string {
-    return `select * from ${this.sqlTableName}`;
-  }
-
-  private async createTrackTable(): Promise<void> {
-    await this.engine.query(`
-        create table ${this.sqlTableName} as
-        with data as (
-          ${this.config.data.sqlSource}
-        )
-        select
-          ${this.config.columns.ts} as ts,
-          ${this.config.columns.value} as value
-        from data
-        order by ts;`);
-  }
-
-  private async dropTrackTable(): Promise<void> {
-    this.engine.tryQuery(`drop table if exists ${this.sqlTableName}`);
-  }
-}
diff --git a/ui/src/core_plugins/debug/index.ts b/ui/src/core_plugins/debug/index.ts
index fad9cae..0c9b1e6 100644
--- a/ui/src/core_plugins/debug/index.ts
+++ b/ui/src/core_plugins/debug/index.ts
@@ -14,9 +14,9 @@
 
 import {uuidv4} from '../../base/uuid';
 import {
-  DEBUG_COUNTER_TRACK_URI,
-  DEBUG_SLICE_TRACK_URI,
-} from '../../frontend/debug_tracks';
+  addDebugCounterTrack,
+  addDebugSliceTrack,
+} from '../../frontend/debug_tracks/debug_tracks';
 import {
   BottomTabToSCSAdapter,
   Plugin,
@@ -24,18 +24,55 @@
   PluginDescriptor,
 } from '../../public';
 
-import {DebugCounterTrack} from './counter_track';
-import {DebugSliceDetailsTab} from './details_tab';
-import {DebugTrackV2} from './slice_track';
+import {DebugSliceDetailsTab} from '../../frontend/debug_tracks/details_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
+import {Optional, exists} from '../../base/utils';
 
-class DebugTrackPlugin implements Plugin {
+class DebugTracksPlugin implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
-    ctx.registerTrack({
-      uri: DEBUG_SLICE_TRACK_URI,
-      trackFactory: (trackCtx) => new DebugTrackV2(ctx.engine, trackCtx),
+    ctx.registerCommand({
+      id: 'perfetto.DebugTracks#addDebugSliceTrack',
+      name: 'Add debug slice track',
+      callback: async (arg: unknown) => {
+        // This command takes a query and creates a debug track out of it The
+        // query can be passed in using the first arg, or if this is not defined
+        // or is the wrong type, we prompt the user for it.
+        const query = await getStringFromArgOrPrompt(ctx, arg);
+        if (exists(query)) {
+          await addDebugSliceTrack(
+            ctx,
+            {
+              sqlSource: query,
+            },
+            'Debug slice track',
+            {ts: 'ts', dur: 'dur', name: 'name'},
+            [],
+          );
+        }
+      },
     });
 
+    ctx.registerCommand({
+      id: 'perfetto.DebugTracks#addDebugCounterTrack',
+      name: 'Add debug counter track',
+      callback: async (arg: unknown) => {
+        const query = await getStringFromArgOrPrompt(ctx, arg);
+        if (exists(query)) {
+          await addDebugCounterTrack(
+            ctx,
+            {
+              sqlSource: query,
+            },
+            'Debug slice track',
+            {ts: 'ts', value: 'value'},
+          );
+        }
+      },
+    });
+
+    // TODO(stevegolton): While debug tracks are in their current state, we rely
+    // on this plugin to provide the details panel for them. In the future, this
+    // details panel will become part of the debug track's definition.
     ctx.registerDetailsPanel(
       new BottomTabToSCSAdapter({
         tabFactory: (selection) => {
@@ -54,15 +91,29 @@
         },
       }),
     );
+  }
+}
 
-    ctx.registerTrack({
-      uri: DEBUG_COUNTER_TRACK_URI,
-      trackFactory: (trackCtx) => new DebugCounterTrack(ctx.engine, trackCtx),
-    });
+// If arg is a string, return it, otherwise prompt the user for a string. An
+// exception is thrown if the prompt is cancelled, so this function handles this
+// and returns undefined in this case.
+async function getStringFromArgOrPrompt(
+  ctx: PluginContextTrace,
+  arg: unknown,
+): Promise<Optional<string>> {
+  if (typeof arg === 'string') {
+    return arg;
+  } else {
+    try {
+      return await ctx.prompt('Enter a query...');
+    } catch {
+      // Prompt was ignored
+      return undefined;
+    }
   }
 }
 
 export const plugin: PluginDescriptor = {
-  pluginId: 'perfetto.DebugSlices',
-  plugin: DebugTrackPlugin,
+  pluginId: 'perfetto.DebugTracks',
+  plugin: DebugTracksPlugin,
 };
diff --git a/ui/src/core_plugins/debug/slice_track.ts b/ui/src/core_plugins/debug/slice_track.ts
deleted file mode 100644
index a0734d9..0000000
--- a/ui/src/core_plugins/debug/slice_track.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) 2023 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.
-
-import {NamedSliceTrackTypes} from '../../frontend/named_slice_track';
-import {TrackContext} from '../../public';
-import {Engine} from '../../trace_processor/engine';
-import {
-  CustomSqlDetailsPanelConfig,
-  CustomSqlTableDefConfig,
-  CustomSqlTableSliceTrack,
-} from '../../frontend/tracks/custom_sql_table_slice_track';
-
-import {DebugSliceDetailsTab} from './details_tab';
-import {
-  ARG_PREFIX,
-  SliceColumns,
-  SqlDataSource,
-} from '../../frontend/debug_tracks';
-import {DisposableCallback} from '../../base/disposable';
-import {uuidv4Sql} from '../../base/uuid';
-
-export interface DebugTrackV2Config {
-  data: SqlDataSource;
-  columns: SliceColumns;
-  argColumns: string[];
-}
-
-export class DebugTrackV2 extends CustomSqlTableSliceTrack<NamedSliceTrackTypes> {
-  private config: DebugTrackV2Config;
-  private sqlTableName: string;
-
-  constructor(engine: Engine, ctx: TrackContext) {
-    super({
-      engine,
-      trackKey: ctx.trackKey,
-    });
-
-    // TODO(stevegolton): Validate params before type asserting.
-    // TODO(stevegolton): Avoid just pushing this config up for some base
-    // class to use. Be more explicit.
-    this.config = ctx.params as DebugTrackV2Config;
-    this.sqlTableName = `__debug_slice_${uuidv4Sql(ctx.trackKey)}`;
-  }
-
-  async getSqlDataSource(): Promise<CustomSqlTableDefConfig> {
-    await this.createTrackTable(
-      this.config.data,
-      this.config.columns,
-      this.config.argColumns,
-    );
-    return {
-      sqlTableName: this.sqlTableName,
-      dispose: new DisposableCallback(() => this.destroyTrackTable()),
-    };
-  }
-
-  getDetailsPanel(): CustomSqlDetailsPanelConfig {
-    return {
-      kind: DebugSliceDetailsTab.kind,
-      config: {
-        sqlTableName: this.sqlTableName,
-        title: 'Debug Slice',
-      },
-    };
-  }
-
-  private async createTrackTable(
-    data: SqlDataSource,
-    sliceColumns: SliceColumns,
-    argColumns: string[],
-  ): Promise<void> {
-    // If the view has clashing names (e.g. "name" coming from joining two
-    // different tables, we will see names like "name_1", "name_2", but they
-    // won't be addressable from the SQL. So we explicitly name them through a
-    // list of columns passed to CTE.
-    const dataColumns =
-      data.columns !== undefined ? `(${data.columns.join(', ')})` : '';
-
-    // TODO(altimin): Support removing this table when the track is closed.
-    const dur = sliceColumns.dur === '0' ? 0 : sliceColumns.dur;
-    await this.engine.query(`
-      create perfetto table ${this.sqlTableName} as
-      with data${dataColumns} as (
-        ${data.sqlSource}
-      ),
-      prepared_data as (
-        select
-          ${sliceColumns.ts} as ts,
-          ifnull(cast(${dur} as int), -1) as dur,
-          printf('%s', ${sliceColumns.name}) as name
-          ${argColumns.length > 0 ? ',' : ''}
-          ${argColumns.map((c) => `${c} as ${ARG_PREFIX}${c}`).join(',\n')}
-        from data
-      )
-      select
-        row_number() over (order by ts) as id,
-        *
-      from prepared_data
-      order by ts;`);
-  }
-
-  private async destroyTrackTable() {
-    await this.engine.tryQuery(`DROP TABLE IF EXISTS ${this.sqlTableName}`);
-  }
-}
diff --git a/ui/src/core_plugins/frames/index.ts b/ui/src/core_plugins/frames/index.ts
index 8e67de9..6ebca8e 100644
--- a/ui/src/core_plugins/frames/index.ts
+++ b/ui/src/core_plugins/frames/index.ts
@@ -12,6 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {
+  ACTUAL_FRAMES_SLICE_TRACK_KIND,
+  EXPECTED_FRAMES_SLICE_TRACK_KIND,
+} from '../../public';
 import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
 import {getTrackName} from '../../public/utils';
 import {NUM, NUM_NULL, STR, STR_NULL} from '../../trace_processor/query_result';
@@ -19,9 +23,6 @@
 import {ActualFramesTrack} from './actual_frames_track';
 import {ExpectedFramesTrack} from './expected_frames_track';
 
-export const EXPECTED_FRAMES_SLICE_TRACK_KIND = 'ExpectedFramesSliceTrack';
-export const ACTUAL_FRAMES_SLICE_TRACK_KIND = 'ActualFramesSliceTrack';
-
 class FramesPlugin implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     this.addExpectedFrames(ctx);
diff --git a/ui/src/core_plugins/ftrace/index.ts b/ui/src/core_plugins/ftrace/index.ts
index 9539c78..11be0a4 100644
--- a/ui/src/core_plugins/ftrace/index.ts
+++ b/ui/src/core_plugins/ftrace/index.ts
@@ -22,7 +22,7 @@
   PluginDescriptor,
 } from '../../public';
 import {NUM} from '../../trace_processor/query_result';
-import {Trash} from '../../base/disposable';
+import {DisposableStack} from '../../base/disposable';
 import {FtraceFilter, FtracePluginState} from './common';
 import {FtraceRawTrack} from './ftrace_track';
 
@@ -36,7 +36,7 @@
 };
 
 class FtraceRawPlugin implements Plugin {
-  private trash = new Trash();
+  private trash = new DisposableStack();
 
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     const store = ctx.mountStore<FtracePluginState>((init: unknown) => {
@@ -51,13 +51,13 @@
         return DEFAULT_STATE;
       }
     });
-    this.trash.add(store);
+    this.trash.use(store);
 
     const filterStore = store.createSubStore(
       ['filter'],
       (x) => x as FtraceFilter,
     );
-    this.trash.add(filterStore);
+    this.trash.use(filterStore);
 
     const cpus = await this.lookupCpuCores(ctx.engine);
     for (const cpuNum of cpus) {
diff --git a/ui/src/core_plugins/heap_profile/index.ts b/ui/src/core_plugins/heap_profile/index.ts
index 193f227..8e0b5ba 100644
--- a/ui/src/core_plugins/heap_profile/index.ts
+++ b/ui/src/core_plugins/heap_profile/index.ts
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {FlamegraphCache} from '../../core/flamegraph_cache';
 import {
-  FlamegraphCache,
   FlamegraphDetailsPanel,
   profileType,
 } from '../../frontend/flamegraph_panel';
diff --git a/ui/src/core_plugins/perf_samples_profile/index.ts b/ui/src/core_plugins/perf_samples_profile/index.ts
index eb4b220..8517739 100644
--- a/ui/src/core_plugins/perf_samples_profile/index.ts
+++ b/ui/src/core_plugins/perf_samples_profile/index.ts
@@ -13,8 +13,9 @@
 // limitations under the License.
 
 import {TrackData} from '../../common/track_data';
+import {PERF_SAMPLES_PROFILE_TRACK_KIND} from '../../public';
+import {FlamegraphCache} from '../../core/flamegraph_cache';
 import {
-  FlamegraphCache,
   FlamegraphDetailsPanel,
   profileType,
 } from '../../frontend/flamegraph_panel';
@@ -22,8 +23,6 @@
 import {NUM} from '../../trace_processor/query_result';
 import {PerfSamplesProfileTrack} from './perf_samples_profile_track';
 
-export const PERF_SAMPLES_PROFILE_TRACK_KIND = 'PerfSamplesProfileTrack';
-
 export interface Data extends TrackData {
   tsStarts: BigInt64Array;
 }
diff --git a/ui/src/core_plugins/perf_samples_profile/perf_samples_profile_track.ts b/ui/src/core_plugins/perf_samples_profile/perf_samples_profile_track.ts
index aca33de..a9b3ca9 100644
--- a/ui/src/core_plugins/perf_samples_profile/perf_samples_profile_track.ts
+++ b/ui/src/core_plugins/perf_samples_profile/perf_samples_profile_track.ts
@@ -25,8 +25,6 @@
 import {Engine, Track} from '../../public';
 import {LONG} from '../../trace_processor/query_result';
 
-export const PERF_SAMPLES_PROFILE_TRACK_KIND = 'PerfSamplesProfileTrack';
-
 export interface Data extends TrackData {
   tsStarts: BigInt64Array;
 }
diff --git a/ui/src/core_plugins/sched/active_cpu_count.ts b/ui/src/core_plugins/sched/active_cpu_count.ts
index f3a4eee..6d12ca7 100644
--- a/ui/src/core_plugins/sched/active_cpu_count.ts
+++ b/ui/src/core_plugins/sched/active_cpu_count.ts
@@ -14,58 +14,30 @@
 
 import m from 'mithril';
 
-import {NullDisposable} from '../../base/disposable';
 import {sqliteString} from '../../base/string_utils';
-import {uuidv4} from '../../base/uuid';
-import {Actions} from '../../common/actions';
-import {SCROLLING_TRACK_GROUP} from '../../common/state';
 import {
   BaseCounterTrack,
   CounterOptions,
 } from '../../frontend/base_counter_track';
 import {CloseTrackButton} from '../../frontend/close_track_button';
-import {globals} from '../../frontend/globals';
-import {Engine, PrimaryTrackSortKey, TrackContext} from '../../public';
+import {Engine, TrackContext} from '../../public';
+import {DisposableStack} from '../../base/disposable';
 
-export function addActiveCPUCountTrack(cpuType?: string) {
-  const cpuTypeName = cpuType === undefined ? '' : ` ${cpuType} `;
-
-  const key = uuidv4();
-
-  globals.dispatchMultiple([
-    Actions.addTrack({
-      key,
-      uri: ActiveCPUCountTrack.kind,
-      name: `Active ${cpuTypeName}CPU count`,
-      trackSortKey: PrimaryTrackSortKey.DEBUG_TRACK,
-      trackGroup: SCROLLING_TRACK_GROUP,
-      params: {
-        cpuType,
-      },
-    }),
-    Actions.toggleTrackPinned({trackKey: key}),
-  ]);
-}
-
-export interface ActiveCPUCountTrackConfig {
-  cpuType?: string;
+export enum CPUType {
+  Big = 'big',
+  Mid = 'mid',
+  Little = 'little',
 }
 
 export class ActiveCPUCountTrack extends BaseCounterTrack {
-  private config: ActiveCPUCountTrackConfig;
+  private readonly cpuType?: CPUType;
 
-  static readonly kind = 'dev.perfetto.Sched.ActiveCPUCount';
-
-  constructor(ctx: TrackContext, engine: Engine) {
+  constructor(ctx: TrackContext, engine: Engine, cpuType?: CPUType) {
     super({
       engine,
       trackKey: ctx.trackKey,
     });
-
-    // TODO(stevegolton): Validate params before type asserting.
-    // TODO(stevegolton): Avoid just pushing this config up for some base
-    // class to use. Be more explicit.
-    this.config = ctx.params as ActiveCPUCountTrackConfig;
+    this.cpuType = cpuType;
   }
 
   getTrackShellButtons(): m.Children {
@@ -85,16 +57,14 @@
     await this.engine.query(
       `INCLUDE PERFETTO MODULE sched.thread_level_parallelism`,
     );
-    return new NullDisposable();
+    return new DisposableStack();
   }
 
   getSqlSource() {
     const sourceTable =
-      this.config!.cpuType === undefined
+      this.cpuType === undefined
         ? 'sched_active_cpu_count'
-        : `sched_active_cpu_count_for_core_type(${sqliteString(
-            this.config!.cpuType,
-          )})`;
+        : `sched_active_cpu_count_for_core_type(${sqliteString(this.cpuType)})`;
     return `
       select
         ts,
diff --git a/ui/src/core_plugins/sched/index.ts b/ui/src/core_plugins/sched/index.ts
index ccebaa1..1ac0b14 100644
--- a/ui/src/core_plugins/sched/index.ts
+++ b/ui/src/core_plugins/sched/index.ts
@@ -12,18 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {uuidv4} from '../../base/uuid';
+import {Actions} from '../../common/actions';
+import {SCROLLING_TRACK_GROUP} from '../../common/state';
+import {globals} from '../../frontend/globals';
 import {
   Plugin,
-  PluginContext,
   PluginContextTrace,
   PluginDescriptor,
+  PrimaryTrackSortKey,
 } from '../../public';
 
-import {ActiveCPUCountTrack, addActiveCPUCountTrack} from './active_cpu_count';
-import {
-  addRunnableThreadCountTrack,
-  RunnableThreadCountTrack,
-} from './runnable_thread_count';
+import {ActiveCPUCountTrack, CPUType} from './active_cpu_count';
+import {RunnableThreadCountTrack} from './runnable_thread_count';
 
 class SchedPlugin implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace) {
@@ -35,33 +36,68 @@
           trackKey: trackCtx.trackKey,
         }),
     });
-    ctx.registerTrack({
-      uri: ActiveCPUCountTrack.kind,
-      trackFactory: (trackCtx) => new ActiveCPUCountTrack(trackCtx, ctx.engine),
-    });
-  }
-
-  onActivate(ctx: PluginContext): void {
     ctx.registerCommand({
       id: 'dev.perfetto.Sched.AddRunnableThreadCountTrackCommand',
       name: 'Add track: runnable thread count',
-      callback: () => addRunnableThreadCountTrack(),
+      callback: () =>
+        addPinnedTrack(RunnableThreadCountTrack.kind, 'Runnable thread count'),
+    });
+
+    const uri = uriForActiveCPUCountTrack();
+    const title = 'Active ${cpuType} CPU count';
+    ctx.registerTrack({
+      uri,
+      displayName: title,
+      trackFactory: (trackCtx) => new ActiveCPUCountTrack(trackCtx, ctx.engine),
     });
     ctx.registerCommand({
       id: 'dev.perfetto.Sched.AddActiveCPUCountTrackCommand',
       name: 'Add track: active CPU count',
-      callback: () => addActiveCPUCountTrack(),
+      callback: () => addPinnedTrack(uri, title),
     });
-    for (const cpuType of ['big', 'little', 'mid']) {
+
+    for (const cpuType of Object.values(CPUType)) {
+      const uri = uriForActiveCPUCountTrack(cpuType);
+      const title = `Active ${cpuType} CPU count`;
+      ctx.registerTrack({
+        uri,
+        displayName: title,
+        trackFactory: (trackCtx) =>
+          new ActiveCPUCountTrack(trackCtx, ctx.engine, cpuType),
+      });
+
       ctx.registerCommand({
         id: `dev.perfetto.Sched.AddActiveCPUCountTrackCommand.${cpuType}`,
         name: `Add track: active ${cpuType} CPU count`,
-        callback: () => addActiveCPUCountTrack(cpuType),
+        callback: () => addPinnedTrack(uri, title),
       });
     }
   }
 }
 
+function uriForActiveCPUCountTrack(cpuType?: CPUType): string {
+  const prefix = `perfetto.sched#ActiveCPUCount`;
+  if (cpuType) {
+    return `${prefix}.${cpuType}`;
+  } else {
+    return prefix;
+  }
+}
+
+function addPinnedTrack(uri: string, title: string) {
+  const key = uuidv4();
+  globals.dispatchMultiple([
+    Actions.addTrack({
+      key,
+      uri,
+      name: title,
+      trackSortKey: PrimaryTrackSortKey.DEBUG_TRACK,
+      trackGroup: SCROLLING_TRACK_GROUP,
+    }),
+    Actions.toggleTrackPinned({trackKey: key}),
+  ]);
+}
+
 export const plugin: PluginDescriptor = {
   pluginId: 'perfetto.Sched',
   plugin: SchedPlugin,
diff --git a/ui/src/core_plugins/sched/runnable_thread_count.ts b/ui/src/core_plugins/sched/runnable_thread_count.ts
index 894824e..7c9423c 100644
--- a/ui/src/core_plugins/sched/runnable_thread_count.ts
+++ b/ui/src/core_plugins/sched/runnable_thread_count.ts
@@ -14,32 +14,13 @@
 
 import m from 'mithril';
 
-import {NullDisposable} from '../../base/disposable';
-import {uuidv4} from '../../base/uuid';
-import {Actions} from '../../common/actions';
-import {SCROLLING_TRACK_GROUP} from '../../common/state';
 import {
   BaseCounterTrack,
   CounterOptions,
 } from '../../frontend/base_counter_track';
 import {CloseTrackButton} from '../../frontend/close_track_button';
-import {globals} from '../../frontend/globals';
 import {NewTrackArgs} from '../../frontend/track';
-import {PrimaryTrackSortKey} from '../../public';
-
-export function addRunnableThreadCountTrack() {
-  const key = uuidv4();
-  globals.dispatchMultiple([
-    Actions.addTrack({
-      key,
-      uri: RunnableThreadCountTrack.kind,
-      name: `Runnable thread count`,
-      trackSortKey: PrimaryTrackSortKey.DEBUG_TRACK,
-      trackGroup: SCROLLING_TRACK_GROUP,
-    }),
-    Actions.toggleTrackPinned({trackKey: key}),
-  ]);
-}
+import {DisposableStack} from '../../base/disposable';
 
 export class RunnableThreadCountTrack extends BaseCounterTrack {
   static readonly kind = 'dev.perfetto.Sched.RunnableThreadCount';
@@ -65,7 +46,7 @@
     await this.engine.query(
       `INCLUDE PERFETTO MODULE sched.thread_level_parallelism`,
     );
-    return new NullDisposable();
+    return new DisposableStack();
   }
 
   getSqlSource() {
diff --git a/ui/src/core_plugins/thread_slice/index.ts b/ui/src/core_plugins/thread_slice/index.ts
index 611b061..6b7da4a 100644
--- a/ui/src/core_plugins/thread_slice/index.ts
+++ b/ui/src/core_plugins/thread_slice/index.ts
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import {uuidv4} from '../../base/uuid';
+import {THREAD_SLICE_TRACK_KIND} from '../../public';
 import {ThreadSliceDetailsTab} from '../../frontend/thread_slice_details_tab';
 import {
   BottomTabToSCSAdapter,
@@ -22,7 +23,7 @@
 } from '../../public';
 import {getTrackName} from '../../public/utils';
 import {NUM, NUM_NULL, STR_NULL} from '../../trace_processor/query_result';
-import {ThreadSliceTrack, THREAD_SLICE_TRACK_KIND} from './thread_slice_track';
+import {ThreadSliceTrack} from '../../frontend/thread_slice_track';
 
 class ThreadSlicesPlugin implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
diff --git a/ui/src/core_plugins/thread_state/index.ts b/ui/src/core_plugins/thread_state/index.ts
index ab0bcc5..864a744 100644
--- a/ui/src/core_plugins/thread_state/index.ts
+++ b/ui/src/core_plugins/thread_state/index.ts
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import {uuidv4} from '../../base/uuid';
+import {THREAD_STATE_TRACK_KIND} from '../../public';
 import {asThreadStateSqlId} from '../../frontend/sql_types';
 import {ThreadStateTab} from '../../frontend/thread_state_tab';
 import {
@@ -26,8 +27,6 @@
 
 import {ThreadStateTrack} from './thread_state_track';
 
-export const THREAD_STATE_TRACK_KIND = 'ThreadStateTrack';
-
 class ThreadState implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     const {engine} = ctx;
diff --git a/ui/src/core_plugins/track_utils/index.ts b/ui/src/core_plugins/track_utils/index.ts
index de39729..6e54875 100644
--- a/ui/src/core_plugins/track_utils/index.ts
+++ b/ui/src/core_plugins/track_utils/index.ts
@@ -31,8 +31,8 @@
     ctx.registerCommand({
       id: 'perfetto.RunQueryInSelectedTimeWindow',
       name: `Run query in selected time window`,
-      callback: () => {
-        const window = getTimeSpanOfSelectionOrVisibleWindow();
+      callback: async () => {
+        const window = await getTimeSpanOfSelectionOrVisibleWindow();
         globals.omnibox.setMode(OmniboxMode.Query);
         globals.omnibox.setText(
           `select  where ts >= ${window.start} and ts < ${window.end}`,
diff --git a/ui/src/core_plugins/visualised_args/index.ts b/ui/src/core_plugins/visualised_args/index.ts
deleted file mode 100644
index 459a7ad..0000000
--- a/ui/src/core_plugins/visualised_args/index.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2022 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.
-
-import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
-import {
-  VISUALISED_ARGS_SLICE_TRACK_URI,
-  VisualisedArgsState,
-} from '../../frontend/visualized_args_tracks';
-import {VisualisedArgsTrack} from './visualized_args_track';
-
-class VisualisedArgsPlugin implements Plugin {
-  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
-    ctx.registerTrack({
-      uri: VISUALISED_ARGS_SLICE_TRACK_URI,
-      tags: {
-        metric: true, // TODO(stevegolton): Is this track really a metric?
-      },
-      trackFactory: (trackCtx) => {
-        // TODO(stevegolton): Validate params properly. Note, this is no
-        // worse than the situation we had before with track config.
-        const params = trackCtx.params as VisualisedArgsState;
-        return new VisualisedArgsTrack(
-          {
-            engine: ctx.engine,
-            trackKey: trackCtx.trackKey,
-          },
-          params.trackId,
-          params.maxDepth,
-          params.argName,
-        );
-      },
-    });
-  }
-}
-
-export const plugin: PluginDescriptor = {
-  pluginId: 'perfetto.VisualisedArgs',
-  plugin: VisualisedArgsPlugin,
-};
diff --git a/ui/src/frontend/aggregation_tab.ts b/ui/src/frontend/aggregation_tab.ts
index 6cd1d0c..0c68f5f 100644
--- a/ui/src/frontend/aggregation_tab.ts
+++ b/ui/src/frontend/aggregation_tab.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Disposable, Trash} from '../base/disposable';
+import {Disposable, DisposableStack} from '../base/disposable';
 import {AggregationPanel} from './aggregation_panel';
 import {globals} from './globals';
 import {isEmptyData} from '../common/aggregation_data';
@@ -28,9 +28,9 @@
   FlamegraphSelectionParams,
 } from './flamegraph_panel';
 import {ProfileType, TrackState} from '../common/state';
-import {PERF_SAMPLES_PROFILE_TRACK_KIND} from '../core_plugins/perf_samples_profile';
 import {assertExists} from '../base/logging';
 import {Monitor} from '../base/monitor';
+import {PERF_SAMPLES_PROFILE_TRACK_KIND} from '../core/track_kinds';
 
 interface View {
   key: string;
@@ -187,7 +187,7 @@
 }
 
 export class AggregationsTabs implements Disposable {
-  private trash = new Trash();
+  private trash = new DisposableStack();
 
   constructor() {
     const unregister = globals.tabManager.registerDetailsPanel({
@@ -200,7 +200,7 @@
       },
     });
 
-    this.trash.add(unregister);
+    this.trash.use(unregister);
   }
 
   dispose(): void {
diff --git a/ui/src/frontend/app.ts b/ui/src/frontend/app.ts
index 97907ef..59a5079 100644
--- a/ui/src/frontend/app.ts
+++ b/ui/src/frontend/app.ts
@@ -15,7 +15,7 @@
 import m from 'mithril';
 
 import {copyToClipboard} from '../base/clipboard';
-import {Trash} from '../base/disposable';
+import {DisposableStack} from '../base/disposable';
 import {findRef} from '../base/dom_utils';
 import {FuzzyFinder} from '../base/fuzzy';
 import {assertExists, assertUnreachable} from '../base/logging';
@@ -56,7 +56,7 @@
 import {OmniboxMode, PromptOption} from './omnibox_manager';
 import {Utid} from './sql_types';
 import {getThreadInfo} from './thread_and_process_info';
-import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
+import {THREAD_STATE_TRACK_KIND} from '../core/track_kinds';
 
 function renderPermalink(): m.Children {
   const hash = globals.permalinkHash;
@@ -112,14 +112,14 @@
 ];
 
 export class App implements m.ClassComponent {
-  private trash = new Trash();
+  private trash = new DisposableStack();
   static readonly OMNIBOX_INPUT_REF = 'omnibox';
   private omniboxInputEl?: HTMLInputElement;
   private recentCommands: string[] = [];
 
   constructor() {
-    this.trash.add(new Notes());
-    this.trash.add(new AggregationsTabs());
+    this.trash.use(new Notes());
+    this.trash.use(new AggregationsTabs());
   }
 
   private getEngine(): Engine | undefined {
@@ -210,7 +210,7 @@
       name: `Critical path lite`,
       callback: async () => {
         const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow();
-        const window = getTimeSpanOfSelectionOrVisibleWindow();
+        const window = await getTimeSpanOfSelectionOrVisibleWindow();
         const engine = this.getEngine();
 
         if (engine !== undefined && trackUtid != 0) {
@@ -218,7 +218,13 @@
             `INCLUDE PERFETTO MODULE sched.thread_executing_span;`,
           );
           await addDebugSliceTrack(
-            engine,
+            // NOTE(stevegolton): This is a temporary patch, this menu should
+            // become part of a critical path plugin, at which point we can just
+            // use the plugin's context object.
+            {
+              engine,
+              registerTrack: (x) => globals.trackManager.registerTrack(x),
+            },
             {
               sqlSource: `
                    SELECT
@@ -252,7 +258,7 @@
       name: `Critical path`,
       callback: async () => {
         const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow();
-        const window = getTimeSpanOfSelectionOrVisibleWindow();
+        const window = await getTimeSpanOfSelectionOrVisibleWindow();
         const engine = this.getEngine();
 
         if (engine !== undefined && trackUtid != 0) {
@@ -260,7 +266,13 @@
             `INCLUDE PERFETTO MODULE sched.thread_executing_span_with_slice;`,
           );
           await addDebugSliceTrack(
-            engine,
+            // NOTE(stevegolton): This is a temporary patch, this menu should
+            // become part of a critical path plugin, at which point we can just
+            // use the plugin's context object.
+            {
+              engine,
+              registerTrack: (x) => globals.trackManager.registerTrack(x),
+            },
             {
               sqlSource: `
                         SELECT cr.id, cr.utid, cr.ts, cr.dur, cr.name, cr.table_name
@@ -283,9 +295,9 @@
     {
       id: 'perfetto.CriticalPathPprof',
       name: `Critical path pprof`,
-      callback: () => {
+      callback: async () => {
         const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow();
-        const window = getTimeSpanOfSelectionOrVisibleWindow();
+        const window = await getTimeSpanOfSelectionOrVisibleWindow();
         const engine = this.getEngine();
 
         if (engine !== undefined && trackUtid != 0) {
@@ -368,8 +380,8 @@
     {
       id: 'perfetto.CopyTimeWindow',
       name: `Copy selected time window to clipboard`,
-      callback: () => {
-        const window = getTimeSpanOfSelectionOrVisibleWindow();
+      callback: async () => {
+        const window = await getTimeSpanOfSelectionOrVisibleWindow();
         const query = `ts >= ${window.start} and ts < ${window.end}`;
         copyToClipboard(query);
       },
@@ -393,8 +405,8 @@
     {
       id: 'perfetto.SetTemporarySpanNote',
       name: 'Set the temporary span note based on the current selection',
-      callback: () => {
-        const range = globals.findTimeRangeOfSelection();
+      callback: async () => {
+        const range = await globals.findTimeRangeOfSelection();
         if (range) {
           globals.dispatch(
             Actions.addSpanNote({
@@ -410,8 +422,8 @@
     {
       id: 'perfetto.AddSpanNote',
       name: 'Add a new span note based on the current selection',
-      callback: () => {
-        const range = globals.findTimeRangeOfSelection();
+      callback: async () => {
+        const range = await globals.findTimeRangeOfSelection();
         if (range) {
           globals.dispatch(
             Actions.addSpanNote({start: range.start, end: range.end}),
@@ -807,7 +819,7 @@
     // Register each command with the command manager
     this.cmds.forEach((cmd) => {
       const dispose = globals.commandManager.registerCommand(cmd);
-      this.trash.add(dispose);
+      this.trash.use(dispose);
     });
   }
 
diff --git a/ui/src/frontend/base_counter_track.ts b/ui/src/frontend/base_counter_track.ts
index d010f95..7e20cf2 100644
--- a/ui/src/frontend/base_counter_track.ts
+++ b/ui/src/frontend/base_counter_track.ts
@@ -15,16 +15,18 @@
 import m from 'mithril';
 
 import {searchSegment} from '../base/binary_search';
-import {Disposable, NullDisposable} from '../base/disposable';
+import {Disposable, DisposableStack} from '../base/disposable';
 import {assertTrue, assertUnreachable} from '../base/logging';
 import {Time, time} from '../base/time';
 import {uuidv4Sql} from '../base/uuid';
 import {drawTrackHoverTooltip} from '../common/canvas_utils';
 import {raf} from '../core/raf_scheduler';
 import {CacheKey} from '../core/timeline_cache';
-import {Engine, LONG, NUM, Track} from '../public';
+import {Track} from '../public';
 import {Button} from '../widgets/button';
 import {MenuDivider, MenuItem, PopupMenu2} from '../widgets/menu';
+import {Engine} from '../trace_processor/engine';
+import {LONG, NUM} from '../trace_processor/query_result';
 
 import {checkerboardExcept} from './checkerboard';
 import {globals} from './globals';
@@ -233,7 +235,7 @@
   // state in trace_processor should be cleaned up when dispose is
   // called on the returned hook.
   async onInit(): Promise<Disposable> {
-    return new NullDisposable();
+    return new DisposableStack();
   }
 
   // This should be an SQL expression returning the columns `ts` and `value`.
diff --git a/ui/src/frontend/base_slice_track.ts b/ui/src/frontend/base_slice_track.ts
index 84e53cf..0e0f00d 100644
--- a/ui/src/frontend/base_slice_track.ts
+++ b/ui/src/frontend/base_slice_track.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Disposable, NullDisposable} from '../base/disposable';
+import {Disposable, DisposableStack} from '../base/disposable';
 import {assertExists} from '../base/logging';
 import {clamp, floatEqual} from '../base/math_utils';
 import {Time, time} from '../base/time';
@@ -224,7 +224,7 @@
   // called on the returned hook. In the common case of where
   // the data for this track is a SQL fragment this does nothing.
   async onInit(): Promise<Disposable> {
-    return new NullDisposable();
+    return new DisposableStack();
   }
 
   // This should be an SQL expression returning all the columns listed
diff --git a/ui/src/frontend/counter_panel.ts b/ui/src/frontend/counter_panel.ts
deleted file mode 100644
index fb34212..0000000
--- a/ui/src/frontend/counter_panel.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use size 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.
-
-import m from 'mithril';
-
-import {DetailsShell} from '../widgets/details_shell';
-import {GridLayout} from '../widgets/grid_layout';
-import {Section} from '../widgets/section';
-import {Tree, TreeNode} from '../widgets/tree';
-
-import {globals} from './globals';
-import {DurationWidget} from './widgets/duration';
-import {Timestamp} from './widgets/timestamp';
-
-export class CounterDetailsPanel implements m.ClassComponent {
-  view() {
-    const counterInfo = globals.counterDetails;
-    if (
-      counterInfo.startTime &&
-      counterInfo.name !== undefined &&
-      counterInfo.value !== undefined &&
-      counterInfo.delta !== undefined &&
-      counterInfo.duration !== undefined
-    ) {
-      return m(
-        DetailsShell,
-        {title: 'Counter', description: `${counterInfo.name}`},
-        m(
-          GridLayout,
-          m(
-            Section,
-            {title: 'Properties'},
-            m(
-              Tree,
-              m(TreeNode, {left: 'Name', right: `${counterInfo.name}`}),
-              m(TreeNode, {
-                left: 'Start time',
-                right: m(Timestamp, {ts: counterInfo.startTime}),
-              }),
-              m(TreeNode, {
-                left: 'Value',
-                right: `${counterInfo.value.toLocaleString()}`,
-              }),
-              m(TreeNode, {
-                left: 'Delta',
-                right: `${counterInfo.delta.toLocaleString()}`,
-              }),
-              m(TreeNode, {
-                left: 'Duration',
-                right: m(DurationWidget, {dur: counterInfo.duration}),
-              }),
-            ),
-          ),
-        ),
-      );
-    } else {
-      return m(DetailsShell, {title: 'Counter', description: 'Loading...'});
-    }
-  }
-
-  renderCanvas() {}
-}
diff --git a/ui/src/frontend/debug_tracks.ts b/ui/src/frontend/debug_tracks.ts
deleted file mode 100644
index 1aa97f8..0000000
--- a/ui/src/frontend/debug_tracks.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-// 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.
-
-import {uuidv4} from '../base/uuid';
-import {Actions, DeferredAction} from '../common/actions';
-import {SCROLLING_TRACK_GROUP} from '../common/state';
-import {DebugTrackV2Config} from '../core_plugins/debug/slice_track';
-import {Engine, PrimaryTrackSortKey} from '../public';
-import {matchesSqlValue} from '../trace_processor/sql_utils';
-
-import {globals} from './globals';
-
-export const ARG_PREFIX = 'arg_';
-export const DEBUG_SLICE_TRACK_URI = 'perfetto.DebugSlices';
-export const DEBUG_COUNTER_TRACK_URI = 'perfetto.DebugCounter';
-
-// Names of the columns of the underlying view to be used as
-// ts / dur / name / pivot.
-export interface SliceColumns {
-  ts: string;
-  dur: string;
-  name: string;
-  pivot?: string;
-}
-
-export interface DebugTrackV2CreateConfig {
-  pinned?: boolean; // default true
-  closeable?: boolean; // default true
-}
-
-let debugTrackCount = 0;
-
-export interface SqlDataSource {
-  // SQL source selecting the necessary data.
-  sqlSource: string;
-
-  // Optional: Rename columns from the query result.
-  // If omitted, original column names from the query are used instead.
-  // The caller is responsible for ensuring that the number of items in this
-  // list matches the number of columns returned by sqlSource.
-  columns?: string[];
-}
-
-// Creates actions to add a debug track. The actions must be dispatched to
-// have an effect. Use this variant if you want to create many tracks at
-// once or want to tweak the actions once produced. Otherwise, use
-// addDebugSliceTrack().
-export async function createDebugSliceTrackActions(
-  _engine: Engine,
-  data: SqlDataSource,
-  trackName: string,
-  sliceColumns: SliceColumns,
-  argColumns: string[],
-  config?: DebugTrackV2CreateConfig,
-): Promise<DeferredAction<{}>[]> {
-  const debugTrackId = ++debugTrackCount;
-  const closeable = config?.closeable ?? true;
-  const trackKey = uuidv4();
-
-  const trackConfig: DebugTrackV2Config = {
-    data,
-    columns: sliceColumns,
-    argColumns,
-  };
-
-  const actions: DeferredAction<{}>[] = [
-    Actions.addTrack({
-      key: trackKey,
-      name: trackName.trim() || `Debug Track ${debugTrackId}`,
-      uri: DEBUG_SLICE_TRACK_URI,
-      trackSortKey: PrimaryTrackSortKey.DEBUG_TRACK,
-      trackGroup: SCROLLING_TRACK_GROUP,
-      params: trackConfig,
-      closeable,
-    }),
-  ];
-  if (config?.pinned ?? true) {
-    actions.push(Actions.toggleTrackPinned({trackKey}));
-  }
-  return actions;
-}
-
-export async function addPivotDebugSliceTracks(
-  engine: Engine,
-  data: SqlDataSource,
-  trackName: string,
-  sliceColumns: SliceColumns,
-  argColumns: string[],
-  config?: DebugTrackV2CreateConfig,
-) {
-  if (sliceColumns.pivot) {
-    // Get distinct values to group by
-    const pivotValues = await engine.query(`
-      with all_vals as (${data.sqlSource})
-      select DISTINCT ${sliceColumns.pivot} from all_vals;`);
-
-    const iter = pivotValues.iter({});
-
-    for (; iter.valid(); iter.next()) {
-      const pivotDataSource: SqlDataSource = {
-        sqlSource: `select * from
-        (${data.sqlSource})
-        where ${sliceColumns.pivot} ${matchesSqlValue(
-          iter.get(sliceColumns.pivot),
-        )}`,
-      };
-
-      const actions = await createDebugSliceTrackActions(
-        engine,
-        pivotDataSource,
-        `${trackName.trim() || 'Pivot Track'}: ${iter.get(sliceColumns.pivot)}`,
-        sliceColumns,
-        argColumns,
-        config,
-      );
-
-      globals.dispatchMultiple(actions);
-    }
-  }
-}
-
-// Adds a debug track immediately. Use createDebugSliceTrackActions() if you
-// want to create many tracks at once.
-export async function addDebugSliceTrack(
-  engine: Engine,
-  data: SqlDataSource,
-  trackName: string,
-  sliceColumns: SliceColumns,
-  argColumns: string[],
-  config?: DebugTrackV2CreateConfig,
-) {
-  const actions = await createDebugSliceTrackActions(
-    engine,
-    data,
-    trackName,
-    sliceColumns,
-    argColumns,
-    config,
-  );
-  globals.dispatchMultiple(actions);
-}
-
-// Names of the columns of the underlying view to be used as ts / dur / name.
-export interface CounterColumns {
-  ts: string;
-  value: string;
-}
-
-export interface CounterDebugTrackConfig {
-  data: SqlDataSource;
-  columns: CounterColumns;
-}
-
-export interface CounterDebugTrackCreateConfig {
-  pinned?: boolean; // default true
-  closeable?: boolean; // default true
-}
-
-// Creates actions to add a debug track. The actions must be dispatched to
-// have an effect. Use this variant if you want to create many tracks at
-// once or want to tweak the actions once produced. Otherwise, use
-// addDebugCounterTrack().
-export async function createDebugCounterTrackActions(
-  data: SqlDataSource,
-  trackName: string,
-  columns: CounterColumns,
-  config?: CounterDebugTrackCreateConfig,
-) {
-  // To prepare displaying the provided data as a track, materialize it and
-  // compute depths.
-  const debugTrackId = ++debugTrackCount;
-
-  const closeable = config?.closeable ?? true;
-  const params: CounterDebugTrackConfig = {
-    data,
-    columns,
-  };
-
-  const trackKey = uuidv4();
-  const actions: DeferredAction<{}>[] = [
-    Actions.addTrack({
-      key: trackKey,
-      uri: DEBUG_COUNTER_TRACK_URI,
-      name: trackName.trim() || `Debug Track ${debugTrackId}`,
-      trackSortKey: PrimaryTrackSortKey.DEBUG_TRACK,
-      trackGroup: SCROLLING_TRACK_GROUP,
-      params,
-      closeable,
-    }),
-  ];
-  if (config?.pinned ?? true) {
-    actions.push(Actions.toggleTrackPinned({trackKey}));
-  }
-  return actions;
-}
-
-// Adds a debug track immediately. Use createDebugCounterTrackActions() if you
-// want to create many tracks at once.
-export async function addDebugCounterTrack(
-  data: SqlDataSource,
-  trackName: string,
-  columns: CounterColumns,
-  config?: CounterDebugTrackCreateConfig,
-) {
-  const actions = await createDebugCounterTrackActions(
-    data,
-    trackName,
-    columns,
-    config,
-  );
-  globals.dispatchMultiple(actions);
-}
diff --git a/ui/src/core_plugins/debug/add_debug_track_menu.ts b/ui/src/frontend/debug_tracks/add_debug_track_menu.ts
similarity index 83%
rename from ui/src/core_plugins/debug/add_debug_track_menu.ts
rename to ui/src/frontend/debug_tracks/add_debug_track_menu.ts
index a0c5b31..6c39bac 100644
--- a/ui/src/core_plugins/debug/add_debug_track_menu.ts
+++ b/ui/src/frontend/debug_tracks/add_debug_track_menu.ts
@@ -26,9 +26,8 @@
   addDebugCounterTrack,
   addDebugSliceTrack,
   addPivotDebugSliceTracks,
-} from '../../frontend/debug_tracks';
-
-export const ARG_PREFIX = 'arg_';
+} from './debug_tracks';
+import {globals} from '../globals';
 
 export function uuidToViewName(uuid: string): string {
   return `view_${uuid.split('-').join('_')}`;
@@ -186,7 +185,13 @@
             case 'slice':
               if (this.renderParams.pivot === '') {
                 addDebugSliceTrack(
-                  vnode.attrs.engine,
+                  // NOTE(stevegolton): This is a temporary patch, this menu
+                  // should become part of the debug tracks plugin, at which
+                  // point we can just use the plugin's context object.
+                  {
+                    engine: vnode.attrs.engine,
+                    registerTrack: (x) => globals.trackManager.registerTrack(x),
+                  },
                   vnode.attrs.dataSource,
                   this.name,
                   {
@@ -198,7 +203,10 @@
                 );
               } else {
                 addPivotDebugSliceTracks(
-                  vnode.attrs.engine,
+                  {
+                    engine: vnode.attrs.engine,
+                    registerTrack: globals.trackManager.registerTrack,
+                  },
                   vnode.attrs.dataSource,
                   this.name,
                   {
@@ -212,10 +220,21 @@
               }
               break;
             case 'counter':
-              addDebugCounterTrack(vnode.attrs.dataSource, this.name, {
-                ts: this.renderParams.ts,
-                value: this.renderParams.value,
-              });
+              addDebugCounterTrack(
+                // TODO(stevegolton): This is a temporary patch, this menu
+                // should become part of the debug tracks plugin, at which
+                // point we can just use the plugin's context object.
+                {
+                  engine: vnode.attrs.engine,
+                  registerTrack: (x) => globals.trackManager.registerTrack(x),
+                },
+                vnode.attrs.dataSource,
+                this.name,
+                {
+                  ts: this.renderParams.ts,
+                  value: this.renderParams.value,
+                },
+              );
               break;
           }
         },
diff --git a/ui/src/frontend/debug_tracks/counter_track.ts b/ui/src/frontend/debug_tracks/counter_track.ts
new file mode 100644
index 0000000..d1b915e
--- /dev/null
+++ b/ui/src/frontend/debug_tracks/counter_track.ts
@@ -0,0 +1,39 @@
+// Copyright (C) 2023 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.
+
+import m from 'mithril';
+
+import {BaseCounterTrack} from '../../frontend/base_counter_track';
+import {TrackContext} from '../../public';
+import {Engine} from '../../trace_processor/engine';
+
+export class DebugCounterTrack extends BaseCounterTrack {
+  private readonly sqlTableName: string;
+
+  constructor(engine: Engine, ctx: TrackContext, tableName: string) {
+    super({
+      engine,
+      trackKey: ctx.trackKey,
+    });
+    this.sqlTableName = tableName;
+  }
+
+  getTrackShellButtons(): m.Children {
+    return this.getCounterContextMenu();
+  }
+
+  getSqlSource(): string {
+    return `select * from ${this.sqlTableName}`;
+  }
+}
diff --git a/ui/src/frontend/debug_tracks/debug_tracks.ts b/ui/src/frontend/debug_tracks/debug_tracks.ts
new file mode 100644
index 0000000..83ca517
--- /dev/null
+++ b/ui/src/frontend/debug_tracks/debug_tracks.ts
@@ -0,0 +1,265 @@
+// 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.
+
+import {uuidv4, uuidv4Sql} from '../../base/uuid';
+import {Actions, DeferredAction} from '../../common/actions';
+import {PrimaryTrackSortKey, SCROLLING_TRACK_GROUP} from '../../common/state';
+import {globals} from '../globals';
+import {TrackDescriptor} from '../../public';
+import {DebugSliceTrack} from './slice_track';
+import {
+  createPerfettoTable,
+  matchesSqlValue,
+} from '../../trace_processor/sql_utils';
+import {Engine} from '../../trace_processor/engine';
+import {DebugCounterTrack} from './counter_track';
+import {ARG_PREFIX} from './details_tab';
+
+// We need to add debug tracks from the core and from plugins. In order to add a
+// debug track we need to pass a context through with we can add the track. This
+// is different for plugins vs the core. This interface defines the generic
+// shape of this context, which can be supplied from a plugin or built from
+// globals.
+//
+// TODO(stevegolton): In the future, both the core and plugins should
+// have access to some Context object which implements the various things we
+// want to do in a generic way, so that we don't have to do this mangling to get
+// this to work.
+interface Context {
+  engine: Engine;
+  registerTrack(track: TrackDescriptor): unknown;
+}
+
+// Names of the columns of the underlying view to be used as
+// ts / dur / name / pivot.
+export interface SliceColumns {
+  ts: string;
+  dur: string;
+  name: string;
+  pivot?: string;
+}
+
+let debugTrackCount = 0;
+
+export interface SqlDataSource {
+  // SQL source selecting the necessary data.
+  sqlSource: string;
+
+  // Optional: Rename columns from the query result.
+  // If omitted, original column names from the query are used instead.
+  // The caller is responsible for ensuring that the number of items in this
+  // list matches the number of columns returned by sqlSource.
+  columns?: string[];
+}
+
+// Creates actions to add a debug track. The actions must be dispatched to
+// have an effect. Use this variant if you want to create many tracks at
+// once or want to tweak the actions once produced. Otherwise, use
+// addDebugSliceTrack().
+function createAddDebugTrackActions(
+  trackName: string,
+  uri: string,
+): DeferredAction<{}>[] {
+  const debugTrackId = ++debugTrackCount;
+  const trackKey = uuidv4();
+
+  const actions: DeferredAction<{}>[] = [
+    Actions.addTrack({
+      key: trackKey,
+      name: trackName.trim() || `Debug Track ${debugTrackId}`,
+      uri,
+      trackSortKey: PrimaryTrackSortKey.DEBUG_TRACK,
+      trackGroup: SCROLLING_TRACK_GROUP,
+      closeable: true,
+    }),
+    Actions.toggleTrackPinned({trackKey}),
+  ];
+
+  return actions;
+}
+
+export async function addPivotDebugSliceTracks(
+  ctx: Context,
+  data: SqlDataSource,
+  trackName: string,
+  sliceColumns: SliceColumns,
+  argColumns: string[],
+) {
+  if (sliceColumns.pivot) {
+    // Get distinct values to group by
+    const pivotValues = await ctx.engine.query(`
+      with all_vals as (${data.sqlSource})
+      select DISTINCT ${sliceColumns.pivot} from all_vals;`);
+
+    const iter = pivotValues.iter({});
+
+    for (; iter.valid(); iter.next()) {
+      const pivotDataSource: SqlDataSource = {
+        sqlSource: `select * from
+        (${data.sqlSource})
+        where ${sliceColumns.pivot} ${matchesSqlValue(
+          iter.get(sliceColumns.pivot),
+        )}`,
+      };
+
+      await addDebugSliceTrack(
+        ctx,
+        pivotDataSource,
+        `${trackName.trim() || 'Pivot Track'}: ${iter.get(sliceColumns.pivot)}`,
+        sliceColumns,
+        argColumns,
+      );
+    }
+  }
+}
+
+// Adds a debug track immediately. Use createDebugSliceTrackActions() if you
+// want to create many tracks at once.
+export async function addDebugSliceTrack(
+  ctx: Context,
+  data: SqlDataSource,
+  trackName: string,
+  sliceColumns: SliceColumns,
+  argColumns: string[],
+): Promise<void> {
+  // Create a new table from the debug track definition. This will be used as
+  // the backing data source for our track and its details panel.
+  const tableName = `__debug_slice_${uuidv4Sql()}`;
+
+  // TODO(stevegolton): Right now we ignore the AsyncDisposable that this
+  // function returns, and so never clean up this table. The problem is we have
+  // no where sensible to do this cleanup.
+  // - If we did it in the track's onDestroy function, we could drop the table
+  //   while the details panel still needs access to it.
+  // - If we did it in the plugin's onTraceUnload function, we could risk
+  //   dropping it n the middle of a track update cycle as track lifecycles are
+  //   not synchronized with plugin lifecycles.
+  await createPerfettoTable(
+    ctx.engine,
+    tableName,
+    createDebugSliceTrackTableExpr(data, sliceColumns, argColumns),
+  );
+
+  const uri = `debug.slice.${uuidv4()}`;
+  ctx.registerTrack({
+    uri,
+    trackFactory: (trackCtx) => {
+      return new DebugSliceTrack(ctx.engine, trackCtx, tableName);
+    },
+  });
+
+  // Create the actions to add this track to the tracklist
+  const actions = await createAddDebugTrackActions(trackName, uri);
+  globals.dispatchMultiple(actions);
+}
+
+function createDebugSliceTrackTableExpr(
+  data: SqlDataSource,
+  sliceColumns: SliceColumns,
+  argColumns: string[],
+): string {
+  const dataColumns =
+    data.columns !== undefined ? `(${data.columns.join(', ')})` : '';
+  const dur = sliceColumns.dur === '0' ? 0 : sliceColumns.dur;
+  return `
+    with data${dataColumns} as (
+      ${data.sqlSource}
+    ),
+    prepared_data as (
+      select
+        ${sliceColumns.ts} as ts,
+        ifnull(cast(${dur} as int), -1) as dur,
+        printf('%s', ${sliceColumns.name}) as name
+        ${argColumns.length > 0 ? ',' : ''}
+        ${argColumns.map((c) => `${c} as ${ARG_PREFIX}${c}`).join(',\n')}
+      from data
+    )
+    select
+      row_number() over (order by ts) as id,
+      *
+    from prepared_data
+    order by ts
+  `;
+}
+
+// Names of the columns of the underlying view to be used as ts / dur / name.
+export interface CounterColumns {
+  ts: string;
+  value: string;
+}
+
+export interface CounterDebugTrackConfig {
+  data: SqlDataSource;
+  columns: CounterColumns;
+}
+
+export interface CounterDebugTrackCreateConfig {
+  pinned?: boolean; // default true
+  closeable?: boolean; // default true
+}
+
+// Adds a debug track immediately. Use createDebugCounterTrackActions() if you
+// want to create many tracks at once.
+export async function addDebugCounterTrack(
+  ctx: Context,
+  data: SqlDataSource,
+  trackName: string,
+  columns: CounterColumns,
+): Promise<void> {
+  // Create a new table from the debug track definition. This will be used as
+  // the backing data source for our track and its details panel.
+  const tableName = `__debug_counter_${uuidv4Sql()}`;
+
+  // TODO(stevegolton): Right now we ignore the AsyncDisposable that this
+  // function returns, and so never clean up this table. The problem is we have
+  // no where sensible to do this cleanup.
+  // - If we did it in the track's onDestroy function, we could drop the table
+  //   while the details panel still needs access to it.
+  // - If we did it in the plugin's onTraceUnload function, we could risk
+  //   dropping it n the middle of a track update cycle as track lifecycles are
+  //   not synchronized with plugin lifecycles.
+  await createPerfettoTable(
+    ctx.engine,
+    tableName,
+    createDebugCounterTrackTableExpr(data, columns),
+  );
+
+  const uri = `debug.counter.${uuidv4()}`;
+  ctx.registerTrack({
+    uri,
+    trackFactory: (trackCtx) => {
+      return new DebugCounterTrack(ctx.engine, trackCtx, tableName);
+    },
+  });
+
+  // Create the actions to add this track to the tracklist
+  const actions = await createAddDebugTrackActions(trackName, uri);
+  globals.dispatchMultiple(actions);
+}
+
+function createDebugCounterTrackTableExpr(
+  data: SqlDataSource,
+  columns: CounterColumns,
+): string {
+  return `
+    with data as (
+      ${data.sqlSource}
+    )
+    select
+      ${columns.ts} as ts,
+      ${columns.value} as value
+    from data
+    order by ts
+  `;
+}
diff --git a/ui/src/core_plugins/debug/details_tab.ts b/ui/src/frontend/debug_tracks/details_tab.ts
similarity index 90%
rename from ui/src/core_plugins/debug/details_tab.ts
rename to ui/src/frontend/debug_tracks/details_tab.ts
index 0926909..5cc0f6f 100644
--- a/ui/src/core_plugins/debug/details_tab.ts
+++ b/ui/src/frontend/debug_tracks/details_tab.ts
@@ -16,23 +16,15 @@
 
 import {duration, Time, time} from '../../base/time';
 import {raf} from '../../core/raf_scheduler';
-import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
-import {ARG_PREFIX} from '../../frontend/debug_tracks';
-import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
-import {hasArgs, renderArguments} from '../../frontend/slice_args';
-import {getSlice, SliceDetails, sliceRef} from '../../frontend/sql/slice';
-import {asSliceSqlId, Utid} from '../../frontend/sql_types';
-import {
-  getProcessName,
-  getThreadName,
-} from '../../frontend/thread_and_process_info';
-import {
-  getThreadState,
-  ThreadState,
-  threadStateRef,
-} from '../../frontend/thread_state';
-import {DurationWidget} from '../../frontend/widgets/duration';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {BottomTab, NewBottomTabArgs} from '../bottom_tab';
+import {GenericSliceDetailsTabConfig} from '../generic_slice_details_tab';
+import {hasArgs, renderArguments} from '../slice_args';
+import {getSlice, SliceDetails, sliceRef} from '../sql/slice';
+import {asSliceSqlId, Utid} from '../sql_types';
+import {getProcessName, getThreadName} from '../thread_and_process_info';
+import {getThreadState, ThreadState, threadStateRef} from '../thread_state';
+import {DurationWidget} from '../widgets/duration';
+import {Timestamp} from '../widgets/timestamp';
 import {
   ColumnType,
   durationFromSql,
@@ -46,6 +38,8 @@
 import {Section} from '../../widgets/section';
 import {dictToTree, dictToTreeNodes, Tree, TreeNode} from '../../widgets/tree';
 
+export const ARG_PREFIX = 'arg_';
+
 function sqlValueToNumber(value?: ColumnType): number | undefined {
   if (typeof value === 'bigint') return Number(value);
   if (typeof value !== 'number') return undefined;
diff --git a/ui/src/frontend/debug_tracks/slice_track.ts b/ui/src/frontend/debug_tracks/slice_track.ts
new file mode 100644
index 0000000..fbc9770
--- /dev/null
+++ b/ui/src/frontend/debug_tracks/slice_track.ts
@@ -0,0 +1,51 @@
+// Copyright (C) 2023 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.
+
+import {NamedSliceTrackTypes} from '../named_slice_track';
+import {
+  CustomSqlDetailsPanelConfig,
+  CustomSqlTableDefConfig,
+  CustomSqlTableSliceTrack,
+} from '../tracks/custom_sql_table_slice_track';
+import {TrackContext} from '../../public';
+import {Engine} from '../../trace_processor/engine';
+import {DebugSliceDetailsTab} from './details_tab';
+
+export class DebugSliceTrack extends CustomSqlTableSliceTrack<NamedSliceTrackTypes> {
+  private readonly sqlTableName: string;
+
+  constructor(engine: Engine, ctx: TrackContext, tableName: string) {
+    super({
+      engine,
+      trackKey: ctx.trackKey,
+    });
+    this.sqlTableName = tableName;
+  }
+
+  async getSqlDataSource(): Promise<CustomSqlTableDefConfig> {
+    return {
+      sqlTableName: this.sqlTableName,
+    };
+  }
+
+  getDetailsPanel(): CustomSqlDetailsPanelConfig {
+    return {
+      kind: DebugSliceDetailsTab.kind,
+      config: {
+        sqlTableName: this.sqlTableName,
+        title: 'Debug Slice',
+      },
+    };
+  }
+}
diff --git a/ui/src/frontend/drag_handle.ts b/ui/src/frontend/drag_handle.ts
index dd43077..039e514 100644
--- a/ui/src/frontend/drag_handle.ts
+++ b/ui/src/frontend/drag_handle.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 
-import {Trash} from '../base/disposable';
+import {DisposableStack} from '../base/disposable';
 import {raf} from '../core/raf_scheduler';
 import {Button} from '../widgets/button';
 import {MenuItem, PopupMenu2} from '../widgets/menu';
@@ -103,7 +103,7 @@
   // We can't get real fullscreen height until the pan_and_zoom_handler
   // exists.
   private fullscreenHeight = 0;
-  private trash = new Trash();
+  private trash = new DisposableStack();
 
   oncreate({dom, attrs}: m.CVnodeDOM<DragHandleAttrs>) {
     this.resize = attrs.resize;
@@ -111,7 +111,7 @@
     this.isClosed = this.height <= 0;
     this.fullscreenHeight = getFullScreenHeight();
     const elem = dom as HTMLElement;
-    this.trash.add(
+    this.trash.use(
       new DragGestureHandler(
         elem,
         this.onDrag.bind(this),
@@ -127,7 +127,7 @@
         this.toggleVisibility();
       },
     });
-    this.trash.add(cmd);
+    this.trash.use(cmd);
   }
 
   private toggleVisibility() {
diff --git a/ui/src/frontend/flamegraph_panel.ts b/ui/src/frontend/flamegraph_panel.ts
index d81ffec..c45a248 100644
--- a/ui/src/frontend/flamegraph_panel.ts
+++ b/ui/src/frontend/flamegraph_panel.ts
@@ -50,6 +50,7 @@
 import {getCurrentTrace} from './sidebar';
 import {convertTraceToPprofAndDownload} from './trace_converter';
 import {AsyncLimiter} from '../base/async_limiter';
+import {FlamegraphCache} from '../core/flamegraph_cache';
 
 const HEADER_HEIGHT = 30;
 
@@ -102,43 +103,6 @@
 
 const MIN_PIXEL_DISPLAYED = 1;
 
-export class FlamegraphCache {
-  private cache: Map<string, string>;
-  private prefix: string;
-  private tableId: number;
-  private cacheSizeLimit: number;
-
-  constructor(prefix: string) {
-    this.cache = new Map<string, string>();
-    this.prefix = prefix;
-    this.tableId = 0;
-    this.cacheSizeLimit = 10;
-  }
-
-  async getTableName(engine: Engine, query: string): Promise<string> {
-    let tableName = this.cache.get(query);
-    if (tableName === undefined) {
-      // TODO(hjd): This should be LRU.
-      if (this.cache.size > this.cacheSizeLimit) {
-        for (const name of this.cache.values()) {
-          await engine.query(`drop table ${name}`);
-        }
-        this.cache.clear();
-      }
-      tableName = `${this.prefix}_${this.tableId++}`;
-      await engine.query(
-        `create temp table if not exists ${tableName} as ${query}`,
-      );
-      this.cache.set(query, tableName);
-    }
-    return tableName;
-  }
-
-  hasQuery(query: string): boolean {
-    return this.cache.get(query) !== undefined;
-  }
-}
-
 function toSelectedCallsite(c: CallsiteInfo | undefined): string {
   if (c !== undefined && c.name !== undefined) {
     return c.name;
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index ef36631..d2a1c57 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -53,10 +53,10 @@
 import {SliceSqlId} from './sql_types';
 import {PxSpan, TimeScale} from './time_scale';
 import {SelectionManager, LegacySelection} from '../core/selection_manager';
-import {exists} from '../base/utils';
+import {Optional, exists} from '../base/utils';
 import {OmniboxManager} from './omnibox_manager';
 import {CallsiteInfo} from '../common/flamegraph_util';
-import {FlamegraphCache} from './flamegraph_panel';
+import {FlamegraphCache} from '../core/flamegraph_cache';
 
 const INSTANT_FOCUS_DURATION = 1n;
 const INCOMPLETE_SLICE_DURATION = 30_000n;
@@ -132,14 +132,6 @@
   name?: string;
 }
 
-export interface CounterDetails {
-  startTime?: time;
-  value?: number;
-  delta?: number;
-  duration?: duration;
-  name?: string;
-}
-
 export interface ThreadStateDetails {
   ts?: time;
   dur?: duration;
@@ -260,7 +252,6 @@
   private _connectedFlows?: Flow[] = undefined;
   private _selectedFlows?: Flow[] = undefined;
   private _visibleFlowCategories?: Map<string, boolean> = undefined;
-  private _counterDetails?: CounterDetails = undefined;
   private _cpuProfileDetails?: CpuProfileDetails = undefined;
   private _numQueriesQueued = 0;
   private _bufferUsage?: number = undefined;
@@ -334,7 +325,6 @@
     this._connectedFlows = [];
     this._selectedFlows = [];
     this._visibleFlowCategories = new Map<string, boolean>();
-    this._counterDetails = {};
     this._threadStateDetails = {};
     this._cpuProfileDetails = {};
     this.engines.clear();
@@ -446,14 +436,6 @@
     this._visibleFlowCategories = assertExists(visibleFlowCategories);
   }
 
-  get counterDetails() {
-    return assertExists(this._counterDetails);
-  }
-
-  set counterDetails(click: CounterDetails) {
-    this._counterDetails = assertExists(click);
-  }
-
   get aggregateDataStore(): AggregateDataStore {
     return assertExists(this._aggregateDataStore);
   }
@@ -616,20 +598,38 @@
 
   setLegacySelection(
     legacySelection: LegacySelection,
-    args: LegacySelectionArgs,
+    args: Partial<LegacySelectionArgs> = {},
   ): void {
     this._selectionManager.setLegacy(legacySelection);
-    if (args.clearSearch) {
+    this.handleSelectionArgs(args);
+  }
+
+  selectSingleEvent(
+    trackKey: string,
+    eventId: number,
+    args: Partial<LegacySelectionArgs> = {},
+  ): void {
+    this._selectionManager.setEvent(trackKey, eventId);
+    this.handleSelectionArgs(args);
+  }
+
+  private handleSelectionArgs(args: Partial<LegacySelectionArgs> = {}): void {
+    const {
+      clearSearch = true,
+      switchToCurrentSelectionTab = true,
+      pendingScrollId = undefined,
+    } = args;
+    if (clearSearch) {
       globals.dispatch(Actions.setSearchIndex({index: -1}));
     }
-    if (args.pendingScrollId !== undefined) {
+    if (pendingScrollId !== undefined) {
       globals.dispatch(
         Actions.setPendingScrollId({
-          pendingScrollId: args.pendingScrollId,
+          pendingScrollId,
         }),
       );
     }
-    if (args.switchToCurrentSelectionTab) {
+    if (switchToCurrentSelectionTab) {
       globals.dispatch(Actions.showTab({uri: 'current_selection'}));
     }
   }
@@ -762,7 +762,9 @@
     return Time.sub(ts, this.timestampOffset());
   }
 
-  findTimeRangeOfSelection(): {start: time; end: time} | undefined {
+  async findTimeRangeOfSelection(): Promise<
+    Optional<{start: time; end: time}>
+  > {
     const sel = globals.state.selection;
     if (sel.kind === 'area') {
       return sel;
@@ -786,6 +788,20 @@
             assertUnreachable(kind);
         }
       }
+    } else if (sel.kind === 'single') {
+      const uri = globals.state.tracks[sel.trackKey]?.uri;
+      if (uri) {
+        const bounds = await globals.trackManager
+          .resolveTrackInfo(uri)
+          ?.getEventBounds?.(sel.eventId);
+        if (bounds) {
+          return {
+            start: bounds.ts,
+            end: Time.add(bounds.ts, bounds.dur),
+          };
+        }
+      }
+      return undefined;
     }
 
     const selection = getLegacySelection(this.state);
@@ -799,8 +815,6 @@
     } else if (selection.kind === 'THREAD_STATE') {
       const threadState = this.threadStateDetails;
       return findTimeRangeOfSlice(threadState);
-    } else if (selection.kind === 'COUNTER') {
-      return {start: selection.leftTs, end: selection.rightTs};
     } else if (selection.kind === 'LOG') {
       // TODO(hjd): Make focus selection work for logs.
     } else if (selection.kind === 'GENERIC_SLICE') {
@@ -850,8 +864,10 @@
 
 // Returns the time span of the current selection, or the visible window if
 // there is no current selection.
-export function getTimeSpanOfSelectionOrVisibleWindow(): Span<time, duration> {
-  const range = globals.findTimeRangeOfSelection();
+export async function getTimeSpanOfSelectionOrVisibleWindow(): Promise<
+  Span<time, duration>
+> {
+  const range = await globals.findTimeRangeOfSelection();
   if (exists(range)) {
     return new TimeSpan(range.start, range.end);
   } else {
diff --git a/ui/src/frontend/keyboard_event_handler.ts b/ui/src/frontend/keyboard_event_handler.ts
index d597376..53d3352 100644
--- a/ui/src/frontend/keyboard_event_handler.ts
+++ b/ui/src/frontend/keyboard_event_handler.ts
@@ -117,11 +117,11 @@
   }
 }
 
-export function findCurrentSelection() {
+export async function findCurrentSelection() {
   const selection = getLegacySelection(globals.state);
   if (selection === null) return;
 
-  const range = globals.findTimeRangeOfSelection();
+  const range = await globals.findTimeRangeOfSelection();
   if (exists(range)) {
     focusHorizontalRange(range.start, range.end);
   }
diff --git a/ui/src/frontend/notes.ts b/ui/src/frontend/notes.ts
index a7f9898..d408861 100644
--- a/ui/src/frontend/notes.ts
+++ b/ui/src/frontend/notes.ts
@@ -1,4 +1,4 @@
-import {Disposable, Trash} from '../base/disposable';
+import {Disposable, DisposableStack} from '../base/disposable';
 
 import {globals} from './globals';
 import {NotesManager} from './notes_manager';
@@ -11,14 +11,14 @@
  * Notes are core functionality thus don't really belong in a plugin.
  */
 export class Notes implements Disposable {
-  private trash = new Trash();
+  private trash = new DisposableStack();
 
   constructor() {
-    this.trash.add(
+    this.trash.use(
       globals.tabManager.registerDetailsPanel(new NotesEditorTab()),
     );
 
-    this.trash.add(
+    this.trash.use(
       globals.tabManager.registerTab({
         uri: 'notes.manager',
         isEphemeral: false,
diff --git a/ui/src/frontend/pan_and_zoom_handler.ts b/ui/src/frontend/pan_and_zoom_handler.ts
index 00d0116..b889deb 100644
--- a/ui/src/frontend/pan_and_zoom_handler.ts
+++ b/ui/src/frontend/pan_and_zoom_handler.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Disposable, Trash} from '../base/disposable';
+import {Disposable, DisposableStack} from '../base/disposable';
 import {currentTargetOffset, elementIsEditable} from '../base/dom_utils';
 import {raf} from '../core/raf_scheduler';
 
@@ -120,7 +120,7 @@
     editing: boolean,
   ) => void;
   private endSelection: (edit: boolean) => void;
-  private trash: Trash;
+  private trash: DisposableStack;
 
   constructor({
     element,
@@ -150,13 +150,13 @@
     this.editSelection = editSelection;
     this.onSelection = onSelection;
     this.endSelection = endSelection;
-    this.trash = new Trash();
+    this.trash = new DisposableStack();
 
     document.body.addEventListener('keydown', this.boundOnKeyDown);
     document.body.addEventListener('keyup', this.boundOnKeyUp);
     this.element.addEventListener('mousemove', this.boundOnMouseMove);
     this.element.addEventListener('wheel', this.boundOnWheel, {passive: true});
-    this.trash.addCallback(() => {
+    this.trash.defer(() => {
       this.element.removeEventListener('wheel', this.boundOnWheel);
       this.element.removeEventListener('mousemove', this.boundOnMouseMove);
       document.body.removeEventListener('keyup', this.boundOnKeyUp);
@@ -167,7 +167,7 @@
     let dragStartX = -1;
     let dragStartY = -1;
     let edit = false;
-    this.trash.add(
+    this.trash.use(
       new DragGestureHandler(
         this.element,
         (x, y) => {
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 5611a19..19331b6 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 
-import {Trash} from '../base/disposable';
+import {DisposableStack} from '../base/disposable';
 import {findRef, toHTMLElement} from '../base/dom_utils';
 import {assertExists, assertFalse} from '../base/logging';
 import {time} from '../base/time';
@@ -103,7 +103,7 @@
 
   private ctx?: CanvasRenderingContext2D;
 
-  private readonly trash = new Trash();
+  private readonly trash = new DisposableStack();
 
   private readonly OVERLAY_REF = 'overlay';
   private readonly PANEL_STACK_REF = 'panel-stack';
@@ -194,12 +194,12 @@
   constructor() {
     const onRedraw = () => this.renderCanvas();
     raf.addRedrawCallback(onRedraw);
-    this.trash.addCallback(() => {
+    this.trash.defer(() => {
       raf.removeRedrawCallback(onRedraw);
     });
 
     perfDisplay.addContainer(this);
-    this.trash.addCallback(() => {
+    this.trash.defer(() => {
       perfDisplay.removeContainer(this);
     });
   }
@@ -216,7 +216,7 @@
     const virtualCanvas = new VirtualCanvas(overlayElement, dom, {
       overdrawPx: CANVAS_OVERDRAW_PX,
     });
-    this.trash.add(virtualCanvas);
+    this.trash.use(virtualCanvas);
     this.virtualCanvas = virtualCanvas;
 
     const ctx = virtualCanvas.canvasElement.getContext('2d');
@@ -242,7 +242,7 @@
     );
 
     // Listen for when the panel stack changes size
-    this.trash.add(
+    this.trash.use(
       new SimpleResizeObserver(panelStackElement, () => {
         attrs.onPanelStackResize?.(
           panelStackElement.clientWidth,
diff --git a/ui/src/frontend/publish.ts b/ui/src/frontend/publish.ts
index a704cbb..7dbcc82 100644
--- a/ui/src/frontend/publish.ts
+++ b/ui/src/frontend/publish.ts
@@ -22,7 +22,6 @@
 import {getLegacySelection} from '../common/state';
 
 import {
-  CounterDetails,
   CpuProfileDetails,
   Flow,
   globals,
@@ -75,11 +74,6 @@
   raf.scheduleFullRedraw();
 }
 
-export function publishCounterDetails(click: CounterDetails) {
-  globals.counterDetails = click;
-  globals.publishRedraw();
-}
-
 export function publishCpuProfileDetails(details: CpuProfileDetails) {
   globals.cpuProfileDetails = details;
   globals.publishRedraw();
diff --git a/ui/src/frontend/query_result_tab.ts b/ui/src/frontend/query_result_tab.ts
index e6feda3..c85df5d 100644
--- a/ui/src/frontend/query_result_tab.ts
+++ b/ui/src/frontend/query_result_tab.ts
@@ -22,7 +22,7 @@
 import {
   AddDebugTrackMenu,
   uuidToViewName,
-} from '../core_plugins/debug/add_debug_track_menu';
+} from './debug_tracks/add_debug_track_menu';
 import {Button} from '../widgets/button';
 import {PopupMenu2} from '../widgets/menu';
 import {PopupPosition} from '../widgets/popup';
diff --git a/ui/src/frontend/simple_counter_track.ts b/ui/src/frontend/simple_counter_track.ts
index 7bf0f0e..5f18545 100644
--- a/ui/src/frontend/simple_counter_track.ts
+++ b/ui/src/frontend/simple_counter_track.ts
@@ -15,8 +15,8 @@
 import m from 'mithril';
 import {Engine, TrackContext} from '../public';
 import {BaseCounterTrack, CounterOptions} from './base_counter_track';
-import {CounterColumns, SqlDataSource} from './debug_tracks';
-import {Disposable, DisposableCallback} from '../base/disposable';
+import {CounterColumns, SqlDataSource} from './debug_tracks/debug_tracks';
+import {Disposable} from '../base/disposable';
 import {uuidv4Sql} from '../base/uuid';
 
 export type SimpleCounterTrackConfig = {
@@ -46,10 +46,12 @@
   async onInit(): Promise<Disposable> {
     const trash = await super.onInit();
     await this.createTrackTable();
-    return new DisposableCallback(() => {
-      trash.dispose();
-      this.dropTrackTable();
-    });
+    return {
+      dispose: () => {
+        trash.dispose();
+        this.dropTrackTable();
+      },
+    };
   }
 
   getTrackShellButtons(): m.Children {
diff --git a/ui/src/frontend/simple_slice_track.ts b/ui/src/frontend/simple_slice_track.ts
index 3eebb83..4b65d4e 100644
--- a/ui/src/frontend/simple_slice_track.ts
+++ b/ui/src/frontend/simple_slice_track.ts
@@ -19,10 +19,9 @@
   CustomSqlTableSliceTrack,
 } from './tracks/custom_sql_table_slice_track';
 import {NamedSliceTrackTypes} from './named_slice_track';
-import {ARG_PREFIX, SliceColumns, SqlDataSource} from './debug_tracks';
+import {SliceColumns, SqlDataSource} from './debug_tracks/debug_tracks';
 import {uuidv4Sql} from '../base/uuid';
-import {DisposableCallback} from '../base/disposable';
-import {DebugSliceDetailsTab} from '../core_plugins/debug/details_tab';
+import {ARG_PREFIX, DebugSliceDetailsTab} from './debug_tracks/details_tab';
 
 export interface SimpleSliceTrackConfig {
   data: SqlDataSource;
@@ -56,7 +55,9 @@
     );
     return {
       sqlTableName: this.sqlTableName,
-      dispose: new DisposableCallback(() => this.destroyTrackTable()),
+      dispose: {
+        dispose: () => this.destroyTrackTable(),
+      },
     };
   }
 
diff --git a/ui/src/frontend/slice_args.ts b/ui/src/frontend/slice_args.ts
index ec9d609..497e967 100644
--- a/ui/src/frontend/slice_args.ts
+++ b/ui/src/frontend/slice_args.ts
@@ -13,30 +13,22 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {v4 as uuidv4} from 'uuid';
 
 import {isString} from '../base/object_utils';
 import {Icons} from '../base/semantic_icons';
 import {sqliteString} from '../base/string_utils';
 import {exists} from '../base/utils';
-import {Actions, AddTrackArgs} from '../common/actions';
-import {InThreadTrackSortKey} from '../common/state';
 import {ArgNode, convertArgsToTree, Key} from '../controller/args_parser';
 import {Engine} from '../trace_processor/engine';
-import {NUM} from '../trace_processor/query_result';
-import {
-  VISUALISED_ARGS_SLICE_TRACK_URI,
-  VisualisedArgsState,
-} from './visualized_args_tracks';
+import {addVisualisedArgTracks} from './visualized_args_tracks';
 import {Anchor} from '../widgets/anchor';
 import {MenuItem, PopupMenu2} from '../widgets/menu';
 import {TreeNode} from '../widgets/tree';
 
-import {globals} from './globals';
 import {Arg} from './sql/args';
 import {addSqlTableTab} from './sql_table/tab';
 import {SqlTables} from './sql_table/well_known_tables';
-import {assertExists} from '../base/logging';
+import {globals} from './globals';
 
 // Renders slice arguments (key/value pairs) as a subtree.
 export function renderArguments(engine: Engine, args: Arg[]): m.Children {
@@ -111,85 +103,19 @@
         label: 'Visualise argument values',
         icon: 'query_stats',
         onclick: () => {
-          addVisualisedArg(engine, fullKey);
+          addVisualisedArgTracks(
+            {
+              engine,
+              registerTrack: (t) => globals.trackManager.registerTrack(t),
+            },
+            fullKey,
+          );
         },
       }),
     );
   }
 }
 
-async function addVisualisedArg(engine: Engine, argName: string) {
-  const escapedArgName = argName.replace(/[^a-zA-Z]/g, '_');
-  const tableName = `__arg_visualisation_helper_${escapedArgName}_slice`;
-
-  const result = await engine.query(`
-        drop table if exists ${tableName};
-
-        create table ${tableName} as
-        with slice_with_arg as (
-          select
-            slice.id,
-            slice.track_id,
-            slice.ts,
-            slice.dur,
-            slice.thread_dur,
-            NULL as cat,
-            args.display_value as name
-          from slice
-          join args using (arg_set_id)
-          where args.key='${argName}'
-        )
-        select
-          *,
-          (select count()
-           from ancestor_slice(s1.id) s2
-           join slice_with_arg s3 on s2.id=s3.id
-          ) as depth
-        from slice_with_arg s1
-        order by id;
-
-        select
-          track_id as trackId,
-          max(depth) as maxDepth
-        from ${tableName}
-        group by track_id;
-    `);
-
-  const tracksToAdd: AddTrackArgs[] = [];
-  const it = result.iter({trackId: NUM, maxDepth: NUM});
-  const addedTrackKeys: string[] = [];
-  for (; it.valid(); it.next()) {
-    const trackKey = globals.trackManager.trackKeyByTrackId.get(it.trackId);
-    const track = globals.state.tracks[assertExists(trackKey)];
-    const utid = (track.trackSortKey as {utid?: number}).utid;
-    const key = uuidv4();
-    addedTrackKeys.push(key);
-
-    const params: VisualisedArgsState = {
-      maxDepth: it.maxDepth,
-      trackId: it.trackId,
-      argName: argName,
-    };
-
-    tracksToAdd.push({
-      key,
-      trackGroup: track.trackGroup,
-      name: argName,
-      trackSortKey:
-        utid === undefined
-          ? track.trackSortKey
-          : {utid, priority: InThreadTrackSortKey.VISUALISED_ARGS_TRACK},
-      params,
-      uri: VISUALISED_ARGS_SLICE_TRACK_URI,
-    });
-  }
-
-  globals.dispatchMultiple([
-    Actions.addTracks({tracks: tracksToAdd}),
-    Actions.sortThreadTracks({}),
-  ]);
-}
-
 function renderArgValue({value}: Arg): m.Children {
   if (isWebLink(value)) {
     return renderWebLink(value);
diff --git a/ui/src/frontend/slice_details_panel.ts b/ui/src/frontend/slice_details_panel.ts
index 3887577..a3683e2 100644
--- a/ui/src/frontend/slice_details_panel.ts
+++ b/ui/src/frontend/slice_details_panel.ts
@@ -16,7 +16,6 @@
 
 import {Actions} from '../common/actions';
 import {translateState} from '../common/thread_state';
-import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
 import {Anchor} from '../widgets/anchor';
 import {DetailsShell} from '../widgets/details_shell';
 import {GridLayout} from '../widgets/grid_layout';
@@ -29,6 +28,7 @@
 import {SlicePanel} from './slice_panel';
 import {DurationWidget} from './widgets/duration';
 import {Timestamp} from './widgets/timestamp';
+import {THREAD_STATE_TRACK_KIND} from '../core/track_kinds';
 
 const MIN_NORMAL_SCHED_PRIORITY = 100;
 
diff --git a/ui/src/frontend/sql_table/tab.ts b/ui/src/frontend/sql_table/tab.ts
index 52b7d58..0affa5f 100644
--- a/ui/src/frontend/sql_table/tab.ts
+++ b/ui/src/frontend/sql_table/tab.ts
@@ -17,7 +17,7 @@
 import {copyToClipboard} from '../../base/clipboard';
 import {Icons} from '../../base/semantic_icons';
 import {exists} from '../../base/utils';
-import {AddDebugTrackMenu} from '../../core_plugins/debug/add_debug_track_menu';
+import {AddDebugTrackMenu} from '../debug_tracks/add_debug_track_menu';
 import {Button} from '../../widgets/button';
 import {DetailsShell} from '../../widgets/details_shell';
 import {Popup, PopupPosition} from '../../widgets/popup';
diff --git a/ui/src/frontend/tab_panel.ts b/ui/src/frontend/tab_panel.ts
index 8bade50..ac4cdcb 100644
--- a/ui/src/frontend/tab_panel.ts
+++ b/ui/src/frontend/tab_panel.ts
@@ -145,6 +145,23 @@
       };
     }
 
+    // Show single selection panels if they are registered
+    if (currentSelection.kind === 'single') {
+      const trackKey = currentSelection.trackKey;
+      const uri = globals.state.tracks[trackKey]?.uri;
+
+      if (uri) {
+        const trackDesc = globals.trackManager.resolveTrackInfo(uri);
+        const panel = trackDesc?.detailsPanel;
+        if (panel) {
+          return {
+            content: panel.render(currentSelection.eventId),
+            isLoading: panel.isLoading?.() ?? false,
+          };
+        }
+      }
+    }
+
     // Get the first "truthy" details panel
     let detailsPanels = globals.tabManager.detailsPanels.map((dp) => {
       return {
diff --git a/ui/src/frontend/thread_slice_details_tab.ts b/ui/src/frontend/thread_slice_details_tab.ts
index 8c051eb..666b43f 100644
--- a/ui/src/frontend/thread_slice_details_tab.ts
+++ b/ui/src/frontend/thread_slice_details_tab.ts
@@ -38,7 +38,7 @@
 } from './sql/thread_state';
 import {asSliceSqlId} from './sql_types';
 import {DurationWidget} from './widgets/duration';
-import {addDebugSliceTrack} from './debug_tracks';
+import {addDebugSliceTrack} from './debug_tracks/debug_tracks';
 import {addQueryResultsTab} from './query_result_tab';
 
 interface ContextMenuItem {
@@ -112,7 +112,13 @@
         )
         .then(() =>
           addDebugSliceTrack(
-            engine,
+            // NOTE(stevegolton): This is a temporary patch, this menu should
+            // become part of another plugin, at which point we can just use the
+            // plugin's context object.
+            {
+              engine,
+              registerTrack: (x) => globals.trackManager.registerTrack(x),
+            },
             {
               sqlSource: `
                                 WITH merged AS (
diff --git a/ui/src/core_plugins/thread_slice/thread_slice_track.ts b/ui/src/frontend/thread_slice_track.ts
similarity index 83%
rename from ui/src/core_plugins/thread_slice/thread_slice_track.ts
rename to ui/src/frontend/thread_slice_track.ts
index 90e9f90..f7bcdd5 100644
--- a/ui/src/core_plugins/thread_slice/thread_slice_track.ts
+++ b/ui/src/frontend/thread_slice_track.ts
@@ -12,20 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {BigintMath as BIMath} from '../../base/bigint_math';
-import {clamp} from '../../base/math_utils';
-import {OnSliceClickArgs} from '../../frontend/base_slice_track';
-import {globals} from '../../frontend/globals';
+import {BigintMath as BIMath} from '../base/bigint_math';
+import {clamp} from '../base/math_utils';
+import {OnSliceClickArgs} from './base_slice_track';
+import {globals} from './globals';
 import {
   NAMED_ROW,
   NamedSliceTrack,
   NamedSliceTrackTypes,
-} from '../../frontend/named_slice_track';
-import {SLICE_LAYOUT_FIT_CONTENT_DEFAULTS} from '../../frontend/slice_layout';
-import {NewTrackArgs} from '../../frontend/track';
-import {LONG_NULL} from '../../trace_processor/query_result';
-
-export const THREAD_SLICE_TRACK_KIND = 'ThreadSliceTrack';
+} from './named_slice_track';
+import {SLICE_LAYOUT_FIT_CONTENT_DEFAULTS} from './slice_layout';
+import {NewTrackArgs} from './track';
+import {LONG_NULL} from '../trace_processor/query_result';
 
 export const THREAD_SLICE_ROW = {
   // Base columns (tsq, ts, dur, id, depth).
diff --git a/ui/src/frontend/thread_state.ts b/ui/src/frontend/thread_state.ts
index d98eb30..6c2ed29 100644
--- a/ui/src/frontend/thread_state.ts
+++ b/ui/src/frontend/thread_state.ts
@@ -19,8 +19,6 @@
 import {exists} from '../base/utils';
 import {Actions} from '../common/actions';
 import {translateState} from '../common/thread_state';
-import {CPU_SLICE_TRACK_KIND} from '../core_plugins/cpu_slices';
-import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
 import {Engine} from '../trace_processor/engine';
 import {LONG, NUM, NUM_NULL, STR_NULL} from '../trace_processor/query_result';
 import {
@@ -34,6 +32,10 @@
 import {scrollToTrackAndTs} from './scroll_helper';
 import {asUtid, SchedSqlId, ThreadStateSqlId, Utid} from './sql_types';
 import {getThreadInfo, ThreadInfo} from './thread_and_process_info';
+import {
+  CPU_SLICE_TRACK_KIND,
+  THREAD_STATE_TRACK_KIND,
+} from '../core/track_kinds';
 
 // Representation of a single thread state object, corresponding to
 // a row for the |thread_slice| table.
diff --git a/ui/src/frontend/thread_state_tab.ts b/ui/src/frontend/thread_state_tab.ts
index f028b15..8ce6eb7 100644
--- a/ui/src/frontend/thread_state_tab.ts
+++ b/ui/src/frontend/thread_state_tab.ts
@@ -42,7 +42,8 @@
 } from './thread_state';
 import {DurationWidget, renderDuration} from './widgets/duration';
 import {Timestamp} from './widgets/timestamp';
-import {addDebugSliceTrack} from './debug_tracks';
+import {addDebugSliceTrack} from './debug_tracks/debug_tracks';
+import {globals} from './globals';
 
 interface ThreadStateTabConfig {
   // Id into |thread_state| sql table.
@@ -325,7 +326,13 @@
             .query(`INCLUDE PERFETTO MODULE sched.thread_executing_span;`)
             .then(() =>
               addDebugSliceTrack(
-                this.engine,
+                // NOTE(stevegolton): This is a temporary patch, this menu
+                // should become part of a critical path plugin, at which point
+                // we can just use the plugin's context object.
+                {
+                  engine: this.engine,
+                  registerTrack: (x) => globals.trackManager.registerTrack(x),
+                },
                 {
                   sqlSource: `
                     SELECT
@@ -363,7 +370,13 @@
             )
             .then(() =>
               addDebugSliceTrack(
-                this.engine,
+                // NOTE(stevegolton): This is a temporary patch, this menu
+                // should become part of a critical path plugin, at which point
+                // we can just use the plugin's context object.
+                {
+                  engine: this.engine,
+                  registerTrack: (x) => globals.trackManager.registerTrack(x),
+                },
                 {
                   sqlSource: `
                     SELECT cr.id, cr.utid, cr.ts, cr.dur, cr.name, cr.table_name
diff --git a/ui/src/frontend/tracks/custom_sql_table_slice_track.ts b/ui/src/frontend/tracks/custom_sql_table_slice_track.ts
index cd31610..73235b5 100644
--- a/ui/src/frontend/tracks/custom_sql_table_slice_track.ts
+++ b/ui/src/frontend/tracks/custom_sql_table_slice_track.ts
@@ -14,7 +14,7 @@
 
 import {v4 as uuidv4} from 'uuid';
 
-import {Disposable, DisposableCallback} from '../../base/disposable';
+import {Disposable} from '../../base/disposable';
 import {Actions} from '../../common/actions';
 import {generateSqlWithInternalLayout} from '../../common/internal_layout_utils';
 import {LegacySelection} from '../../common/state';
@@ -89,10 +89,12 @@
         whereClause: config.whereClause,
       });
     await this.engine.query(sql);
-    return DisposableCallback.from(() => {
-      this.engine.tryQuery(`DROP VIEW ${this.tableName}`);
-      config.dispose?.dispose();
-    });
+    return {
+      dispose: () => {
+        this.engine.tryQuery(`DROP VIEW ${this.tableName}`);
+        config.dispose?.dispose();
+      },
+    };
   }
 
   getSqlSource(): string {
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index b3c5b9f..6b24faa 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -333,10 +333,10 @@
   // Resolve a track and its metadata through the track cache
   private resolveTrack(key: string): TrackBundle {
     const trackState = globals.state.tracks[key];
-    const {uri, params, name, labels, closeable} = trackState;
+    const {uri, name, labels, closeable} = trackState;
     const trackDesc = globals.trackManager.resolveTrackInfo(uri);
     const trackCacheEntry =
-      trackDesc && globals.trackManager.resolveTrack(key, trackDesc, params);
+      trackDesc && globals.trackManager.resolveTrack(key, trackDesc);
     const trackFSM = trackCacheEntry;
     const tags = trackCacheEntry?.desc.tags;
     const trackIds = trackCacheEntry?.desc.trackIds;
diff --git a/ui/src/frontend/virtual_canvas.ts b/ui/src/frontend/virtual_canvas.ts
index 4035614..24b0d8a 100644
--- a/ui/src/frontend/virtual_canvas.ts
+++ b/ui/src/frontend/virtual_canvas.ts
@@ -32,7 +32,7 @@
  * using the "floating" canvas technique described above.
  */
 
-import {Disposable, Trash} from '../base/disposable';
+import {Disposable, DisposableStack} from '../base/disposable';
 import {
   Rect,
   Size,
@@ -66,7 +66,7 @@
 }
 
 export class VirtualCanvas implements Disposable {
-  private readonly _trash = new Trash();
+  private readonly _trash = new DisposableStack();
   private readonly _canvasElement: HTMLCanvasElement;
   private readonly _targetElement: HTMLElement;
 
@@ -153,7 +153,7 @@
     containerElement.addEventListener('scroll', updateCanvas, {
       passive: true,
     });
-    this._trash.addCallback(() =>
+    this._trash.defer(() =>
       containerElement.removeEventListener('scroll', updateCanvas),
     );
 
@@ -164,7 +164,7 @@
 
     resizeObserver.observe(containerElement);
     resizeObserver.observe(targetElement);
-    this._trash.addCallback(() => {
+    this._trash.defer(() => {
       resizeObserver.disconnect();
     });
 
@@ -174,7 +174,7 @@
     const canvas = document.createElement('canvas');
     canvas.style.position = 'absolute';
     targetElement.appendChild(canvas);
-    this._trash.addCallback(() => {
+    this._trash.defer(() => {
       targetElement.removeChild(canvas);
     });
 
diff --git a/ui/src/core_plugins/visualised_args/visualized_args_track.ts b/ui/src/frontend/visualized_args_track.ts
similarity index 70%
rename from ui/src/core_plugins/visualised_args/visualized_args_track.ts
rename to ui/src/frontend/visualized_args_track.ts
index 03f9f98..43b51cd 100644
--- a/ui/src/core_plugins/visualised_args/visualized_args_track.ts
+++ b/ui/src/frontend/visualized_args_track.ts
@@ -14,34 +14,44 @@
 
 import m from 'mithril';
 
-import {Actions} from '../../common/actions';
-import {globals} from '../../frontend/globals';
-import {Button} from '../../widgets/button';
-import {Icons} from '../../base/semantic_icons';
-import {ThreadSliceTrack} from '../thread_slice/thread_slice_track';
-import {uuidv4Sql} from '../../base/uuid';
-import {NewTrackArgs} from '../../frontend/track';
-import {Disposable, DisposableCallback} from '../../base/disposable';
+import {Actions} from '../common/actions';
+import {globals} from './globals';
+import {Button} from '../widgets/button';
+import {Icons} from '../base/semantic_icons';
+import {ThreadSliceTrack} from './thread_slice_track';
+import {uuidv4Sql} from '../base/uuid';
+import {Disposable} from '../base/disposable';
+import {Engine} from '../trace_processor/engine';
 
-// Similar to a SliceTrack, but creates a view
+export interface VisualizedArgsTrackAttrs {
+  readonly trackKey: string;
+  readonly engine: Engine;
+  readonly trackId: number;
+  readonly maxDepth: number;
+  readonly argName: string;
+}
+
 export class VisualisedArgsTrack extends ThreadSliceTrack {
-  private viewName: string;
+  private readonly viewName: string;
+  private readonly argName: string;
 
-  constructor(
-    args: NewTrackArgs,
-    trackId: number,
-    maxDepth: number,
-    private argName: string,
-  ) {
+  constructor({
+    trackKey,
+    engine,
+    trackId,
+    maxDepth,
+    argName,
+  }: VisualizedArgsTrackAttrs) {
     const uuid = uuidv4Sql();
     const escapedArgName = argName.replace(/[^a-zA-Z]/g, '_');
     const viewName = `__arg_visualisation_helper_${escapedArgName}_${uuid}_slice`;
-    super(args, trackId, maxDepth, viewName);
+
+    super({engine, trackKey}, trackId, maxDepth, viewName);
     this.viewName = viewName;
+    this.argName = argName;
   }
 
   async onInit(): Promise<Disposable> {
-    // Create the helper view - just one which is relevant to this slice
     await this.engine.query(`
         create view ${this.viewName} as
         with slice_with_arg as (
@@ -67,9 +77,9 @@
         order by id;
     `);
 
-    return new DisposableCallback(() => {
-      this.engine.tryQuery(`drop view ${this.viewName}`);
-    });
+    return {
+      dispose: () => this.engine.tryQuery(`drop view ${this.viewName}`),
+    };
   }
 
   getTrackShellButtons(): m.Children {
diff --git a/ui/src/frontend/visualized_args_tracks.ts b/ui/src/frontend/visualized_args_tracks.ts
index cf498cb..465e354 100644
--- a/ui/src/frontend/visualized_args_tracks.ts
+++ b/ui/src/frontend/visualized_args_tracks.ts
@@ -12,10 +12,110 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-export const VISUALISED_ARGS_SLICE_TRACK_URI = 'perfetto.VisualisedArgs';
+import {assertExists} from '../base/logging';
+import {uuidv4} from '../base/uuid';
+import {Actions, AddTrackArgs} from '../common/actions';
+import {InThreadTrackSortKey} from '../common/state';
+import {Engine, NUM, TrackDescriptor} from '../public';
+import {globals} from './globals';
+import {VisualisedArgsTrack} from './visualized_args_track';
 
-export interface VisualisedArgsState {
-  argName: string;
-  maxDepth: number;
-  trackId: number;
+const VISUALISED_ARGS_SLICE_TRACK_URI_PREFIX = 'perfetto.VisualisedArgs';
+
+// We need to add tracks from the core and from plugins. In order to add a debug
+// track we need to pass a context through with we can add the track. This is
+// different for plugins vs the core. This interface defines the generic shape
+// of this context, which can be supplied from a plugin or built from globals.
+//
+// TODO(stevegolton): In the future, both the core and plugins should have
+// access to some Context object which implements the various things we want to
+// do in a generic way, so that we don't have to do this mangling to get this to
+// work.
+interface Context {
+  engine: Engine;
+  registerTrack(track: TrackDescriptor): unknown;
+}
+
+export async function addVisualisedArgTracks(ctx: Context, argName: string) {
+  const escapedArgName = argName.replace(/[^a-zA-Z]/g, '_');
+  const tableName = `__arg_visualisation_helper_${escapedArgName}_slice`;
+
+  const result = await ctx.engine.query(`
+        drop table if exists ${tableName};
+
+        create table ${tableName} as
+        with slice_with_arg as (
+          select
+            slice.id,
+            slice.track_id,
+            slice.ts,
+            slice.dur,
+            slice.thread_dur,
+            NULL as cat,
+            args.display_value as name
+          from slice
+          join args using (arg_set_id)
+          where args.key='${argName}'
+        )
+        select
+          *,
+          (select count()
+           from ancestor_slice(s1.id) s2
+           join slice_with_arg s3 on s2.id=s3.id
+          ) as depth
+        from slice_with_arg s1
+        order by id;
+
+        select
+          track_id as trackId,
+          max(depth) as maxDepth
+        from ${tableName}
+        group by track_id;
+    `);
+
+  const tracksToAdd: AddTrackArgs[] = [];
+  const it = result.iter({trackId: NUM, maxDepth: NUM});
+  const addedTrackKeys: string[] = [];
+  for (; it.valid(); it.next()) {
+    const trackId = it.trackId;
+    const maxDepth = it.maxDepth;
+    const trackKey = globals.trackManager.trackKeyByTrackId.get(trackId);
+    const track = globals.state.tracks[assertExists(trackKey)];
+    const utid = (track.trackSortKey as {utid?: number}).utid;
+    const key = uuidv4();
+    addedTrackKeys.push(key);
+
+    const uri = `${VISUALISED_ARGS_SLICE_TRACK_URI_PREFIX}#${uuidv4()}`;
+    ctx.registerTrack({
+      uri,
+      tags: {
+        metric: true, // TODO(stevegolton): Is this track really a metric?
+      },
+      trackFactory: (trackCtx) => {
+        return new VisualisedArgsTrack({
+          engine: ctx.engine,
+          trackKey: trackCtx.trackKey,
+          trackId,
+          maxDepth,
+          argName,
+        });
+      },
+    });
+
+    tracksToAdd.push({
+      key,
+      trackGroup: track.trackGroup,
+      name: argName,
+      trackSortKey:
+        utid === undefined
+          ? track.trackSortKey
+          : {utid, priority: InThreadTrackSortKey.VISUALISED_ARGS_TRACK},
+      uri,
+    });
+  }
+
+  globals.dispatchMultiple([
+    Actions.addTracks({tracks: tracksToAdd}),
+    Actions.sortThreadTracks({}),
+  ]);
 }
diff --git a/ui/src/plugins/com.google.PixelMemory/index.ts b/ui/src/plugins/com.google.PixelMemory/index.ts
index c79faf2..0412e19 100644
--- a/ui/src/plugins/com.google.PixelMemory/index.ts
+++ b/ui/src/plugins/com.google.PixelMemory/index.ts
@@ -14,7 +14,7 @@
 
 import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
 
-import {addDebugCounterTrack} from '../../frontend/debug_tracks';
+import {addDebugCounterTrack} from '../../frontend/debug_tracks/debug_tracks';
 
 class PixelMemory implements Plugin {
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
@@ -39,6 +39,7 @@
         `;
         await ctx.engine.query(RSS_ALL);
         await addDebugCounterTrack(
+          ctx,
           {
             sqlSource: `
                 SELECT
diff --git a/ui/src/plugins/dev.perfetto.AndroidClientServer/index.ts b/ui/src/plugins/dev.perfetto.AndroidClientServer/index.ts
index d60fdb7..7470c48 100644
--- a/ui/src/plugins/dev.perfetto.AndroidClientServer/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidClientServer/index.ts
@@ -173,7 +173,7 @@
         for (; it.valid(); it.next()) {
           if (it.tstate_upid !== null) {
             await addDebugSliceTrack(
-              ctx.engine,
+              ctx,
               {
                 sqlSource: `
                   SELECT ts, dur, name
@@ -187,7 +187,7 @@
             );
           }
           await addDebugSliceTrack(
-            ctx.engine,
+            ctx,
             {
               sqlSource: `
                 SELECT ts, dur, name
diff --git a/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts b/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
index b4f20ca..b011143 100644
--- a/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
@@ -171,7 +171,7 @@
       callback: () => {
         ctx.engine.query(JANK_CUJ_QUERY_PRECONDITIONS).then(() => {
           addDebugSliceTrack(
-            ctx.engine,
+            ctx,
             {
               sqlSource: JANK_CUJ_QUERY,
               columns: JANK_COLUMNS,
@@ -199,7 +199,7 @@
       name: 'Add track: Android latency CUJs',
       callback: () => {
         addDebugSliceTrack(
-          ctx.engine,
+          ctx,
           {
             sqlSource: LATENCY_CUJ_QUERY,
             columns: LATENCY_COLUMNS,
@@ -224,7 +224,7 @@
       callback: () => {
         ctx.engine.query(JANK_CUJ_QUERY_PRECONDITIONS).then(() =>
           addDebugSliceTrack(
-            ctx.engine,
+            ctx,
             {
               sqlSource: BLOCKING_CALLS_DURING_CUJS_QUERY,
               columns: BLOCKING_CALLS_DURING_CUJS_COLUMNS,
diff --git a/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts b/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
index 03ed11c..bfaac60 100644
--- a/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
@@ -24,6 +24,15 @@
   SimpleCounterTrackConfig,
 } from '../../frontend/simple_counter_track';
 
+interface ContainedTrace {
+  uuid: string;
+  subscription: string;
+  trigger: string;
+  // NB: these are millis.
+  ts: number;
+  dur: number;
+}
+
 const DEFAULT_NETWORK = `
   with base as (
       select
@@ -557,7 +566,7 @@
   with_ratio as (
     select
       ts,
-      iif(dur is null, 0, 100.0 * cpu_dur / dur) as value,
+      iif(dur is null, 0, max(0, 100.0 * cpu_dur / dur)) as value,
       case cluster when 0 then 'little' when 1 then 'mid' when 2 then 'big' else 'cl-' || cluster end as cluster,
       case
           when uid = 0 then 'AID_ROOT'
@@ -1259,7 +1268,7 @@
             str_value AS name,
             ifnull(
             (select package_name from package_list where uid = int_value % 100000),
-            int_value) as package
+            "uid="||int_value) as package
         FROM android_battery_stats_event_slices
         WHERE track_name = "battery_stats.longwake"`,
       undefined,
@@ -1723,6 +1732,38 @@
     );
   }
 
+  async addContainedTraces(
+    ctx: PluginContextTrace,
+    containedTraces: ContainedTrace[],
+  ): Promise<void> {
+    const bySubscription = new Map<string, ContainedTrace[]>();
+    for (const trace of containedTraces) {
+      if (!bySubscription.has(trace.subscription)) {
+        bySubscription.set(trace.subscription, []);
+      }
+      bySubscription.get(trace.subscription)!.push(trace);
+    }
+
+    bySubscription.forEach((traces, subscription) =>
+      this.addSliceTrack(
+        ctx,
+        subscription,
+        traces
+          .map(
+            (t) => `SELECT
+          CAST(${t.ts} * 1e6 AS int) AS ts,
+          CAST(${t.dur} * 1e6 AS int) AS dur,
+          '${t.trigger === '' ? 'Trace' : t.trigger}' AS name,
+          'http://go/trace-uuid/${t.uuid}' AS link
+        `,
+          )
+          .join(' UNION ALL '),
+        'Other traces',
+        ['link'],
+      ),
+    );
+  }
+
   async findFeatures(e: Engine): Promise<Set<string>> {
     const features = new Set<string>();
 
@@ -1762,6 +1803,9 @@
   async addTracks(ctx: PluginContextTrace): Promise<void> {
     const features: Set<string> = await this.findFeatures(ctx.engine);
 
+    const containedTraces = (ctx.openerPluginArgs?.containedTraces ??
+      []) as ContainedTrace[];
+
     await this.addNetworkSummary(ctx, features),
       await this.addModemDetail(ctx, features);
     await this.addKernelWakelocks(ctx, features);
@@ -1769,6 +1813,7 @@
     await this.addDeviceState(ctx, features);
     await this.addHighCpu(ctx, features);
     await this.addBluetooth(ctx, features);
+    await this.addContainedTraces(ctx, containedTraces);
   }
 
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
diff --git a/ui/src/plugins/dev.perfetto.AndroidNetwork/index.ts b/ui/src/plugins/dev.perfetto.AndroidNetwork/index.ts
index 34fd087..1c85c98 100644
--- a/ui/src/plugins/dev.perfetto.AndroidNetwork/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidNetwork/index.ts
@@ -14,20 +14,19 @@
 
 import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
 import {addDebugSliceTrack} from '../../public';
-import {Engine} from '../../trace_processor/engine';
 
 class AndroidNetwork implements Plugin {
   // Adds a debug track using the provided query and given columns. The columns
   // must be start with ts, dur, and a name column. The name column and all
   // following columns are shown as arguments in slice details.
   async addSimpleTrack(
-    engine: Engine,
+    ctx: PluginContextTrace,
     trackName: string,
     tableOrQuery: string,
     columns: string[],
   ): Promise<void> {
     await addDebugSliceTrack(
-      engine,
+      ctx,
       {
         sqlSource: `SELECT ${columns.join(',')} FROM ${tableOrQuery}`,
         columns: columns,
@@ -50,7 +49,7 @@
 
         await ctx.engine.query(`SELECT IMPORT('android.battery_stats');`);
         await this.addSimpleTrack(
-          ctx.engine,
+          ctx,
           track,
           `(SELECT *
             FROM android_battery_stats_event_slices
@@ -89,7 +88,7 @@
         // The first group column is used for the slice name.
         const groupCols = groupby.replaceAll(' ', '').split(',');
         await this.addSimpleTrack(
-          ctx.engine,
+          ctx,
           trackName || 'Network Activity',
           `android_network_activity_${suffix}`,
           ['ts', 'dur', ...groupCols, 'packet_length', 'packet_count'],
diff --git a/ui/src/plugins/dev.perfetto.AndroidPerf/index.ts b/ui/src/plugins/dev.perfetto.AndroidPerf/index.ts
index 25f11f4..b256bb3 100644
--- a/ui/src/plugins/dev.perfetto.AndroidPerf/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidPerf/index.ts
@@ -18,11 +18,10 @@
   PluginContextTrace,
   PluginDescriptor,
 } from '../../public';
-import {Engine} from '../../trace_processor/engine';
 
 class AndroidPerf implements Plugin {
   async addAppProcessStartsDebugTrack(
-    engine: Engine,
+    ctx: PluginContextTrace,
     reason: string,
     sliceName: string,
   ): Promise<void> {
@@ -36,7 +35,7 @@
       'table_name',
     ];
     await addDebugSliceTrack(
-      engine,
+      ctx,
       {
         sqlSource: `
                     SELECT
@@ -172,11 +171,7 @@
 
         const startReason = ['activity', 'service', 'broadcast', 'provider'];
         for (const reason of startReason) {
-          await this.addAppProcessStartsDebugTrack(
-            ctx.engine,
-            reason,
-            'process_name',
-          );
+          await this.addAppProcessStartsDebugTrack(ctx, reason, 'process_name');
         }
       },
     });
@@ -191,11 +186,7 @@
 
         const startReason = ['activity', 'service', 'broadcast'];
         for (const reason of startReason) {
-          await this.addAppProcessStartsDebugTrack(
-            ctx.engine,
-            reason,
-            'intent',
-          );
+          await this.addAppProcessStartsDebugTrack(ctx, reason, 'intent');
         }
       },
     });
diff --git a/ui/src/plugins/dev.perfetto.AndroidPerfTraceCounters/index.ts b/ui/src/plugins/dev.perfetto.AndroidPerfTraceCounters/index.ts
index b8feb57..4270a76 100644
--- a/ui/src/plugins/dev.perfetto.AndroidPerfTraceCounters/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidPerfTraceCounters/index.ts
@@ -83,7 +83,7 @@
         `;
 
         await addDebugSliceTrack(
-          ctx.engine,
+          ctx,
           {
             sqlSource:
               sqlPrefix +
diff --git a/ui/src/plugins/dev.perfetto.Chaos/index.ts b/ui/src/plugins/dev.perfetto.Chaos/index.ts
index c18ff7f..028b8dd 100644
--- a/ui/src/plugins/dev.perfetto.Chaos/index.ts
+++ b/ui/src/plugins/dev.perfetto.Chaos/index.ts
@@ -52,7 +52,7 @@
       name: 'Chaos: add crashing debug track',
       callback: () => {
         addDebugSliceTrack(
-          ctx.engine,
+          ctx,
           {
             sqlSource: `
             syntactically
diff --git a/ui/src/plugins/dev.perfetto.TimelineSync/index.ts b/ui/src/plugins/dev.perfetto.TimelineSync/index.ts
index f15d3eb..f07b62d 100644
--- a/ui/src/plugins/dev.perfetto.TimelineSync/index.ts
+++ b/ui/src/plugins/dev.perfetto.TimelineSync/index.ts
@@ -79,6 +79,12 @@
       name: 'Disable timeline sync',
       callback: () => this.disableTimelineSync(this._sessionId),
     });
+    ctx.registerCommand({
+      id: `dev.perfetto.SplitScreen#toggleTimelineSync`,
+      name: 'Toggle timeline sync with other PerfettoUI tabs',
+      callback: () => this.toggleTimelineSync(),
+      defaultHotkey: 'Mod+Alt+S',
+    });
 
     // Start advertising this tab. This allows the command run in other
     // instances to discover us.
@@ -128,6 +134,14 @@
     } as SyncMessage);
   }
 
+  private toggleTimelineSync() {
+    if (this._sessionId === 0) {
+      this.showTimelineSyncDialog();
+    } else {
+      this.disableTimelineSync(this._sessionId);
+    }
+  }
+
   private showTimelineSyncDialog() {
     let clientsSelect: HTMLSelectElement;
 
diff --git a/ui/src/plugins/org.kernel.LinuxKernelDevices/index.ts b/ui/src/plugins/org.kernel.LinuxKernelDevices/index.ts
index 9f627b8..60f72cc 100644
--- a/ui/src/plugins/org.kernel.LinuxKernelDevices/index.ts
+++ b/ui/src/plugins/org.kernel.LinuxKernelDevices/index.ts
@@ -19,8 +19,8 @@
   PluginDescriptor,
   STR_NULL,
 } from '../../public';
-import {ASYNC_SLICE_TRACK_KIND} from '../../core_plugins/async_slices';
 import {AsyncSliceTrack} from '../../core_plugins/async_slices/async_slice_track';
+import {ASYNC_SLICE_TRACK_KIND} from '../../public';
 
 // This plugin renders visualizations of runtime power state transitions for
 // Linux kernel devices (devices managed by Linux drivers).
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index 293f4bd..f5c4bde 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -18,12 +18,13 @@
 import {Span, duration, time} from '../base/time';
 import {Migrate, Store} from '../base/store';
 import {ColorScheme} from '../core/colorizer';
-import {LegacySelection, Selection} from '../common/state';
+import {LegacySelection, PrimaryTrackSortKey, Selection} from '../common/state';
 import {PanelSize} from '../frontend/panel';
 import {Engine} from '../trace_processor/engine';
 import {UntypedEventSet} from '../core/event_set';
 import {TraceContext} from '../frontend/globals';
 import {PromptOption} from '../frontend/omnibox_manager';
+import {Optional} from '../base/utils';
 
 export {Engine} from '../trace_processor/engine';
 export {
@@ -37,12 +38,10 @@
 export {BottomTabToSCSAdapter} from './utils';
 export {createStore, Migrate, Store} from '../base/store';
 export {PromptOption} from '../frontend/omnibox_manager';
+export {PrimaryTrackSortKey} from '../common/state';
 
-// This is a temporary fix until this is available in the plugin API.
-export {
-  createDebugSliceTrackActions,
-  addDebugSliceTrack,
-} from '../frontend/debug_tracks';
+export {addDebugSliceTrack} from '../frontend/debug_tracks/debug_tracks';
+export * from '../core/track_kinds';
 
 export interface Slice {
   // These properties are updated only once per query result when the Slice
@@ -155,19 +154,6 @@
 export interface TrackContext {
   // This track's key, used for making selections et al.
   trackKey: string;
-
-  // Set of params passed in when the track was created.
-  params: unknown;
-
-  // Creates a new store overlaying the track instance's state object.
-  // A migrate function must be passed to convert any existing state to a
-  // compatible format.
-  // When opening a fresh trace, the value of |init| will be undefined, and
-  // state should be updated to an appropriate default value.
-  // When loading a permalink, the value of |init| will be whatever was saved
-  // when the permalink was shared, which might be from an old version of this
-  // track.
-  mountStore<State>(migrate: Migrate<State>): Store<State>;
 }
 
 export interface SliceRect {
@@ -258,36 +244,12 @@
 
   // Placeholder - presently unused.
   displayName?: string;
-}
 
-// Tracks within track groups (usually corresponding to processes) are sorted.
-// As we want to group all tracks related to a given thread together, we use
-// two keys:
-// - Primary key corresponds to a priority of a track block (all tracks related
-//   to a given thread or a single track if it's not thread-associated).
-// - Secondary key corresponds to a priority of a given thread-associated track
-//   within its thread track block.
-// Each track will have a sort key, which either a primary sort key
-// (for non-thread tracks) or a tid and secondary sort key (mapping of tid to
-// primary sort key is done independently).
-export enum PrimaryTrackSortKey {
-  DEBUG_TRACK,
-  NULL_TRACK,
-  PROCESS_SCHEDULING_TRACK,
-  PROCESS_SUMMARY_TRACK,
-  EXPECTED_FRAMES_SLICE_TRACK,
-  ACTUAL_FRAMES_SLICE_TRACK,
-  PERF_SAMPLES_PROFILE_TRACK,
-  HEAP_PROFILE_TRACK,
-  MAIN_THREAD,
-  RENDER_THREAD,
-  GPU_COMPLETION_THREAD,
-  CHROME_IO_THREAD,
-  CHROME_COMPOSITOR_THREAD,
-  ORDINARY_THREAD,
-  COUNTER_TRACK,
-  ASYNC_SLICE_TRACK,
-  ORDINARY_TRACK,
+  // Optional: method to look up the start and duration of an event on this track
+  getEventBounds?: (id: number) => Promise<Optional<{ts: time; dur: duration}>>;
+
+  // Optional: A details panel to use when this track is selected.
+  detailsPanel?: TrackSelectionDetailsPanel;
 }
 
 export interface SliceTrackColNames {
@@ -348,6 +310,11 @@
   isLoading?(): boolean;
 }
 
+export interface TrackSelectionDetailsPanel {
+  render(id: number): m.Children;
+  isLoading?(): boolean;
+}
+
 // Similar to PluginContext but with additional methods to operate on the
 // currently loaded trace. Passed to trace-relevant hooks on a plugin instead of
 // PluginContext.
@@ -485,9 +452,6 @@
   // A human readable name for this track - displayed in the track shell.
   displayName: string;
 
-  // Optional: An opaque object used to customize this instance of the track.
-  params?: unknown;
-
   // Optional: Used to define default sort order for new traces.
   // Note: This will be deprecated soon in favour of tags & sort rules.
   sortKey?: PrimaryTrackSortKey;
diff --git a/ui/src/trace_processor/sql_utils.ts b/ui/src/trace_processor/sql_utils.ts
index bb3836d..b9f220b 100644
--- a/ui/src/trace_processor/sql_utils.ts
+++ b/ui/src/trace_processor/sql_utils.ts
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import {SortDirection} from '../base/comparison_utils';
+import {AsyncDisposable} from '../base/disposable';
 import {isString} from '../base/object_utils';
 import {sqliteString} from '../base/string_utils';
 
@@ -152,4 +153,39 @@
     count: NUM,
   }).count;
 }
+
 export {SqlValue};
+
+/**
+ * Asynchronously creates a 'perfetto' table using the given engine and returns
+ * an disposable object to handle its cleanup.
+ *
+ * @param engine - The database engine to execute the query.
+ * @param tableName - The name of the table to be created.
+ * @param expression - The SQL expression to define the table.
+ * @returns An AsyncDisposable which drops the created table when disposed.
+ *
+ * @example
+ * const engine = new Engine();
+ * const tableName = 'my_perfetto_table';
+ * const expression = 'SELECT * FROM source_table';
+ *
+ * const table = await createPerfettoTable(engine, tableName, expression);
+ *
+ * // Use the table...
+ *
+ * // Cleanup the table when done
+ * await table.disposeAsync();
+ */
+export async function createPerfettoTable(
+  engine: Engine,
+  tableName: string,
+  expression: string,
+): Promise<AsyncDisposable> {
+  await engine.query(`CREATE PERFETTO TABLE ${tableName} AS ${expression}`);
+  return {
+    disposeAsync: async () => {
+      await engine.tryQuery(`DROP TABLE IF EXISTS ${tableName}`);
+    },
+  };
+}
diff --git a/ui/src/widgets/virtual_scroll_helper.ts b/ui/src/widgets/virtual_scroll_helper.ts
index 4fbe5c1..f544f86 100644
--- a/ui/src/widgets/virtual_scroll_helper.ts
+++ b/ui/src/widgets/virtual_scroll_helper.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Trash} from '../base/disposable';
+import {DisposableStack} from '../base/disposable';
 import * as Geometry from '../base/geom';
 
 export interface VirtualScrollHelperOpts {
@@ -30,7 +30,7 @@
 }
 
 export class VirtualScrollHelper {
-  private readonly _trash = new Trash();
+  private readonly _trash = new DisposableStack();
   private readonly _data: Data[] = [];
 
   constructor(
@@ -51,7 +51,7 @@
     containerElement.addEventListener('scroll', recalculateRects, {
       passive: true,
     });
-    this._trash.addCallback(() =>
+    this._trash.defer(() =>
       containerElement.removeEventListener('scroll', recalculateRects),
     );
 
@@ -62,7 +62,7 @@
 
     resizeObserver.observe(containerElement);
     resizeObserver.observe(sliderElement);
-    this._trash.addCallback(() => {
+    this._trash.defer(() => {
       resizeObserver.disconnect();
     });
   }
diff --git a/ui/src/widgets/virtual_table.ts b/ui/src/widgets/virtual_table.ts
index 0b97b96..99e11e1 100644
--- a/ui/src/widgets/virtual_table.ts
+++ b/ui/src/widgets/virtual_table.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Trash} from '../base/disposable';
+import {DisposableStack} from '../base/disposable';
 import {findRef, toHTMLElement} from '../base/dom_utils';
 import {Rect} from '../base/geom';
 import {assertExists} from '../base/logging';
@@ -144,7 +144,7 @@
 export class VirtualTable implements m.ClassComponent<VirtualTableAttrs> {
   private readonly CONTAINER_REF = 'CONTAINER';
   private readonly SLIDER_REF = 'SLIDER';
-  private readonly trash = new Trash();
+  private readonly trash = new DisposableStack();
   private renderBounds = {rowStart: 0, rowEnd: 0};
 
   view({attrs}: m.Vnode<VirtualTableAttrs>): m.Children {
@@ -253,7 +253,7 @@
         },
       },
     ]);
-    this.trash.add(virtualScrollHelper);
+    this.trash.use(virtualScrollHelper);
   }
 
   onremove(_: m.VnodeDOM<VirtualTableAttrs>) {