Merge "Add Renderthread frame times for CUJ jank tracking"
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
index 0d2eafa..80ea2f4 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
@@ -1 +1 @@
-7be9a9c82f04d1322c9e4e7b4ce75dc688152ca72af2391a0292749673ce8c1f
\ No newline at end of file
+436e358bbe14b492a48f369a4296fff45de2879308196ce9838ea1df7adcd7c8
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
index 729dfb4..0f1fd38 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
@@ -1 +1 @@
-6699299b62ee4632f851f555703c94b541de0fb9a2792262bd428c49ec3a321c
\ No newline at end of file
+70b1ccb9b490aa038837b8b010d4c4a03418b5b5c78e0ef26a10343763a80565
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
index 9ed6aa2..43fc3a3 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
@@ -1 +1 @@
-404f4b826297b80b5d4c67c3326bf7b044b297114247d912114efae308f99370
\ No newline at end of file
+0ec0548f87e4bac68a968606ed5dbaac3b0be7048df84161adb8f6113ac10a36
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256 b/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
index 035f335..1c1134b 100644
--- a/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
@@ -1 +1 @@
-e8ffe0046ea98d27b4216c8d1ff64c665392c90ab3d90e2db99069ae54d12707
\ No newline at end of file
+582c5550a14de74aabcd2b0342f8295b040febc4cf6bcb4e9fb6cb155e9c8d4d
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256 b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
index d7c7cb9..ce4aadf 100644
--- a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
@@ -1 +1 @@
-14e436dda252119dbe75bbe7233e8b62ade69e27bbad311181927c097adb86d0
\ No newline at end of file
+72e9695470c9067005bca54fcdee310feaeed23d58d6bf4c2e575748a992027e
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
index b5f3a52..3c99db9 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
@@ -1 +1 @@
-77ead2d35348d988d3938b88fc66f0731f494fb77f2165d3fbddca7f37df97f6
\ No newline at end of file
+9425ea9f775ab88ebd9d0f47946c0835191a7d4579da40d04f20c9db9d288e95
\ No newline at end of file
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index 46f9db4..964c283 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -452,6 +452,13 @@
 
 .panel {
   position: relative;  // Otherwise canvas covers panel dom.
+
+  &.sticky {
+    position: sticky;
+    z-index: 3;
+    top: 0;
+    background-color: hsl(215, 22%, 19%);
+  }
 }
 
 .pan-and-zoom-content {
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 662cd54..132d7cf 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -30,6 +30,7 @@
   RunningStatistics,
   runningStatStr
 } from './perf';
+import {TrackGroupAttrs} from './viewer_page';
 
 /**
  * If the panel container scrolls, the backing canvas height is
@@ -216,21 +217,45 @@
     perfDisplay.removeContainer(this);
   }
 
+  isTrackGroupAttrs(attrs: unknown): attrs is TrackGroupAttrs {
+    return (attrs as {collapsed?: boolean}).collapsed !== undefined;
+  }
+
+  renderPanel(node: AnyAttrsVnode, key: string, extraClass = ''): m.Vnode {
+    assertFalse(this.panelByKey.has(key));
+    this.panelByKey.set(key, node);
+
+    return m(
+        `.panel${extraClass}`,
+        {key, 'data-key': key},
+        perfDebug() ?
+            [node, m('.debug-panel-border', {key: 'debug-panel-border'})] :
+            node);
+  }
+
+  // Render a tree of panels into one vnode. Argument `path` is used to build
+  // `key` attribute for intermediate tree vnodes: otherwise Mithril internals
+  // will complain about keyed and non-keyed vnodes mixed together.
+  renderTree(node: AnyAttrsVnode, path: string): m.Vnode {
+    if (this.isTrackGroupAttrs(node.attrs)) {
+      return m(
+          'div',
+          {key: path},
+          this.renderPanel(
+              node.attrs.header,
+              `${path}-header`,
+              node.attrs.collapsed ? '' : '.sticky'),
+          ...node.attrs.childTracks.map(
+              (child, index) => this.renderTree(child, `${path}-${index}`)));
+    }
+    return this.renderPanel(node, assertExists(node.key) as string);
+  }
+
   view({attrs}: m.CVnode<Attrs>) {
     this.attrs = attrs;
     this.panelByKey.clear();
-    const children = [];
-    for (const panel of attrs.panels) {
-      const key = assertExists(panel.key) as string;
-      assertFalse(this.panelByKey.has(key));
-      this.panelByKey.set(key, panel);
-      children.push(
-          m('.panel',
-            {key: panel.key, 'data-key': panel.key},
-            perfDebug() ?
-                [panel, m('.debug-panel-border', {key: 'debug-panel-border'})] :
-                panel));
-    }
+    const children = attrs.panels.map(
+        (panel, index) => this.renderTree(panel, `track-tree-${index}`));
 
     return [
       m(
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index 0cce472..f2370a9 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -59,6 +59,20 @@
   return null;
 }
 
+export interface TrackGroupAttrs {
+  header: AnyAttrsVnode;
+  collapsed: boolean;
+  childTracks: AnyAttrsVnode[];
+}
+
+export class TrackGroup implements m.ClassComponent<TrackGroupAttrs> {
+  view() {
+    // TrackGroup component acts as a holder for a bunch of tracks rendered
+    // together: the actual rendering happens in PanelContainer. In order to
+    // avoid confusion, this method remains empty.
+  }
+}
+
 /**
  * Top-most level component for the viewer page. Holds tracks, brush timeline,
  * panels, and everything else that's part of the main trace viewer page.
@@ -212,22 +226,30 @@
         id => m(TrackPanel, {key: id, id, selectable: true}));
 
     for (const group of Object.values(globals.state.trackGroups)) {
-      scrollingPanels.push(m(TrackGroupPanel, {
+      const headerPanel = m(TrackGroupPanel, {
         trackGroupId: group.id,
         key: `trackgroup-${group.id}`,
-        selectable: true,
-      }));
-      if (group.collapsed) continue;
+        selectable: true
+      });
+
+      const childTracks: AnyAttrsVnode[] = [];
       // The first track is the summary track, and is displayed as part of the
       // group panel, we don't want to display it twice so we start from 1.
-      for (let i = 1; i < group.tracks.length; ++i) {
-        const id = group.tracks[i];
-        scrollingPanels.push(m(TrackPanel, {
-          key: `track-${group.id}-${id}`,
-          id,
-          selectable: true,
-        }));
+      if (!group.collapsed) {
+        for (let i = 1; i < group.tracks.length; ++i) {
+          const id = group.tracks[i];
+          childTracks.push(m(TrackPanel, {
+            key: `track-${group.id}-${id}`,
+            id,
+            selectable: true,
+          }));
+        }
       }
+      scrollingPanels.push(m(TrackGroup, {
+        header: headerPanel,
+        collapsed: group.collapsed,
+        childTracks,
+      } as TrackGroupAttrs));
     }
 
     return m(