Merge "[stdlib] Improvements to experimental_slice_flattened"
diff --git a/Android.bp b/Android.bp
index b78f538..4f29bca 100644
--- a/Android.bp
+++ b/Android.bp
@@ -4364,6 +4364,7 @@
     srcs: [
         "protos/perfetto/metrics/android/android_blocking_calls_cuj_metric.proto",
         "protos/perfetto/metrics/android/android_frame_timeline_metric.proto",
+        "protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto",
         "protos/perfetto/metrics/android/android_trusty_workqueues.proto",
         "protos/perfetto/metrics/android/batt_metric.proto",
         "protos/perfetto/metrics/android/binder_metric.proto",
@@ -4437,6 +4438,7 @@
     srcs: [
         "protos/perfetto/metrics/android/android_blocking_calls_cuj_metric.proto",
         "protos/perfetto/metrics/android/android_frame_timeline_metric.proto",
+        "protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto",
         "protos/perfetto/metrics/android/android_trusty_workqueues.proto",
         "protos/perfetto/metrics/android/batt_metric.proto",
         "protos/perfetto/metrics/android/binder_metric.proto",
@@ -4493,6 +4495,7 @@
     srcs: [
         "protos/perfetto/metrics/android/android_blocking_calls_cuj_metric.proto",
         "protos/perfetto/metrics/android/android_frame_timeline_metric.proto",
+        "protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto",
         "protos/perfetto/metrics/android/android_trusty_workqueues.proto",
         "protos/perfetto/metrics/android/batt_metric.proto",
         "protos/perfetto/metrics/android/binder_metric.proto",
@@ -10055,6 +10058,7 @@
         "src/trace_processor/metrics/sql/android/android_simpleperf.sql",
         "src/trace_processor/metrics/sql/android/android_startup.sql",
         "src/trace_processor/metrics/sql/android/android_surfaceflinger.sql",
+        "src/trace_processor/metrics/sql/android/android_sysui_notifications_blocking_calls_metric.sql",
         "src/trace_processor/metrics/sql/android/android_task_names.sql",
         "src/trace_processor/metrics/sql/android/android_trace_quality.sql",
         "src/trace_processor/metrics/sql/android/android_trusty_workqueues.sql",
diff --git a/BUILD b/BUILD
index 36b9664..886e9a5 100644
--- a/BUILD
+++ b/BUILD
@@ -1790,6 +1790,7 @@
         "src/trace_processor/metrics/sql/android/android_simpleperf.sql",
         "src/trace_processor/metrics/sql/android/android_startup.sql",
         "src/trace_processor/metrics/sql/android/android_surfaceflinger.sql",
+        "src/trace_processor/metrics/sql/android/android_sysui_notifications_blocking_calls_metric.sql",
         "src/trace_processor/metrics/sql/android/android_task_names.sql",
         "src/trace_processor/metrics/sql/android/android_trace_quality.sql",
         "src/trace_processor/metrics/sql/android/android_trusty_workqueues.sql",
@@ -3934,6 +3935,7 @@
     srcs = [
         "protos/perfetto/metrics/android/android_blocking_calls_cuj_metric.proto",
         "protos/perfetto/metrics/android/android_frame_timeline_metric.proto",
+        "protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto",
         "protos/perfetto/metrics/android/android_trusty_workqueues.proto",
         "protos/perfetto/metrics/android/batt_metric.proto",
         "protos/perfetto/metrics/android/binder_metric.proto",
diff --git a/OWNERS b/OWNERS
index 5c57ecb..3c29a20 100644
--- a/OWNERS
+++ b/OWNERS
@@ -25,6 +25,9 @@
 # UI, Chromium-related metrics and simpler trace processor changes.
 altimin@google.com
 
+# UI
+stevegolton@google.com
+
 # Most Android-related metrics.
 ilkos@google.com
 
diff --git a/include/perfetto/tracing/internal/track_event_data_source.h b/include/perfetto/tracing/internal/track_event_data_source.h
index 32993ac..784cf84 100644
--- a/include/perfetto/tracing/internal/track_event_data_source.h
+++ b/include/perfetto/tracing/internal/track_event_data_source.h
@@ -557,9 +557,8 @@
   // The DecayArgType method is used to avoid unnecessary instantiations of
   // templates on:
   // * string constants of different sizes.
-  // * integers of different sizes or constness.
-  // * floats of different sizes.
-  // This allows to avoid extra instantiations of TraceForCategory templates.
+  // * primitive of different constness (or references).
+  // This avoids extra instantiations of TraceForCategory templates.
   template <typename T>
   static T&& DecayArgType(T&& t) {
     return std::forward<T>(t);
@@ -567,15 +566,15 @@
 
   static const char* DecayArgType(const char* s) { return s; }
   static uint64_t DecayArgType(uint64_t u) { return u; }
-  static uint64_t DecayArgType(uint32_t u) { return u; }
-  static uint64_t DecayArgType(uint16_t u) { return u; }
-  static uint64_t DecayArgType(uint8_t u) { return u; }
+  static uint32_t DecayArgType(uint32_t u) { return u; }
+  static uint16_t DecayArgType(uint16_t u) { return u; }
+  static uint8_t DecayArgType(uint8_t u) { return u; }
   static int64_t DecayArgType(int64_t i) { return i; }
-  static int64_t DecayArgType(int32_t i) { return i; }
-  static int64_t DecayArgType(int16_t i) { return i; }
-  static int64_t DecayArgType(int8_t i) { return i; }
+  static int32_t DecayArgType(int32_t i) { return i; }
+  static int16_t DecayArgType(int16_t i) { return i; }
+  static int8_t DecayArgType(int8_t i) { return i; }
   static bool DecayArgType(bool b) { return b; }
-  static double DecayArgType(float f) { return static_cast<double>(f); }
+  static float DecayArgType(float f) { return f; }
   static double DecayArgType(double f) { return f; }
 
   // Once we've determined tracing to be enabled for this category, actually
@@ -699,7 +698,7 @@
       perfetto::protos::pbzero::TrackEvent::Type type,
       CounterTrack track,
       TimestampType timestamp,
-      ValueType value) PERFETTO_ALWAYS_INLINE {
+      ValueType value) PERFETTO_NO_INLINE {
     PERFETTO_DCHECK(type == perfetto::protos::pbzero::TrackEvent::TYPE_COUNTER);
     TraceForCategoryImpl(
         instances, category, /*name=*/nullptr, type, track, timestamp,
diff --git a/protos/perfetto/metrics/android/BUILD.gn b/protos/perfetto/metrics/android/BUILD.gn
index 2fed17a..5a48545 100644
--- a/protos/perfetto/metrics/android/BUILD.gn
+++ b/protos/perfetto/metrics/android/BUILD.gn
@@ -20,6 +20,7 @@
     "source_set",
   ]
   sources = [
+    "android_sysui_notifications_blocking_calls_metric.proto",
     "android_blocking_calls_cuj_metric.proto",
     "android_frame_timeline_metric.proto",
     "android_trusty_workqueues.proto",
diff --git a/protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto b/protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto
new file mode 100644
index 0000000..909c48c
--- /dev/null
+++ b/protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+syntax = "proto2";
+
+package perfetto.protos;
+
+import "protos/perfetto/metrics/android/process_metadata.proto";
+
+// Blocking calls inside System UI Notifications. Shows count and total duration for each.
+message AndroidSysUINotificationsBlockingCallsMetric {
+  repeated BlockingCall blocking_calls = 1;
+
+  // Blocking call on the main thread.
+  message BlockingCall {
+    // Name of the blocking call
+    optional string name = 1;
+    // Number of times it happened within the CUJ
+    optional int64 cnt = 2;
+    // Total duration within the CUJ
+    optional int64 total_dur_ms = 3;
+    // Maximal duration within the CUJ
+    optional int64 max_dur_ms = 4;
+    // Minimal duration within the CUJ
+    optional int64 min_dur_ms = 5;
+    // Total duration within the CUJ in nanoseconds
+    optional int64 total_dur_ns = 6;
+    // Maximal duration within the CUJ in nanoseconds
+    optional int64 max_dur_ns = 7;
+    // Minimal duration within the CUJ in nanoseconds
+    optional int64 min_dur_ns = 8;
+  }
+}
diff --git a/protos/perfetto/metrics/metrics.proto b/protos/perfetto/metrics/metrics.proto
index 8b5e5e4..1beec18 100644
--- a/protos/perfetto/metrics/metrics.proto
+++ b/protos/perfetto/metrics/metrics.proto
@@ -20,6 +20,7 @@
 
 import "protos/perfetto/metrics/android/android_frame_timeline_metric.proto";
 import "protos/perfetto/metrics/android/batt_metric.proto";
+import "protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto";
 import "protos/perfetto/metrics/android/android_blocking_calls_cuj_metric.proto";
 import "protos/perfetto/metrics/android/cpu_metric.proto";
 import "protos/perfetto/metrics/android/camera_metric.proto";
@@ -102,7 +103,7 @@
 
 // Root message for all Perfetto-based metrics.
 //
-// Next id: 50
+// Next id: 52
 message TraceMetrics {
   reserved 4, 10, 13, 14, 16, 19;
 
@@ -239,6 +240,8 @@
 
   optional AndroidMonitorContentionMetric android_monitor_contention = 50;
 
+  optional AndroidSysUINotificationsBlockingCallsMetric android_sysui_notifications_blocking_calls_metric = 51;
+
   // Demo extensions.
   extensions 450 to 499;
 
diff --git a/protos/perfetto/metrics/perfetto_merged_metrics.proto b/protos/perfetto/metrics/perfetto_merged_metrics.proto
index 393095c..0758d23 100644
--- a/protos/perfetto/metrics/perfetto_merged_metrics.proto
+++ b/protos/perfetto/metrics/perfetto_merged_metrics.proto
@@ -133,6 +133,35 @@
 
 // End of protos/perfetto/metrics/android/android_frame_timeline_metric.proto
 
+// Begin of protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto
+
+// Blocking calls inside System UI Notifications. Shows count and total duration for each.
+message AndroidSysUINotificationsBlockingCallsMetric {
+  repeated BlockingCall blocking_calls = 1;
+
+  // Blocking call on the main thread.
+  message BlockingCall {
+    // Name of the blocking call
+    optional string name = 1;
+    // Number of times it happened within the CUJ
+    optional int64 cnt = 2;
+    // Total duration within the CUJ
+    optional int64 total_dur_ms = 3;
+    // Maximal duration within the CUJ
+    optional int64 max_dur_ms = 4;
+    // Minimal duration within the CUJ
+    optional int64 min_dur_ms = 5;
+    // Total duration within the CUJ in nanoseconds
+    optional int64 total_dur_ns = 6;
+    // Maximal duration within the CUJ in nanoseconds
+    optional int64 max_dur_ns = 7;
+    // Minimal duration within the CUJ in nanoseconds
+    optional int64 min_dur_ns = 8;
+  }
+}
+
+// End of protos/perfetto/metrics/android/android_sysui_notifications_blocking_calls_metric.proto
+
 // Begin of protos/perfetto/metrics/android/android_trusty_workqueues.proto
 
 // Metric used to generate a simplified view of the Trusty kworker events.
@@ -2042,7 +2071,7 @@
 
 // Root message for all Perfetto-based metrics.
 //
-// Next id: 50
+// Next id: 52
 message TraceMetrics {
   reserved 4, 10, 13, 14, 16, 19;
 
@@ -2179,6 +2208,8 @@
 
   optional AndroidMonitorContentionMetric android_monitor_contention = 50;
 
+  optional AndroidSysUINotificationsBlockingCallsMetric android_sysui_notifications_blocking_calls_metric = 51;
+
   // Demo extensions.
   extensions 450 to 499;
 
diff --git a/protos/perfetto/trace/test_extensions.proto b/protos/perfetto/trace/test_extensions.proto
index dca2db6..0d33c09 100644
--- a/protos/perfetto/trace/test_extensions.proto
+++ b/protos/perfetto/trace/test_extensions.proto
@@ -31,6 +31,7 @@
     repeated int32 int_extension_for_testing = 9901;
     optional string omitted_extension_for_testing = 9902;
     optional TestExtensionChild nested_message_extension_for_testing = 9903;
+    optional uint32 uint_extension_for_testing = 9904;
   }
 }
 
diff --git a/python/BUILD.gn b/python/BUILD.gn
index 17ad6f3..dbd1e9d 100644
--- a/python/BUILD.gn
+++ b/python/BUILD.gn
@@ -26,7 +26,6 @@
   sources = [
     "generators/stdlib_docs/extractor.py",
     "generators/stdlib_docs/parse.py",
-    "generators/stdlib_docs/types.py",
     "generators/stdlib_docs/utils.py",
   ]
 }
diff --git a/python/generators/stdlib_docs/extractor.py b/python/generators/stdlib_docs/extractor.py
index 94db4d3..fce88f2 100644
--- a/python/generators/stdlib_docs/extractor.py
+++ b/python/generators/stdlib_docs/extractor.py
@@ -17,7 +17,7 @@
 from re import Match
 from typing import List, Optional, Tuple
 
-from python.generators.stdlib_docs.types import ObjKind
+from python.generators.stdlib_docs.utils import ObjKind
 from python.generators.stdlib_docs.utils import extract_comment
 from python.generators.stdlib_docs.utils import match_pattern
 from python.generators.stdlib_docs.utils import PATTERN_BY_KIND
diff --git a/python/generators/stdlib_docs/parse.py b/python/generators/stdlib_docs/parse.py
index a78bcca..0bb4c28 100644
--- a/python/generators/stdlib_docs/parse.py
+++ b/python/generators/stdlib_docs/parse.py
@@ -20,7 +20,7 @@
 from typing import Any, Dict, List, Optional, Set, Tuple, Union
 
 from python.generators.stdlib_docs.extractor import DocsExtractor
-from python.generators.stdlib_docs.types import ObjKind
+from python.generators.stdlib_docs.utils import ObjKind
 from python.generators.stdlib_docs.utils import ARG_ANNOTATION_PATTERN
 from python.generators.stdlib_docs.utils import NAME_AND_TYPE_PATTERN
 from python.generators.stdlib_docs.utils import FUNCTION_RETURN_PATTERN
diff --git a/python/generators/stdlib_docs/types.py b/python/generators/stdlib_docs/types.py
deleted file mode 100644
index a9baad3..0000000
--- a/python/generators/stdlib_docs/types.py
+++ /dev/null
@@ -1,21 +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.
-
-from enum import Enum
-
-
-class ObjKind(str, Enum):
-  table_view = 'table_view'
-  function = 'function'
-  view_function = 'view_function'
diff --git a/python/generators/stdlib_docs/utils.py b/python/generators/stdlib_docs/utils.py
index eeccc01..d635b08 100644
--- a/python/generators/stdlib_docs/utils.py
+++ b/python/generators/stdlib_docs/utils.py
@@ -12,14 +12,14 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from enum import Enum
 import re
 from typing import Dict, List
 
-from python.generators.stdlib_docs.types import ObjKind
-
 LOWER_NAME = r'[a-z_\d]+'
 UPPER_NAME = r'[A-Z_\d]+'
 ANY_WORDS = r'[^\s].*'
+ANY_NON_QUOTE = r'[^\']*.*'
 TYPE = r'[A-Z]+'
 SQL = r'[\s\S]*?'
 
@@ -47,10 +47,17 @@
     # Args: anything before closing bracket with '.
     fr"({ANY_WORDS})\)',\s*"
     # Return columns: anything between two '.
-    fr"'\s*({ANY_WORDS})',\s*"
+    fr"'\s*({ANY_NON_QUOTE})\s*',\s*"
     # Sql: Anything between ' and ');. We are catching \'.
     fr"'({SQL})'\s*\);")
 
+
+class ObjKind(str, Enum):
+  table_view = 'table_view'
+  function = 'function'
+  view_function = 'view_function'
+
+
 PATTERN_BY_KIND = {
     ObjKind.table_view: CREATE_TABLE_VIEW_PATTERN,
     ObjKind.function: CREATE_FUNCTION_PATTERN,
diff --git a/python/perfetto/trace_processor/metrics.descriptor b/python/perfetto/trace_processor/metrics.descriptor
index 536dff9..fcdc866 100644
--- a/python/perfetto/trace_processor/metrics.descriptor
+++ b/python/perfetto/trace_processor/metrics.descriptor
Binary files differ
diff --git a/python/perfetto/trace_processor/metrics.descriptor.sha1 b/python/perfetto/trace_processor/metrics.descriptor.sha1
new file mode 100644
index 0000000..d0befe1
--- /dev/null
+++ b/python/perfetto/trace_processor/metrics.descriptor.sha1
@@ -0,0 +1,6 @@
+
+// SHA1(tools/gen_binary_descriptors)
+// 6886b319e65925c037179e71a803b8473d06dc7d
+// SHA1(protos/perfetto/metrics/metrics.proto)
+// fe539c6187d701d117ab2f757e1a6b20a035af9f
+  
\ No newline at end of file
diff --git a/src/trace_processor/containers/row_map.h b/src/trace_processor/containers/row_map.h
index 45eedf5..a2a6a41 100644
--- a/src/trace_processor/containers/row_map.h
+++ b/src/trace_processor/containers/row_map.h
@@ -451,6 +451,12 @@
     NoVariantMatched();
   }
 
+  // Returns the data in RowMap BitVector, nullptr if RowMap is in a different
+  // mode.
+  const BitVector* GetIfBitVector() const {
+    return std::get_if<BitVector>(&data_);
+  }
+
   // Returns the iterator over the rows in this RowMap.
   Iterator IterateRows() const { return Iterator(this); }
 
diff --git a/src/trace_processor/db/overlays/null_overlay.cc b/src/trace_processor/db/overlays/null_overlay.cc
index ee27295..7bc1b43 100644
--- a/src/trace_processor/db/overlays/null_overlay.cc
+++ b/src/trace_processor/db/overlays/null_overlay.cc
@@ -16,6 +16,7 @@
 
 #include "src/trace_processor/db/overlays/null_overlay.h"
 #include "perfetto/ext/base/flat_hash_map.h"
+#include "src/trace_processor/containers/bit_vector.h"
 #include "src/trace_processor/db/overlays/types.h"
 
 namespace perfetto {
@@ -28,7 +29,19 @@
   uint32_t start = non_null_->CountSetBits(t_range.range.start);
   uint32_t end = non_null_->CountSetBits(t_range.range.end);
 
-  return StorageRange({Range(start, end)});
+  return StorageRange(start, end);
+}
+
+TableRangeOrBitVector NullOverlay::MapToTableRangeOrBitVector(
+    StorageRange s_range,
+    OverlayOp op) const {
+  PERFETTO_DCHECK(s_range.range.end <= non_null_->CountSetBits());
+
+  BitVector range_to_bv(s_range.range.start, false);
+  range_to_bv.Resize(s_range.range.end, true);
+
+  return TableRangeOrBitVector(
+      MapToTableBitVector(StorageBitVector{std::move(range_to_bv)}, op).bv);
 }
 
 TableBitVector NullOverlay::MapToTableBitVector(StorageBitVector s_bv,
diff --git a/src/trace_processor/db/overlays/null_overlay.h b/src/trace_processor/db/overlays/null_overlay.h
index 328dc9b..5753fa3 100644
--- a/src/trace_processor/db/overlays/null_overlay.h
+++ b/src/trace_processor/db/overlays/null_overlay.h
@@ -18,6 +18,7 @@
 #define SRC_TRACE_PROCESSOR_DB_OVERLAYS_NULL_OVERLAY_H_
 
 #include "src/trace_processor/db/overlays/storage_overlay.h"
+#include "src/trace_processor/db/overlays/types.h"
 
 namespace perfetto {
 namespace trace_processor {
@@ -31,6 +32,9 @@
 
   StorageRange MapToStorageRange(TableRange) const override;
 
+  TableRangeOrBitVector MapToTableRangeOrBitVector(StorageRange,
+                                                   OverlayOp) const override;
+
   TableBitVector MapToTableBitVector(StorageBitVector,
                                      OverlayOp) const override;
 
diff --git a/src/trace_processor/db/overlays/null_overlay_unittest.cc b/src/trace_processor/db/overlays/null_overlay_unittest.cc
index 0c02ad7..d69780c 100644
--- a/src/trace_processor/db/overlays/null_overlay_unittest.cc
+++ b/src/trace_processor/db/overlays/null_overlay_unittest.cc
@@ -25,7 +25,7 @@
 TEST(NullOverlay, MapToStorageRangeOutsideBoundary) {
   BitVector bv{0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0};
   NullOverlay overlay(&bv);
-  StorageRange r = overlay.MapToStorageRange({RowMap::Range(1, 6)});
+  StorageRange r = overlay.MapToStorageRange(TableRange(1, 6));
 
   ASSERT_EQ(r.range.start, 0u);
   ASSERT_EQ(r.range.end, 2u);
@@ -34,12 +34,31 @@
 TEST(NullOverlay, MapToStorageRangeOnBoundary) {
   BitVector bv{0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0};
   NullOverlay overlay(&bv);
-  StorageRange r = overlay.MapToStorageRange({RowMap::Range(3, 8)});
+  StorageRange r = overlay.MapToStorageRange(TableRange(3, 8));
 
   ASSERT_EQ(r.range.start, 1u);
   ASSERT_EQ(r.range.end, 4u);
 }
 
+TEST(NullOverlay, MapToTableRangeOutsideBoundary) {
+  BitVector bv{0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0};
+  NullOverlay overlay(&bv);
+  auto r =
+      overlay.MapToTableRangeOrBitVector(StorageRange(1, 3), OverlayOp::kOther);
+
+  // All set bits between |bv| index 3 and 6.
+  ASSERT_EQ(r.TakeIfBitVector().CountSetBits(), 2u);
+}
+
+TEST(NullOverlay, MapToTableRangeOnBoundary) {
+  BitVector bv{0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0};
+  NullOverlay overlay(&bv);
+  auto r =
+      overlay.MapToTableRangeOrBitVector(StorageRange(0, 5), OverlayOp::kOther);
+
+  ASSERT_EQ(r.TakeIfBitVector().CountSetBits(), 5u);
+}
+
 TEST(NullOverlay, MapToTableBitVector) {
   BitVector bv{0, 1, 1, 0, 0, 1, 1, 0};
   NullOverlay overlay(&bv);
diff --git a/src/trace_processor/db/overlays/selector_overlay.cc b/src/trace_processor/db/overlays/selector_overlay.cc
index bc6fcfb..fc40bc6 100644
--- a/src/trace_processor/db/overlays/selector_overlay.cc
+++ b/src/trace_processor/db/overlays/selector_overlay.cc
@@ -16,6 +16,7 @@
 
 #include "src/trace_processor/db/overlays/selector_overlay.h"
 #include "src/trace_processor/containers/bit_vector.h"
+#include "src/trace_processor/db/overlays/types.h"
 
 namespace perfetto {
 namespace trace_processor {
@@ -30,6 +31,18 @@
             selected_->IndexOfNthSet(t_range.range.end - 1) + 1)};
 }
 
+TableRangeOrBitVector SelectorOverlay::MapToTableRangeOrBitVector(
+    StorageRange s_range,
+    OverlayOp) const {
+  if (s_range.range.size() == 0)
+    return TableRangeOrBitVector(Range());
+
+  uint32_t start = selected_->CountSetBits(s_range.range.start);
+  uint32_t end = selected_->CountSetBits(s_range.range.end);
+
+  return TableRangeOrBitVector(Range(start, end));
+}
+
 TableBitVector SelectorOverlay::MapToTableBitVector(StorageBitVector s_bv,
                                                     OverlayOp) const {
   PERFETTO_DCHECK(selected_->size() >= s_bv.bv.size());
diff --git a/src/trace_processor/db/overlays/selector_overlay.h b/src/trace_processor/db/overlays/selector_overlay.h
index 1cad92c..ac591d3 100644
--- a/src/trace_processor/db/overlays/selector_overlay.h
+++ b/src/trace_processor/db/overlays/selector_overlay.h
@@ -27,10 +27,13 @@
 // Overlay responsible for selecting specific rows from Storage.
 class SelectorOverlay : public StorageOverlay {
  public:
-  explicit SelectorOverlay(BitVector* selected) : selected_(selected) {}
+  explicit SelectorOverlay(const BitVector* selected) : selected_(selected) {}
 
   StorageRange MapToStorageRange(TableRange) const override;
 
+  TableRangeOrBitVector MapToTableRangeOrBitVector(StorageRange,
+                                                   OverlayOp) const override;
+
   TableBitVector MapToTableBitVector(StorageBitVector,
                                      OverlayOp) const override;
 
@@ -44,7 +47,7 @@
   CostEstimatePerRow EstimateCostPerRow(OverlayOp) const override;
 
  private:
-  BitVector* selected_;
+  const BitVector* selected_;
 };
 
 }  // namespace overlays
diff --git a/src/trace_processor/db/overlays/selector_overlay_unittest.cc b/src/trace_processor/db/overlays/selector_overlay_unittest.cc
index 8c743e8..1e4a130 100644
--- a/src/trace_processor/db/overlays/selector_overlay_unittest.cc
+++ b/src/trace_processor/db/overlays/selector_overlay_unittest.cc
@@ -15,6 +15,7 @@
  */
 
 #include "src/trace_processor/db/overlays/selector_overlay.h"
+#include "src/trace_processor/db/overlays/types.h"
 #include "test/gtest_and_gmock.h"
 
 namespace perfetto {
@@ -25,7 +26,7 @@
 TEST(SelectorOverlay, MapToStorageRangeFirst) {
   BitVector selector{0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1};
   SelectorOverlay overlay(&selector);
-  StorageRange r = overlay.MapToStorageRange({RowMap::Range(1, 4)});
+  StorageRange r = overlay.MapToStorageRange(TableRange(1, 4));
 
   ASSERT_EQ(r.range.start, 4u);
   ASSERT_EQ(r.range.end, 8u);
@@ -34,12 +35,32 @@
 TEST(SelectorOverlay, MapToStorageRangeSecond) {
   BitVector selector{0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 0};
   SelectorOverlay overlay(&selector);
-  StorageRange r = overlay.MapToStorageRange({RowMap::Range(1, 3)});
+  StorageRange r = overlay.MapToStorageRange(TableRange(1, 3));
 
   ASSERT_EQ(r.range.start, 4u);
   ASSERT_EQ(r.range.end, 7u);
 }
 
+TEST(SelectorOverlay, MapToTableRangeFirst) {
+  BitVector selector{0, 1, 0, 1, 1, 0, 1, 1, 0, 0, 1};
+  SelectorOverlay overlay(&selector);
+  auto r =
+      overlay.MapToTableRangeOrBitVector(StorageRange(2, 5), OverlayOp::kOther);
+
+  ASSERT_EQ(r.TakeIfRange().start, 1u);
+  ASSERT_EQ(r.TakeIfRange().end, 3u);
+}
+
+TEST(SelectorOverlay, MapToTableRangeSecond) {
+  BitVector selector{0, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0};
+  SelectorOverlay overlay(&selector);
+  auto r = overlay.MapToTableRangeOrBitVector(StorageRange(0, 10),
+                                              OverlayOp::kOther);
+
+  ASSERT_EQ(r.TakeIfRange().start, 0u);
+  ASSERT_EQ(r.TakeIfRange().end, 6u);
+}
+
 TEST(SelectorOverlay, MapToTableBitVector) {
   BitVector selector{0, 1, 1, 0, 0, 1, 1, 0};
   SelectorOverlay overlay(&selector);
diff --git a/src/trace_processor/db/overlays/storage_overlay.h b/src/trace_processor/db/overlays/storage_overlay.h
index c31610b..d6ad8df 100644
--- a/src/trace_processor/db/overlays/storage_overlay.h
+++ b/src/trace_processor/db/overlays/storage_overlay.h
@@ -50,6 +50,11 @@
   // indices in the storage space.
   virtual StorageRange MapToStorageRange(TableRange) const = 0;
 
+  // Returns the smallest Range or BitVector containing all of the elements
+  // matching the OverlayOp.
+  virtual TableRangeOrBitVector MapToTableRangeOrBitVector(StorageRange,
+                                                           OverlayOp) const = 0;
+
   // Maps a BitVector of indices in storage space to an equivalent range of
   // indices in the table space.
   virtual TableBitVector MapToTableBitVector(StorageBitVector,
diff --git a/src/trace_processor/db/overlays/types.h b/src/trace_processor/db/overlays/types.h
index 7978ada..83af6bc 100644
--- a/src/trace_processor/db/overlays/types.h
+++ b/src/trace_processor/db/overlays/types.h
@@ -16,6 +16,8 @@
 #ifndef SRC_TRACE_PROCESSOR_DB_OVERLAYS_TYPES_H_
 #define SRC_TRACE_PROCESSOR_DB_OVERLAYS_TYPES_H_
 
+#include <variant>
+#include "perfetto/base/logging.h"
 #include "src/trace_processor/containers/bit_vector.h"
 #include "src/trace_processor/containers/row_map.h"
 #include "src/trace_processor/db/storage/types.h"
@@ -24,14 +26,22 @@
 namespace trace_processor {
 namespace overlays {
 
+using Range = RowMap::Range;
+
 // A range of indices in the table space.
 struct TableRange {
-  RowMap::Range range;
+  TableRange(uint32_t start, uint32_t end) : range(start, end) {}
+  explicit TableRange(Range r) : range(r) {}
+
+  Range range;
 };
 
 // A range of indices in the storage space.
 struct StorageRange {
-  RowMap::Range range;
+  StorageRange(uint32_t start, uint32_t end) : range(start, end) {}
+  explicit StorageRange(Range r) : range(r) {}
+
+  Range range;
 };
 
 // A BitVector with set bits corresponding to indices in the table space.
@@ -44,6 +54,27 @@
   BitVector bv;
 };
 
+using RangeOrBitVector = std::variant<Range, BitVector>;
+
+struct TableRangeOrBitVector {
+  explicit TableRangeOrBitVector(Range range) : val(range) {}
+  explicit TableRangeOrBitVector(BitVector bv) : val(std::move(bv)) {}
+
+  bool IsRange() const { return std::holds_alternative<Range>(val); }
+  bool IsBitVector() const { return std::holds_alternative<BitVector>(val); }
+
+  BitVector TakeIfBitVector() {
+    PERFETTO_DCHECK(IsBitVector());
+    return std::move(*std::get_if<BitVector>(&val));
+  }
+  Range TakeIfRange() {
+    PERFETTO_DCHECK(IsRange());
+    return std::move(*std::get_if<Range>(&val));
+  }
+
+  RangeOrBitVector val = Range();
+};
+
 // Represents a vector of indices in the table space.
 struct TableIndexVector {
   std::vector<uint32_t> indices;
diff --git a/src/trace_processor/db/query_executor.cc b/src/trace_processor/db/query_executor.cc
index 46cbd29..8fceb62 100644
--- a/src/trace_processor/db/query_executor.cc
+++ b/src/trace_processor/db/query_executor.cc
@@ -22,10 +22,14 @@
 
 #include "perfetto/base/logging.h"
 #include "perfetto/ext/base/status_or.h"
+#include "src/trace_processor/containers/row_map.h"
 #include "src/trace_processor/db/overlays/null_overlay.h"
+#include "src/trace_processor/db/overlays/selector_overlay.h"
 #include "src/trace_processor/db/overlays/storage_overlay.h"
+#include "src/trace_processor/db/overlays/types.h"
 #include "src/trace_processor/db/query_executor.h"
 #include "src/trace_processor/db/storage/numeric_storage.h"
+#include "src/trace_processor/db/storage/types.h"
 #include "src/trace_processor/db/table.h"
 
 namespace perfetto {
@@ -124,10 +128,16 @@
   if (rm->empty())
     return;
 
+  if (col.sorted_intrinsically && c.op != FilterOp::kNe) {
+    BinarySearch(c, col, rm);
+    return;
+  }
+
   uint32_t rm_size = rm->size();
   uint32_t rm_first = rm->Get(0);
   uint32_t rm_last = rm->Get(rm_size - 1);
   uint32_t range_size = rm_last - rm_first;
+
   // If the number of elements in the rowmap is small or the number of elements
   // is less than 1/10th of the range, use indexed filtering.
   // TODO(b/283763282): use Overlay estimations.
@@ -157,13 +167,13 @@
                                       const SimpleColumn& col,
                                       RowMap* rm) {
   // TODO(b/283763282): We should align these to word boundaries.
-  TableRange table_range{Range(rm->Get(0), rm->Get(rm->size() - 1) + 1)};
+  TableRange table_range(rm->Get(0), rm->Get(rm->size() - 1) + 1);
   base::SmallVector<Range, kMaxOverlayCount> overlay_bounds;
 
   for (const auto& overlay : col.overlays) {
     StorageRange storage_range = overlay->MapToStorageRange(table_range);
     overlay_bounds.emplace_back(storage_range.range);
-    table_range = TableRange({storage_range.range});
+    table_range = TableRange(storage_range.range);
   }
 
   // Use linear search algorithm on storage.
@@ -179,6 +189,45 @@
   return std::move(filtered_storage.bv);
 }
 
+void QueryExecutor::BinarySearch(const Constraint& c,
+                                 const SimpleColumn& col,
+                                 RowMap* rm) {
+  // TODO(b/283763282): We should align these to word boundaries.
+  TableRange table_range{Range(rm->Get(0), rm->Get(rm->size() - 1) + 1)};
+  base::SmallVector<Range, kMaxOverlayCount> overlay_bounds;
+
+  for (const auto& overlay : col.overlays) {
+    StorageRange storage_range = overlay->MapToStorageRange(table_range);
+    overlay_bounds.emplace_back(storage_range.range);
+    table_range = TableRange({storage_range.range});
+  }
+
+  // Use binary search algorithm on storage.
+  overlays::TableRangeOrBitVector res(
+      col.storage->BinarySearchIntrinsic(c.op, c.value, table_range.range));
+
+  OverlayOp op = overlays::FilterOpToOverlayOp(c.op);
+  for (uint32_t i = 0; i < col.overlays.size(); ++i) {
+    uint32_t rev_i = static_cast<uint32_t>(col.overlays.size()) - 1 - i;
+
+    if (res.IsBitVector()) {
+      TableBitVector t_bv = col.overlays[rev_i]->MapToTableBitVector(
+          StorageBitVector{res.TakeIfBitVector()}, op);
+      res.val = std::move(t_bv.bv);
+    } else {
+      res = col.overlays[rev_i]->MapToTableRangeOrBitVector(
+          StorageRange(res.TakeIfRange()), op);
+    }
+  }
+
+  if (res.IsBitVector()) {
+    rm->Intersect(RowMap(res.TakeIfBitVector()));
+    return;
+  }
+
+  rm->Intersect(RowMap(res.TakeIfRange().start, res.TakeIfRange().end));
+}
+
 RowMap QueryExecutor::IndexSearch(const Constraint& c,
                                   const SimpleColumn& col,
                                   RowMap* rm) {
@@ -252,10 +301,12 @@
     use_legacy = use_legacy || (overlays::FilterOpToOverlayOp(c.op) ==
                                     overlays::OverlayOp::kOther &&
                                 col.type() != c.value.type);
+    use_legacy = use_legacy || col.IsDense() || col.IsSetId();
+    use_legacy =
+        use_legacy || (col.overlay().size() != col.storage_base().size() &&
+                       !col.overlay().row_map().IsBitVector());
     use_legacy = use_legacy ||
-                 col.overlay().row_map().size() != col.storage_base().size();
-    use_legacy = use_legacy || col.IsSorted() || col.IsDense() || col.IsSetId();
-    use_legacy = use_legacy || col.overlay().row_map().IsIndexVector();
+                 (col.IsSorted() && col.overlay().row_map().IsIndexVector());
     if (use_legacy) {
       col.FilterInto(c.op, c.value, &rm);
       continue;
@@ -265,9 +316,14 @@
     uint32_t s_size = col.storage_base().non_null_size();
 
     storage::NumericStorage storage(s_data, s_size, col.col_type());
-    overlays::NullOverlay null_overlay(col.storage_base().bv());
+    SimpleColumn s_col{OverlaysVec(), &storage, col.IsSorted()};
 
-    SimpleColumn s_col{OverlaysVec(), &storage};
+    overlays::SelectorOverlay selector_overlay(
+        col.overlay().row_map().GetIfBitVector());
+    if (col.overlay().size() != col.storage_base().size())
+      s_col.overlays.emplace_back(&selector_overlay);
+
+    overlays::NullOverlay null_overlay(col.storage_base().bv());
     if (col.IsNullable()) {
       s_col.overlays.emplace_back(&null_overlay);
     }
diff --git a/src/trace_processor/db/query_executor.h b/src/trace_processor/db/query_executor.h
index 2e04bb9..d875453 100644
--- a/src/trace_processor/db/query_executor.h
+++ b/src/trace_processor/db/query_executor.h
@@ -42,6 +42,8 @@
     base::SmallVector<const overlays::StorageOverlay*, kMaxOverlayCount>
         overlays;
     const storage::Storage* storage;
+    // TODO(b/283763282): Move knowledge about sorted state to Storage
+    bool sorted_intrinsically = false;
   };
 
   // |row_count| is the size of the last overlay.
@@ -85,6 +87,12 @@
     return IndexSearch(c, col, rm);
   }
 
+  static void BinarySearchForTesting(const Constraint& c,
+                                     const SimpleColumn& col,
+                                     RowMap* rm) {
+    BinarySearch(c, col, rm);
+  }
+
  private:
   // Updates RowMap with result of filtering single column using the Constraint.
   static void FilterColumn(const Constraint&, const SimpleColumn&, RowMap*);
@@ -95,6 +103,8 @@
                                 const SimpleColumn&,
                                 RowMap*);
 
+  static void BinarySearch(const Constraint&, const SimpleColumn&, RowMap*);
+
   // Filters the column using Index algorithm - finds the indices to filter the
   // storage with.
   static RowMap IndexSearch(const Constraint&, const SimpleColumn&, RowMap*);
diff --git a/src/trace_processor/db/query_executor_benchmark.cc b/src/trace_processor/db/query_executor_benchmark.cc
index 55a7251..e6eb97d 100644
--- a/src/trace_processor/db/query_executor_benchmark.cc
+++ b/src/trace_processor/db/query_executor_benchmark.cc
@@ -15,18 +15,40 @@
  */
 
 #include <benchmark/benchmark.h>
+#include <string>
 
 #include "perfetto/ext/base/file_utils.h"
 #include "perfetto/ext/base/string_utils.h"
 #include "src/base/test/utils.h"
 #include "src/trace_processor/db/query_executor.h"
+#include "src/trace_processor/db/table.h"
+#include "src/trace_processor/tables/metadata_tables_py.h"
 #include "src/trace_processor/tables/slice_tables_py.h"
+#include "src/trace_processor/tables/track_tables_py.h"
 
 namespace perfetto {
 namespace trace_processor {
 namespace {
 
 using SliceTable = tables::SliceTable;
+using ThreadTrackTable = tables::ThreadTrackTable;
+using ExpectedFrameTimelineSliceTable = tables::ExpectedFrameTimelineSliceTable;
+using RawTable = tables::RawTable;
+using FtraceEventTable = tables::FtraceEventTable;
+
+// `SELECT * FROM SLICE` on android_monitor_contention_trace.at
+static char kSliceTable[] = "test/data/slice_table_for_benchmarks.csv";
+
+// `SELECT * FROM SLICE` on android_monitor_contention_trace.at
+static char kExpectedFrameTimelineTable[] =
+    "test/data/expected_frame_timeline_for_benchmarks.csv";
+
+// `SELECT id, cpu FROM raw` on chrome_android_systrace.pftrace.
+static char kRawTable[] = "test/data/raw_cpu_for_benchmarks.csv";
+
+// `SELECT id, cpu FROM ftrace_event` on chrome_android_systrace.pftrace.
+static char kFtraceEventTable[] =
+    "test/data/ftrace_event_cpu_for_benchmarks.csv";
 
 enum DB { V1, V2 };
 
@@ -51,12 +73,10 @@
   return output;
 }
 
-std::vector<SliceTable::Row> LoadRowsFromCSVToSliceTable(
-    benchmark::State& state) {
-  std::vector<SliceTable::Row> rows;
+std::vector<std::string> ReadCSV(benchmark::State& state,
+                                 std::string file_name) {
   std::string table_csv;
-  static const char kTestTrace[] = "test/data/example_android_trace_30s.csv";
-  perfetto::base::ReadFile(perfetto::base::GetTestDataPath(kTestTrace),
+  perfetto::base::ReadFile(perfetto::base::GetTestDataPath(file_name),
                            &table_csv);
   if (table_csv.empty()) {
     state.SkipWithError(
@@ -65,44 +85,112 @@
     return {};
   }
   PERFETTO_CHECK(!table_csv.empty());
-
-  std::vector<std::string> rows_strings = base::SplitString(table_csv, "\n");
-  for (size_t i = 1; i < rows_strings.size(); ++i) {
-    std::vector<std::string> row_vec = SplitCSVLine(rows_strings[i]);
-    SliceTable::Row row;
-    PERFETTO_CHECK(row_vec.size() >= 12);
-    row.ts = *base::StringToInt64(row_vec[2]);
-    row.dur = *base::StringToInt64(row_vec[3]);
-    row.track_id =
-        tables::ThreadTrackTable::Id(*base::StringToUInt32(row_vec[4]));
-    row.depth = *base::StringToUInt32(row_vec[7]);
-    row.stack_id = *base::StringToInt32(row_vec[8]);
-    row.parent_stack_id = *base::StringToInt32(row_vec[9]);
-    row.parent_id = base::StringToUInt32(row_vec[11]).has_value()
-                        ? std::make_optional<SliceTable::Id>(
-                              *base::StringToUInt32(row_vec[11]))
-                        : std::nullopt;
-    row.arg_set_id = *base::StringToUInt32(row_vec[11]);
-    row.thread_ts = base::StringToInt64(row_vec[12]);
-    row.thread_dur = base::StringToInt64(row_vec[13]);
-    rows.emplace_back(row);
-  }
-  return rows;
+  return base::SplitString(table_csv, "\n");
 }
 
-struct BenchmarkSliceTable {
-  explicit BenchmarkSliceTable(benchmark::State& state) : table_{&pool_} {
-    auto rows = LoadRowsFromCSVToSliceTable(state);
-    for (uint32_t i = 0; i < rows.size(); ++i) {
-      table_.Insert(rows[i]);
+SliceTable::Row GetSliceTableRow(std::string string_row) {
+  std::vector<std::string> row_vec = SplitCSVLine(string_row);
+  SliceTable::Row row;
+  PERFETTO_CHECK(row_vec.size() >= 12);
+  row.ts = *base::StringToInt64(row_vec[2]);
+  row.dur = *base::StringToInt64(row_vec[3]);
+  row.track_id = ThreadTrackTable::Id(*base::StringToUInt32(row_vec[4]));
+  row.depth = *base::StringToUInt32(row_vec[7]);
+  row.stack_id = *base::StringToInt32(row_vec[8]);
+  row.parent_stack_id = *base::StringToInt32(row_vec[9]);
+  row.parent_id = base::StringToUInt32(row_vec[11]).has_value()
+                      ? std::make_optional<SliceTable::Id>(
+                            *base::StringToUInt32(row_vec[11]))
+                      : std::nullopt;
+  row.arg_set_id = *base::StringToUInt32(row_vec[11]);
+  row.thread_ts = base::StringToInt64(row_vec[12]);
+  row.thread_dur = base::StringToInt64(row_vec[13]);
+  return row;
+}
+
+struct SliceTableForBenchmark {
+  explicit SliceTableForBenchmark(benchmark::State& state) : table_{&pool_} {
+    std::vector<std::string> rows_strings = ReadCSV(state, kSliceTable);
+
+    for (size_t i = 1; i < rows_strings.size(); ++i) {
+      table_.Insert(GetSliceTableRow(rows_strings[i]));
     }
   }
+
   StringPool pool_;
   SliceTable table_;
 };
 
-void SliceTableBenchmark(benchmark::State& state,
-                         BenchmarkSliceTable& table,
+struct ExpectedFrameTimelineTableForBenchmark {
+  explicit ExpectedFrameTimelineTableForBenchmark(benchmark::State& state)
+      : table_{&pool_, &parent_} {
+    std::vector<std::string> table_rows_as_string =
+        ReadCSV(state, kExpectedFrameTimelineTable);
+    std::vector<std::string> parent_rows_as_string =
+        ReadCSV(state, kSliceTable);
+
+    uint32_t cur_idx = 0;
+    for (size_t i = 1; i < table_rows_as_string.size(); ++i, ++cur_idx) {
+      std::vector<std::string> row_vec = SplitCSVLine(table_rows_as_string[i]);
+
+      uint32_t idx = *base::StringToUInt32(row_vec[0]);
+      while (cur_idx < idx) {
+        parent_.Insert(GetSliceTableRow(parent_rows_as_string[cur_idx + 1]));
+        cur_idx++;
+      }
+
+      ExpectedFrameTimelineSliceTable::Row row;
+      row.ts = *base::StringToInt64(row_vec[2]);
+      row.dur = *base::StringToInt64(row_vec[3]);
+      row.track_id = ThreadTrackTable::Id(*base::StringToUInt32(row_vec[4]));
+      row.depth = *base::StringToUInt32(row_vec[7]);
+      row.stack_id = *base::StringToInt32(row_vec[8]);
+      row.parent_stack_id = *base::StringToInt32(row_vec[9]);
+      row.parent_id = base::StringToUInt32(row_vec[11]).has_value()
+                          ? std::make_optional<SliceTable::Id>(
+                                *base::StringToUInt32(row_vec[11]))
+                          : std::nullopt;
+      row.arg_set_id = *base::StringToUInt32(row_vec[11]);
+      row.thread_ts = base::StringToInt64(row_vec[12]);
+      row.thread_dur = base::StringToInt64(row_vec[13]);
+      table_.Insert(row);
+    }
+  }
+  StringPool pool_;
+  SliceTable parent_{&pool_};
+  ExpectedFrameTimelineSliceTable table_;
+};
+
+struct FtraceEventTableForBenchmark {
+  explicit FtraceEventTableForBenchmark(benchmark::State& state) {
+    std::vector<std::string> raw_rows = ReadCSV(state, kRawTable);
+    std::vector<std::string> ftrace_event_rows =
+        ReadCSV(state, kFtraceEventTable);
+
+    uint32_t cur_idx = 0;
+    for (size_t i = 1; i < ftrace_event_rows.size(); ++i, cur_idx++) {
+      std::vector<std::string> row_vec = SplitCSVLine(ftrace_event_rows[i]);
+      uint32_t idx = *base::StringToUInt32(row_vec[0]);
+      while (cur_idx < idx) {
+        std::vector<std::string> raw_row = SplitCSVLine(raw_rows[cur_idx + 1]);
+        RawTable::Row r;
+        r.cpu = *base::StringToUInt32(raw_row[1]);
+        raw_.Insert(r);
+        cur_idx++;
+      }
+      FtraceEventTable::Row row;
+      row.cpu = *base::StringToUInt32(row_vec[1]);
+      table_.Insert(row);
+    }
+  }
+
+  StringPool pool_;
+  RawTable raw_{&pool_};
+  tables::FtraceEventTable table_{&pool_, &raw_};
+};
+
+void BenchmarkSliceTable(benchmark::State& state,
+                         SliceTableForBenchmark& table,
                          Constraint c) {
   Table::kUseFilterV2 = state.range(0) == 1;
   for (auto _ : state) {
@@ -114,26 +202,73 @@
                              benchmark::Counter::kInvert);
 }
 
-static void BM_DBv2SliceTableTrackIdEquals(benchmark::State& state) {
-  BenchmarkSliceTable table(state);
-  SliceTableBenchmark(state, table, table.table_.track_id().eq(100));
+void BenchmarkExpectedFrameTable(benchmark::State& state,
+                                 ExpectedFrameTimelineTableForBenchmark& table,
+                                 Constraint c) {
+  Table::kUseFilterV2 = state.range(0) == 1;
+  for (auto _ : state) {
+    benchmark::DoNotOptimize(table.table_.FilterToRowMap({c}));
+  }
+  state.counters["s/row"] =
+      benchmark::Counter(static_cast<double>(table.table_.row_count()),
+                         benchmark::Counter::kIsIterationInvariantRate |
+                             benchmark::Counter::kInvert);
 }
 
-BENCHMARK(BM_DBv2SliceTableTrackIdEquals)->ArgsProduct({{DB::V1, DB::V2}});
-
-static void BM_DBv2SliceTableParentIdIsNotNull(benchmark::State& state) {
-  BenchmarkSliceTable table(state);
-  SliceTableBenchmark(state, table, table.table_.parent_id().is_not_null());
+void BenchmarkFtraceEventTable(benchmark::State& state,
+                               FtraceEventTableForBenchmark& table,
+                               Constraint c) {
+  Table::kUseFilterV2 = state.range(0) == 1;
+  for (auto _ : state) {
+    benchmark::DoNotOptimize(table.table_.FilterToRowMap({c}));
+  }
+  state.counters["s/row"] =
+      benchmark::Counter(static_cast<double>(table.table_.row_count()),
+                         benchmark::Counter::kIsIterationInvariantRate |
+                             benchmark::Counter::kInvert);
 }
 
-BENCHMARK(BM_DBv2SliceTableParentIdIsNotNull)->ArgsProduct({{DB::V1, DB::V2}});
-
-static void BM_DBv2SliceTableParentIdEq(benchmark::State& state) {
-  BenchmarkSliceTable table(state);
-  SliceTableBenchmark(state, table, table.table_.parent_id().eq(88));
+static void BM_QESliceTableTrackIdEq(benchmark::State& state) {
+  SliceTableForBenchmark table(state);
+  BenchmarkSliceTable(state, table, table.table_.track_id().eq(100));
 }
 
-BENCHMARK(BM_DBv2SliceTableParentIdEq)->ArgsProduct({{DB::V1, DB::V2}});
+BENCHMARK(BM_QESliceTableTrackIdEq)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QESliceTableParentIdIsNotNull(benchmark::State& state) {
+  SliceTableForBenchmark table(state);
+  BenchmarkSliceTable(state, table, table.table_.parent_id().is_not_null());
+}
+
+BENCHMARK(BM_QESliceTableParentIdIsNotNull)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QESliceTableParentIdEq(benchmark::State& state) {
+  SliceTableForBenchmark table(state);
+  BenchmarkSliceTable(state, table, table.table_.parent_id().eq(88));
+}
+
+BENCHMARK(BM_QESliceTableParentIdEq)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QESliceTableSorted(benchmark::State& state) {
+  SliceTableForBenchmark table(state);
+  BenchmarkSliceTable(state, table, table.table_.ts().gt(1000));
+}
+
+BENCHMARK(BM_QESliceTableSorted)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QEFilterWithSparseSelector(benchmark::State& state) {
+  ExpectedFrameTimelineTableForBenchmark table(state);
+  BenchmarkExpectedFrameTable(state, table, table.table_.track_id().eq(88));
+}
+
+BENCHMARK(BM_QEFilterWithSparseSelector)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QEFilterWithDenseSelector(benchmark::State& state) {
+  FtraceEventTableForBenchmark table(state);
+  BenchmarkFtraceEventTable(state, table, table.table_.cpu().eq(4));
+}
+
+BENCHMARK(BM_QEFilterWithDenseSelector)->ArgsProduct({{DB::V1, DB::V2}});
 
 }  // namespace
 }  // namespace trace_processor
diff --git a/src/trace_processor/db/query_executor_unittest.cc b/src/trace_processor/db/query_executor_unittest.cc
index 01fff7a..3e02d79 100644
--- a/src/trace_processor/db/query_executor_unittest.cc
+++ b/src/trace_processor/db/query_executor_unittest.cc
@@ -224,51 +224,110 @@
 }
 
 TEST(QueryExecutor, SingleConstraintWithNullAndSelector) {
-  std::vector<int64_t> storage_data{0, 1, 2, 3, 4, 0, 1, 2, 3, 4};
+  std::vector<int64_t> storage_data{0, 1, 2, 3, 0, 1, 2, 3};
   NumericStorage storage(storage_data.data(), 10, ColumnType::kInt64);
 
-  // Select 6 elements from storage, resulting in a vector {0, 1, 3, 4, 1, 2}.
-  BitVector selector_bv{1, 1, 0, 1, 1, 0, 1, 1, 0, 0};
-  SelectorOverlay selector_overlay(&selector_bv);
-
-  // Add nulls, final vector {0, 1, NULL, 3, 4, NULL, 1, 2, NULL}.
-  BitVector null_bv{1, 1, 0, 1, 1, 0, 1, 1, 0};
+  // Current vector
+  // 0, 1, NULL, 2, 3, 0, NULL, NULL, 1, 2, 3, NULL
+  BitVector null_bv{1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0};
   NullOverlay null_overlay(&null_bv);
 
+  // Final vector
+  // 0, NULL, 3, NULL, 1, 3
+  BitVector selector_bv{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0};
+  SelectorOverlay selector_overlay(&selector_bv);
+
   // Create the column.
   OverlaysVec overlays_vec;
-  overlays_vec.emplace_back(&null_overlay);
   overlays_vec.emplace_back(&selector_overlay);
+  overlays_vec.emplace_back(&null_overlay);
   SimpleColumn col{overlays_vec, &storage};
 
   // Filter.
   Constraint c{0, FilterOp::kGe, SqlValue::Long(2)};
-  QueryExecutor exec({col}, 9);
+  QueryExecutor exec({col}, 6);
   RowMap res = exec.Filter({c});
 
-  ASSERT_EQ(res.size(), 3u);
-  ASSERT_EQ(res.Get(0), 3u);
-  ASSERT_EQ(res.Get(1), 4u);
-  ASSERT_EQ(res.Get(2), 7u);
+  ASSERT_EQ(res.size(), 2u);
+  ASSERT_EQ(res.Get(0), 2u);
+  ASSERT_EQ(res.Get(1), 5u);
 }
 
 TEST(QueryExecutor, IsNull) {
-  std::vector<int64_t> storage_data{0, 1, 2, 3, 4, 0, 1, 2, 3, 4};
+  std::vector<int64_t> storage_data{0, 1, 2, 3, 0, 1, 2, 3};
   NumericStorage storage(storage_data.data(), 10, ColumnType::kInt64);
 
-  // Select 6 elements from storage, resulting in a vector {0, 1, 3, 4, 1, 2}.
+  // Current vector
+  // 0, 1, NULL, 2, 3, 0, NULL, NULL, 1, 2, 3, NULL
+  BitVector null_bv{1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0};
+  NullOverlay null_overlay(&null_bv);
+
+  // Final vector
+  // 0, NULL, 3, NULL, 1, 3
+  BitVector selector_bv{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0};
+  SelectorOverlay selector_overlay(&selector_bv);
+
+  // Create the column.
+  OverlaysVec overlays_vec;
+  overlays_vec.emplace_back(&selector_overlay);
+  overlays_vec.emplace_back(&null_overlay);
+  SimpleColumn col{overlays_vec, &storage};
+
+  // Filter.
+  Constraint c{0, FilterOp::kIsNull, SqlValue::Long(0)};
+  QueryExecutor exec({col}, 6);
+  RowMap res = exec.Filter({c});
+
+  ASSERT_EQ(res.size(), 2u);
+  ASSERT_EQ(res.Get(0), 1u);
+  ASSERT_EQ(res.Get(1), 3u);
+}
+
+TEST(QueryExecutor, BinarySearch) {
+  std::vector<int64_t> storage_data{0, 1, 2, 3, 4, 5, 6};
+  NumericStorage storage(storage_data.data(), 7, ColumnType::kInt64);
+
+  // Add nulls - {0, 1, NULL, NULL, 2, 3, NULL, NULL, 4, 5, 6, NULL}
+  BitVector null_bv{1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0};
+  NullOverlay null_overlay(&null_bv);
+
+  // Final vector {1, NULL, 3, NULL, 5, NULL}.
+  BitVector selector_bv{0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1};
+  SelectorOverlay selector_overlay(&selector_bv);
+
+  // Create the column.
+  OverlaysVec overlays_vec;
+  overlays_vec.emplace_back(&selector_overlay);
+  overlays_vec.emplace_back(&null_overlay);
+  SimpleColumn col{overlays_vec, &storage, true};
+
+  // Filter.
+  Constraint c{0, FilterOp::kGe, SqlValue::Long(3)};
+  QueryExecutor exec({col}, 6);
+  RowMap res = exec.Filter({c});
+
+  ASSERT_EQ(res.size(), 2u);
+  ASSERT_EQ(res.Get(0), 2u);
+  ASSERT_EQ(res.Get(1), 4u);
+}
+
+TEST(QueryExecutor, BinarySearchIsNull) {
+  std::vector<int64_t> storage_data{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+  NumericStorage storage(storage_data.data(), 10, ColumnType::kInt64);
+
+  // Select 6 elements from storage, resulting in a vector {0, 1, 3, 4, 6, 7}.
   BitVector selector_bv{1, 1, 0, 1, 1, 0, 1, 1, 0, 0};
   SelectorOverlay selector_overlay(&selector_bv);
 
-  // Add nulls, final vector {0, 1, NULL, 3, 4, NULL, 1, 2, NULL}.
-  BitVector null_bv{1, 1, 0, 1, 1, 0, 1, 1, 0};
+  // Add nulls, final vector {NULL, NULL, NULL 0, 1, 3, 4, 6, 7}.
+  BitVector null_bv{0, 0, 0, 1, 1, 1, 1, 1, 1};
   NullOverlay null_overlay(&null_bv);
 
   // Create the column.
   OverlaysVec overlays_vec;
   overlays_vec.emplace_back(&null_overlay);
   overlays_vec.emplace_back(&selector_overlay);
-  SimpleColumn col{overlays_vec, &storage};
+  SimpleColumn col{overlays_vec, &storage, true};
 
   // Filter.
   Constraint c{0, FilterOp::kIsNull, SqlValue::Long(0)};
@@ -276,9 +335,9 @@
   RowMap res = exec.Filter({c});
 
   ASSERT_EQ(res.size(), 3u);
-  ASSERT_EQ(res.Get(0), 2u);
-  ASSERT_EQ(res.Get(1), 5u);
-  ASSERT_EQ(res.Get(2), 8u);
+  ASSERT_EQ(res.Get(0), 0u);
+  ASSERT_EQ(res.Get(1), 1u);
+  ASSERT_EQ(res.Get(2), 2u);
 }
 
 }  // namespace
diff --git a/src/trace_processor/db/storage/numeric_storage.cc b/src/trace_processor/db/storage/numeric_storage.cc
index 0c3f345..71eebec 100644
--- a/src/trace_processor/db/storage/numeric_storage.cc
+++ b/src/trace_processor/db/storage/numeric_storage.cc
@@ -321,7 +321,7 @@
     RowMap::Range search_range) const {
   std::optional<NumericValue> val = GetNumericTypeVariant(type_, sql_val);
   if (op == FilterOp::kIsNotNull)
-    return RowMap::Range(0, size());
+    return search_range;
 
   if (!val.has_value() || op == FilterOp::kIsNull || op == FilterOp::kGlob)
     return RowMap::Range();
diff --git a/src/trace_processor/metrics/sql/android/BUILD.gn b/src/trace_processor/metrics/sql/android/BUILD.gn
index a566661..d61d0e3 100644
--- a/src/trace_processor/metrics/sql/android/BUILD.gn
+++ b/src/trace_processor/metrics/sql/android/BUILD.gn
@@ -22,6 +22,7 @@
     "android_batt.sql",
     "android_binder.sql",
     "android_blocking_calls_cuj_metric.sql",
+    "android_sysui_notifications_blocking_calls_metric.sql",
     "android_camera.sql",
     "android_camera_unagg.sql",
     "android_cpu.sql",
diff --git a/src/trace_processor/metrics/sql/android/android_sysui_notifications_blocking_calls_metric.sql b/src/trace_processor/metrics/sql/android/android_sysui_notifications_blocking_calls_metric.sql
new file mode 100644
index 0000000..470b245
--- /dev/null
+++ b/src/trace_processor/metrics/sql/android/android_sysui_notifications_blocking_calls_metric.sql
@@ -0,0 +1,53 @@
+--
+-- Copyright 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
+--
+--     https://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.
+
+SELECT IMPORT('android.slices');
+
+DROP TABLE IF EXISTS android_sysui_notifications_blocking_calls;
+CREATE TABLE android_sysui_notifications_blocking_calls AS
+SELECT
+    s.name name,
+    COUNT(s.name) count,
+    MAX(dur) AS max_dur_ns,
+    MIN(dur) AS min_dur_ns,
+    SUM(dur) AS total_dur_ns
+FROM slice s
+    JOIN thread_track ON s.track_id = thread_track.id
+    JOIN thread USING (utid)
+WHERE
+    thread.is_main_thread AND
+    s.dur > 0 AND (
+       s.name GLOB 'NotificationStackScrollLayout#onMeasure'
+    OR s.name GLOB 'NotificationToplineView#onMeasure'
+    OR s.name GLOB 'ExpNotRow#*'
+)
+GROUP BY s.name;
+
+DROP VIEW IF EXISTS android_sysui_notifications_blocking_calls_metric_output;
+CREATE VIEW android_sysui_notifications_blocking_calls_metric_output AS
+SELECT AndroidSysUINotificationsBlockingCallsMetric('blocking_calls', (
+        SELECT RepeatedField(
+                AndroidSysUINotificationsBlockingCallsMetric_BlockingCall(
+                'name', a.name,
+                'cnt', a.count,
+                'total_dur_ns', a.total_dur_ns,
+                'max_dur_ns', a.max_dur_ns,
+                'min_dur_ns', a.min_dur_ns
+            )
+        )
+        FROM android_sysui_notifications_blocking_calls a
+        ORDER BY total_dur_ns DESC
+    )
+);
diff --git a/src/trace_processor/stdlib/experimental/thread_executing_span.sql b/src/trace_processor/stdlib/experimental/thread_executing_span.sql
index 338ec66..44bac68 100644
--- a/src/trace_processor/stdlib/experimental/thread_executing_span.sql
+++ b/src/trace_processor/stdlib/experimental/thread_executing_span.sql
@@ -339,6 +339,8 @@
 -- @column blocked_dur        Duration of blocking thread state before waking up.
 -- @column blocked_state      Thread state ('D' or 'S') of blocked thread_state before waking up.
 -- @column blocked_function   Kernel blocking function of thread state before waking up.
+-- @column is_root            Whether this span is the root in the slice tree.
+-- @column is_leaf            Whether this span is the leaf in the slice tree.
 -- @column depth              Tree depth from |root_id|
 -- @column root_id            Thread state id used to start the recursion. Helpful for SQL JOINs
 SELECT CREATE_VIEW_FUNCTION(
@@ -410,6 +412,8 @@
 -- @column blocked_dur        Duration of blocking thread state before waking up.
 -- @column blocked_state      Thread state ('D' or 'S') of blocked thread_state before waking up.
 -- @column blocked_function   Kernel blocking function of thread state before waking up.
+-- @column is_root            Whether this span is the root in the slice tree.
+-- @column is_leaf            Whether this span is the leaf in the slice tree.
 -- @column height             Tree height from |leaf_id|
 -- @column leaf_id            Thread state id used to start the recursion. Helpful for SQL JOINs
 SELECT CREATE_VIEW_FUNCTION(
diff --git a/src/tracing/test/api_integrationtest.cc b/src/tracing/test/api_integrationtest.cc
index be2a27c..18d302c 100644
--- a/src/tracing/test/api_integrationtest.cc
+++ b/src/tracing/test/api_integrationtest.cc
@@ -3092,13 +3092,15 @@
     TRACE_EVENT(
         "test", "TestEventWithExtensionArgs",
         perfetto::protos::pbzero::TestExtension::kIntExtensionForTesting,
-        std::vector<int>{42});
+        std::vector<int>{42},
+        perfetto::protos::pbzero::TestExtension::kUintExtensionForTesting, 42u);
   }
 
   std::vector<char> raw_trace = StopSessionAndReturnBytes(tracing_session);
   EXPECT_GE(raw_trace.size(), 0u);
 
-  bool found_extension = false;
+  bool found_int_extension = false;
+  bool found_uint_extension = false;
   perfetto::protos::pbzero::Trace_Decoder trace(
       reinterpret_cast<uint8_t*>(raw_trace.data()), raw_trace.size());
 
@@ -3116,12 +3118,17 @@
          f = decoder.ReadField()) {
       if (f.id() == perfetto::protos::pbzero::TestExtension::
                         FieldMetadata_IntExtensionForTesting::kFieldId) {
-        found_extension = true;
+        found_int_extension = true;
+      } else if (f.id() ==
+                 perfetto::protos::pbzero::TestExtension::
+                     FieldMetadata_UintExtensionForTesting::kFieldId) {
+        found_uint_extension = true;
       }
     }
   }
 
-  EXPECT_TRUE(found_extension);
+  EXPECT_TRUE(found_int_extension);
+  EXPECT_TRUE(found_uint_extension);
 }
 
 TEST_P(PerfettoApiTest, TrackEventInstant) {
diff --git a/test/trace_processor/diff_tests/android/android_sysui_notifications_blocking_calls_metric.out b/test/trace_processor/diff_tests/android/android_sysui_notifications_blocking_calls_metric.out
new file mode 100644
index 0000000..5b69129
--- /dev/null
+++ b/test/trace_processor/diff_tests/android/android_sysui_notifications_blocking_calls_metric.out
@@ -0,0 +1,23 @@
+android_sysui_notifications_blocking_calls_metric {
+    blocking_calls {
+        name: "ExpNotRow#onMeasure(BigTextStyle)"
+        cnt: 1
+        total_dur_ns: 10000000
+        max_dur_ns: 10000000
+        min_dur_ns: 10000000
+    }
+    blocking_calls {
+        name: "ExpNotRow#onMeasure(MessagingStyle)"
+        cnt: 1
+        total_dur_ns: 10000000
+        max_dur_ns: 10000000
+        min_dur_ns: 10000000
+    }
+    blocking_calls {
+        name: "NotificationStackScrollLayout#onMeasure"
+        cnt: 1
+        total_dur_ns: 10000000
+        max_dur_ns: 10000000
+        min_dur_ns: 10000000
+    }
+}
diff --git a/test/trace_processor/diff_tests/android/android_sysui_notifications_blocking_calls_metric.py b/test/trace_processor/diff_tests/android/android_sysui_notifications_blocking_calls_metric.py
new file mode 100644
index 0000000..c5df8b4
--- /dev/null
+++ b/test/trace_processor/diff_tests/android/android_sysui_notifications_blocking_calls_metric.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+# 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.
+
+from os import sys, path
+import synth_common
+
+# com.android.systemui
+SYSUI_PID = 1000
+
+THIRD_PROCESS_PID = 3000
+
+# List of blocking calls
+blocking_call_names = [
+    'NotificationStackScrollLayout#onMeasure', 'ExpNotRow#onMeasure(MessagingStyle)',
+    'ExpNotRow#onMeasure(BigTextStyle)',
+    'Should not be in the metric'
+]
+
+
+def add_main_thread_atrace(trace, ts, ts_end, buf, pid):
+  trace.add_atrace_begin(ts=ts, tid=pid, pid=pid, buf=buf)
+  trace.add_atrace_end(ts=ts_end, tid=pid, pid=pid)
+
+
+def add_async_trace(trace, ts, ts_end, buf, pid):
+  trace.add_atrace_async_begin(ts=ts, tid=pid, pid=pid, buf=buf)
+  trace.add_atrace_async_end(ts=ts_end, tid=pid, pid=pid, buf=buf)
+
+# Creates a trace that contains one of each blocking call.
+def add_all_sysui_notifications_blocking_calls(trace, pid):
+  blocking_call_dur = 10_000_000
+  blocking_call_ts = 2_000_000
+
+  cuj_dur = len(blocking_call_names) * blocking_call_dur
+  add_async_trace(
+      trace,
+      ts=blocking_call_ts,
+      ts_end=blocking_call_ts + cuj_dur,
+      buf="L<TEST_WITH_MANY_BLOCKING_CALLS>",
+      pid=pid)
+
+  for blocking_call in blocking_call_names:
+    add_main_thread_atrace(
+        trace,
+        ts=blocking_call_ts,
+        ts_end=blocking_call_ts + blocking_call_dur,
+        buf=blocking_call,
+        pid=pid)
+    blocking_call_ts += blocking_call_dur
+
+
+def add_process(trace, package_name, uid, pid):
+  trace.add_package_list(ts=0, name=package_name, uid=uid, version_code=1)
+  trace.add_process(
+      pid=pid, ppid=0, cmdline=package_name, uid=uid)
+  trace.add_thread(tid=pid, tgid=pid, cmdline="MainThread", name="MainThread")
+
+
+def setup_trace():
+  trace = synth_common.create_trace()
+  trace.add_packet()
+  add_process(trace, package_name="com.android.systemui", uid=10001,
+              pid=SYSUI_PID)
+  trace.add_ftrace_packet(cpu=0)
+  return trace
+
+
+trace = setup_trace()
+
+
+add_all_sysui_notifications_blocking_calls(trace, pid=SYSUI_PID)
+
+# See test_android_sysui_notifications_blocking_calls.
+sys.stdout.buffer.write(trace.trace.SerializeToString())
diff --git a/test/trace_processor/diff_tests/android/tests.py b/test/trace_processor/diff_tests/android/tests.py
index 7d683cf..ba2d198 100644
--- a/test/trace_processor/diff_tests/android/tests.py
+++ b/test/trace_processor/diff_tests/android/tests.py
@@ -396,6 +396,12 @@
         """,
         out=Path('android_slice_standardization.out'))
 
+  def test_android_sysui_notifications_blocking_calls(self):
+    return DiffTestBlueprint(
+        trace=Path('android_sysui_notifications_blocking_calls_metric.py'),
+        query=Metric('android_sysui_notifications_blocking_calls_metric'),
+        out=Path('android_sysui_notifications_blocking_calls_metric.out'))
+
   def test_monitor_contention_extraction(self):
     return DiffTestBlueprint(
         trace=DataPath('android_monitor_contention_trace.atr'),
diff --git a/tools/check_sql_modules.py b/tools/check_sql_modules.py
index 8037fca..f50fdbd 100755
--- a/tools/check_sql_modules.py
+++ b/tools/check_sql_modules.py
@@ -16,6 +16,7 @@
 # This tool checks that every SQL object created without prefix
 # 'internal_' is documented with proper schema.
 
+import argparse
 import os
 import sys
 
@@ -26,9 +27,13 @@
 
 
 def main():
+  parser = argparse.ArgumentParser()
+  parser.add_argument(
+      '--stdlib-sources',
+      default=os.path.join(ROOT_DIR, "src", "trace_processor", "stdlib"))
+  args = parser.parse_args()
   errors = []
-  metrics_sources = os.path.join(ROOT_DIR, "src", "trace_processor", "stdlib")
-  for root, _, files in os.walk(metrics_sources, topdown=True):
+  for root, _, files in os.walk(args.stdlib_sources, topdown=True):
     for f in files:
       path = os.path.join(root, f)
       if not path.endswith(".sql"):
diff --git a/tools/gen_stdlib_docs_json.py b/tools/gen_stdlib_docs_json.py
index 1f2e5be..dcb4023 100755
--- a/tools/gen_stdlib_docs_json.py
+++ b/tools/gen_stdlib_docs_json.py
@@ -71,6 +71,11 @@
     import_key = path.split(".sql")[0].replace("/", ".")
 
     docs = parse_file_to_dict(path, sql)
+    if isinstance(docs, list):
+      for d in docs:
+        print(d)
+      return 1
+
     assert isinstance(docs, dict)
     if not any(docs.values()):
       continue
diff --git a/ui/OWNERS b/ui/OWNERS
index 7b9c12b..aaf587f 100644
--- a/ui/OWNERS
+++ b/ui/OWNERS
@@ -1,5 +1,6 @@
 hjd@google.com
 primiano@google.com
+stevegolton@google.com
 
 # Chrome-related bits (but also good escalations for many other UI changes).
 ddrone@google.com
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index 08c2ac6..ad91f01 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -219,7 +219,8 @@
   }
 
   td {
-    padding: 2px 1px;
+    padding: 3px 5px;
+    white-space: nowrap;
 
     i.material-icons {
       // Shrink the icons inside the table cells to increase the information
@@ -261,14 +262,22 @@
   }
 }
 
-.query-table {
-  width: 100%;
+.pf-query-panel {
+  display: contents;
+  .pf-query-warning {
+    padding: 4px;
+    position: sticky;
+    left: 0;
+  }
+}
+
+.pf-query-table {
+  min-width: 100%;
   font-size: 14px;
   border: 0;
   thead td {
-    // TODO(stevegolton): Get sticky working again.
-    // position: sticky;
-    // top: 0;
+    position: sticky;
+    top: 0;
     background-color: hsl(214, 22%, 90%);
     color: #262f3c;
     text-align: center;
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 640b30e..0b1e705 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -26,20 +26,23 @@
 @import "flags_page";
 @import "hiring_banner";
 @import "widgets_page";
+@import "widgets/anchor";
 @import "widgets/button";
 @import "widgets/checkbox";
-@import "widgets/text_input";
-@import "widgets/empty_state";
-@import "widgets/anchor";
-@import "widgets/popup";
-@import "widgets/multiselect";
-@import "widgets/select";
-@import "widgets/menu";
-@import "widgets/spinner";
-@import "widgets/tree";
-@import "widgets/switch";
-@import "widgets/form";
 @import "widgets/details_shell";
+@import "widgets/empty_state";
+@import "widgets/error";
+@import "widgets/form";
 @import "widgets/grid_layout";
+@import "widgets/menu";
+@import "widgets/multiselect";
+@import "widgets/popup";
 @import "widgets/section";
 @import "widgets/timestamp";
+@import "widgets/select";
+@import "widgets/spinner";
+@import "widgets/switch";
+@import "widgets/text_input";
+@import "widgets/tree";
+@import "widgets/virtual_scroll_container";
+@import "widgets/callout";
diff --git a/ui/src/assets/widgets/callout.scss b/ui/src/assets/widgets/callout.scss
new file mode 100644
index 0000000..da47edf
--- /dev/null
+++ b/ui/src/assets/widgets/callout.scss
@@ -0,0 +1,33 @@
+// 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 "theme";
+
+.pf-callout {
+  display: flex;
+  font-family: inherit;
+  font-size: inherit;
+  line-height: 1;
+  color: $pf-minimal-foreground;
+  background: rgb(239, 239, 239);
+  border-radius: $pf-border-radius;
+  padding: 4px;
+  border: solid lightgray 1px;
+
+  & > .pf-left-icon {
+    font-size: inherit;
+    margin-right: 6px;
+    align-items: baseline;
+  }
+}
diff --git a/ui/src/assets/widgets/details_shell.scss b/ui/src/assets/widgets/details_shell.scss
index 75c6af9..a71f237 100644
--- a/ui/src/assets/widgets/details_shell.scss
+++ b/ui/src/assets/widgets/details_shell.scss
@@ -18,17 +18,10 @@
   font-family: $pf-font;
   display: flex;
   flex-direction: column;
-  min-height: 100%;
-
-  &.pf-match-parent {
-    height: 100%;
-  }
 
   .pf-header-bar {
-    z-index: 1; // HACK: Make the header bar appear above the content
-    position: sticky;
-    top: 0;
-    left: 0;
+    z-index: 1;
+
     display: flex;
     flex-direction: row;
     align-items: baseline;
@@ -36,7 +29,6 @@
     background-color: white;
     color: black;
     padding: 8px 8px 5px 8px;
-    box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2);
     border-bottom: 1px solid rgba(0, 0, 0, 0.2);
 
     .pf-header-title {
@@ -58,6 +50,7 @@
       display: flex;
       min-width: min-content;
       gap: 4px;
+      align-items: baseline;
     }
   }
 
@@ -65,45 +58,13 @@
     font-size: smaller;
     flex-grow: 1;
     font-weight: 300;
+    overflow-x: auto;
+  }
 
-    table {
-      @include transition(0.1s);
-      @include table-font-size;
-      width: 100%;
-      // Aggregation panel uses multiple table elements that need to be aligned,
-      // which is done by using fixed table layout.
-      table-layout: fixed;
-      word-wrap: break-word;
-      padding: 0 10px;
-      tr:hover {
-        td,
-        th {
-          background-color: $table-hover-color;
-
-          &.no-highlight {
-            background-color: white;
-          }
-        }
-      }
-      th {
-        text-align: left;
-        width: 30%;
-        font-weight: normal;
-        vertical-align: top;
-      }
-      td.value {
-        white-space: pre-wrap;
-      }
-      td.padding {
-        min-width: 10px;
-      }
-      .array-index {
-        text-align: right;
-      }
-    }
-
-    .auto-layout {
-      table-layout: auto;
+  &.pf-fill-parent {
+    height: 100%;
+    .pf-content {
+      overflow-y: auto;
     }
   }
 }
diff --git a/ui/src/assets/widgets/details_table.scss b/ui/src/assets/widgets/details_table.scss
new file mode 100644
index 0000000..297c7ae
--- /dev/null
+++ b/ui/src/assets/widgets/details_table.scss
@@ -0,0 +1,53 @@
+// 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.
+
+table.pf-details-table {
+  @include transition(0.1s);
+  @include table-font-size;
+  width: 100%;
+  // Aggregation panel uses multiple table elements that need to be aligned,
+  // which is done by using fixed table layout.
+  table-layout: fixed;
+  word-wrap: break-word;
+  padding: 0 10px;
+  tr:hover {
+    td,
+    th {
+      background-color: $table-hover-color;
+
+      &.no-highlight {
+        background-color: white;
+      }
+    }
+  }
+  th {
+    text-align: left;
+    width: 30%;
+    font-weight: normal;
+    vertical-align: top;
+  }
+  td.value {
+    white-space: pre-wrap;
+  }
+  td.padding {
+    min-width: 10px;
+  }
+  .array-index {
+    text-align: right;
+  }
+}
+
+.auto-layout {
+  table-layout: auto;
+}
diff --git a/ui/src/assets/widgets/error.scss b/ui/src/assets/widgets/error.scss
new file mode 100644
index 0000000..dfee07c
--- /dev/null
+++ b/ui/src/assets/widgets/error.scss
@@ -0,0 +1,20 @@
+// 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.
+
+.pf-error {
+  padding: 20px 10px;
+  color: hsl(-10, 50%, 50%);
+  font-family: $pf-font;
+  font-weight: 300;
+}
diff --git a/ui/src/assets/widgets/timestamp.scss b/ui/src/assets/widgets/timestamp.scss
index f361ff4..c71bb8d 100644
--- a/ui/src/assets/widgets/timestamp.scss
+++ b/ui/src/assets/widgets/timestamp.scss
@@ -14,23 +14,17 @@
 
 @import "theme";
 
-// Make millis, micros, & nanos slightly smaller than hh:mm:ss for readability.
-$subsec-font-size: 0.9em;
-
 .pf-timecode {
   // Spacing the sub sections using CSS rather than spaces makes the spaces
   // disappear when copying.
   .pf-timecode-millis {
     margin-left: 1px;
-    font-size: $subsec-font-size;
   }
   .pf-timecode-micros {
     margin-left: 2px;
-    font-size: $subsec-font-size;
   }
   .pf-timecode-nanos {
     margin-left: 2px;
-    font-size: $subsec-font-size;
   }
   .pf-button {
     margin-left: 2px;
diff --git a/ui/src/assets/widgets/virtual_scroll_container.scss b/ui/src/assets/widgets/virtual_scroll_container.scss
new file mode 100644
index 0000000..d48fbeb
--- /dev/null
+++ b/ui/src/assets/widgets/virtual_scroll_container.scss
@@ -0,0 +1,20 @@
+// 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 "theme";
+
+.pf-virtual-scroll-container {
+  overflow: auto;
+  height: 100%;
+}
diff --git a/ui/src/assets/widgets_page.scss b/ui/src/assets/widgets_page.scss
index 96286bc..4d3309a 100644
--- a/ui/src/assets/widgets_page.scss
+++ b/ui/src/assets/widgets_page.scss
@@ -58,6 +58,7 @@
   .widget-container {
     display: flex;
     min-width: 300px;
+    max-width: 600px;
     min-height: 250px;
     border-radius: 3px;
     box-shadow: inset 2px 2px 10px #00000020;
diff --git a/ui/src/base/array_utils.ts b/ui/src/base/array_utils.ts
index a3a9980..7236a43 100644
--- a/ui/src/base/array_utils.ts
+++ b/ui/src/base/array_utils.ts
@@ -31,3 +31,12 @@
 export function allUnique(x: string[]): boolean {
   return x.length == new Set(x).size;
 }
+
+export function arrayEquals(a: any[]|undefined, b: any[]|undefined): boolean {
+  if (a === undefined || b === undefined) return false;
+  if (a.length !== b.length) return false;
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] !== b[i]) return false;
+  }
+  return true;
+}
diff --git a/ui/src/chrome_extension/chrome_tracing_controller.ts b/ui/src/chrome_extension/chrome_tracing_controller.ts
index 3b95c53..42f2a48 100644
--- a/ui/src/chrome_extension/chrome_tracing_controller.ts
+++ b/ui/src/chrome_extension/chrome_tracing_controller.ts
@@ -178,7 +178,7 @@
     };
     this.sendMessage(response);
     if (res.eof) return;
-    this.readBuffers(offset + res.data.length);
+    this.readBuffers(offset + chunk.length);
   }
 
   async disableTracing() {
diff --git a/ui/src/common/queries.ts b/ui/src/common/queries.ts
index 1d63819..bf2518a 100644
--- a/ui/src/common/queries.ts
+++ b/ui/src/common/queries.ts
@@ -28,8 +28,14 @@
   statementWithOutputCount: number;
 }
 
+export interface QueryRunParams {
+  // If true, replaces nulls with "NULL" string. Default is true.
+  convertNullsToString?: boolean;
+}
+
 export async function runQuery(
-    sqlQuery: string, engine: EngineProxy): Promise<QueryResponse> {
+    sqlQuery: string, engine: EngineProxy, params?: QueryRunParams):
+    Promise<QueryResponse> {
   const startMs = performance.now();
   const queryRes = engine.query(sqlQuery);
 
@@ -47,6 +53,8 @@
     // errored, the frontend will show a graceful message instead.
   }
 
+  const convertNullsToString = params?.convertNullsToString ?? true;
+
   const durationMs = performance.now() - startMs;
   const rows: Row[] = [];
   const columns = queryRes.columns();
@@ -55,7 +63,7 @@
     const row: Row = {};
     for (const colName of columns) {
       const value = iter.get(colName);
-      row[colName] = value === null ? 'NULL' : value;
+      row[colName] = value === null && convertNullsToString ? 'NULL' : value;
     }
     rows.push(row);
     if (++numRows >= MAX_DISPLAY_ROWS) break;
diff --git a/ui/src/common/query_result.ts b/ui/src/common/query_result.ts
index 90fb8f4..9a9883c 100644
--- a/ui/src/common/query_result.ts
+++ b/ui/src/common/query_result.ts
@@ -71,6 +71,7 @@
 export const LONG_NULL: bigint|null = 1n;
 
 export type ColumnType = string|number|bigint|null|Uint8Array;
+export type SqlValue = ColumnType;
 
 const SHIFT_32BITS = 32n;
 
@@ -159,7 +160,7 @@
 
 // One row extracted from an SQL result:
 export interface Row {
-  [key: string]: ColumnType|undefined;
+  [key: string]: ColumnType;
 }
 
 // The methods that any iterator has to implement.
diff --git a/ui/src/frontend/analyze_page.ts b/ui/src/frontend/analyze_page.ts
index 85ad308..dc0025f 100644
--- a/ui/src/frontend/analyze_page.ts
+++ b/ui/src/frontend/analyze_page.ts
@@ -220,6 +220,7 @@
             state.queryResult = undefined;
             globals.rafScheduler.scheduleFullRedraw();
           },
+          fillParent: false,
         }),
         m(QueryHistoryComponent));
   },
diff --git a/ui/src/frontend/bottom_tab.ts b/ui/src/frontend/bottom_tab.ts
index f78bd0c..e192969 100644
--- a/ui/src/frontend/bottom_tab.ts
+++ b/ui/src/frontend/bottom_tab.ts
@@ -118,6 +118,10 @@
       void;
   abstract viewTab(): void|m.Children;
 
+  close(): void {
+    closeTab(this.uuid);
+  }
+
   createPanelVnode(): m.Vnode<any, any> {
     return m(
         BottomTabAdapter,
diff --git a/ui/src/frontend/chrome_slice_details_tab.ts b/ui/src/frontend/chrome_slice_details_tab.ts
index 4dd2fdf..1d34836 100644
--- a/ui/src/frontend/chrome_slice_details_tab.ts
+++ b/ui/src/frontend/chrome_slice_details_tab.ts
@@ -21,19 +21,27 @@
 import {runQuery} from '../common/queries';
 import {LONG, LONG_NULL, NUM, STR_NULL} from '../common/query_result';
 import {
+  formatDuration,
   TPDuration,
   TPTime,
 } from '../common/time';
 import {ArgNode, convertArgsToTree, Key} from '../controller/args_parser';
 
 import {Anchor} from './anchor';
-import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
+import {
+  addTab,
+  BottomTab,
+  bottomTabRegistry,
+  NewBottomTabArgs,
+} from './bottom_tab';
 import {FlowPoint, globals} from './globals';
 import {PanelSize} from './panel';
 import {runQueryInNewTab} from './query_result_tab';
 import {Icons} from './semantic_icons';
 import {Arg} from './sql/args';
 import {getSlice, SliceDetails, SliceRef} from './sql/slice';
+import {SqlTableTab} from './sql_table/tab';
+import {SqlTables} from './sql_table/well_known_tables';
 import {asSliceSqlId, asTPTimestamp} from './sql_types';
 import {getProcessName, getThreadName} from './thread_and_process_info';
 import {Button} from './widgets/button';
@@ -266,7 +274,7 @@
 function computeDuration(ts: TPTime, dur: TPDuration): m.Children {
   if (dur === -1n) {
     const minDuration = globals.state.traceTime.end - ts;
-    return [m(Duration, {dur: minDuration}), ' (Did not end)'];
+    return `${formatDuration(minDuration)} (Did not end)`;
   } else {
     return m(Duration, {dur});
   }
@@ -326,7 +334,7 @@
 
 export class ChromeSliceDetailsTab extends
     BottomTab<ChromeSliceDetailsTabConfig> {
-  static readonly kind = 'org.perfetto.ChromeSliceDetailsTab';
+  static readonly kind = 'dev.perfetto.ChromeSliceDetailsTab';
 
   private sliceDetails?: SliceDetails;
 
@@ -398,7 +406,28 @@
         {title: 'Details'},
         m(
             Tree,
-            m(TreeNode, {left: 'Name', right: slice.name}),
+            m(TreeNode, {
+              left: 'Name',
+              right: m(
+                  PopupMenu2,
+                  {
+                    trigger: m(Anchor, slice.name),
+                  },
+                  m(MenuItem, {
+                    label: 'Slices with the same name',
+                    onclick: () => {
+                      addTab({
+                        kind: SqlTableTab.kind,
+                        config: {
+                          table: SqlTables.slice,
+                          displayName: 'slice',
+                          filters: [`name = ${sqliteString(slice.name)}`],
+                        },
+                      });
+                    },
+                  }),
+                  ),
+            }),
             m(TreeNode, {
               left: 'Category',
               right: !slice.category || slice.category === '[NULL]' ?
diff --git a/ui/src/frontend/ftrace_panel.ts b/ui/src/frontend/ftrace_panel.ts
index 84337a9..1fb0c2c 100644
--- a/ui/src/frontend/ftrace_panel.ts
+++ b/ui/src/frontend/ftrace_panel.ts
@@ -15,7 +15,6 @@
 import m from 'mithril';
 import {StringListPatch} from 'src/common/state';
 
-import {assertExists} from '../base/logging';
 import {Actions} from '../common/actions';
 import {colorForString} from '../common/colorizer';
 import {TPTime} from '../common/time';
@@ -31,6 +30,7 @@
 } from './widgets/multiselect';
 import {PopupPosition} from './widgets/popup';
 import {Timestamp} from './widgets/timestamp';
+import {VirtualScrollContainer} from './widgets/virtual_scroll_container';
 
 const ROW_H = 20;
 const PAGE_SIZE = 250;
@@ -63,23 +63,14 @@
           title: this.renderTitle(),
           buttons: this.renderFilterPanel(),
         },
-        m('.ftrace-panel', this.renderRows()));
-  }
-
-  private scrollContainer(dom: Element): HTMLElement {
-    const el = dom.parentElement;
-    return assertExists(el);
-  }
-
-  oncreate({dom}: m.CVnodeDOM) {
-    const sc = this.scrollContainer(dom);
-    sc.addEventListener('scroll', this.onScroll);
-    this.recomputeVisibleRowsAndUpdate(sc);
-  }
-
-  onupdate({dom}: m.CVnodeDOM) {
-    const sc = this.scrollContainer(dom);
-    this.recomputeVisibleRowsAndUpdate(sc);
+        m(
+            VirtualScrollContainer,
+            {
+              onScroll: this.onScroll,
+            },
+            m('.ftrace-panel', this.renderRows()),
+            ),
+    );
   }
 
   recomputeVisibleRowsAndUpdate(scrollContainer: HTMLElement) {
@@ -101,19 +92,15 @@
     }
   }
 
-  onremove({dom}: m.CVnodeDOM) {
-    const sc = this.scrollContainer(dom);
-    sc.removeEventListener('scroll', this.onScroll);
-
+  onremove(_: m.CVnodeDOM) {
     globals.dispatch(Actions.updateFtracePagination({
       offset: 0,
       count: 0,
     }));
   }
 
-  onScroll = (e: Event) => {
-    const scrollContainer = e.target as HTMLElement;
-    this.recomputeVisibleRowsAndUpdate(scrollContainer);
+  onScroll = (container: HTMLElement) => {
+    this.recomputeVisibleRowsAndUpdate(container);
   };
 
   onRowOver(ts: TPTime) {
@@ -153,7 +140,6 @@
         {
           label: 'Filter',
           minimal: true,
-          compact: true,
           icon: 'filter_list_alt',
           popupPosition: PopupPosition.Top,
           options,
diff --git a/ui/src/frontend/generic_slice_details_tab.ts b/ui/src/frontend/generic_slice_details_tab.ts
index 0cf3043..5d206f7 100644
--- a/ui/src/frontend/generic_slice_details_tab.ts
+++ b/ui/src/frontend/generic_slice_details_tab.ts
@@ -46,7 +46,7 @@
 // need to be rendered and how.
 export class GenericSliceDetailsTab extends
     BottomTab<GenericSliceDetailsTabConfig> {
-  static readonly kind = 'org.perfetto.GenericSliceDetailsTab';
+  static readonly kind = 'dev.perfetto.GenericSliceDetailsTab';
 
   data: {[key: string]: ColumnType}|undefined;
 
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index be1f6cd..dfa4e0f 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -578,12 +578,16 @@
     this._ftracePanelData = data;
   }
 
-  makeSelection(action: DeferredAction<{}>, tabToOpen = 'current_selection') {
+  makeSelection(
+      action: DeferredAction<{}>, tab: string|null = 'current_selection') {
     const previousState = this.state;
     // A new selection should cancel the current search selection.
     globals.dispatch(Actions.setSearchIndex({index: -1}));
-    const tab = action.type === 'deselect' ? undefined : tabToOpen;
-    globals.dispatch(Actions.setCurrentTab({tab}));
+    if (action.type === 'deselect') {
+      globals.dispatch(Actions.setCurrentTab({tab: undefined}));
+    } else if (tab !== null) {
+      globals.dispatch(Actions.setCurrentTab({tab: tab}));
+    }
     globals.dispatch(action);
 
     // HACK(stevegolton + altimin): This is a workaround to allow passing the
diff --git a/ui/src/frontend/logs_filters.ts b/ui/src/frontend/logs_filters.ts
index 432913f..dff5ef3 100644
--- a/ui/src/frontend/logs_filters.ts
+++ b/ui/src/frontend/logs_filters.ts
@@ -15,7 +15,11 @@
 import m from 'mithril';
 
 import {Actions} from '../common/actions';
+
 import {globals} from './globals';
+import {Button} from './widgets/button';
+import {Select} from './widgets/select';
+import {TextInput} from './widgets/text_input';
 
 export const LOG_PRIORITIES =
     ['-', '-', 'Verbose', 'Debug', 'Info', 'Warn', 'Error', 'Fatal'];
@@ -50,7 +54,7 @@
           m('option', {value: i, selected}, attrs.options[i]));
     }
     return m(
-        'select',
+        Select,
         {
           onchange: (e: InputEvent) => {
             const selectionValue = (e.target as HTMLSelectElement).value;
@@ -64,16 +68,11 @@
 
 class LogTagChip implements m.ClassComponent<LogTagChipAttrs> {
   view({attrs}: m.CVnode<LogTagChipAttrs>) {
-    return m(
-        '.chip',
-        m('.chip-text', attrs.name),
-        m('button.chip-button',
-          {
-            onclick: () => {
-              attrs.removeTag(attrs.name);
-            },
-          },
-          '×'));
+    return m(Button, {
+      label: attrs.name,
+      rightIcon: 'close',
+      onclick: () => attrs.removeTag(attrs.name),
+    });
   }
 }
 
@@ -84,13 +83,14 @@
 
   view(vnode: m.Vnode<LogTagsWidgetAttrs>) {
     const tags = vnode.attrs.tags;
-    return m(
-        '.tag-container',
-        m('.chips', tags.map((tag) => m(LogTagChip, {
-                               name: tag,
-                               removeTag: this.removeTag.bind(this),
-                             }))),
-        m(`input.chip-input[placeholder='Add new tag']`, {
+    return [
+      tags.map((tag) => m(LogTagChip, {
+                 name: tag,
+                 removeTag: this.removeTag.bind(this),
+               })),
+      m(TextInput,
+        {
+          placeholder: 'Filter by tag...',
           onkeydown: (e: KeyboardEvent) => {
             // This is to avoid zooming on 'w'(and other unexpected effects
             // of key presses in this input field).
@@ -115,14 +115,16 @@
                 Actions.addLogTag({tag: htmlElement.value.trim()}));
             htmlElement.value = '';
           },
-        }));
+        }),
+    ];
   }
 }
 
 class LogTextWidget implements m.ClassComponent {
   view() {
     return m(
-        '.tag-container', m(`input.chip-input[placeholder='Search log text']`, {
+        TextInput, {
+          placeholder: 'Search logs...',
           onkeydown: (e: KeyboardEvent) => {
             // This is to avoid zooming on 'w'(and other unexpected effects
             // of key presses in this input field).
@@ -136,7 +138,7 @@
             globals.dispatch(
                 Actions.updateLogFilterText({textEntry: htmlElement.value}));
           },
-        }));
+        });
   }
 }
 
@@ -145,35 +147,32 @@
     const icon = attrs.hideNonMatching ? 'unfold_less' : 'unfold_more';
     const tooltip = attrs.hideNonMatching ? 'Expand all and view highlighted' :
                                             'Collapse all';
-    return m(
-        '.filter-widget',
-        m('.tooltip', tooltip),
-        m('i.material-icons',
-          {
-            onclick: () => {
-              globals.dispatch(Actions.toggleCollapseByTextEntry({}));
-            },
-          },
-          icon));
+    return m(Button, {
+      icon,
+      title: tooltip,
+      disabled: globals.state.logFilteringCriteria.textEntry === '',
+      minimal: true,
+      onclick: () => globals.dispatch(Actions.toggleCollapseByTextEntry({})),
+    });
   }
 }
 
 export class LogsFilters implements m.ClassComponent {
   view(_: m.CVnode<{}>) {
-    return m(
-        '.log-filters',
-        m('.log-label', 'Log Level'),
-        m(LogPriorityWidget, {
-          options: LOG_PRIORITIES,
-          selectedIndex: globals.state.logFilteringCriteria.minimumLevel,
-          onSelect: (minimumLevel) => {
-            globals.dispatch(Actions.setMinimumLogLevel({minimumLevel}));
-          },
-        }),
-        m(LogTagsWidget, {tags: globals.state.logFilteringCriteria.tags}),
-        m(LogTextWidget),
-        m(FilterByTextWidget, {
-          hideNonMatching: globals.state.logFilteringCriteria.hideNonMatching,
-        }));
+    return [
+      m('.log-label', 'Log Level'),
+      m(LogPriorityWidget, {
+        options: LOG_PRIORITIES,
+        selectedIndex: globals.state.logFilteringCriteria.minimumLevel,
+        onSelect: (minimumLevel) => {
+          globals.dispatch(Actions.setMinimumLogLevel({minimumLevel}));
+        },
+      }),
+      m(LogTagsWidget, {tags: globals.state.logFilteringCriteria.tags}),
+      m(LogTextWidget),
+      m(FilterByTextWidget, {
+        hideNonMatching: globals.state.logFilteringCriteria.hideNonMatching,
+      }),
+    ];
   }
 }
diff --git a/ui/src/frontend/logs_panel.ts b/ui/src/frontend/logs_panel.ts
index eb9a5c2..71c3b20 100644
--- a/ui/src/frontend/logs_panel.ts
+++ b/ui/src/frontend/logs_panel.ts
@@ -14,7 +14,6 @@
 
 import m from 'mithril';
 
-import {assertExists} from '../base/logging';
 import {Actions} from '../common/actions';
 import {HighPrecisionTimeSpan} from '../common/high_precision_time';
 import {
@@ -30,21 +29,20 @@
 import {LOG_PRIORITIES, LogsFilters} from './logs_filters';
 import {Panel} from './panel';
 import {asTPTimestamp} from './sql_types';
+import {DetailsShell} from './widgets/details_shell';
 import {Timestamp} from './widgets/timestamp';
+import {VirtualScrollContainer} from './widgets/virtual_scroll_container';
 
 const ROW_H = 20;
 
 export class LogPanel extends Panel<{}> {
-  private scrollContainer?: HTMLElement;
   private bounds?: LogBounds;
   private entries?: LogEntries;
 
   private visibleRowOffset = 0;
   private visibleRowCount = 0;
 
-  recomputeVisibleRowsAndUpdate() {
-    const scrollContainer = assertExists(this.scrollContainer);
-
+  recomputeVisibleRowsAndUpdate(scrollContainer: HTMLElement) {
     const prevOffset = this.visibleRowOffset;
     const prevCount = this.visibleRowCount;
     this.visibleRowOffset = Math.floor(scrollContainer.scrollTop / ROW_H);
@@ -59,15 +57,11 @@
     }
   }
 
-  oncreate({dom}: m.CVnodeDOM) {
-    this.scrollContainer = assertExists(dom.parentElement as HTMLElement);
-    this.scrollContainer.addEventListener(
-        'scroll', this.onScroll.bind(this), {passive: true});
+  oncreate(_: m.CVnodeDOM) {
     // TODO(stevegolton): Type assersions are a source of bugs.
     // Let's try to find another way of doing this.
     this.bounds = globals.trackDataStore.get(LogBoundsKey) as LogBounds;
     this.entries = globals.trackDataStore.get(LogEntriesKey) as LogEntries;
-    this.recomputeVisibleRowsAndUpdate();
   }
 
   onbeforeupdate(_: m.CVnodeDOM) {
@@ -75,14 +69,12 @@
     // Let's try to find another way of doing this.
     this.bounds = globals.trackDataStore.get(LogBoundsKey) as LogBounds;
     this.entries = globals.trackDataStore.get(LogEntriesKey) as LogEntries;
-    this.recomputeVisibleRowsAndUpdate();
   }
 
-  onScroll() {
-    if (this.scrollContainer === undefined) return;
-    this.recomputeVisibleRowsAndUpdate();
+  onScroll = (scrollContainer: HTMLElement) => {
+    this.recomputeVisibleRowsAndUpdate(scrollContainer);
     globals.rafScheduler.scheduleFullRedraw();
-  }
+  };
 
   onRowOver(ts: TPTime) {
     globals.dispatch(Actions.setHoverCursorTimestamp({ts}));
@@ -167,18 +159,22 @@
       }
     }
 
+    // TODO(stevegolton): Add a 'loading' state to DetailsShell, which shows a
+    // scrolling scrolly bar at the bottom of the banner & map isStale to it
     return m(
-        '.log-panel',
-        m('header',
-          {
-            'class': isStale ? 'stale' : '',
-          },
-          [
-            m('.log-rows-label',
-              `Logs rows [${offset}, ${offset + count}] / ${total}`),
-            m(LogsFilters),
-          ]),
-        m('.rows', {style: {height: `${total * ROW_H}px`}}, rows));
+        DetailsShell,
+        {
+          title: 'Android Logs',
+          description: `[${offset}, ${offset + count}] / ${total}`,
+          buttons: m(LogsFilters),
+        },
+        m(
+            VirtualScrollContainer,
+            {onScroll: this.onScroll},
+            m('.log-panel',
+              m('.rows', {style: {height: `${total * ROW_H}px`}}, rows)),
+            ),
+    );
   }
 
   renderCanvas() {}
diff --git a/ui/src/frontend/query_result_tab.ts b/ui/src/frontend/query_result_tab.ts
index e0eb644..dfcaf8c 100644
--- a/ui/src/frontend/query_result_tab.ts
+++ b/ui/src/frontend/query_result_tab.ts
@@ -56,7 +56,7 @@
 }
 
 export class QueryResultTab extends BottomTab<QueryResultTabConfig> {
-  static readonly kind = 'org.perfetto.QueryResultTab';
+  static readonly kind = 'dev.perfetto.QueryResultTab';
 
   queryResponse?: QueryResponse;
   sqlViewName?: string;
@@ -105,6 +105,7 @@
     return m(QueryTable, {
       query: this.config.query,
       resp: this.queryResponse,
+      fillParent: true,
       onClose: () => closeTab(this.uuid),
       contextButtons: [
         this.sqlViewName === undefined ?
diff --git a/ui/src/frontend/query_table.ts b/ui/src/frontend/query_table.ts
index be864fc..ad75cb6 100644
--- a/ui/src/frontend/query_table.ts
+++ b/ui/src/frontend/query_table.ts
@@ -14,8 +14,8 @@
 
 
 import m from 'mithril';
-import {BigintMath} from '../base/bigint_math';
 
+import {BigintMath} from '../base/bigint_math';
 import {Actions} from '../common/actions';
 import {QueryResponse} from '../common/queries';
 import {Row} from '../common/query_result';
@@ -28,6 +28,9 @@
 import {Router} from './router';
 import {reveal} from './scroll_helper';
 import {Button} from './widgets/button';
+import {Callout} from './widgets/callout';
+import {DetailsShell} from './widgets/details_shell';
+import {exists} from './widgets/utils';
 
 interface QueryTableRowAttrs {
   row: Row;
@@ -183,7 +186,8 @@
     if (resp.error) {
       return m('.query-error', `SQL error: ${resp.error}`);
     } else {
-      return m('table.query-table', m('thead', tableHeader), m('tbody', rows));
+      return m(
+          'table.pf-query-table', m('thead', tableHeader), m('tbody', rows));
     }
   }
 }
@@ -193,59 +197,81 @@
   onClose: () => void;
   resp?: QueryResponse;
   contextButtons?: m.Child[];
+  fillParent: boolean;
 }
 
 export class QueryTable extends Panel<QueryTableAttrs> {
-  view(vnode: m.CVnode<QueryTableAttrs>) {
-    const resp = vnode.attrs.resp;
+  view({attrs}: m.CVnode<QueryTableAttrs>) {
+    const {
+      resp,
+      query,
+      onClose,
+      contextButtons = [],
+      fillParent,
+    } = attrs;
 
-    const header: m.Child[] = [
-      m('span',
-        resp ? `Query result - ${Math.round(resp.durationMs)} ms` :
-               `Query - running`),
-      m('span.code.text-select', vnode.attrs.query),
-      m('span.spacer'),
-      ...(vnode.attrs.contextButtons ?? []),
+    return m(
+        DetailsShell,
+        {
+          title: this.renderTitle(resp),
+          description: query,
+          buttons: this.renderButtons(query, onClose, contextButtons, resp),
+          fillParent,
+        },
+        resp && this.renderTableContent(resp),
+    );
+  }
+
+  renderTitle(resp?: QueryResponse) {
+    if (exists(resp)) {
+      return `Query result - ${Math.round(resp.durationMs)} ms`;
+    } else {
+      return 'Query - running';
+    }
+  }
+
+  renderButtons(
+      query: string, onClose: () => void, contextButtons: m.Child[],
+      resp?: QueryResponse) {
+    return [
+      contextButtons,
       m(Button, {
         label: 'Copy query',
         minimal: true,
         onclick: () => {
-          copyToClipboard(vnode.attrs.query);
+          copyToClipboard(query);
         },
       }),
+      (resp && resp.error === undefined) && m(Button, {
+        label: 'Copy result (.tsv)',
+        minimal: true,
+        onclick: () => {
+          queryResponseToClipboard(resp);
+        },
+      }),
+      m(Button, {
+        minimal: true,
+        label: 'Close',
+        onclick: onClose,
+      }),
     ];
-    if (resp) {
-      if (resp.error === undefined) {
-        header.push(m(Button, {
-          label: 'Copy result (.tsv)',
-          minimal: true,
-          onclick: () => {
-            queryResponseToClipboard(resp);
-          },
-        }));
-      }
-    }
-    header.push(m(Button, {
-      label: 'Close',
-      minimal: true,
-      onclick: () => vnode.attrs.onClose(),
-    }));
+  }
 
-    const headers = [m('header.overview', ...header)];
-
-    if (resp === undefined) {
-      return headers;
-    }
-
-    if (resp.statementWithOutputCount > 1) {
-      headers.push(
-          m('header.overview',
-            `${resp.statementWithOutputCount} out of ${resp.statementCount} ` +
-                `statements returned a result. Only the results for the last ` +
-                `statement are displayed in the table below.`));
-    }
-
-    return [...headers, m(QueryTableContent, {resp})];
+  renderTableContent(resp: QueryResponse) {
+    return m(
+        '.pf-query-panel',
+        resp.statementWithOutputCount > 1 &&
+            m('.pf-query-warning',
+              m(
+                  Callout,
+                  {icon: 'warning'},
+                  `${resp.statementWithOutputCount} out of ${
+                      resp.statementCount} `,
+                  'statements returned a result. ',
+                  'Only the results for the last statement are displayed.',
+                  )),
+        m(QueryTableContent, {resp}),
+    );
   }
 
   renderCanvas() {}
diff --git a/ui/src/frontend/semantic_icons.ts b/ui/src/frontend/semantic_icons.ts
index a80acca..da6326e 100644
--- a/ui/src/frontend/semantic_icons.ts
+++ b/ui/src/frontend/semantic_icons.ts
@@ -19,4 +19,12 @@
   static readonly ContextMenu = 'arrow_drop_down';  // Could be 'more_vert'
   static readonly Copy = 'content_copy';
   static readonly Delete = 'delete';
+  static readonly SortedAsc = 'arrow_upward';
+  static readonly SortedDesc = 'arrow_downward';
+  static readonly GoBack = 'chevron_left';
+  static readonly GoForward = 'chevron_right';
+  static readonly AddColumn = 'add';
+  static readonly Close = 'close';
+  static readonly Hide = 'visibility_off';
+  static readonly Filter = 'filter_list';
 }
diff --git a/ui/src/frontend/sql/args.ts b/ui/src/frontend/sql/args.ts
index a03a8b5..6d76ad4 100644
--- a/ui/src/frontend/sql/args.ts
+++ b/ui/src/frontend/sql/args.ts
@@ -27,7 +27,7 @@
 } from '../sql_types';
 
 export type ArgValue = bigint|string|number|boolean|null;
-type ArgValueType = 'int'|'uint'|'string'|'bool'|'real'|'null';
+type ArgValueType = 'int'|'uint'|'pointer'|'string'|'bool'|'real'|'null';
 
 export interface Arg {
   id: ArgsId;
@@ -90,6 +90,9 @@
     case 'int':
     case 'uint':
       return value.intValue;
+    case 'pointer':
+      return value.intValue === null ? null :
+                                       `0x${value.intValue.toString(16)}`;
     case 'string':
       return value.stringValue;
     case 'bool':
diff --git a/ui/src/frontend/sql/slice.ts b/ui/src/frontend/sql/slice.ts
index d29706b..fa9c5d6 100644
--- a/ui/src/frontend/sql/slice.ts
+++ b/ui/src/frontend/sql/slice.ts
@@ -185,10 +185,16 @@
   readonly ts: TPTimestamp;
   readonly dur: TPDuration;
   readonly sqlTrackId: number;
+
+  // Whether clicking on the reference should change the current tab
+  // to "current selection" tab in addition to updating the selection
+  // and changing the viewport. True by default.
+  readonly switchToCurrentSelectionTab?: boolean;
 }
 
 export class SliceRef implements m.ClassComponent<SliceRefAttrs> {
   view(vnode: m.Vnode<SliceRefAttrs>) {
+    const switchTab = vnode.attrs.switchToCurrentSelectionTab ?? true;
     return m(
         Anchor,
         {
@@ -201,8 +207,10 @@
             // Clamp duration to 1 - i.e. for instant events
             const dur = BigintMath.max(1n, vnode.attrs.dur);
             focusHorizontalRange(vnode.attrs.ts, vnode.attrs.ts + dur);
-            globals.makeSelection(Actions.selectChromeSlice(
-                {id: vnode.attrs.id, trackId: uiTrackId, table: 'slice'}));
+            globals.makeSelection(
+                Actions.selectChromeSlice(
+                    {id: vnode.attrs.id, trackId: uiTrackId, table: 'slice'}),
+                switchTab ? 'current_selection' : null);
           },
         },
         vnode.attrs.name);
diff --git a/ui/src/frontend/sql_table/argument_selector.ts b/ui/src/frontend/sql_table/argument_selector.ts
new file mode 100644
index 0000000..0189244
--- /dev/null
+++ b/ui/src/frontend/sql_table/argument_selector.ts
@@ -0,0 +1,85 @@
+// 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 {EngineProxy} from '../../common/engine';
+import {STR} from '../../common/query_result';
+import {globals} from '../globals';
+import {constraintsToQueryFragment} from '../sql_utils';
+import {FilterableSelect} from '../widgets/select';
+import {Spinner} from '../widgets/spinner';
+
+import {argColumn} from './column';
+import {ArgSetIdColumn} from './table_description';
+
+const MAX_ARGS_TO_DISPLAY = 15;
+
+interface ArgumentSelectorAttrs {
+  engine: EngineProxy;
+  argSetId: ArgSetIdColumn;
+  tableName: string;
+  filters: string[];
+  // List of aliases for existing columns by the table.
+  alreadySelectedColumns: Set<string>;
+  onArgumentSelected: (argument: string) => void;
+}
+
+// A widget which allows the user to select a new argument to display.
+// Dinamically queries Trace Processor to find the relevant set of arg_set_ids
+// and which args are present in these arg sets.
+export class ArgumentSelector implements
+    m.ClassComponent<ArgumentSelectorAttrs> {
+  argList?: string[];
+
+  constructor({attrs}: m.Vnode<ArgumentSelectorAttrs>) {
+    this.load(attrs);
+  }
+
+  private async load(attrs: ArgumentSelectorAttrs) {
+    const queryResult = await attrs.engine.query(`
+      -- Encapsulate the query in a CTE to avoid clashes between filters
+      -- and columns of the 'args' table.
+      WITH arg_sets AS (
+        SELECT DISTINCT ${attrs.argSetId.name} as arg_set_id
+        FROM ${attrs.tableName}
+        ${constraintsToQueryFragment({
+      filters: attrs.filters,
+    })}
+      )
+      SELECT
+        DISTINCT args.key as key
+      FROM arg_sets
+      JOIN args USING (arg_set_id)
+    `);
+    this.argList = [];
+    const it = queryResult.iter({key: STR});
+    for (; it.valid(); it.next()) {
+      const arg = argColumn(attrs.argSetId, it.key);
+      if (attrs.alreadySelectedColumns.has(arg.alias)) continue;
+      this.argList.push(it.key);
+    }
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  view({attrs}: m.Vnode<ArgumentSelectorAttrs>) {
+    if (this.argList === undefined) return m(Spinner);
+    return m(FilterableSelect, {
+      values: this.argList,
+      onSelected: (value: string) => attrs.onArgumentSelected(value),
+      maxDisplayedItems: MAX_ARGS_TO_DISPLAY,
+      autofocusInput: true,
+    });
+  }
+}
diff --git a/ui/src/frontend/sql_table/column.ts b/ui/src/frontend/sql_table/column.ts
new file mode 100644
index 0000000..e94da6f
--- /dev/null
+++ b/ui/src/frontend/sql_table/column.ts
@@ -0,0 +1,66 @@
+// 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 {sqliteString} from '../../base/string_utils';
+
+import {
+  ArgSetIdColumn,
+  dependendentColumns,
+  DisplayConfig,
+  RegularSqlTableColumn,
+} from './table_description';
+
+// This file contains the defintions of different column types that can be
+// displayed in the table viewer.
+
+export interface Column {
+  // SQL expression calculating the value of this column.
+  expression: string;
+  // Unique name for this column.
+  // The relevant bit of SQL fetching this column will be ${expression} as
+  // ${alias}.
+  alias: string;
+  // Title to be displayed in the table header.
+  title: string;
+  // How the value of this column should be rendered.
+  display?: DisplayConfig;
+}
+
+export function columnFromSqlTableColumn(c: RegularSqlTableColumn): Column {
+  return {
+    expression: c.name,
+    alias: c.name,
+    title: c.title || c.name,
+    display: c.display,
+  };
+}
+
+export function argColumn(c: ArgSetIdColumn, argName: string): Column {
+  const escape = (name: string) => name.replace(/\.|\[|\]/g, '_');
+  return {
+    expression: `extract_arg(${c.name}, ${sqliteString(argName)})`,
+    alias: `_arg_${c.name}_${escape(argName)}`,
+    title: `${c.title ?? c.name} ${argName}`,
+  };
+}
+
+// Returns a list of projections (i.e. parts of the SELECT clause) that should
+// be added to the query fetching the data to be able to display the given
+// column (e.g. `foo` or `f(bar) as baz`).
+// Some table columns are backed by multiple SQL columns (e.g. slice_id is
+// backed by id, ts, dur and track_id), so we need to return a list.
+export function sqlProjectionsForColumn(column: Column): string[] {
+  return [`${column.expression} as ${column.alias}`].concat(
+      dependendentColumns(column.display).map((c) => `${c} as ${c}`));
+}
diff --git a/ui/src/frontend/sql_table/render_cell.ts b/ui/src/frontend/sql_table/render_cell.ts
new file mode 100644
index 0000000..b3d0a4b
--- /dev/null
+++ b/ui/src/frontend/sql_table/render_cell.ts
@@ -0,0 +1,214 @@
+// 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 {sqliteString} from '../../base/string_utils';
+import {Row, SqlValue} from '../../common/query_result';
+import {formatDuration, TPTime} from '../../common/time';
+import {Anchor} from '../anchor';
+import {copyToClipboard} from '../clipboard';
+import {Icons} from '../semantic_icons';
+import {SliceRef} from '../sql/slice';
+import {asSliceSqlId, asTPTimestamp} from '../sql_types';
+import {sqlValueToString} from '../sql_utils';
+import {Err} from '../widgets/error';
+import {MenuItem, PopupMenu2} from '../widgets/menu';
+import {Timestamp} from '../widgets/timestamp';
+
+import {Column} from './column';
+import {SqlTableState} from './state';
+import {SliceIdDisplayConfig} from './table_description';
+
+// This file is responsible for rendering a value in a given sell based on the
+// column type.
+
+function filterOptionMenuItem(
+    label: string, filter: string, state: SqlTableState): m.Child {
+  return m(MenuItem, {
+    label,
+    onclick: () => {
+      state.addFilter(filter);
+    },
+  });
+}
+
+function getStandardFilters(
+    c: Column, value: SqlValue, state: SqlTableState): m.Child[] {
+  if (value === null) {
+    return [
+      filterOptionMenuItem('is null', `${c.expression} is null`, state),
+      filterOptionMenuItem('is not null', `${c.expression} is not null`, state),
+    ];
+  }
+  if (typeof value === 'string') {
+    return [
+      filterOptionMenuItem(
+          'equals to', `${c.expression} = ${sqliteString(value)}`, state),
+      filterOptionMenuItem(
+          'not equals to', `${c.expression} != ${sqliteString(value)}`, state),
+    ];
+  }
+  if (typeof value === 'bigint' || typeof value === 'number') {
+    return [
+      filterOptionMenuItem('equals to', `${c.expression} = ${value}`, state),
+      filterOptionMenuItem(
+          'not equals to', `${c.expression} != ${value}`, state),
+      filterOptionMenuItem('greater than', `${c.expression} > ${value}`, state),
+      filterOptionMenuItem(
+          'greater or equals than', `${c.expression} >= ${value}`, state),
+      filterOptionMenuItem('less than', `${c.expression} < ${value}`, state),
+      filterOptionMenuItem(
+          'less or equals than', `${c.expression} <= ${value}`, state),
+    ];
+  }
+  return [];
+}
+
+function displayValue(value: SqlValue): m.Child {
+  if (value === null) {
+    return m('i', 'NULL');
+  }
+  return sqlValueToString(value);
+}
+
+function displayDuration(value: TPTime): string;
+function displayDuration(value: SqlValue): m.Children;
+function displayDuration(value: SqlValue): m.Children {
+  if (typeof value !== 'bigint') return displayValue(value);
+  return formatDuration(value);
+}
+
+function display(column: Column, row: Row): m.Children {
+  const value = row[column.alias];
+
+  // Handle all cases when we have non-trivial formatting.
+  switch (column.display?.type) {
+    case 'duration':
+    case 'thread_duration':
+      return displayDuration(value);
+  }
+
+  return displayValue(value);
+}
+
+function copyMenuItem(label: string, value: string): m.Child {
+  return m(MenuItem, {
+    icon: Icons.Copy,
+    label,
+    onclick: () => {
+      copyToClipboard(value);
+    },
+  });
+}
+
+function getContextMenuItems(
+    column: Column, row: Row, state: SqlTableState): m.Child[] {
+  const result: m.Child[] = [];
+  const value = row[column.alias];
+
+  if ((column.display?.type === 'duration' ||
+       column.display?.type === 'thread_duration') &&
+      typeof value === 'bigint') {
+    result.push(copyMenuItem('Copy raw duration', `${value}`));
+    result.push(
+        copyMenuItem('Copy formatted duration', displayDuration(value)));
+  }
+  if (typeof value === 'string') {
+    result.push(copyMenuItem('Copy', value));
+  }
+
+  const filters = getStandardFilters(column, value, state);
+  if (filters.length > 0) {
+    result.push(
+        m(MenuItem, {label: 'Add filter', icon: Icons.Filter}, ...filters));
+  }
+
+  return result;
+}
+
+function renderStandardColumn(
+    column: Column, row: Row, state: SqlTableState): m.Children {
+  const displayValue = display(column, row);
+  const contextMenuItems: m.Child[] = getContextMenuItems(column, row, state);
+  return m(
+      PopupMenu2,
+      {
+        trigger: m(Anchor, displayValue),
+      },
+      ...contextMenuItems,
+  );
+}
+
+function renderTimestampColumn(
+    column: Column, row: Row, state: SqlTableState): m.Children {
+  const value = row[column.alias];
+  if (typeof value !== 'bigint') {
+    return renderStandardColumn(column, row, state);
+  }
+
+  return m(Timestamp, {
+    ts: asTPTimestamp(value),
+    extraMenuItems: getContextMenuItems(column, row, state),
+  });
+}
+
+function renderSliceIdColumn(
+    column: {alias: string, display: SliceIdDisplayConfig},
+    row: Row): m.Children {
+  const config = column.display;
+  const id = row[column.alias];
+  const ts = row[config.ts];
+  const dur = row[config.dur] === null ? -1n : row[config.dur];
+  const trackId = row[config.trackId];
+
+  const columnNotFoundError = (type: string, name: string) =>
+      m(Err, `${type} column ${name} not found`);
+  const wrongTypeError = (type: string, name: string, value: SqlValue) =>
+      m(Err,
+        `Wrong type for ${type} column ${name}: bigint expected, ${
+            typeof value} found`);
+
+  if (typeof id !== 'bigint') return sqlValueToString(id);
+  if (ts === undefined) return columnNotFoundError('Timestamp', config.ts);
+  if (typeof ts !== 'bigint') return wrongTypeError('timestamp', config.ts, ts);
+  if (dur === undefined) return columnNotFoundError('Duration', config.dur);
+  if (typeof dur !== 'bigint') {
+    return wrongTypeError('duration', config.dur, ts);
+  }
+  if (trackId === undefined) return columnNotFoundError('Track id', trackId);
+  if (typeof trackId !== 'bigint') {
+    return wrongTypeError('track id', config.trackId, trackId);
+  }
+
+  return m(SliceRef, {
+    id: asSliceSqlId(Number(id)),
+    name: `${id}`,
+    ts: asTPTimestamp(ts as bigint),
+    dur: dur,
+    sqlTrackId: Number(trackId),
+    switchToCurrentSelectionTab: false,
+  });
+}
+
+export function renderCell(
+    column: Column, row: Row, state: SqlTableState): m.Children {
+  if (column.display && column.display.type === 'slice_id') {
+    return renderSliceIdColumn(
+        {alias: column.alias, display: column.display}, row);
+  } else if (column.display && column.display.type === 'timestamp') {
+    return renderTimestampColumn(column, row, state);
+  }
+  return renderStandardColumn(column, row, state);
+}
diff --git a/ui/src/frontend/sql_table/state.ts b/ui/src/frontend/sql_table/state.ts
new file mode 100644
index 0000000..409d409
--- /dev/null
+++ b/ui/src/frontend/sql_table/state.ts
@@ -0,0 +1,296 @@
+// 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 {arrayEquals} from '../../base/array_utils';
+import {SortDirection} from '../../base/comparison_utils';
+import {EngineProxy} from '../../common/engine';
+import {NUM, Row} from '../../common/query_result';
+import {globals} from '../globals';
+import {constraintsToQueryFragment} from '../sql_utils';
+
+import {
+  Column,
+  columnFromSqlTableColumn,
+  sqlProjectionsForColumn,
+} from './column';
+import {SqlTableDescription, startsHidden} from './table_description';
+
+interface ColumnOrderClause {
+  // We only allow the table to be sorted by the columns which are displayed to
+  // the user to avoid confusion, so we use a reference to the underlying Column
+  // here and compare it by reference down the line.
+  column: Column;
+  direction: SortDirection;
+}
+
+const ROW_LIMIT = 100;
+
+// Result of the execution of the query.
+interface Data {
+  // Rows to show, including pagination.
+  rows: Row[];
+  error?: string;
+}
+
+interface RowCount {
+  // Total number of rows in view, excluding the pagination.
+  // Undefined if the query returned an error.
+  count: number;
+  // Filters which were used to compute this row count.
+  // We need to recompute the totalRowCount only when filters change and not
+  // when the set of columns / order by changes.
+  filters: string[];
+}
+
+export class SqlTableState {
+  private readonly engine_: EngineProxy;
+  private readonly table_: SqlTableDescription;
+
+  get engine() {
+    return this.engine_;
+  }
+  get table() {
+    return this.table_;
+  }
+
+  private filters: string[];
+  private columns: Column[];
+  private orderBy: ColumnOrderClause[];
+  private offset = 0;
+  private data?: Data;
+  private rowCount?: RowCount;
+
+  constructor(
+      engine: EngineProxy, table: SqlTableDescription, filters?: string[]) {
+    this.engine_ = engine;
+    this.table_ = table;
+
+    this.filters = filters || [];
+    this.columns = [];
+    for (const column of this.table.columns) {
+      if (startsHidden(column)) continue;
+      this.columns.push(columnFromSqlTableColumn(column));
+    }
+    this.orderBy = [];
+
+    this.reload();
+  }
+
+  // Compute the actual columns to fetch.
+  private getSQLProjections(): string[] {
+    const result = new Set<string>();
+    for (const column of this.columns) {
+      for (const p of sqlProjectionsForColumn(column)) {
+        result.add(p);
+      }
+    }
+    return Array.from(result);
+  }
+
+  private getSQLImports() {
+    return (this.table.imports || [])
+        .map((i) => `SELECT IMPORT("${i}");`)
+        .join('\n');
+  }
+
+  private getCountRowsSQLQuery(): string {
+    return `
+      ${this.getSQLImports()}
+
+      SELECT
+        COUNT() AS count
+      FROM ${this.table.name}
+      ${constraintsToQueryFragment({
+      filters: this.filters,
+    })}
+    `;
+  }
+
+  getNonPaginatedSQLQuery(): string {
+    const orderBy = this.orderBy.map((c) => ({
+                                       fieldName: c.column.alias,
+                                       direction: c.direction,
+                                     }));
+    return `
+      ${this.getSQLImports()}
+
+      SELECT
+        ${this.getSQLProjections().join(',\n')}
+      FROM ${this.table.name}
+      ${constraintsToQueryFragment({
+      filters: this.filters,
+      orderBy: orderBy,
+    })}
+    `;
+  }
+
+  getPaginatedSQLQuery():
+      string {  // We fetch one more row to determine if we can go forward.
+    return `
+      ${this.getNonPaginatedSQLQuery()}
+      LIMIT ${ROW_LIMIT + 1}
+      OFFSET ${this.offset}
+    `;
+  }
+
+  canGoForward(): boolean {
+    if (this.data === undefined) return false;
+    return this.data.rows.length > ROW_LIMIT;
+  }
+
+  canGoBack(): boolean {
+    if (this.data === undefined) return false;
+    return this.offset > 0;
+  }
+
+  goForward() {
+    if (!this.canGoForward()) return;
+    this.offset += ROW_LIMIT;
+    this.reload({offset: 'keep'});
+  }
+
+  goBack() {
+    if (!this.canGoBack()) return;
+    this.offset -= ROW_LIMIT;
+    this.reload({offset: 'keep'});
+  }
+
+  getDisplayedRange(): {from: number, to: number}|undefined {
+    if (this.data === undefined) return undefined;
+    return {
+      from: this.offset + 1,
+      to: this.offset + Math.min(this.data.rows.length, ROW_LIMIT),
+    };
+  }
+
+  private async loadRowCount(): Promise<RowCount|undefined> {
+    const filters = Array.from(this.filters);
+    const res = await this.engine.query(this.getCountRowsSQLQuery());
+    if (res.error() !== undefined) return undefined;
+    return {
+      count: res.firstRow({count: NUM}).count,
+      filters: filters,
+    };
+  }
+
+  private async loadData(): Promise<Data> {
+    const queryRes = await this.engine.query(this.getPaginatedSQLQuery());
+    const rows: Row[] = [];
+    for (const it = queryRes.iter({}); it.valid(); it.next()) {
+      const row: Row = {};
+      for (const column of queryRes.columns()) {
+        row[column] = it.get(column);
+      }
+      rows.push(row);
+    }
+
+    return {
+      rows,
+      error: queryRes.error(),
+    };
+  }
+
+  private async reload(params?: {offset: 'reset'|'keep'}) {
+    if ((params?.offset ?? 'reset') === 'reset') {
+      this.offset = 0;
+    }
+    const updateRowCount = !arrayEquals(this.rowCount?.filters, this.filters);
+    this.data = undefined;
+    if (updateRowCount) {
+      this.rowCount = undefined;
+    }
+
+    // Delay the visual update by 50ms to avoid flickering (if the query returns
+    // before the data is loaded.
+    setTimeout(() => globals.rafScheduler.scheduleFullRedraw(), 50);
+
+    if (updateRowCount) {
+      this.rowCount = await this.loadRowCount();
+    }
+    this.data = await this.loadData();
+
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  getTotalRowCount(): number|undefined {
+    return this.rowCount?.count;
+  }
+
+  getDisplayedRows(): Row[] {
+    return this.data?.rows || [];
+  }
+
+  getQueryError(): string|undefined {
+    return this.data?.error;
+  }
+
+  isLoading() {
+    return this.data === undefined;
+  }
+
+  removeFilter(filter: string) {
+    this.filters.splice(this.filters.indexOf(filter), 1);
+    this.reload();
+  }
+
+  addFilter(filter: string) {
+    this.filters.push(filter);
+    this.reload();
+  }
+
+  getFilters(): string[] {
+    return this.filters;
+  }
+
+  sortBy(clause: ColumnOrderClause) {
+    this.orderBy = this.orderBy || [];
+    // Remove previous sort by the same column.
+    this.orderBy = this.orderBy.filter((c) => c.column !== clause.column);
+    // Add the new sort clause to the front, so we effectively stable-sort the
+    // data currently displayed to the user.
+    this.orderBy.unshift(clause);
+    this.reload();
+  }
+
+  unsort() {
+    this.orderBy = [];
+    this.reload();
+  }
+
+  isSortedBy(column: Column): SortDirection|undefined {
+    if (!this.orderBy) return undefined;
+    if (this.orderBy.length === 0) return undefined;
+    if (this.orderBy[0].column !== column) return undefined;
+    return this.orderBy[0].direction;
+  }
+
+  addColumn(column: Column, index: number) {
+    this.columns.splice(index + 1, 0, column);
+    this.reload({offset: 'keep'});
+  }
+
+  hideColumnAtIndex(index: number) {
+    const column = this.columns[index];
+    this.columns.splice(index, 1);
+    // We can only filter by the visibile columns to avoid confusing the user,
+    // so we remove order by clauses that refer to the hidden column.
+    this.orderBy = this.orderBy.filter((c) => c.column !== column);
+    // TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed.
+    this.reload({offset: 'keep'});
+  }
+
+  getSelectedColumns(): Column[] {
+    return this.columns;
+  }
+};
diff --git a/ui/src/frontend/sql_table/tab.ts b/ui/src/frontend/sql_table/tab.ts
new file mode 100644
index 0000000..bc3318f
--- /dev/null
+++ b/ui/src/frontend/sql_table/tab.ts
@@ -0,0 +1,106 @@
+// 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 {BottomTab, bottomTabRegistry, NewBottomTabArgs} from '../bottom_tab';
+import {copyToClipboard} from '../clipboard';
+import {Icons} from '../semantic_icons';
+import {Button} from '../widgets/button';
+import {DetailsShell} from '../widgets/details_shell';
+import {exists} from '../widgets/utils';
+
+import {SqlTableState} from './state';
+import {SqlTable} from './table';
+import {SqlTableDescription} from './table_description';
+
+interface SqlTableTabConfig {
+  table: SqlTableDescription;
+  displayName?: string;
+  filters?: string[];
+}
+
+export class SqlTableTab extends BottomTab<SqlTableTabConfig> {
+  static readonly kind = 'dev.perfetto.SqlTableTab';
+
+  private state: SqlTableState;
+
+  constructor(args: NewBottomTabArgs) {
+    super(args);
+
+    this.state =
+        new SqlTableState(this.engine, this.config.table, this.config.filters);
+  }
+
+  static create(args: NewBottomTabArgs): SqlTableTab {
+    return new SqlTableTab(args);
+  }
+
+  viewTab() {
+    const range = this.state.getDisplayedRange();
+    const rowCount = this.state.getTotalRowCount();
+    const navigation = [
+      exists(range) && exists(rowCount) &&
+          `Showing rows ${range.from}-${range.to} of ${rowCount}`,
+      m(Button, {
+        icon: Icons.GoBack,
+        disabled: !this.state.canGoBack(),
+        onclick: () => this.state.goBack(),
+        minimal: true,
+      }),
+      m(Button, {
+        icon: Icons.GoForward,
+        disabled: !this.state.canGoForward(),
+        onclick: () => this.state.goForward(),
+        minimal: true,
+      }),
+    ];
+
+    return m(
+        DetailsShell,
+        {
+          title: 'Table',
+          description: this.config.displayName ?? this.config.table.name,
+          buttons: [
+            ...navigation,
+            m(Button, {
+              label: 'Copy SQL query',
+              onclick: () =>
+                  copyToClipboard(this.state.getNonPaginatedSQLQuery()),
+            }),
+            m(Button, {
+              label: 'Close',
+              onclick: () => this.close(),
+            }),
+          ],
+        },
+        m(SqlTable, {
+          state: this.state,
+        }));
+  }
+
+  renderTabCanvas() {}
+
+  getTitle(): string {
+    const rowCount = this.state.getTotalRowCount();
+    const rows = rowCount === undefined ? '' : `(${rowCount})`;
+    return `Table ${this.config.displayName ?? this.config.table.name} ${rows}`;
+  }
+
+  isLoading(): boolean {
+    return this.state.isLoading();
+  }
+}
+
+bottomTabRegistry.register(SqlTableTab);
diff --git a/ui/src/frontend/sql_table/table.ts b/ui/src/frontend/sql_table/table.ts
new file mode 100644
index 0000000..a42ad89
--- /dev/null
+++ b/ui/src/frontend/sql_table/table.ts
@@ -0,0 +1,165 @@
+// 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 {EngineProxy} from '../../common/engine';
+import {Row} from '../../common/query_result';
+import {Anchor} from '../anchor';
+import {Icons} from '../semantic_icons';
+import {BasicTable} from '../widgets/basic_table';
+import {Button} from '../widgets/button';
+import {MenuDivider, MenuItem, PopupMenu2} from '../widgets/menu';
+
+import {ArgumentSelector} from './argument_selector';
+import {argColumn, Column, columnFromSqlTableColumn} from './column';
+import {renderCell} from './render_cell';
+import {SqlTableState} from './state';
+import {isArgSetIdColumn, SqlTableDescription} from './table_description';
+
+export interface SqlTableConfig {
+  readonly state: SqlTableState;
+}
+
+export class SqlTable implements m.ClassComponent<SqlTableConfig> {
+  private readonly table: SqlTableDescription;
+  private readonly engine: EngineProxy;
+
+  private state: SqlTableState;
+
+  constructor(vnode: m.Vnode<SqlTableConfig>) {
+    this.state = vnode.attrs.state;
+    this.table = this.state.table;
+    this.engine = this.state.engine;
+  }
+
+  renderFilters(): m.Children {
+    const filters: m.Child[] = [];
+    for (const filter of this.state.getFilters()) {
+      filters.push(m(Button, {
+        label: filter,
+        icon: 'close',
+        onclick: () => {
+          this.state.removeFilter(filter);
+        },
+      }));
+    }
+    return filters;
+  }
+
+  renderAddColumnOptions(addColumn: (column: Column) => void): m.Children {
+    // We do not want to add columns which already exist, so we track the
+    // columns which we are already showing here.
+    // TODO(altimin): Theoretically a single table can have two different
+    // arg_set_ids, so we should track (arg_set_id_column, arg_name) pairs here.
+    const existingColumns = new Set<string>();
+
+    for (const column of this.state.getSelectedColumns()) {
+      existingColumns.add(column.alias);
+    }
+
+    const result = [];
+    for (const column of this.table.columns) {
+      if (existingColumns.has(column.name)) continue;
+      if (isArgSetIdColumn(column)) {
+        result.push(
+            m(MenuItem,
+              {
+                label: column.name,
+              },
+              m(ArgumentSelector, {
+                engine: this.engine,
+                argSetId: column,
+                tableName: this.table.name,
+                filters: this.state.getFilters(),
+                alreadySelectedColumns: existingColumns,
+                onArgumentSelected: (argument: string) => {
+                  addColumn(argColumn(column, argument));
+                },
+              })));
+        continue;
+      }
+      result.push(m(MenuItem, {
+        label: column.name,
+        onclick: () => addColumn(
+            columnFromSqlTableColumn(column),
+            ),
+      }));
+    }
+    return result;
+  }
+
+  renderColumnHeader(column: Column, index: number) {
+    const sorted = this.state.isSortedBy(column);
+    const icon = sorted === 'ASC' ?
+        Icons.SortedAsc :
+        sorted === 'DESC' ? Icons.SortedDesc : Icons.ContextMenu;
+    return m(
+        PopupMenu2,
+        {
+          trigger: m(Anchor, {icon}, column.title),
+        },
+        sorted !== 'DESC' && m(MenuItem, {
+          label: 'Sort: highest first',
+          icon: Icons.SortedDesc,
+          onclick: () => {
+            this.state.sortBy({column, direction: 'DESC'});
+          },
+        }),
+        sorted !== 'ASC' && m(MenuItem, {
+          label: 'Sort: lowest first',
+          icon: Icons.SortedAsc,
+          onclick: () => {
+            this.state.sortBy({column, direction: 'ASC'});
+          },
+        }),
+        sorted !== undefined && m(MenuItem, {
+          label: 'Unsort',
+          icon: Icons.Close,
+          onclick: () => this.state.unsort(),
+        }),
+        this.state.getSelectedColumns().length > 1 && m(MenuItem, {
+          label: 'Hide',
+          icon: Icons.Hide,
+          onclick: () => this.state.hideColumnAtIndex(index),
+        }),
+        m(MenuDivider),
+        m(MenuItem,
+          {label: 'Add column', icon: Icons.AddColumn},
+          this.renderAddColumnOptions((column) => {
+            this.state.addColumn(column, index);
+          })),
+    );
+  }
+
+  view() {
+    const rows = this.state.getDisplayedRows();
+
+    return [
+      m('div', this.renderFilters()),
+      m(BasicTable, {
+        data: rows,
+        columns: this.state.getSelectedColumns().map(
+            (column, i) => ({
+              title: this.renderColumnHeader(column, i),
+              render: (row: Row) => renderCell(column, row, this.state),
+            })),
+      }),
+      this.state.getQueryError() !== undefined &&
+          m('.query-error', this.state.getQueryError()),
+    ];
+  }
+};
+
+export {SqlTableDescription};
diff --git a/ui/src/frontend/sql_table/table_description.ts b/ui/src/frontend/sql_table/table_description.ts
new file mode 100644
index 0000000..1aef50b
--- /dev/null
+++ b/ui/src/frontend/sql_table/table_description.ts
@@ -0,0 +1,96 @@
+// 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.
+
+
+// Definition of the SQL table to be displayed in the SQL table widget,
+// including the semantic definitions of the columns (e.g. timestamp
+// column which requires special formatting). Also note that some of the
+// columns require other columns for advanced display features (e.g. timestamp
+// and duration taken together define "a time range", which can be used for
+// additional filtering.
+
+export type DisplayConfig =
+    SliceIdDisplayConfig|Timestamp|Duration|ThreadDuration;
+
+// Common properties for all columns.
+interface SqlTableColumnBase {
+  // Name of the column in the SQL table.
+  name: string;
+  // Display name of the column in the UI.
+  title?: string;
+}
+
+export interface ArgSetIdColumn extends SqlTableColumnBase {
+  type: 'arg_set_id';
+}
+
+export interface RegularSqlTableColumn extends SqlTableColumnBase {
+  // Special rendering instructions for this column, including the list
+  // of additional columns required for the rendering.
+  display?: DisplayConfig;
+  // Whether the column should be hidden by default.
+  startsHidden?: boolean;
+}
+
+export type SqlTableColumn = RegularSqlTableColumn|ArgSetIdColumn;
+
+export function startsHidden(c: SqlTableColumn): boolean {
+  if (isArgSetIdColumn(c)) return true;
+  return c.startsHidden ?? false;
+}
+
+export function isArgSetIdColumn(c: SqlTableColumn): c is ArgSetIdColumn {
+  return (c as {type?: string}).type === 'arg_set_id';
+}
+
+export interface SqlTableDescription {
+  readonly imports?: string[];
+  name: string;
+  columns: SqlTableColumn[];
+}
+
+// Additional columns needed to display the given column.
+export function dependendentColumns(display?: DisplayConfig): string[] {
+  switch (display?.type) {
+    case 'slice_id':
+      return [display.ts, display.dur, display.trackId];
+    default:
+      return [];
+  }
+}
+
+// Column displaying ids into the `slice` table. Requires the ts, dur and
+// track_id columns to be able to display the value, including the
+// "go-to-slice-on-click" functionality.
+export interface SliceIdDisplayConfig {
+  type: 'slice_id';
+  ts: string;
+  dur: string;
+  trackId: string;
+}
+
+// Column displaying timestamps.
+interface Timestamp {
+  type: 'timestamp';
+}
+
+// Column displaying durations.
+export interface Duration {
+  type: 'duration';
+}
+
+// Column displaying thread durations.
+export interface ThreadDuration {
+  type: 'thread_duration';
+}
diff --git a/ui/src/frontend/sql_table/well_known_tables.ts b/ui/src/frontend/sql_table/well_known_tables.ts
new file mode 100644
index 0000000..0af9690
--- /dev/null
+++ b/ui/src/frontend/sql_table/well_known_tables.ts
@@ -0,0 +1,112 @@
+// 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 {SqlTableDescription} from './table';
+
+const sliceTable: SqlTableDescription = {
+  imports: ['experimental.slices'],
+  name: 'experimental_slice_with_thread_and_process_info',
+  columns: [
+    {
+      name: 'id',
+      title: 'ID',
+      display: {
+        type: 'slice_id',
+        ts: 'ts',
+        dur: 'dur',
+        trackId: 'track_id',
+      },
+    },
+    {
+      name: 'ts',
+      title: 'Timestamp',
+      display: {
+        type: 'timestamp',
+      },
+    },
+    {
+      name: 'dur',
+      title: 'Duration',
+      display: {
+        type: 'duration',
+      },
+    },
+    {
+      name: 'thread_dur',
+      title: 'Thread duration',
+      display: {
+        type: 'thread_duration',
+      },
+    },
+    {
+      name: 'category',
+      title: 'Category',
+    },
+    {
+      name: 'name',
+      title: 'Name',
+    },
+    {
+      name: 'track_id',
+      title: 'Track ID',
+      startsHidden: true,
+    },
+    {
+      name: 'track_name',
+      title: 'Track name',
+      startsHidden: true,
+    },
+    {
+      name: 'thread_name',
+      title: 'Thread name',
+    },
+    {
+      name: 'utid',
+      startsHidden: true,
+    },
+    {
+      name: 'tid',
+    },
+    {
+      name: 'process_name',
+      title: 'Process name',
+    },
+    {
+      name: 'upid',
+      startsHidden: true,
+    },
+    {
+      name: 'pid',
+    },
+    {
+      name: 'depth',
+      title: 'Depth',
+      startsHidden: true,
+    },
+    {
+      name: 'parent_id',
+      title: 'Parent slice ID',
+      startsHidden: true,
+    },
+    {
+      name: 'arg_set_id',
+      title: 'Arg',
+      type: 'arg_set_id',
+    },
+  ],
+};
+
+export class SqlTables {
+  static readonly slice = sliceTable;
+}
diff --git a/ui/src/frontend/sql_utils.ts b/ui/src/frontend/sql_utils.ts
index 2bbcd0e..6ef80f6 100644
--- a/ui/src/frontend/sql_utils.ts
+++ b/ui/src/frontend/sql_utils.ts
@@ -12,10 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {EngineProxy} from '../common/engine';
+import {ColumnType, NUM} from '../common/query_result';
 import {SortDirection} from '../common/state';
-import {ColumnType} from '../common/query_result';
 
-interface OrderClause {
+export interface OrderClause {
   fieldName: string;
   direction?: SortDirection;
 }
@@ -81,7 +82,10 @@
   return n;
 }
 
-export function sqlValueToString(val: ColumnType): string {
+export function sqlValueToString(val: ColumnType): string;
+export function sqlValueToString(val?: ColumnType): string|undefined;
+export function sqlValueToString(val?: ColumnType): string|undefined {
+  if (val === undefined) return undefined;
   if (val instanceof Uint8Array) {
     return `<blob length=${val.length}>`;
   }
@@ -90,3 +94,17 @@
   }
   return val.toString();
 }
+
+export async function getTableRowCount(
+    engine: EngineProxy, tableName: string): Promise<number|undefined> {
+  const result =
+      await engine.query(`SELECT COUNT() as count FROM ${tableName}`);
+  if (result.numRows() === 0) {
+    return undefined;
+  }
+  return result
+      .firstRow({
+        count: NUM,
+      })
+      .count;
+}
diff --git a/ui/src/frontend/thread_and_process_info.ts b/ui/src/frontend/thread_and_process_info.ts
index 46ab39e..a255902 100644
--- a/ui/src/frontend/thread_and_process_info.ts
+++ b/ui/src/frontend/thread_and_process_info.ts
@@ -119,3 +119,11 @@
 export function getThreadName(info?: ThreadInfo): string|undefined {
   return getDisplayName(info?.name, info?.tid);
 }
+
+// Return the full thread name, including the process name.
+export function getFullThreadName(info?: ThreadInfo): string|undefined {
+  if (info?.process === undefined) {
+    return getThreadName(info);
+  }
+  return `${getThreadName(info)} ${getProcessName(info.process)}`;
+}
diff --git a/ui/src/frontend/thread_state.ts b/ui/src/frontend/thread_state.ts
index a4a71a1..e2355d6 100644
--- a/ui/src/frontend/thread_state.ts
+++ b/ui/src/frontend/thread_state.ts
@@ -22,14 +22,17 @@
   TPDuration,
   TPTime,
 } from '../common/time';
-import {Anchor} from './anchor';
 
+import {Anchor} from './anchor';
 import {globals} from './globals';
 import {scrollToTrackAndTs} from './scroll_helper';
+import {Icons} from './semantic_icons';
 import {
+  asTPTimestamp,
   asUtid,
   SchedSqlId,
   ThreadStateSqlId,
+  TPTimestamp,
   Utid,
 } from './sql_types';
 import {
@@ -50,7 +53,7 @@
   // Id of the corresponding entry in the |sched| table.
   schedSqlId?: SchedSqlId;
   // Timestamp of the beginning of this thread state in nanoseconds.
-  ts: TPTime;
+  ts: TPTimestamp;
   // Duration of this thread state in nanoseconds.
   dur: TPDuration;
   // CPU id if this thread state corresponds to a thread running on the CPU.
@@ -109,7 +112,7 @@
     result.push({
       threadStateSqlId: it.threadStateSqlId as ThreadStateSqlId,
       schedSqlId: fromNumNull(it.schedSqlId) as (SchedSqlId | undefined),
-      ts: it.ts,
+      ts: asTPTimestamp(it.ts),
       dur: it.dur,
       cpu: fromNumNull(it.cpu),
       state: translateState(it.state || undefined, ioWait),
@@ -153,7 +156,7 @@
 
 interface ThreadStateRefAttrs {
   id: ThreadStateSqlId;
-  ts: TPTime;
+  ts: TPTimestamp;
   dur: TPDuration;
   utid: Utid;
   // If not present, a placeholder name will be used.
@@ -165,7 +168,7 @@
     return m(
         Anchor,
         {
-          icon: 'open_in_new',
+          icon: Icons.UpdateSelection,
           onclick: () => {
             let trackId: string|number|undefined;
             for (const track of Object.values(globals.state.tracks)) {
diff --git a/ui/src/frontend/thread_state_tab.ts b/ui/src/frontend/thread_state_tab.ts
index c4a7295..3d38cc5 100644
--- a/ui/src/frontend/thread_state_tab.ts
+++ b/ui/src/frontend/thread_state_tab.ts
@@ -14,18 +14,25 @@
 
 import m from 'mithril';
 
-import {TPTime} from '../common/time';
+import {formatDurationShort, TPTime} from '../common/time';
 
 import {Anchor} from './anchor';
 import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
 import {globals} from './globals';
 import {asTPTimestamp, SchedSqlId, ThreadStateSqlId} from './sql_types';
 import {
+  getFullThreadName,
   getProcessName,
   getThreadName,
   ThreadInfo,
 } from './thread_and_process_info';
-import {getThreadState, goToSchedSlice, ThreadState} from './thread_state';
+import {
+  getThreadState,
+  getThreadStateFromConstraints,
+  goToSchedSlice,
+  ThreadState,
+  ThreadStateRef,
+} from './thread_state';
 import {DetailsShell} from './widgets/details_shell';
 import {Duration} from './widgets/duration';
 import {GridLayout} from './widgets/grid_layout';
@@ -39,10 +46,18 @@
   readonly id: ThreadStateSqlId;
 }
 
+interface RelatedThreadStates {
+  prev?: ThreadState;
+  next?: ThreadState;
+  waker?: ThreadState;
+  wakee?: ThreadState[];
+}
+
 export class ThreadStateTab extends BottomTab<ThreadStateTabConfig> {
-  static readonly kind = 'org.perfetto.ThreadStateTab';
+  static readonly kind = 'dev.perfetto.ThreadStateTab';
 
   state?: ThreadState;
+  relatedStates?: RelatedThreadStates;
   loaded: boolean = false;
 
   static create(args: NewBottomTabArgs): ThreadStateTab {
@@ -52,13 +67,55 @@
   constructor(args: NewBottomTabArgs) {
     super(args);
 
-    getThreadState(this.engine, this.config.id).then((state?: ThreadState) => {
+    this.load().then(() => {
       this.loaded = true;
-      this.state = state;
       globals.rafScheduler.scheduleFullRedraw();
     });
   }
 
+  async load() {
+    this.state = await getThreadState(this.engine, this.config.id);
+
+    if (!this.state) {
+      return;
+    }
+
+    const relatedStates: RelatedThreadStates = {};
+    relatedStates.prev = (await getThreadStateFromConstraints(this.engine, {
+      filters: [
+        `ts + dur = ${this.state.ts}`,
+        `utid = ${this.state.thread?.utid}`,
+      ],
+      limit: 1,
+    }))[0];
+    relatedStates.next = (await getThreadStateFromConstraints(this.engine, {
+      filters: [
+        `ts = ${this.state.ts + this.state.dur}`,
+        `utid = ${this.state.thread?.utid}`,
+      ],
+      limit: 1,
+    }))[0];
+    if (this.state.wakerThread?.utid !== undefined) {
+      relatedStates.waker = (await getThreadStateFromConstraints(this.engine, {
+        filters: [
+          `utid = ${this.state.wakerThread?.utid}`,
+          `ts <= ${this.state.ts}`,
+          `ts + dur >= ${this.state.ts}`,
+        ],
+      }))[0];
+    }
+    relatedStates.wakee = await getThreadStateFromConstraints(this.engine, {
+      filters: [
+        `waker_utid = ${this.state.thread?.utid}`,
+        `state = 'R'`,
+        `ts >= ${this.state.ts}`,
+        `ts <= ${this.state.ts + this.state.dur}`,
+      ],
+    });
+
+    this.relatedStates = relatedStates;
+  }
+
   getTitle() {
     // TODO(altimin): Support dynamic titles here.
     return 'Current Selection';
@@ -75,7 +132,10 @@
               Section,
               {title: 'Details'},
               this.state && this.renderTree(this.state),
-              )),
+              ),
+          m(Section,
+            {title: 'Related thread states'},
+            this.renderRelatedThreadStates())),
     );
   }
 
@@ -154,8 +214,63 @@
     );
   }
 
+  private renderRelatedThreadStates(): m.Children {
+    if (this.state === undefined || this.relatedStates === undefined) {
+      return 'Loading';
+    }
+    const startTs = this.state.ts;
+    const renderRef = (state: ThreadState, name?: string) => m(ThreadStateRef, {
+      id: state.threadStateSqlId,
+      ts: state.ts,
+      dur: state.dur,
+      utid: state.thread!.utid,
+      name,
+    });
+
+    const nameForNextOrPrev = (state: ThreadState) =>
+        `${state.state} for ${formatDurationShort(state.dur)}`;
+    return m(
+        Tree,
+        this.relatedStates.waker && m(TreeNode, {
+          left: 'Waker',
+          right: renderRef(
+              this.relatedStates.waker,
+              getFullThreadName(this.relatedStates.waker.thread)),
+        }),
+        this.relatedStates.prev && m(TreeNode, {
+          left: 'Previous state',
+          right: renderRef(
+              this.relatedStates.prev,
+              nameForNextOrPrev(this.relatedStates.prev)),
+        }),
+        this.relatedStates.next && m(TreeNode, {
+          left: 'Next state',
+          right: renderRef(
+              this.relatedStates.next,
+              nameForNextOrPrev(this.relatedStates.next)),
+        }),
+        this.relatedStates.wakee && this.relatedStates.wakee.length > 0 &&
+            m(TreeNode,
+              {
+                left: 'Woken threads',
+              },
+              this.relatedStates.wakee.map(
+                  (state) =>
+                      m(TreeNode, ({
+                          left: m(Timestamp, {
+                            ts: state.ts,
+                            display: `Start+${
+                                formatDurationShort(state.ts - startTs)}`,
+                          }),
+                          right:
+                              renderRef(state, getFullThreadName(state.thread)),
+                        })))),
+    );
+  }
+
+
   isLoading() {
-    return this.state === undefined;
+    return this.state === undefined || this.relatedStates === undefined;
   }
 
   renderTabCanvas(): void {}
diff --git a/ui/src/frontend/widgets/basic_table.ts b/ui/src/frontend/widgets/basic_table.ts
new file mode 100644
index 0000000..578b9d7
--- /dev/null
+++ b/ui/src/frontend/widgets/basic_table.ts
@@ -0,0 +1,56 @@
+// Copyright (C) 2023 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';
+
+export interface ColumnDescriptor<T> {
+  title: m.Children;
+  render: (row: T) => m.Children;
+}
+
+export interface TableAttrs<T> {
+  data: T[];
+  columns: ColumnDescriptor<T>[];
+}
+
+export class BasicTable implements m.ClassComponent<TableAttrs<any>> {
+  renderColumnHeader(
+      _vnode: m.Vnode<TableAttrs<any>>,
+      column: ColumnDescriptor<any>): m.Children {
+    return m('td', column.title);
+  }
+
+  view(vnode: m.Vnode<TableAttrs<any>>): m.Child {
+    const attrs = vnode.attrs;
+
+    return m(
+        'table.generic-table',
+        {
+          // TODO(altimin, stevegolton): this should be the default for
+          // generic-table, but currently it is overriden by
+          // .pf-details-shell .pf-content table, so specify this here for now.
+          style: {
+            'table-layout': 'auto',
+          },
+        },
+        m('thead',
+          m('tr.header',
+            attrs.columns.map(
+                (column) => this.renderColumnHeader(vnode, column)))),
+        attrs.data.map(
+            (row) =>
+                m('tr',
+                  attrs.columns.map((column) => m('td', column.render(row))))));
+  }
+}
diff --git a/ui/src/frontend/widgets/callout.ts b/ui/src/frontend/widgets/callout.ts
new file mode 100644
index 0000000..cd583af
--- /dev/null
+++ b/ui/src/frontend/widgets/callout.ts
@@ -0,0 +1,36 @@
+// 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 {Icon} from './icon';
+
+interface CalloutAttrs {
+  icon?: string;
+  // Remaining attributes forwarded to the underlying HTML <button>.
+  [htmlAttrs: string]: any;
+}
+
+export class Callout implements m.ClassComponent<CalloutAttrs> {
+  view({attrs, children}: m.CVnode<CalloutAttrs>) {
+    const {icon, ...htmlAttrs} = attrs;
+
+    return m(
+        '.pf-callout',
+        {...htmlAttrs},
+        icon && m(Icon, {className: 'pf-left-icon', icon}),
+        children,
+    );
+  }
+}
diff --git a/ui/src/frontend/widgets/details_shell.ts b/ui/src/frontend/widgets/details_shell.ts
index 7fe684b..8b6aa41 100644
--- a/ui/src/frontend/widgets/details_shell.ts
+++ b/ui/src/frontend/widgets/details_shell.ts
@@ -19,10 +19,8 @@
   title: m.Children;
   description?: m.Children;
   buttons?: m.Children;
-  // If true, this container will fill the parent, and content scrolling is
-  // expected to be handled internally.
-  // Defaults to false.
-  matchParent?: boolean;
+  // Stretch/shrink the content to fill the parent vertically.
+  fillParent?: boolean;
 }
 
 // A shell for details panels to be more visually consistent.
@@ -33,12 +31,12 @@
       title,
       description,
       buttons,
-      matchParent,
+      fillParent = true,
     } = attrs;
 
     return m(
         'section.pf-details-shell',
-        {class: classNames(matchParent && 'pf-match-parent')},
+        {class: classNames(fillParent && 'pf-fill-parent')},
         m(
             'header.pf-header-bar',
             m('h1.pf-header-title', title),
diff --git a/ui/src/frontend/widgets/error.ts b/ui/src/frontend/widgets/error.ts
new file mode 100644
index 0000000..17c91df
--- /dev/null
+++ b/ui/src/frontend/widgets/error.ts
@@ -0,0 +1,21 @@
+// 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';
+
+export class Err implements m.Component {
+  view(vnode: m.Vnode) {
+    return m('.pf-error', vnode.children);
+  }
+}
diff --git a/ui/src/frontend/widgets/select.ts b/ui/src/frontend/widgets/select.ts
index 7fdbdd4..2ec2bc2 100644
--- a/ui/src/frontend/widgets/select.ts
+++ b/ui/src/frontend/widgets/select.ts
@@ -14,8 +14,16 @@
 
 import m from 'mithril';
 
+import {globals} from '../globals';
+
+import {Menu, MenuItem} from './menu';
+import {TextInput} from './text_input';
+import {exists} from './utils';
+
 export interface SelectAttrs {
   disabled?: boolean;
+  // Whether to show a search box. Defaults to false.
+  filterable?: boolean;
   [htmlAttrs: string]: any;
 }
 
@@ -29,3 +37,50 @@
         children);
   }
 }
+
+export interface FilterableSelectAttrs extends SelectAttrs {
+  values: string[];
+  onSelected: (value: string) => void;
+  maxDisplayedItems?: number;
+  autofocusInput?: boolean;
+}
+
+export class FilterableSelect implements
+    m.ClassComponent<FilterableSelectAttrs> {
+  searchText = '';
+
+  view({attrs}: m.CVnode<FilterableSelectAttrs>) {
+    const filteredValues = attrs.values.filter((name) => {
+      return name.toLowerCase().includes(this.searchText.toLowerCase());
+    });
+
+    const extraItems = exists(attrs.maxDisplayedItems) &&
+        Math.max(0, filteredValues.length - attrs.maxDisplayedItems);
+
+    // TODO(altimin): when the user presses enter and there is only one item,
+    // select the first one.
+    // MAYBE(altimin): when the user presses enter and there are multiple items,
+    // select the first one.
+    return m(
+        'div',
+        m('.pf-search-bar',
+          m(TextInput, {
+            autofocus: attrs.autofocusInput,
+            oninput: (event: Event) => {
+              const eventTarget = event.target as HTMLTextAreaElement;
+              this.searchText = eventTarget.value;
+              globals.rafScheduler.scheduleFullRedraw();
+            },
+            value: this.searchText,
+            placeholder: 'Filter options...',
+            extraClasses: 'pf-search-box',
+          }),
+          m(Menu,
+            ...filteredValues.map(
+                (value) => m(MenuItem, {
+                  label: value,
+                  onclick: () => attrs.onSelected(value),
+                }),
+                extraItems && m('i', `+${extraItems} more`)))));
+  }
+}
diff --git a/ui/src/frontend/widgets/timestamp.ts b/ui/src/frontend/widgets/timestamp.ts
index b945489..91b9b82 100644
--- a/ui/src/frontend/widgets/timestamp.ts
+++ b/ui/src/frontend/widgets/timestamp.ts
@@ -14,12 +14,14 @@
 
 import m from 'mithril';
 
+import {Actions} from '../../common/actions';
 import {Timecode, toDomainTime} from '../../common/time';
+import {Anchor} from '../anchor';
 import {copyToClipboard} from '../clipboard';
+import {globals} from '../globals';
 import {Icons} from '../semantic_icons';
 import {TPTimestamp} from '../sql_types';
 
-import {Button} from './button';
 import {MenuItem, PopupMenu2} from './menu';
 
 // import {MenuItem, PopupMenu2} from './menu';
@@ -28,43 +30,51 @@
   // The timestamp to print, this should be the absolute, raw timestamp as
   // found in trace processor.
   ts: TPTimestamp;
+  // Custom text value to show instead of the default HH:MM:SS.mmm uuu nnn
+  // formatting.
+  display?: m.Children;
+  extraMenuItems?: m.Child[];
 }
 
 export class Timestamp implements m.ClassComponent<TimestampAttrs> {
   view({attrs}: m.Vnode<TimestampAttrs>) {
     const {ts} = attrs;
     return m(
-        'span.pf-timecode',
-        renderTimecode(ts),
-        m(
-            PopupMenu2,
-            {
-              trigger: m(Button, {
-                icon: Icons.ContextMenu,
-                compact: true,
-                minimal: true,
-              }),
-            },
-            m(MenuItem, {
-              icon: Icons.Copy,
-              label: `Copy raw value`,
-              onclick: () => {
-                copyToClipboard(ts.toString());
+        PopupMenu2,
+        {
+          trigger: m(
+              Anchor,
+              {
+                onmouseover: () => {
+                  globals.dispatch(Actions.setHoverCursorTimestamp({ts}));
+                },
+                onmouseout: () => {
+                  globals.dispatch(Actions.setHoverCursorTimestamp({ts: -1n}));
+                },
               },
-            }),
-            ),
+              attrs.display ?? renderTimecode(ts)),
+        },
+        m(MenuItem, {
+          icon: Icons.Copy,
+          label: `Copy raw value`,
+          onclick: () => {
+            copyToClipboard(ts.toString());
+          },
+        }),
+        ...(attrs.extraMenuItems ?? []),
     );
   }
 }
 
-function renderTimecode(ts: TPTimestamp): m.Children {
+export function renderTimecode(ts: TPTimestamp): m.Children {
   const relTime = toDomainTime(ts);
   const {dhhmmss, millis, micros, nanos} = new Timecode(relTime);
-  return [
-    m('span.pf-timecode-hms', dhhmmss),
-    '.',
-    m('span.pf-timecode-millis', millis),
-    m('span.pf-timecode-micros', micros),
-    m('span.pf-timecode-nanos', nanos),
-  ];
+  return m(
+      'span.pf-timecode',
+      m('span.pf-timecode-hms', dhhmmss),
+      '.',
+      m('span.pf-timecode-millis', millis),
+      m('span.pf-timecode-micros', micros),
+      m('span.pf-timecode-nanos', nanos),
+  );
 }
diff --git a/ui/src/frontend/widgets/virtual_scroll_container.ts b/ui/src/frontend/widgets/virtual_scroll_container.ts
new file mode 100644
index 0000000..2e210ff
--- /dev/null
+++ b/ui/src/frontend/widgets/virtual_scroll_container.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 m from 'mithril';
+
+import {findRef, toHTMLElement} from './utils';
+
+interface VirtualScrollContainerAttrs {
+  // Called when the scrolling element is created, updates, or scrolls.
+  onScroll?: (dom: HTMLElement) => void;
+}
+
+export class VirtualScrollContainer implements
+    m.ClassComponent<VirtualScrollContainerAttrs> {
+  private readonly REF = 'virtual-scroll-container';
+  view({attrs, children}: m.Vnode<VirtualScrollContainerAttrs>) {
+    const {
+      onScroll = () => {},
+    } = attrs;
+
+    return m(
+        '.pf-virtual-scroll-container',
+        {
+          ref: this.REF,
+          onscroll: (e: Event) => onScroll(e.target as HTMLElement),
+        },
+        children);
+  }
+
+  oncreate({dom, attrs}: m.VnodeDOM<VirtualScrollContainerAttrs, this>) {
+    const {
+      onScroll = () => {},
+    } = attrs;
+
+    const element = findRef(dom, this.REF);
+    if (element) {
+      onScroll(toHTMLElement(element));
+    }
+  }
+}
diff --git a/ui/src/frontend/widgets_page.ts b/ui/src/frontend/widgets_page.ts
index e3fc2e3..ea0e632 100644
--- a/ui/src/frontend/widgets_page.ts
+++ b/ui/src/frontend/widgets_page.ts
@@ -23,6 +23,7 @@
 import {Icons} from './semantic_icons';
 import {TableShowcase} from './tables/table_showcase';
 import {Button} from './widgets/button';
+import {Callout} from './widgets/callout';
 import {Checkbox} from './widgets/checkbox';
 import {EmptyState} from './widgets/empty_state';
 import {Form, FormButtonBar, FormLabel} from './widgets/form';
@@ -649,6 +650,20 @@
               }),
             ),
           }),
+          m('h2', 'Callout'),
+          m(
+            WidgetShowcase, {
+              renderWidget: () => m(
+                Callout,
+                {
+                  icon: 'info',
+                },
+                'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
+                'Nulla rhoncus tempor neque, sed malesuada eros dapibus vel. ' +
+                'Aliquam in ligula vitae tortor porttitor laoreet iaculis ' +
+                'finibus est.',
+              ),
+            }),
     );
   },
 });
diff --git a/ui/src/tracks/debug/details_tab.ts b/ui/src/tracks/debug/details_tab.ts
index 1a743fb..0451cbe 100644
--- a/ui/src/tracks/debug/details_tab.ts
+++ b/ui/src/tracks/debug/details_tab.ts
@@ -87,7 +87,7 @@
 
 export class DebugSliceDetailsTab extends
     BottomTab<DebugSliceDetailsTabConfig> {
-  static readonly kind = 'org.perfetto.DebugSliceDetailsTab';
+  static readonly kind = 'dev.perfetto.DebugSliceDetailsTab';
 
   data?: {
     name: string,