Merge "[ui] Fixed some merge issues regarding uri and track keys." into main
diff --git a/docs/contributing/ui-plugins.md b/docs/contributing/ui-plugins.md
index 939e334..809018a 100644
--- a/docs/contributing/ui-plugins.md
+++ b/docs/contributing/ui-plugins.md
@@ -14,7 +14,7 @@
 ```sh
 git clone https://android.googlesource.com/platform/external/perfetto/
 cd perfetto
-./tool/install-build-deps --ui
+./tools/install-build-deps --ui
 ```
 
 ### Copy the plugin skeleton
diff --git a/src/trace_processor/db/BUILD.gn b/src/trace_processor/db/BUILD.gn
index 604929d..869c9da 100644
--- a/src/trace_processor/db/BUILD.gn
+++ b/src/trace_processor/db/BUILD.gn
@@ -66,6 +66,7 @@
     ":view_unittest",
     "../../../gn:default_deps",
     "../../../gn:gtest_and_gmock",
+    "../../../include/perfetto/trace_processor:basic_types",
     "../../base",
     "../tables",
     "../views",
diff --git a/src/trace_processor/db/query_executor.cc b/src/trace_processor/db/query_executor.cc
index 1151079..c77cb38 100644
--- a/src/trace_processor/db/query_executor.cc
+++ b/src/trace_processor/db/query_executor.cc
@@ -24,6 +24,7 @@
 
 #include "perfetto/base/logging.h"
 #include "perfetto/ext/base/status_or.h"
+#include "perfetto/trace_processor/basic_types.h"
 #include "src/trace_processor/containers/string_pool.h"
 #include "src/trace_processor/db/query_executor.h"
 #include "src/trace_processor/db/storage/arrangement_storage.h"
@@ -160,10 +161,14 @@
     use_legacy = use_legacy || (col.overlay().size() != column_size &&
                                 col.overlay().row_map().IsRange());
 
-    // Mismatched types.
+    // Comparing ints with doubles and doubles with ints.
+    bool int_with_double =
+        col.type() == SqlValue::kLong && c.value.type == SqlValue::kDouble;
+    bool double_with_int =
+        col.type() == SqlValue::kDouble && c.value.type == SqlValue::kLong;
     use_legacy = use_legacy ||
                  (c.op != FilterOp::kIsNull && c.op != FilterOp::kIsNotNull &&
-                  col.type() != c.value.type && !c.value.is_null());
+                  (int_with_double || double_with_int));
 
     // Dense columns.
     use_legacy = use_legacy || col.IsDense();
diff --git a/src/trace_processor/db/query_executor_unittest.cc b/src/trace_processor/db/query_executor_unittest.cc
index 95ce5ef..a2c05d8 100644
--- a/src/trace_processor/db/query_executor_unittest.cc
+++ b/src/trace_processor/db/query_executor_unittest.cc
@@ -16,6 +16,7 @@
 
 #include "src/trace_processor/db/query_executor.h"
 
+#include "perfetto/trace_processor/basic_types.h"
 #include "src/trace_processor/db/storage/arrangement_storage.h"
 #include "src/trace_processor/db/storage/fake_storage.h"
 #include "src/trace_processor/db/storage/id_storage.h"
@@ -547,6 +548,17 @@
   ASSERT_EQ(res.Get(0), 0u);
 }
 
+TEST(QueryExecutor, MismatchedTypeIdWithString) {
+  IdStorage storage(5);
+
+  // Filter.
+  Constraint c{0, FilterOp::kGe, SqlValue::String("cheese")};
+  QueryExecutor exec({&storage}, 5);
+  RowMap res = exec.Filter({c});
+
+  ASSERT_EQ(res.size(), 0u);
+}
+
 #if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
 TEST(QueryExecutor, StringBinarySearchRegex) {
   StringPool pool;
diff --git a/src/trace_processor/db/storage/numeric_storage_unittest.cc b/src/trace_processor/db/storage/numeric_storage_unittest.cc
index e82883f..36fa51a 100644
--- a/src/trace_processor/db/storage/numeric_storage_unittest.cc
+++ b/src/trace_processor/db/storage/numeric_storage_unittest.cc
@@ -30,7 +30,7 @@
 
 using Range = RowMap::Range;
 
-TEST(IdStorageUnittest, InvalidSearchConstraintsGeneralChecks) {
+TEST(NumericStorageUnittest, InvalidSearchConstraintsGeneralChecks) {
   std::vector<uint32_t> data_vec(128);
   std::iota(data_vec.begin(), data_vec.end(), 0);
   NumericStorage<uint32_t> storage(&data_vec, ColumnType::kUint32);
@@ -66,7 +66,7 @@
   ASSERT_EQ(search_result, empty_range);
 }
 
-TEST(IdStorageUnittest, InvalidValueBoundsUint32) {
+TEST(NumericStorageUnittest, InvalidValueBoundsUint32) {
   std::vector<uint32_t> data_vec(128);
   std::iota(data_vec.begin(), data_vec.end(), 0);
   NumericStorage<uint32_t> storage(&data_vec, ColumnType::kUint32);
@@ -120,7 +120,7 @@
   ASSERT_EQ(search_result, empty_range);
 }
 
-TEST(IdStorageUnittest, InvalidValueBoundsInt32) {
+TEST(NumericStorageUnittest, InvalidValueBoundsInt32) {
   std::vector<int32_t> data_vec(128);
   std::iota(data_vec.begin(), data_vec.end(), 0);
   NumericStorage<int32_t> storage(&data_vec, ColumnType::kInt32);
diff --git a/src/trace_processor/db/storage/set_id_storage.cc b/src/trace_processor/db/storage/set_id_storage.cc
index 570afb7..94b94dc 100644
--- a/src/trace_processor/db/storage/set_id_storage.cc
+++ b/src/trace_processor/db/storage/set_id_storage.cc
@@ -139,6 +139,8 @@
                                 std::to_string(static_cast<uint32_t>(op)));
                     });
 
+  PERFETTO_DCHECK(search_range.end <= size());
+
   // After this switch we assume the search is valid.
   switch (ValidateSearchConstraints(sql_val, op)) {
     case SearchValidationResult::kOk:
@@ -149,7 +151,6 @@
       return RangeOrBitVector(Range());
   }
 
-  PERFETTO_DCHECK(search_range.end <= size());
   uint32_t val = static_cast<uint32_t>(sql_val.AsLong());
 
   if (op == FilterOp::kNe) {
diff --git a/src/trace_processor/db/storage/set_id_storage_unittest.cc b/src/trace_processor/db/storage/set_id_storage_unittest.cc
index 658b731..5023345 100644
--- a/src/trace_processor/db/storage/set_id_storage_unittest.cc
+++ b/src/trace_processor/db/storage/set_id_storage_unittest.cc
@@ -29,7 +29,7 @@
 
 using Range = RowMap::Range;
 
-TEST(IdStorageUnittest, InvalidSearchConstraints) {
+TEST(SetIdStorageUnittest, InvalidSearchConstraints) {
   std::vector<uint32_t> storage_data{0, 0, 0, 3, 3, 3, 6, 6, 6, 9, 9, 9};
   SetIdStorage storage(&storage_data);
 
diff --git a/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_cause_map.sql b/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_cause_map.sql
index f049075..0a21d27 100644
--- a/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_cause_map.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_cause_map.sql
@@ -2,6 +2,8 @@
 -- Use of this source code is governed by a BSD-style license that can be
 -- found in the LICENSE file.
 
+INCLUDE PERFETTO MODULE chrome.event_latency_description;
+
 -- Source of truth of the descriptions of EventLatency-based scroll jank causes.
 CREATE PERFETTO TABLE chrome_scroll_jank_cause_descriptions (
   -- The name of the EventLatency stage.
@@ -92,3 +94,27 @@
   cause_thread,
   cause_description
 FROM cause_descriptions;
+
+-- Combined description of scroll jank cause and associated event latency stage.
+CREATE PERFETTO VIEW chrome_scroll_jank_causes_with_event_latencies(
+  -- The name of the EventLatency stage.
+  name STRING,
+  -- Description of the EventLatency stage.
+  description STRING,
+  -- The process name that may cause scroll jank.
+  cause_process STRING,
+  -- The thread name that may cause scroll jank. The thread will be on the
+  -- cause_process.
+  cause_thread STRING,
+  -- Description of the cause of scroll jank on this process and thread.
+  cause_description STRING
+) AS
+SELECT
+  stages.name,
+  stages.description,
+  causes.cause_process,
+  causes.cause_thread,
+  causes.cause_description
+FROM chrome_event_latency_stage_descriptions stages
+LEFT JOIN chrome_scroll_jank_cause_descriptions causes
+    ON causes.event_latency_stage = stages.name;
diff --git a/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_cause_utils.sql b/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_cause_utils.sql
index 2c6d0d0..35780fb 100644
--- a/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_cause_utils.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_cause_utils.sql
@@ -3,46 +3,181 @@
 -- found in the LICENSE file.
 
 
--- Retrieve the thread id of the thread on a particular process, if the name of
--- that process is known. Returns an error if there are multiple threads in
--- the given process with the same name.
-CREATE PERFETTO FUNCTION internal_find_utid_by_upid_and_name(
-  -- Unique process id
-  upid INT,
-  -- The name of the thread
-  thread_name STRING)
+-- Function to retrieve the upid for a surfaceflinger, as these are attributed
+-- to the GPU but are recorded on a different data source (and track group).
+CREATE PERFETTO FUNCTION internal_get_process_id_for_surfaceflinger()
+-- The process id for surfaceflinger.
+RETURNS INT AS
+SELECT
+ upid
+FROM process
+WHERE name GLOB '*surfaceflinger*'
+LIMIT 1;
+
+-- Map a generic process type to a specific name or substring of a name that
+-- can be found in the trace process table.
+CREATE PERFETTO TABLE internal_process_type_to_name (
+  -- The process type: one of 'Browser' or 'GPU'.
+  process_type STRING,
+  -- The process name for Chrome traces.
+  process_name STRING,
+  -- Substring identifying the process for system traces.
+  process_glob STRING
+) AS
+WITH process_names (
+  process_type,
+  process_name,
+  process_glob
+  )
+AS (
+VALUES
+  ('Browser', 'Browser', '*.chrome'),
+  ('GPU', 'Gpu', '*.chrome:privileged_process*'))
+SELECT
+  process_type,
+  process_name,
+  process_glob
+FROM process_names;
+
+CREATE PERFETTO FUNCTION internal_get_process_name(
+  -- The process type: one of 'Browser' or 'GPU'.
+  type STRING
+)
+-- The process name
+RETURNS STRING AS
+SELECT
+    process_name
+FROM internal_process_type_to_name
+WHERE process_type = $type
+LIMIT 1;
+
+CREATE PERFETTO FUNCTION internal_get_process_glob(
+  -- The process type: one of 'Browser' or 'GPU'.
+  type STRING
+)
+-- A substring of the process name that can be used in GLOB calculations.
+RETURNS STRING AS
+SELECT
+    process_glob
+FROM internal_process_type_to_name
+WHERE process_type = $type
+LIMIT 1;
+
+-- TODO(b/309937901): Add chrome instance id for multiple chromes/webviews in a
+-- trace, as this may result in  multiple browser and GPU processes.
+-- Function to retrieve the chrome process ID for a specific process type. Does
+-- not retrieve the Renderer process, as this is determined when the
+-- EventLatency is known. See function
+-- internal_get_renderer_upid_for_event_latency below.
+CREATE PERFETTO FUNCTION internal_get_process_id_by_type(
+  -- The process type: one of 'Browser' or 'GPU'.
+  type STRING
+)
 RETURNS TABLE (
-  -- Unique thread id.
-  utid INT
+    -- The process id for the process type.
+    upid INT
 ) AS
 SELECT
-  DISTINCT utid
-FROM thread
-WHERE upid = $upid
-  AND name = $thread_name;
+  upid
+FROM process
+WHERE name = internal_get_process_name($type)
+  OR name GLOB internal_get_process_glob($type);
 
--- Function to retrieve the track id of the thread on a particular process if
+-- Function to retrieve the chrome process ID that a given EventLatency slice
+-- occurred on. This is the Renderer process.
+CREATE PERFETTO FUNCTION internal_get_renderer_upid_for_event_latency(
+  -- The slice id for an EventLatency slice.
+  id INT
+)
+-- The process id for an EventLatency slice. This is the Renderer process.
+RETURNS INT AS
+SELECT
+  upid
+FROM process_slice
+WHERE id = $id;
+
+-- Helper function to retrieve all of the upids for a given process, thread,
+-- or EventLatency.
+CREATE PERFETTO FUNCTION internal_processes_by_type_for_event_latency(
+  -- The process type that the thread is on: one of 'Browser', 'Renderer' or
+  -- 'GPU'.
+  type STRING,
+  -- The name of the thread.
+  thread STRING,
+  -- The slice id of an EventLatency slice.
+  event_latency_id INT)
+RETURNS TABLE (
+    upid INT
+) AS
+WITH all_upids AS (
+  -- Renderer process upids
+  SELECT
+    $type AS process,
+    $thread AS thread,
+    $event_latency_id AS event_latency_id,
+    internal_get_renderer_upid_for_event_latency($event_latency_id) AS upid
+  WHERE $type = 'Renderer'
+  UNION ALL
+  -- surfaceflinger upids
+  SELECT
+    $type AS process,
+    $thread AS thread,
+    $event_latency_id AS event_latency_id,
+    internal_get_process_id_for_surfaceflinger() AS upid
+  WHERE $type = 'GPU' AND $thread = 'surfaceflinger'
+  UNION ALL
+  -- Generic Browser and GPU process upids
+  SELECT
+    $type AS process,
+    $thread AS thread,
+    $event_latency_id AS event_latency_id,
+    upid
+  FROM internal_get_process_id_by_type($type)
+  WHERE $type = 'Browser'
+    OR ($type = 'GPU' AND $thread != 'surfaceflinger')
+)
+SELECT
+  upid
+FROM all_upids;
+
+-- Function to retrieve the thread id of the thread on a particular process if
 -- there are any slices during a particular EventLatency slice duration; this
 -- upid/thread combination refers to a cause of Scroll Jank.
-CREATE PERFETTO FUNCTION chrome_select_scroll_jank_cause_track(
+CREATE PERFETTO FUNCTION chrome_select_scroll_jank_cause_thread(
   -- The slice id of an EventLatency slice.
   event_latency_id INT,
-  -- The process id that the thread is on.
-  upid INT,
+  -- The process type that the thread is on: one of 'Browser', 'Renderer' or
+  -- 'GPU'.
+  process_type STRING,
   -- The name of the thread.
   thread_name STRING)
 RETURNS TABLE (
-  -- The track id associated with |thread| on the process with |upid|.
-  track_id INT
+  -- The utid associated with |thread| on the process with |upid|.
+  utid INT
 ) AS
+WITH threads AS (
+  SELECT
+    utid
+  FROM thread
+  WHERE upid IN
+    (
+      SELECT DISTINCT
+        upid
+      FROM internal_processes_by_type_for_event_latency(
+        $process_type,
+        $thread_name,
+        $event_latency_id)
+    )
+    AND name = $thread_name
+)
 SELECT
- DISTINCT track_id
+ DISTINCT utid
 FROM thread_slice
 WHERE utid IN
   (
     SELECT
       utid
-    FROM internal_find_utid_by_upid_and_name($upid, $thread_name)
+    FROM threads
   )
   AND ts >= (SELECT ts FROM slice WHERE id = $event_latency_id LIMIT 1)
   AND ts <= (SELECT ts + dur FROM slice WHERE id = $event_latency_id LIMIT 1);
diff --git a/ui/src/base/dom_utils.ts b/ui/src/base/dom_utils.ts
index d7c7582..5caf9d9 100644
--- a/ui/src/base/dom_utils.ts
+++ b/ui/src/base/dom_utils.ts
@@ -82,3 +82,25 @@
 
   return {x: e.offsetX, y: e.offsetY};
 }
+
+function calculateScrollbarWidth() {
+  const outer = document.createElement('div');
+  outer.style.overflowY = 'scroll';
+  const inner = document.createElement('div');
+  outer.appendChild(inner);
+  document.body.appendChild(outer);
+  const width =
+      outer.getBoundingClientRect().width - inner.getBoundingClientRect().width;
+  document.body.removeChild(outer);
+  return width;
+}
+
+let cachedScrollBarWidth: number|undefined = undefined;
+
+// Calculate the space a scrollbar takes up.
+export function getScrollbarWidth() {
+  if (cachedScrollBarWidth === undefined) {
+    cachedScrollBarWidth = calculateScrollbarWidth();
+  }
+  return cachedScrollBarWidth;
+}
diff --git a/ui/src/frontend/chrome_slice_details_tab.ts b/ui/src/frontend/chrome_slice_details_tab.ts
index 7f83b36..5311cb2 100644
--- a/ui/src/frontend/chrome_slice_details_tab.ts
+++ b/ui/src/frontend/chrome_slice_details_tab.ts
@@ -35,7 +35,7 @@
   NewBottomTabArgs,
 } from './bottom_tab';
 import {FlowPoint, globals} from './globals';
-import {renderArguments} from './slice_args';
+import {hasArgs, renderArguments} from './slice_args';
 import {renderDetails} from './slice_details';
 import {getSlice, SliceDetails, SliceRef} from './sql/slice';
 import {
@@ -288,7 +288,10 @@
   private renderRhs(engine: EngineProxy, slice: SliceDetails): m.Children {
     const precFlows = this.renderPrecedingFlows(slice);
     const followingFlows = this.renderFollowingFlows(slice);
-    const args = renderArguments(engine, slice);
+    const args = hasArgs(slice) &&
+        m(Section,
+          {title: 'Arguments'},
+          m(Tree, renderArguments(engine, slice)));
     if (precFlows ?? followingFlows ?? args) {
       return m(
           GridLayoutColumn,
diff --git a/ui/src/frontend/frontend_local_state.ts b/ui/src/frontend/frontend_local_state.ts
index 86c30db..a649966 100644
--- a/ui/src/frontend/frontend_local_state.ts
+++ b/ui/src/frontend/frontend_local_state.ts
@@ -26,7 +26,6 @@
   VisibleState,
 } from '../common/state';
 import {raf} from '../core/raf_scheduler';
-import {HttpRpcState} from '../trace_processor/http_rpc_engine';
 
 import {globals} from './globals';
 import {ratelimit} from './rate_limiters';
@@ -47,20 +46,6 @@
   return current;
 }
 
-// Calculate the space a scrollbar takes up so that we can subtract it from
-// the canvas width.
-function calculateScrollbarWidth() {
-  const outer = document.createElement('div');
-  outer.style.overflowY = 'scroll';
-  const inner = document.createElement('div');
-  outer.appendChild(inner);
-  document.body.appendChild(outer);
-  const width =
-      outer.getBoundingClientRect().width - inner.getBoundingClientRect().width;
-  document.body.removeChild(outer);
-  return width;
-}
-
 // Immutable object describing a (high precision) time window, providing methods
 // for common mutation operations (pan, zoom), and accessors for common
 // properties such as spans and durations in several formats.
@@ -155,17 +140,10 @@
   private visibleWindow = new TimeWindow();
   private _timeScale = this.visibleWindow.createTimeScale(0, 0);
   private _windowSpan = PxSpan.ZERO;
-  showPanningHint = false;
-  showCookieConsent = false;
-  scrollToTrackKey?: string|number;
-  httpRpcState: HttpRpcState = {connected: false};
-  newVersionAvailable = false;
 
   // This is used to calculate the tracks within a Y range for area selection.
   areaY: Range = {};
 
-  private scrollBarWidth?: number;
-
   private _visibleState: VisibleState = {
     lastUpdate: 0,
     start: Time.ZERO,
@@ -179,18 +157,6 @@
   // and a |timeScale| have a notion of time range. That should live in one
   // place only.
 
-  getScrollbarWidth() {
-    if (this.scrollBarWidth === undefined) {
-      this.scrollBarWidth = calculateScrollbarWidth();
-    }
-    return this.scrollBarWidth;
-  }
-
-  setHttpRpcState(httpRpcState: HttpRpcState) {
-    this.httpRpcState = httpRpcState;
-    raf.scheduleFullRedraw();
-  }
-
   zoomVisibleWindow(ratio: number, centerPoint: number) {
     this.visibleWindow = this.visibleWindow.zoom(ratio, centerPoint);
     this._timeScale = this.visibleWindow.createTimeScale(
@@ -231,7 +197,6 @@
     assertTrue(
         end >= start,
         `Impossible select area: start [${start}] >= end [${end}]`);
-    this.showPanningHint = true;
     this._selectedArea = {start, end, tracks};
     raf.scheduleFullRedraw();
   }
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 7323ba5..435bf99 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -47,6 +47,7 @@
 import {setPerfHooks} from '../core/perf';
 import {raf} from '../core/raf_scheduler';
 import {Engine} from '../trace_processor/engine';
+import {HttpRpcState} from '../trace_processor/http_rpc_engine';
 
 import {Analytics, initAnalytics} from './analytics';
 import {BottomTabList} from './bottom_tab';
@@ -283,6 +284,11 @@
   private _utcOffset = Time.ZERO;
   private _openQueryHandler?: OpenQueryHandler;
 
+  scrollToTrackKey?: string|number;
+  httpRpcState: HttpRpcState = {connected: false};
+  newVersionAvailable = false;
+  showPanningHint = false;
+
   // TODO(hjd): Remove once we no longer need to update UUID on redraw.
   private _publishRedraw?: () => void = undefined;
 
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 14550e7..5497d8e 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -15,6 +15,7 @@
 import m from 'mithril';
 
 import {Trash} from '../base/disposable';
+import {getScrollbarWidth} from '../base/dom_utils';
 import {assertExists, assertFalse} from '../base/logging';
 import {SimpleResizeObserver} from '../base/resize_observer';
 import {
@@ -333,8 +334,7 @@
     // On non-MacOS if there is a solid scroll bar it can cover important
     // pixels, reduce the size of the canvas so it doesn't overlap with
     // the scroll bar.
-    this.parentWidth =
-        clientRect.width - globals.frontendLocalState.getScrollbarWidth();
+    this.parentWidth = clientRect.width - getScrollbarWidth();
     this.parentHeight = clientRect.height;
     return this.parentHeight !== oldHeight || this.parentWidth !== oldWidth;
   }
diff --git a/ui/src/frontend/publish.ts b/ui/src/frontend/publish.ts
index 8ed0c2d..d23be62 100644
--- a/ui/src/frontend/publish.ts
+++ b/ui/src/frontend/publish.ts
@@ -25,6 +25,7 @@
 import {MetricResult} from '../common/metric_data';
 import {CurrentSearchResults, SearchSummary} from '../common/search_data';
 import {raf} from '../core/raf_scheduler';
+import {HttpRpcState} from '../trace_processor/http_rpc_engine';
 
 import {
   CounterDetails,
@@ -81,6 +82,11 @@
   globals.publishRedraw();
 }
 
+export function publishHttpRpcState(httpRpcState: HttpRpcState) {
+  globals.httpRpcState = httpRpcState;
+  raf.scheduleFullRedraw();
+}
+
 export function publishCounterDetails(click: CounterDetails) {
   globals.counterDetails = click;
   globals.publishRedraw();
@@ -216,3 +222,8 @@
   globals.ftracePanelData = data;
   globals.publishRedraw();
 }
+
+export function publishShowPanningHint() {
+  globals.showPanningHint = true;
+  globals.publishRedraw();
+}
diff --git a/ui/src/frontend/rpc_http_dialog.ts b/ui/src/frontend/rpc_http_dialog.ts
index c2a64a2..54796b8 100644
--- a/ui/src/frontend/rpc_http_dialog.ts
+++ b/ui/src/frontend/rpc_http_dialog.ts
@@ -22,6 +22,7 @@
 
 import {globals} from './globals';
 import {showModal} from './modal';
+import {publishHttpRpcState} from './publish';
 
 const CURRENT_API_VERSION =
     TraceProcessorApiVersion.TRACE_PROCESSOR_CURRENT_API_VERSION;
@@ -79,7 +80,7 @@
 // having to open a trace).
 export async function CheckHttpRpcConnection(): Promise<void> {
   const state = await HttpRpcEngine.checkConnection();
-  globals.frontendLocalState.setHttpRpcState(state);
+  publishHttpRpcState(state);
   if (!state.connected) return;
   const tpStatus = assertExists(state.status);
 
diff --git a/ui/src/frontend/scroll_helper.ts b/ui/src/frontend/scroll_helper.ts
index dccad06..b514b61 100644
--- a/ui/src/frontend/scroll_helper.ts
+++ b/ui/src/frontend/scroll_helper.ts
@@ -135,7 +135,7 @@
   // group and scroll to the track or just scroll to the track group.
   if (openGroup) {
     // After the track exists in the dom, it will be scrolled to.
-    globals.frontendLocalState.scrollToTrackKey = trackKey;
+    globals.scrollToTrackKey = trackKey;
     globals.dispatch(Actions.toggleTrackGroupCollapsed({trackGroupId}));
     return;
   } else {
diff --git a/ui/src/frontend/service_worker_controller.ts b/ui/src/frontend/service_worker_controller.ts
index a6082fe..de5323e 100644
--- a/ui/src/frontend/service_worker_controller.ts
+++ b/ui/src/frontend/service_worker_controller.ts
@@ -88,7 +88,7 @@
       // Ctrl+Shift+R). In these cases, we are already at the last
       // version.
       if (sw !== this._initialWorker && this._initialWorker) {
-        globals.frontendLocalState.newVersionAvailable = true;
+        globals.newVersionAvailable = true;
       }
     }
   }
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index c0c23bf..6d3e79b 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -631,7 +631,7 @@
     // RPC server is shut down after we load the UI and cached httpRpcState)
     // this will eventually become  consistent once the engine is created.
     if (mode === undefined) {
-      if (globals.frontendLocalState.httpRpcState.connected &&
+      if (globals.httpRpcState.connected &&
           globals.state.newEngineMode === 'USE_HTTP_RPC_IF_AVAILABLE') {
         mode = 'HTTP_RPC';
       } else {
diff --git a/ui/src/frontend/slice_args.ts b/ui/src/frontend/slice_args.ts
index 70beacb..9e1798f 100644
--- a/ui/src/frontend/slice_args.ts
+++ b/ui/src/frontend/slice_args.ts
@@ -30,8 +30,7 @@
 } from '../tracks/visualised_args';
 import {Anchor} from '../widgets/anchor';
 import {MenuItem, PopupMenu2} from '../widgets/menu';
-import {Section} from '../widgets/section';
-import {Tree, TreeNode} from '../widgets/tree';
+import {TreeNode} from '../widgets/tree';
 
 import {addTab} from './bottom_tab';
 import {globals} from './globals';
@@ -40,20 +39,21 @@
 import {SqlTableTab} from './sql_table/tab';
 import {SqlTables} from './sql_table/well_known_tables';
 
-// Renders slice arguments (key/value pairs) into a Tree widget.
+// Renders slice arguments (key/value pairs) as a subtree.
 export function renderArguments(
     engine: EngineProxy, slice: SliceDetails): m.Children {
   if (slice.args && slice.args.length > 0) {
     const tree = convertArgsToTree(slice.args);
-    return m(
-        Section,
-        {title: 'Arguments'},
-        m(Tree, renderArgTreeNodes(engine, tree)));
+    return renderArgTreeNodes(engine, tree);
   } else {
     return undefined;
   }
 }
 
+export function hasArgs(slice: SliceDetails): boolean {
+  return exists(slice.args) && slice.args.length > 0;
+}
+
 function renderArgTreeNodes(
     engine: EngineProxy, args: ArgNode<Arg>[]): m.Children {
   return args.map((arg) => {
diff --git a/ui/src/frontend/tables/table.ts b/ui/src/frontend/tables/table.ts
index 4d1b773..eb12883 100644
--- a/ui/src/frontend/tables/table.ts
+++ b/ui/src/frontend/tables/table.ts
@@ -89,6 +89,11 @@
   return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter});
 }
 
+export function widgetColumn<T>(
+    name: string, getter: (t: T) => m.Child): ColumnDescriptor<T> {
+  return new ColumnDescriptor<T>(name, getter);
+}
+
 interface SortingInfo<T> {
   columnId: string;
   direction: SortDirection;
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index 70aed74..4bea985 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -45,7 +45,7 @@
         m('button.notification-btn.preferred',
           {
             onclick: () => {
-              globals.frontendLocalState.newVersionAvailable = false;
+              globals.newVersionAvailable = false;
               raf.scheduleFullRedraw();
             },
           },
@@ -61,7 +61,7 @@
     // does not persist for iFrames. The host is responsible for communicating
     // to users that they can press '?' for help.
     if (globals.embeddedMode || dismissed === 'true' ||
-        !globals.frontendLocalState.showPanningHint) {
+        !globals.showPanningHint) {
       return;
     }
     return m(
@@ -72,7 +72,7 @@
         m('button.hint-dismiss-button',
           {
             onclick: () => {
-              globals.frontendLocalState.showPanningHint = false;
+              globals.showPanningHint = false;
               localStorage.setItem(DISMISSED_PANNING_HINT_KEY, 'true');
               raf.scheduleFullRedraw();
             },
@@ -113,9 +113,7 @@
     return m(
         '.topbar',
         {class: globals.state.sidebarVisible ? '' : 'hide-sidebar'},
-        globals.frontendLocalState.newVersionAvailable ?
-            m(NewVersionNotification) :
-            omnibox,
+        globals.newVersionAvailable ? m(NewVersionNotification) : omnibox,
         m(Progress),
         m(HelpPanningNotification),
         m(TraceErrorIcon));
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index c877ad1..56318d1 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -297,9 +297,9 @@
   }
 
   oncreate({attrs}: m.CVnode<TrackComponentAttrs>) {
-    if (globals.frontendLocalState.scrollToTrackKey === attrs.trackState.key) {
+    if (globals.scrollToTrackKey === attrs.trackState.key) {
       verticalScrollToTrack(attrs.trackState.key);
-      globals.frontendLocalState.scrollToTrackKey = undefined;
+      globals.scrollToTrackKey = undefined;
     }
   }
 }
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index 8aeabde..57aa0a3 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -14,6 +14,7 @@
 
 import m from 'mithril';
 
+import {getScrollbarWidth} from '../base/dom_utils';
 import {clamp} from '../base/math_utils';
 import {Time} from '../base/time';
 import {Actions} from '../common/actions';
@@ -28,6 +29,7 @@
 import {createPage} from './pages';
 import {PanAndZoomHandler} from './pan_and_zoom_handler';
 import {AnyAttrsVnode, PanelContainer} from './panel_container';
+import {publishShowPanningHint} from './publish';
 import {TickmarkPanel} from './tickmark_panel';
 import {TimeAxisPanel} from './time_axis_panel';
 import {TimeSelectionPanel} from './time_selection_panel';
@@ -96,9 +98,7 @@
     const updateDimensions = () => {
       const rect = vnode.dom.getBoundingClientRect();
       frontendLocalState.updateLocalLimits(
-          0,
-          rect.width - TRACK_SHELL_WIDTH -
-              frontendLocalState.getScrollbarWidth());
+          0, rect.width - TRACK_SHELL_WIDTH - getScrollbarWidth());
     };
 
     updateDimensions();
@@ -193,6 +193,7 @@
           );
           frontendLocalState.areaY.start = dragStartY;
           frontendLocalState.areaY.end = currentY;
+          publishShowPanningHint();
         }
         raf.scheduleRedraw();
       },
diff --git a/ui/src/tracks/chrome_critical_user_interactions/index.ts b/ui/src/tracks/chrome_critical_user_interactions/index.ts
index 2e0a381..8ddb3d4 100644
--- a/ui/src/tracks/chrome_critical_user_interactions/index.ts
+++ b/ui/src/tracks/chrome_critical_user_interactions/index.ts
@@ -24,6 +24,7 @@
   NamedSliceTrackTypes,
 } from '../../frontend/named_slice_track';
 import {
+  NUM,
   Plugin,
   PluginContext,
   PluginContextTrace,
@@ -40,17 +41,20 @@
 } from '../custom_sql_table_slices';
 
 import {PageLoadDetailsPanel} from './page_load_details_panel';
+import {StartupDetailsPanel} from './startup_details_panel';
 
 export const CRITICAL_USER_INTERACTIONS_KIND =
     'org.chromium.CriticalUserInteraction.track';
 
 export const CRITICAL_USER_INTERACTIONS_ROW = {
   ...NAMED_ROW,
+  scopedId: NUM,
   type: STR,
 };
 export type CriticalUserInteractionRow = typeof CRITICAL_USER_INTERACTIONS_ROW;
 
 export interface CriticalUserInteractionSlice extends Slice {
+  scopedId: number;
   type: string;
 }
 
@@ -63,6 +67,7 @@
 enum CriticalUserInteractionType {
   UNKNOWN = 'Unknown',
   PAGE_LOAD = 'chrome_page_loads',
+  STARTUP = 'chrome_startups',
 }
 
 function convertToCriticalUserInteractionType(cujType: string):
@@ -70,6 +75,8 @@
   switch (cujType) {
     case CriticalUserInteractionType.PAGE_LOAD:
       return CriticalUserInteractionType.PAGE_LOAD;
+    case CriticalUserInteractionType.STARTUP:
+      return CriticalUserInteractionType.STARTUP;
     default:
       return CriticalUserInteractionType.UNKNOWN;
   }
@@ -81,7 +88,17 @@
 
   getSqlDataSource(): CustomSqlTableDefConfig {
     return {
-      columns: ['scoped_id AS id', 'name', 'ts', 'dur', 'type'],
+      columns: [
+        // The scoped_id is not a unique identifier within the table; generate
+        // a unique id from type and scoped_id on the fly to use for slice
+        // selection.
+        'hash(type, scoped_id) AS id',
+        'scoped_id AS scopedId',
+        'name',
+        'ts',
+        'dur',
+        'type',
+      ],
       sqlTableName: 'chrome_interactions',
     };
   }
@@ -107,12 +124,37 @@
           },
         };
         break;
+      case CriticalUserInteractionType.STARTUP:
+        detailsPanel = {
+          kind: StartupDetailsPanel.kind,
+          config: {
+            sqlTableName: this.tableName,
+            title: 'Chrome Startup',
+          },
+        };
+        break;
       default:
         break;
     }
     return detailsPanel;
   }
 
+  onSliceClick(
+      args: OnSliceClickArgs<CriticalUserInteractionSliceTrackTypes['slice']>) {
+    const detailsPanelConfig = this.getDetailsPanel(args);
+    globals.makeSelection(Actions.selectGenericSlice({
+      id: args.slice.scopedId,
+      sqlTableName: this.tableName,
+      start: args.slice.ts,
+      duration: args.slice.dur,
+      trackKey: this.trackKey,
+      detailsPanelConfig: {
+        kind: detailsPanelConfig.kind,
+        config: detailsPanelConfig.config,
+      },
+    }));
+  }
+
   getSqlImports(): CustomSqlImportConfig {
     return {
       modules: ['chrome.interactions'],
@@ -126,8 +168,9 @@
   rowToSlice(row: CriticalUserInteractionSliceTrackTypes['row']):
       CriticalUserInteractionSliceTrackTypes['slice'] {
     const baseSlice = super.rowToSlice(row);
+    const scopedId = row.scopedId;
     const type = row.type;
-    return {...baseSlice, type};
+    return {...baseSlice, scopedId, type};
   }
 }
 
diff --git a/ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts b/ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts
new file mode 100644
index 0000000..dbcde70
--- /dev/null
+++ b/ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts
@@ -0,0 +1,147 @@
+// 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 {duration, Time, time} from '../../base/time';
+import {
+  BottomTab,
+  bottomTabRegistry,
+  NewBottomTabArgs,
+} from '../../frontend/bottom_tab';
+import {
+  GenericSliceDetailsTabConfig,
+} from '../../frontend/generic_slice_details_tab';
+import {DurationWidget} from '../../frontend/widgets/duration';
+import {Timestamp} from '../../frontend/widgets/timestamp';
+import {LONG, NUM, STR, STR_NULL} from '../../trace_processor/query_result';
+import {DetailsShell} from '../../widgets/details_shell';
+import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout';
+import {Section} from '../../widgets/section';
+import {SqlRef} from '../../widgets/sql_ref';
+import {dictToTreeNodes, Tree} from '../../widgets/tree';
+import {asUpid, Upid} from '../../frontend/sql_types';
+
+interface Data {
+  startupId: number;
+  eventName: string;
+  startupBeginTs: time;
+  durToFirstVisibleContent: duration;
+  launchCause?: string;
+  upid: Upid;
+}
+
+export class StartupDetailsPanel extends
+    BottomTab<GenericSliceDetailsTabConfig> {
+  static readonly kind = 'org.perfetto.StartupDetailsPanel';
+  private loaded = false;
+  private data: Data|undefined;
+
+  static create(args: NewBottomTabArgs): StartupDetailsPanel {
+    return new StartupDetailsPanel(args);
+  }
+
+  constructor(args: NewBottomTabArgs) {
+    super(args);
+    this.loadData();
+  }
+
+  private async loadData() {
+    const queryResult = await this.engine.query(`
+      SELECT
+        activity_id AS startupId,
+        name,
+        startup_begin_ts AS startupBeginTs,
+        CASE
+          WHEN first_visible_content_ts IS NULL THEN 0
+          ELSE first_visible_content_ts - startup_begin_ts
+        END AS durTofirstVisibleContent,
+        launch_cause AS launchCause,
+        browser_upid AS upid
+      FROM chrome_startups
+      WHERE id = ${this.config.id};
+    `);
+
+    const iter = queryResult.firstRow({
+      startupId: NUM,
+      name: STR,
+      startupBeginTs: LONG,
+      durTofirstVisibleContent: LONG,
+      launchCause: STR_NULL,
+      upid: NUM,
+    });
+
+    this.data = {
+      startupId: iter.startupId,
+      eventName: iter.name,
+      startupBeginTs: Time.fromRaw(iter.startupBeginTs),
+      durToFirstVisibleContent: iter.durTofirstVisibleContent,
+      upid: asUpid(iter.upid),
+    };
+
+    if (iter.launchCause) {
+      this.data.launchCause = iter.launchCause;
+    }
+
+    this.loaded = true;
+  }
+
+  private getDetailsDictionary() {
+    const details: {[key: string]: m.Child} = {};
+    if (this.data === undefined) return details;
+    details['Activity ID'] = this.data.startupId;
+    details['Browser Upid'] = this.data.upid;
+    details['Startup Event'] = this.data.eventName;
+    details['Startup Timestamp'] = m(Timestamp, {ts: this.data.startupBeginTs});
+    details['Duration to First Visible Content'] =
+        m(DurationWidget, {dur: this.data.durToFirstVisibleContent});
+    if (this.data.launchCause) {
+      details['Launch Cause'] = this.data.launchCause;
+    }
+    details['SQL ID'] =
+        m(SqlRef, {table: 'chrome_startups', id: this.config.id});
+    return details;
+  }
+
+  viewTab() {
+    if (this.isLoading()) {
+      return m('h2', 'Loading');
+    }
+
+    return m(
+        DetailsShell,
+        {
+          title: this.getTitle(),
+        },
+        m(GridLayout,
+          m(
+              GridLayoutColumn,
+              m(
+                  Section,
+                  {title: 'Details'},
+                  m(Tree, dictToTreeNodes(this.getDetailsDictionary())),
+                  ),
+              )));
+  }
+
+  getTitle(): string {
+    return this.config.title;
+  }
+
+  isLoading() {
+    return !this.loaded;
+  }
+}
+
+bottomTabRegistry.register(StartupDetailsPanel);
diff --git a/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts b/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts
index 5014d14..0510eeb 100644
--- a/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts
+++ b/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 
-import {exists} from '../../base/utils';
+import {duration, time} from '../../base/time';
 import {raf} from '../../core/raf_scheduler';
 import {
   BottomTab,
@@ -27,8 +27,14 @@
 import {renderArguments} from '../../frontend/slice_args';
 import {renderDetails} from '../../frontend/slice_details';
 import {getSlice, SliceDetails, sliceRef} from '../../frontend/sql/slice';
-import {asSliceSqlId} from '../../frontend/sql_types';
-import {NUM} from '../../trace_processor/query_result';
+import {asSliceSqlId, SliceSqlId} from '../../frontend/sql_types';
+import {
+  ColumnDescriptor,
+  Table,
+  TableData,
+  widgetColumn,
+} from '../../frontend/tables/table';
+import {NUM, STR} from '../../trace_processor/query_result';
 import {DetailsShell} from '../../widgets/details_shell';
 import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout';
 import {Section} from '../../widgets/section';
@@ -36,6 +42,14 @@
 import {Tree, TreeNode} from '../../widgets/tree';
 
 import {
+  EventLatencyCauseThreadTracks,
+  EventLatencyStage,
+  getCauseLink,
+  getEventLatencyCauseTracks,
+  getScrollJankCauseStage,
+} from './scroll_jank_cause_link_utils';
+import {ScrollJankCauseMap} from './scroll_jank_cause_map';
+import {
   getScrollJankSlices,
   getSliceForTrack,
   ScrollJankSlice,
@@ -47,10 +61,22 @@
   static readonly kind = 'dev.perfetto.EventLatencySliceDetailsPanel';
 
   private loaded = false;
+  private name = '';
+  private topEventLatencyId: SliceSqlId|undefined = undefined;
 
   private sliceDetails?: SliceDetails;
   private jankySlice?: ScrollJankSlice;
 
+  // Whether this stage has caused jank. This is also true for top level
+  // EventLatency slices where a descendant is a cause of jank.
+  private isJankStage = false;
+
+  // For top level EventLatency slices - if any descendant is a cause of jank,
+  // this field stores information about that descendant slice. Otherwise, this
+  // is stores information about the current stage;
+  private relevantThreadStage: EventLatencyStage|undefined;
+  private relevantThreadTracks: EventLatencyCauseThreadTracks[] = [];
+
   static create(args: NewBottomTabArgs): EventLatencySliceDetailsPanel {
     return new EventLatencySliceDetailsPanel(args);
   }
@@ -62,8 +88,22 @@
   }
 
   async loadData() {
+    const queryResult = await this.engine.query(`
+      SELECT
+        name
+      FROM ${this.config.sqlTableName}
+      WHERE id = ${this.config.id}
+      `);
+
+    const iter = queryResult.firstRow({
+      name: STR,
+    });
+
+    this.name = iter.name;
+
     await this.loadSlice();
     await this.loadJankSlice();
+    await this.loadRelevantThreads();
     this.loaded = true;
   }
 
@@ -74,37 +114,152 @@
   }
 
   async loadJankSlice() {
-    if (exists(this.sliceDetails)) {
-      // Get the id for the top-level EventLatency slice (this or parent), as
-      // this id is used in the ScrollJankV3 track to identify the corresponding
-      // janky interval.
-      let eventLatencyId = -1;
-      if (this.sliceDetails.name == 'EventLatency') {
-        eventLatencyId = this.sliceDetails.id;
-      } else {
-        const queryResult = await this.engine.query(`
-          SELECT
-            id
-          FROM ancestor_slice(${this.sliceDetails.id})
-          WHERE name = 'EventLatency'
-        `);
-        const it = queryResult.iter({
-          id: NUM,
-        });
-        for (; it.valid(); it.next()) {
-          eventLatencyId = it.id;
-          break;
-        }
-      }
-
-      const possibleSlices =
-          await getScrollJankSlices(this.engine, eventLatencyId);
-      // We may not get any slices if the EventLatency doesn't indicate any
-      // jank occurred.
-      if (possibleSlices.length > 0) {
-        this.jankySlice = possibleSlices[0];
-      }
+    if (!this.sliceDetails) return;
+    // Get the id for the top-level EventLatency slice (this or parent), as
+    // this id is used in the ScrollJankV3 track to identify the corresponding
+    // janky interval.
+    if (this.sliceDetails.name === 'EventLatency') {
+      this.topEventLatencyId = this.sliceDetails.id;
+    } else {
+      this.topEventLatencyId =
+          asSliceSqlId(await this.getOldestAncestorSliceId());
     }
+
+    const possibleSlices =
+        await getScrollJankSlices(this.engine, this.topEventLatencyId);
+    // We may not get any slices if the EventLatency doesn't indicate any
+    // jank occurred.
+    if (possibleSlices.length > 0) {
+      this.jankySlice = possibleSlices[0];
+    }
+  }
+
+  async loadRelevantThreads() {
+    if (!this.sliceDetails) return;
+    if (!this.topEventLatencyId) return;
+
+    // Relevant threads should only be available on a "Janky" EventLatency
+    // slice to allow the user to jump to the possible cause of jank.
+    if (this.sliceDetails.name === 'EventLatency' && !this.jankySlice) return;
+
+    const possibleScrollJankStage =
+        await getScrollJankCauseStage(this.engine, this.topEventLatencyId);
+    if (this.sliceDetails.name === 'EventLatency') {
+      this.isJankStage = true;
+      this.relevantThreadStage = possibleScrollJankStage;
+    } else {
+      if (possibleScrollJankStage &&
+          this.sliceDetails.name === possibleScrollJankStage.name) {
+        this.isJankStage = true;
+      }
+      this.relevantThreadStage = {
+        name: this.sliceDetails.name,
+        eventLatencyId: this.topEventLatencyId,
+        ts: this.sliceDetails.ts,
+        dur: this.sliceDetails.dur,
+      };
+    }
+
+    if (this.relevantThreadStage) {
+      this.relevantThreadTracks = await getEventLatencyCauseTracks(
+          this.engine, this.relevantThreadStage);
+    }
+  }
+
+  private getRelevantLinks(): m.Child {
+    if (!this.sliceDetails) return undefined;
+
+    // Relevant threads should only be available on a "Janky" EventLatency
+    // slice to allow the user to jump to the possible cause of jank.
+    if (this.sliceDetails.name === 'EventLatency' &&
+        !this.relevantThreadStage) {
+      return undefined;
+    }
+
+    const name = this.relevantThreadStage ? this.relevantThreadStage.name :
+                                            this.sliceDetails.name;
+    const ts = this.relevantThreadStage ? this.relevantThreadStage.ts :
+                                          this.sliceDetails.ts;
+    const dur = this.relevantThreadStage ? this.relevantThreadStage.dur :
+                                           this.sliceDetails.dur;
+    const stageDetails = ScrollJankCauseMap.getEventLatencyDetails(name);
+    if (stageDetails === undefined) return undefined;
+
+    const childWidgets: m.Child[] = [];
+    childWidgets.push(m(TextParagraph, {text: stageDetails.description}));
+
+    interface RelevantThreadRow {
+      description: string;
+      tracks: EventLatencyCauseThreadTracks;
+      ts: time;
+      dur: duration;
+    }
+
+    const columns: ColumnDescriptor<RelevantThreadRow>[] = [
+      widgetColumn<RelevantThreadRow>(
+          'Relevant Thread', (x) => getCauseLink(x.tracks, x.ts, x.dur)),
+      widgetColumn<RelevantThreadRow>(
+          'Description',
+          (x) => {
+            if (x.description === '') {
+              return x.description;
+            } else {
+              return m(TextParagraph, {text: x.description});
+            }
+          }),
+    ];
+
+    const trackLinks: RelevantThreadRow[] = [];
+
+    for (let i = 0; i < this.relevantThreadTracks.length; i++) {
+      const track = this.relevantThreadTracks[i];
+      let description = '';
+      if (i == 0 || track.thread != this.relevantThreadTracks[i - 1].thread) {
+        description = track.causeDescription;
+      }
+      trackLinks.push({
+        description: description,
+        tracks: this.relevantThreadTracks[i],
+        ts: ts,
+        dur: dur,
+      });
+    }
+
+    const tableData = new TableData(trackLinks);
+
+    if (trackLinks.length > 0) {
+      childWidgets.push(m(Table, {
+        data: tableData,
+        columns: columns,
+      }));
+    }
+
+    return m(
+        Section,
+        {title: this.isJankStage ? `Jank Cause: ${name}` : name},
+        childWidgets);
+  }
+
+  private async getOldestAncestorSliceId(): Promise<number> {
+    let eventLatencyId = -1;
+    if (!this.sliceDetails) return eventLatencyId;
+    const queryResult = await this.engine.query(`
+      SELECT
+        id
+      FROM ancestor_slice(${this.sliceDetails.id})
+      WHERE name = 'EventLatency'
+    `);
+
+    const it = queryResult.iter({
+      id: NUM,
+    });
+
+    for (; it.valid(); it.next()) {
+      eventLatencyId = it.id;
+      break;
+    }
+
+    return eventLatencyId;
   }
 
   private getLinksSection(): m.Child {
@@ -114,20 +269,20 @@
         m(
             Tree,
             m(TreeNode, {
-              left: exists(this.sliceDetails) ?
+              left: this.sliceDetails ?
                   sliceRef(
                       this.sliceDetails,
                       'EventLatency in context of other Input events') :
                   'EventLatency in context of other Input events',
-              right: exists(this.sliceDetails) ? '' : 'N/A',
+              right: this.sliceDetails ? '' : 'N/A',
             }),
             m(TreeNode, {
-              left: exists(this.jankySlice) ? getSliceForTrack(
-                                                  this.jankySlice,
-                                                  ScrollJankV3Track.kind,
-                                                  'Jank Interval') :
-                                              'Jank Interval',
-              right: exists(this.jankySlice) ? '' : 'N/A',
+              left: this.jankySlice ? getSliceForTrack(
+                                          this.jankySlice,
+                                          ScrollJankV3Track.kind,
+                                          'Jank Interval') :
+                                      'Jank Interval',
+              right: this.jankySlice ? '' : 'N/A',
             }),
             ),
     );
@@ -160,23 +315,34 @@
   }
 
   viewTab() {
-    if (exists(this.sliceDetails)) {
+    if (this.sliceDetails) {
       const slice = this.sliceDetails;
+
+      const rightSideWidgets: m.Child[] = [];
+      rightSideWidgets.push(
+          m(Section,
+            {title: 'Description'},
+            m('.div', this.getDescriptionText())));
+
+      const stageWidget = this.getRelevantLinks();
+      if (stageWidget) {
+        rightSideWidgets.push(stageWidget);
+      }
+      rightSideWidgets.push(this.getLinksSection());
+
       return m(
           DetailsShell,
           {
             title: 'Slice',
-            description: slice.name,
+            description: this.name,
           },
           m(GridLayout,
             m(GridLayoutColumn,
               renderDetails(slice),
-              renderArguments(this.engine, slice)),
-            m(GridLayoutColumn,
               m(Section,
-                {title: 'Description'},
-                m('.div', this.getDescriptionText())),
-              this.getLinksSection())),
+                {title: 'Arguments'},
+                m(Tree, renderArguments(this.engine, slice)))),
+            m(GridLayoutColumn, rightSideWidgets)),
       );
     } else {
       return m(DetailsShell, {title: 'Slice', description: 'Loading...'});
diff --git a/ui/src/tracks/chrome_scroll_jank/index.ts b/ui/src/tracks/chrome_scroll_jank/index.ts
index 583909f..34df4e1 100644
--- a/ui/src/tracks/chrome_scroll_jank/index.ts
+++ b/ui/src/tracks/chrome_scroll_jank/index.ts
@@ -38,6 +38,7 @@
   EventLatencyTrack,
   JANKY_LATENCY_NAME,
 } from './event_latency_track';
+import {ScrollJankCauseMap} from './scroll_jank_cause_map';
 import {
   addScrollJankV3ScrollTrack,
   ScrollJankV3Track,
@@ -117,7 +118,7 @@
   }
 }
 
-export async function getScrollJankTracks(_engine: Engine):
+export async function getScrollJankTracks(engine: Engine):
     Promise<ScrollJankTrackGroup> {
   const result: ScrollJankTracks = {
     tracksToAdd: [],
@@ -149,6 +150,7 @@
     fixedOrdering: true,
   });
 
+  await ScrollJankCauseMap.initialize(engine);
   return {tracks: result, addTrackGroup};
 }
 
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts b/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts
index 63e913f..2837ce1 100644
--- a/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts
+++ b/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts
@@ -31,6 +31,7 @@
   numberColumn,
   Table,
   TableData,
+  widgetColumn,
 } from '../../frontend/tables/table';
 import {DurationWidget} from '../../frontend/widgets/duration';
 import {Timestamp} from '../../frontend/widgets/timestamp';
@@ -55,11 +56,6 @@
 } from './scroll_jank_slice';
 import {ScrollJankV3Track} from './scroll_jank_v3_track';
 
-function widgetColumn<T>(
-    name: string, getter: (t: T) => m.Child): ColumnDescriptor<T> {
-  return new ColumnDescriptor<T>(name, getter);
-}
-
 interface Data {
   // Scroll ID.
   id: number;
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_link_utils.ts b/ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_link_utils.ts
new file mode 100644
index 0000000..7209356
--- /dev/null
+++ b/ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_link_utils.ts
@@ -0,0 +1,218 @@
+// 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 {Icons} from '../../base/semantic_icons';
+import {duration, Time, time} from '../../base/time';
+import {exists} from '../../base/utils';
+import {Actions} from '../../common/actions';
+import {globals} from '../../frontend/globals';
+import {
+  focusHorizontalRange,
+  verticalScrollToTrack,
+} from '../../frontend/scroll_helper';
+import {SliceSqlId} from '../../frontend/sql_types';
+import {EngineProxy} from '../../trace_processor/engine';
+import {LONG, NUM, STR} from '../../trace_processor/query_result';
+import {Anchor} from '../../widgets/anchor';
+
+import {
+  CauseProcess,
+  CauseThread,
+  ScrollJankCauseMap,
+} from './scroll_jank_cause_map';
+
+const UNKNOWN_NAME = 'Unknown';
+
+export interface EventLatencyStage {
+  name: string;
+  // Slice id of the top level EventLatency slice (not a stage).
+  eventLatencyId: SliceSqlId;
+  ts: time;
+  dur: duration;
+}
+
+export interface EventLatencyCauseThreadTracks {
+  // A thread may have multiple tracks associated with it (e.g. from ATrace
+  // events).
+  trackIds: number[];
+  thread: CauseThread;
+  causeDescription: string;
+}
+
+export async function getScrollJankCauseStage(
+    engine: EngineProxy,
+    eventLatencyId: SliceSqlId): Promise<EventLatencyStage|undefined> {
+  const queryResult = await engine.query(`
+    SELECT
+      IFNULL(cause_of_jank, '${UNKNOWN_NAME}') AS causeOfJank,
+      IFNULL(sub_cause_of_jank, '${UNKNOWN_NAME}') AS subCauseOfJank,
+      IFNULL(substage.ts, -1) AS ts,
+      IFNULL(substage.dur, -1) AS dur
+    FROM chrome_janky_frame_presentation_intervals
+      JOIN descendant_slice(event_latency_id) substage
+    WHERE event_latency_id = ${eventLatencyId}
+      AND substage.name = COALESCE(sub_cause_of_jank, cause_of_jank)
+  `);
+
+  const causeIt = queryResult.iter({
+    causeOfJank: STR,
+    subCauseOfJank: STR,
+    ts: LONG,
+    dur: LONG,
+  });
+
+  for (; causeIt.valid(); causeIt.next()) {
+    const causeOfJank = causeIt.causeOfJank;
+    const subCauseOfJank = causeIt.subCauseOfJank;
+
+    if (causeOfJank == '' || causeOfJank == UNKNOWN_NAME) return undefined;
+    const cause = subCauseOfJank == UNKNOWN_NAME ? causeOfJank : subCauseOfJank;
+    const stageDetails: EventLatencyStage = {
+      name: cause,
+      eventLatencyId: eventLatencyId,
+      ts: Time.fromRaw(causeIt.ts),
+      dur: causeIt.dur,
+    };
+
+    return stageDetails;
+  }
+
+  return undefined;
+}
+
+export async function getEventLatencyCauseTracks(
+    engine: EngineProxy, scrollJankCauseStage: EventLatencyStage):
+    Promise<EventLatencyCauseThreadTracks[]> {
+  const threadTracks: EventLatencyCauseThreadTracks[] = [];
+  const causeDetails =
+      ScrollJankCauseMap.getEventLatencyDetails(scrollJankCauseStage.name);
+  if (causeDetails === undefined) return threadTracks;
+
+  for (const cause of causeDetails.jankCauses) {
+    switch (cause.process) {
+      case CauseProcess.RENDERER:
+      case CauseProcess.BROWSER:
+      case CauseProcess.GPU:
+        const tracksForProcess = await getChromeCauseTracks(
+            engine,
+            scrollJankCauseStage.eventLatencyId,
+            cause.process,
+            cause.thread);
+        for (const track of tracksForProcess) {
+          track.causeDescription = cause.description;
+          threadTracks.push(track);
+        }
+        break;
+      case CauseProcess.UNKNOWN:
+      default:
+        break;
+    }
+  }
+
+  return threadTracks;
+}
+
+async function getChromeCauseTracks(
+    engine: EngineProxy,
+    eventLatencySliceId: number,
+    processName: CauseProcess,
+    threadName: CauseThread): Promise<EventLatencyCauseThreadTracks[]> {
+  const queryResult = await engine.query(`
+      INCLUDE PERFETTO MODULE chrome.scroll_jank.scroll_jank_cause_utils;
+
+      SELECT DISTINCT
+        utid,
+        id AS trackId
+      FROM thread_track
+      WHERE utid IN (
+        SELECT DISTINCT
+          utid
+        FROM chrome_select_scroll_jank_cause_thread(
+          ${eventLatencySliceId},
+          '${processName}',
+          '${threadName}'
+        )
+      );
+  `);
+
+  const it = queryResult.iter({
+    utid: NUM,
+    trackId: NUM,
+  });
+
+  const threadsWithTrack: {[id: number]: EventLatencyCauseThreadTracks;} = {};
+  const utids: number[] = [];
+  for (; it.valid(); it.next()) {
+    const utid = it.utid;
+    if (!(utid in threadsWithTrack)) {
+      threadsWithTrack[utid] = {
+        trackIds: [it.trackId],
+        thread: threadName,
+        causeDescription: '',
+      };
+      utids.push(utid);
+    } else {
+      threadsWithTrack[utid].trackIds.push(it.trackId);
+    }
+  }
+
+  return utids.map((each) => threadsWithTrack[each]);
+}
+
+export function getCauseLink(
+    threadTracks: EventLatencyCauseThreadTracks,
+    ts: time|undefined,
+    dur: duration|undefined): m.Child {
+  const trackKeys: string[] = [];
+  for (const trackId of threadTracks.trackIds) {
+    const trackKey = globals.state.trackKeyByTrackId[trackId];
+    if (trackKey === undefined) {
+      return `Could not locate track ${trackId} for thread ${
+          threadTracks.thread} in the global state`;
+    }
+    trackKeys.push(trackKey);
+  }
+
+  if (trackKeys.length == 0) {
+    return `No valid tracks for thread ${threadTracks.thread}.`;
+  }
+
+  // Fixed length of a container to ensure that the icon does not overlap with
+  // the text due to table formatting.
+  return m(
+      `div[style='width:250px']`,
+      m(Anchor,
+        {
+          icon: Icons.UpdateSelection,
+          onclick: () => {
+            verticalScrollToTrack(trackKeys[0], true);
+            if (exists(ts) && exists(dur)) {
+              focusHorizontalRange(ts, Time.fromRaw(ts + dur), 0.3);
+              globals.frontendLocalState.selectArea(
+                  ts, Time.fromRaw(ts + dur), trackKeys);
+
+              globals.dispatch(Actions.selectArea({
+                area: {
+                  start: ts,
+                  end: Time.fromRaw(ts + dur),
+                  tracks: trackKeys,
+                },
+              }));
+            }
+          },
+        },
+        threadTracks.thread));
+}
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_map.ts b/ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_map.ts
new file mode 100644
index 0000000..c4842ef
--- /dev/null
+++ b/ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_map.ts
@@ -0,0 +1,149 @@
+// 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 {exists} from '../../base/utils';
+import {Engine} from '../../trace_processor/engine';
+import {STR} from '../../trace_processor/query_result';
+
+export enum CauseProcess {
+  UNKNOWN,
+  BROWSER = 'Browser',
+  RENDERER = 'Renderer',
+  GPU = 'GPU',
+}
+
+export enum CauseThread {
+  UNKNOWN,
+  BROWSER_MAIN = 'CrBrowserMain',
+  RENDERER_MAIN = 'CrRendererMain',
+  COMPOSITOR = 'Compositor',
+  CHROME_CHILD_IO_THREAD = 'Chrome_ChildIOThread',
+  VIZ_COMPOSITOR = 'VizCompositorThread',
+  SURFACE_FLINGER = 'surfaceflinger'
+}
+
+export interface ScrollJankCause {
+  description: string;
+  process: CauseProcess;
+  thread: CauseThread;
+}
+
+export interface EventLatencyStageDetails {
+  description: string;
+  jankCauses: ScrollJankCause[];
+}
+
+export interface ScrollJankCauseMapInternal {
+  // Key corresponds with the EventLatency stage.
+  [key: string]: EventLatencyStageDetails;
+}
+
+function getScrollJankProcess(process: string): CauseProcess {
+  switch (process) {
+    case CauseProcess.BROWSER:
+      return CauseProcess.BROWSER;
+    case CauseProcess.RENDERER:
+      return CauseProcess.RENDERER;
+    case CauseProcess.GPU:
+      return CauseProcess.GPU;
+    default:
+      return CauseProcess.UNKNOWN;
+  }
+}
+
+function getScrollJankThread(thread: string): CauseThread {
+  switch (thread) {
+    case CauseThread.BROWSER_MAIN:
+      return CauseThread.BROWSER_MAIN;
+    case CauseThread.RENDERER_MAIN:
+      return CauseThread.RENDERER_MAIN;
+    case CauseThread.CHROME_CHILD_IO_THREAD:
+      return CauseThread.CHROME_CHILD_IO_THREAD;
+    case CauseThread.COMPOSITOR:
+      return CauseThread.COMPOSITOR;
+    case CauseThread.VIZ_COMPOSITOR:
+      return CauseThread.VIZ_COMPOSITOR;
+    case CauseThread.SURFACE_FLINGER:
+      return CauseThread.SURFACE_FLINGER;
+    default:
+      return CauseThread.UNKNOWN;
+  }
+}
+
+export class ScrollJankCauseMap {
+  private static instance: ScrollJankCauseMap;
+  private causes: ScrollJankCauseMapInternal;
+
+  private constructor() {
+    this.causes = {};
+  }
+
+  private async initializeCauseMap(engine: Engine) {
+    const queryResult = await engine.query(`
+      INCLUDE PERFETTO MODULE chrome.scroll_jank.scroll_jank_cause_map;
+
+      SELECT
+        IFNULL(name, '') AS name,
+        IFNULL(description, '') AS description,
+        IFNULL(cause_process, '') AS causeProcess,
+        IFNULL(cause_thread, '') AS causeThread,
+        IFNULL(cause_description, '') AS causeDescription
+      FROM chrome_scroll_jank_causes_with_event_latencies;
+    `);
+
+    const iter = queryResult.iter({
+      name: STR,
+      description: STR,
+      causeProcess: STR,
+      causeThread: STR,
+      causeDescription: STR,
+    });
+
+    for (; iter.valid(); iter.next()) {
+      const eventLatencyStage = iter.name;
+      if (!(eventLatencyStage in this.causes)) {
+        this.causes[eventLatencyStage] = {
+          description: iter.description,
+          jankCauses: [] as ScrollJankCause[],
+        };
+      }
+
+      const causeProcess = getScrollJankProcess(iter.causeProcess);
+      const causeThread = getScrollJankThread(iter.causeThread);
+
+      this.causes[eventLatencyStage].jankCauses.push({
+        description: iter.causeDescription,
+        process: causeProcess,
+        thread: causeThread,
+      });
+    }
+  }
+
+  // Must be called before this item is accessed, as the object is populated
+  // from SQL data.
+  public static async initialize(engine: Engine) {
+    if (!exists(ScrollJankCauseMap.instance)) {
+      ScrollJankCauseMap.instance = new ScrollJankCauseMap();
+      await ScrollJankCauseMap.instance.initializeCauseMap(engine);
+    }
+  }
+
+  public static getEventLatencyDetails(eventLatency: string):
+      EventLatencyStageDetails|undefined {
+    if (eventLatency in ScrollJankCauseMap.instance.causes) {
+      return ScrollJankCauseMap.instance.causes[eventLatency];
+    }
+    return undefined;
+  }
+}
diff --git a/ui/src/tracks/debug/details_tab.ts b/ui/src/tracks/debug/details_tab.ts
index 3523f4f..5616a6b 100644
--- a/ui/src/tracks/debug/details_tab.ts
+++ b/ui/src/tracks/debug/details_tab.ts
@@ -24,6 +24,7 @@
 import {
   GenericSliceDetailsTabConfig,
 } from '../../frontend/generic_slice_details_tab';
+import {hasArgs, renderArguments} from '../../frontend/slice_args';
 import {
   getSlice,
   SliceDetails,
@@ -162,11 +163,24 @@
           left: sliceRef(this.slice, 'Slice'),
           right: '',
         },
-        renderTreeContents({
-          'Name': this.slice.name,
-          'Thread': getThreadName(this.slice.thread),
-          'Process': getProcessName(this.slice.process),
-        }));
+        m(TreeNode, {
+          left: 'Name',
+          right: this.slice.name,
+        }),
+        m(TreeNode, {
+          left: 'Thread',
+          right: getThreadName(this.slice.thread),
+        }),
+        m(TreeNode, {
+          left: 'Process',
+          right: getProcessName(this.slice.process),
+        }),
+        hasArgs(this.slice) &&
+            m(TreeNode,
+              {
+                left: 'Args',
+              },
+              renderArguments(this.engine, this.slice)));
   }