Merge "Increase granularity of Lock contention blocking calls" into main
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index 9535b08..afadad0 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -20,7 +20,7 @@
 import {globals} from '../frontend/globals';
 import {
   Command,
-  CurrentSelectionSection,
+  DetailsPanel,
   EngineProxy,
   MetricVisualisation,
   Migrate,
@@ -41,6 +41,7 @@
 import {Actions} from './actions';
 import {Registry} from './registry';
 import {SCROLLING_TRACK_GROUP} from './state';
+import {addQueryResultsTab} from '../frontend/query_result_tab';
 
 // Every plugin gets its own PluginContext. This is how we keep track
 // what each plugin is doing and how we can blame issues on particular
@@ -164,13 +165,13 @@
     this.trash.addCallback(() => globals.tabManager.unregisterTab(desc.uri));
   }
 
-  registerCurrentSelectionSection(section: CurrentSelectionSection): void {
+  registerDetailsPanel(section: DetailsPanel): void {
     if (!this.alive) return;
 
     const tabMan = globals.tabManager;
-    tabMan.registerCurrentSelectionSection(section);
+    tabMan.registerDetailsPanel(section);
     this.trash.addCallback(
-      () => tabMan.unregisterCurrentSelectionSection(section));
+      () => tabMan.unregisterDetailsPanel(section));
   }
 
   get sidebar() {
@@ -179,7 +180,7 @@
 
   readonly tabs = {
     openQuery: (query: string, title: string) => {
-      globals.openQuery(query, title);
+      addQueryResultsTab({query, title});
     },
 
     showTab(uri: string):
diff --git a/ui/src/common/tab_registry.ts b/ui/src/common/tab_registry.ts
index d564c9b..c9faca7 100644
--- a/ui/src/common/tab_registry.ts
+++ b/ui/src/common/tab_registry.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import {Disposable} from '../base/disposable';
-import {CurrentSelectionSection, TabDescriptor} from '../public';
+import {DetailsPanel, TabDescriptor} from '../public';
 
 export interface ResolvedTab {
   uri: string;
@@ -26,7 +26,7 @@
  */
 export class TabManager implements Disposable {
   private _registry = new Map<string, TabDescriptor>();
-  private _currentSelectionSectionReg = new Set<CurrentSelectionSection>();
+  private _detailsPanelsRegistry = new Set<DetailsPanel>();
   private _currentTabs = new Map<string, TabDescriptor>();
 
   dispose(): void {
@@ -45,12 +45,12 @@
     this._registry.delete(uri);
   }
 
-  registerCurrentSelectionSection(section: CurrentSelectionSection): void {
-    this._currentSelectionSectionReg.add(section);
+  registerDetailsPanel(section: DetailsPanel): void {
+    this._detailsPanelsRegistry.add(section);
   }
 
-  unregisterCurrentSelectionSection(section: CurrentSelectionSection): void {
-    this._currentSelectionSectionReg.delete(section);
+  unregisterDetailsPanel(section: DetailsPanel): void {
+    this._detailsPanelsRegistry.delete(section);
   }
 
   resolveTab(uri: string): TabDescriptor|undefined {
@@ -61,8 +61,8 @@
     return Array.from(this._registry.values());
   }
 
-  get currentSelectionSections(): CurrentSelectionSection[] {
-    return Array.from(this._currentSelectionSectionReg);
+  get detailsPanels(): DetailsPanel[] {
+    return Array.from(this._detailsPanelsRegistry);
   }
 
   /**
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index f752427..b5c6994 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -59,6 +59,7 @@
   publishRealtimeOffset,
   publishThreads,
 } from '../frontend/publish';
+import {addQueryResultsTab} from '../frontend/query_result_tab';
 import {Router} from '../frontend/router';
 import {Engine} from '../trace_processor/engine';
 import {HttpRpcEngine} from '../trace_processor/http_rpc_engine';
@@ -629,7 +630,10 @@
           pendingDeeplink.visStart, pendingDeeplink.visEnd);
       }
       if (pendingDeeplink.query !== undefined) {
-        globals.openQuery(pendingDeeplink.query, 'Deeplink Query');
+        addQueryResultsTab({
+          query: pendingDeeplink.query,
+          title: 'Deeplink Query',
+        });
       }
     }
 
diff --git a/ui/src/core/feature_flags.ts b/ui/src/core/feature_flags.ts
index c6c98bd..f00d535 100644
--- a/ui/src/core/feature_flags.ts
+++ b/ui/src/core/feature_flags.ts
@@ -244,3 +244,10 @@
   description: 'Record using V2 interface',
   defaultValue: false,
 });
+
+export const TABS_V2_FLAG = featureFlags.register({
+  id: 'tabsv2',
+  name: 'Tabs V2',
+  description: 'Use Tabs V2',
+  defaultValue: false,
+});
diff --git a/ui/src/frontend/aggregation_panel.ts b/ui/src/frontend/aggregation_panel.ts
index c7f3eb8..5232759 100644
--- a/ui/src/frontend/aggregation_panel.ts
+++ b/ui/src/frontend/aggregation_panel.ts
@@ -19,21 +19,30 @@
   AggregateData,
   Column,
   ThreadStateExtra,
+  isEmptyData,
 } from '../common/aggregation_data';
 import {colorForState} from '../common/colorizer';
 import {translateState} from '../common/thread_state';
 
 import {globals} from './globals';
 import {DurationWidget} from './widgets/duration';
+import {EmptyState} from '../widgets/empty_state';
 
 export interface AggregationPanelAttrs {
-  data: AggregateData;
+  data?: AggregateData;
   kind: string;
 }
 
 export class AggregationPanel implements
     m.ClassComponent<AggregationPanelAttrs> {
   view({attrs}: m.CVnode<AggregationPanelAttrs>) {
+    if (!attrs.data || isEmptyData(attrs.data)) {
+      return m(EmptyState, {
+        className: 'pf-noselection',
+        header: 'Nothing selected',
+        detail: 'Aggregation data will appear here',
+      });
+    }
     return m(
       '.details-panel',
       m('.details-panel-heading.aggregation',
diff --git a/ui/src/frontend/aggregation_tab.ts b/ui/src/frontend/aggregation_tab.ts
new file mode 100644
index 0000000..89b2044
--- /dev/null
+++ b/ui/src/frontend/aggregation_tab.ts
@@ -0,0 +1,68 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Disposable} from '../base/disposable';
+import {AggregationPanel} from './aggregation_panel';
+import {globals} from './globals';
+
+export class AggregationsTabs implements Disposable {
+  private tabs = [
+    {
+      type: 'cpu_aggregation',
+      title: 'CPU by thread',
+    },
+    {
+      type: 'thread_state_aggregation',
+      title: 'Thread States',
+    },
+    {
+      type: 'cpu_by_process_aggregation',
+      title: 'CPU by process',
+    },
+    {
+      type: 'slice_aggregation',
+      title: 'Slices',
+    },
+    {
+      type: 'counter_aggregation',
+      title: 'Counters',
+    },
+    {
+      type: 'frame_aggregation',
+      title: 'Frames',
+    },
+  ];
+
+  constructor() {
+    for (const {type, title} of this.tabs) {
+      globals.tabManager.registerTab({
+        uri: `aggregationTab#${type}`,
+        isEphemeral: false,
+        content: {
+          getTitle: () => `Aggregation: ${title}`,
+          render: () => {
+            const data = globals.aggregateDataStore.get(type);
+            return m(AggregationPanel, {kind: type, data});
+          },
+        },
+      });
+    }
+  }
+
+  dispose(): void {
+    for (const {type} of this.tabs) {
+      globals.tabManager.unregisterTab(`aggregationTab#${type}`);
+    }
+  }
+}
diff --git a/ui/src/frontend/app.ts b/ui/src/frontend/app.ts
index 8eed31d..9d634fe 100644
--- a/ui/src/frontend/app.ts
+++ b/ui/src/frontend/app.ts
@@ -43,23 +43,24 @@
 import {HotkeyGlyphs} from '../widgets/hotkey_glyphs';
 import {maybeRenderFullscreenModalDialog} from '../widgets/modal';
 
-import {addTab} from './bottom_tab';
 import {onClickCopy} from './clipboard';
 import {CookieConsent} from './cookie_consent';
 import {globals} from './globals';
 import {toggleHelp} from './help_modal';
+import {Notes} from './notes';
 import {Omnibox, OmniboxOption} from './omnibox';
-import {runQueryInNewTab} from './query_result_tab';
+import {addQueryResultsTab} from './query_result_tab';
 import {verticalScrollToTrack} from './scroll_helper';
 import {executeSearch} from './search_handler';
 import {Sidebar} from './sidebar';
-import {SqlTableTab} from './sql_table/tab';
-import {SqlTables} from './sql_table/well_known_tables';
 import {Utid} from './sql_types';
 import {getThreadInfo} from './thread_and_process_info';
 import {Topbar} from './topbar';
 import {shareTrace} from './trace_attrs';
 import {addDebugSliceTrack} from './debug_tracks';
+import {AggregationsTabs} from './aggregation_tab';
+import {addSqlTableTab} from './sql_table/tab';
+import {SqlTables} from './sql_table/well_known_tables';
 
 function renderPermalink(): m.Children {
   const permalink = globals.state.permalink;
@@ -149,6 +150,8 @@
   constructor() {
     const unreg = globals.commandManager.registerCommandSource(this);
     this.trash.add(unreg);
+    this.trash.add(new Notes());
+    this.trash.add(new AggregationsTabs());
   }
 
   private getEngine(): EngineProxy|undefined {
@@ -395,8 +398,8 @@
             const engine = this.getEngine();
 
             if (engine !== undefined && trackUtid != 0) {
-              runQueryInNewTab(
-                `SELECT IMPORT('experimental.thread_executing_span');
+              addQueryResultsTab({
+                query: `SELECT IMPORT('experimental.thread_executing_span');
                    SELECT *
                       FROM
                         experimental_thread_executing_span_critical_path_graph(
@@ -404,8 +407,7 @@
                          ${trackUtid},
                          ${window.start},
                          ${window.end} - ${window.start}) cr`,
-                'Critical path',
-                'omnibox_query');
+                title: 'Critical path'});
             }
           },
     },
@@ -414,12 +416,9 @@
       name: 'Show slice table',
       callback:
           () => {
-            addTab({
-              kind: SqlTableTab.kind,
-              config: {
-                table: SqlTables.slice,
-                displayName: 'slice',
-              },
+            addSqlTableTab({
+              table: SqlTables.slice,
+              displayName: 'slice',
             });
           },
     },
@@ -722,11 +721,13 @@
         this.queryText = value;
         raf.scheduleFullRedraw();
       },
-      onSubmit: (value, alt) => {
-        globals.openQuery(
-          undoCommonChatAppReplacements(value),
-          alt ? 'Pinned query' : 'Omnibox query',
-          alt ? undefined : 'omnibox_query');
+      onSubmit: (query, alt) => {
+        const config = {
+          query: undoCommonChatAppReplacements(query),
+          title: alt ? 'Pinned query' : 'Omnibox query',
+        };
+        const tag = alt? undefined : 'omnibox_query';
+        addQueryResultsTab(config, tag);
       },
       onClose: () => {
         this.queryText = '';
diff --git a/ui/src/frontend/chrome_slice_details_tab.ts b/ui/src/frontend/chrome_slice_details_tab.ts
index 6f5502b..920c8c8 100644
--- a/ui/src/frontend/chrome_slice_details_tab.ts
+++ b/ui/src/frontend/chrome_slice_details_tab.ts
@@ -44,6 +44,7 @@
 import {asSliceSqlId} from './sql_types';
 import {DurationWidget} from './widgets/duration';
 import {addDebugSliceTrack} from './debug_tracks';
+import {addQueryResultsTab} from './query_result_tab';
 
 interface ContextMenuItem {
   name: string;
@@ -91,10 +92,10 @@
   {
     name: 'Average duration of slice name',
     shouldDisplay: (slice: SliceDetails) => hasName(slice),
-    run: (slice: SliceDetails) => globals.openQuery(
-      `SELECT AVG(dur) / 1e9 FROM slice WHERE name = '${slice.name!}'`,
-      `${slice.name} average dur`,
-    ),
+    run: (slice: SliceDetails) => addQueryResultsTab({
+      query: `SELECT AVG(dur) / 1e9 FROM slice WHERE name = '${slice.name!}'`,
+      title: `${slice.name} average dur`,
+    }),
   },
   {
     name: 'Binder txn names + monitor contention on thread',
diff --git a/ui/src/frontend/drag_handle.ts b/ui/src/frontend/drag_handle.ts
index 425899c..5176039 100644
--- a/ui/src/frontend/drag_handle.ts
+++ b/ui/src/frontend/drag_handle.ts
@@ -47,6 +47,9 @@
 
   // Called when tab dropdown entry is clicked.
   onClick: () => void;
+
+  // Whether this tab is checked or not
+  checked: boolean;
 }
 
 export interface DragHandleAttrs {
@@ -241,6 +244,7 @@
           key: entry.key,
           label: entry.title,
           onclick: () => entry.onClick(),
+          icon: entry.checked ? 'check_box' : 'check_box_outline_blank',
         });
       }),
     );
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 205beab..2f0cd7f 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -238,8 +238,6 @@
   clearSearch?: boolean;
 }
 
-type OpenQueryHandler = (query: string, title: string, tag?: string) => void;
-
 /**
  * Global accessors for state/dispatch in the frontend.
  */
@@ -286,7 +284,6 @@
   private _realtimeOffset = Time.ZERO;
   private _utcOffset = Time.ZERO;
   private _traceTzOffset = Time.ZERO;
-  private _openQueryHandler?: OpenQueryHandler;
   private _tabManager = new TabManager();
   private _trackManager = new TrackManager(this._store);
 
@@ -872,22 +869,6 @@
     return {start, end};
   }
 
-  // The implementation of the query results tab is not part of the core so we
-  // decouple globals from the implementation using this registration interface.
-  // Once we move the implementation to a plugin, this decoupling will be
-  // simpler as we just need to call a command with a well-known ID, and a
-  // plugin will provide the implementation.
-  registerOpenQueryHandler(cb: OpenQueryHandler) {
-    this._openQueryHandler = cb;
-  }
-
-  // Runs a query and displays results in a new tab.
-  // Queries will override previously opened queries with the same tag.
-  // If the tag is omitted, the results will always open in a new tab.
-  openQuery(query: string, title: string, tag?: string) {
-    assertExists(this._openQueryHandler)(query, title, tag);
-  }
-
   panToTimestamp(ts: time): void {
     horizontalScrollToTs(ts);
   }
diff --git a/ui/src/frontend/notes.ts b/ui/src/frontend/notes.ts
new file mode 100644
index 0000000..d4b86a9
--- /dev/null
+++ b/ui/src/frontend/notes.ts
@@ -0,0 +1,45 @@
+import {Disposable} from '../base/disposable';
+import {assertExists} from '../base/logging';
+import {uuidv4} from '../base/uuid';
+import {BottomTabToSCSAdapter} from '../public';
+
+import {globals} from './globals';
+import {NotesEditorTab} from './notes_panel';
+
+function getEngine() {
+  const engineId = assertExists(globals.getCurrentEngine()).id;
+  const engine = assertExists(globals.engines.get(engineId));
+  return engine;
+}
+
+/**
+ * Registers with the tab manager to show notes details panels when notes are
+ * selected.
+ *
+ * Notes are core functionality thus don't really belong in a plugin.
+ */
+export class Notes implements Disposable {
+  private csc = new BottomTabToSCSAdapter({
+    tabFactory: (selection) => {
+      if (selection.kind === 'NOTE') {
+        return new NotesEditorTab({
+          config: {
+            id: selection.id,
+          },
+          engine: getEngine().getProxy('Notes'),
+          uuid: uuidv4(),
+        });
+      } else {
+        return undefined;
+      }
+    },
+  });
+
+  constructor() {
+    globals.tabManager.registerDetailsPanel(this.csc);
+  }
+
+  dispose(): void {
+    globals.tabManager.unregisterDetailsPanel(this.csc);
+  }
+}
diff --git a/ui/src/frontend/pivot_table.ts b/ui/src/frontend/pivot_table.ts
index 483ead2..d241534 100644
--- a/ui/src/frontend/pivot_table.ts
+++ b/ui/src/frontend/pivot_table.ts
@@ -29,7 +29,6 @@
 import {raf} from '../core/raf_scheduler';
 import {ColumnType} from '../trace_processor/query_result';
 
-import {addTab} from './bottom_tab';
 import {globals} from './globals';
 import {
   aggregationIndex,
@@ -47,7 +46,7 @@
 } from './pivot_table_types';
 import {PopupMenuButton, popupMenuIcon, PopupMenuItem} from './popup_menu';
 import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells';
-import {SqlTableTab} from './sql_table/tab';
+import {addSqlTableTab} from './sql_table/tab';
 import {SqlTables} from './sql_table/well_known_tables';
 import {AttributeModalHolder} from './tables/attribute_modal_holder';
 import {DurationWidget} from './widgets/duration';
@@ -136,12 +135,9 @@
             if (this.constrainToArea) {
               queryFilters.push(...areaFilters(area));
             }
-            addTab({
-              kind: SqlTableTab.kind,
-              config: {
-                table: SqlTables.slice,
-                filters: queryFilters,
-              },
+            addSqlTableTab({
+              table: SqlTables.slice,
+              filters: queryFilters,
             });
           },
         },
diff --git a/ui/src/frontend/query_page.ts b/ui/src/frontend/query_page.ts
index 98a4881..4ff1715 100644
--- a/ui/src/frontend/query_page.ts
+++ b/ui/src/frontend/query_page.ts
@@ -24,11 +24,10 @@
 import {Callout} from '../widgets/callout';
 import {Editor} from '../widgets/editor';
 
-import {addTab} from './bottom_tab';
 import {globals} from './globals';
 import {createPage} from './pages';
 import {QueryHistoryComponent, queryHistoryStorage} from './query_history';
-import {QueryResultTab} from './query_result_tab';
+import {addQueryResultsTab} from './query_result_tab';
 import {QueryTable} from './query_table';
 
 interface QueryPageState {
@@ -52,15 +51,14 @@
   if (engine) {
     runQuery(undoCommonChatAppReplacements(query), engine)
       .then((resp: QueryResponse) => {
-        addTab({
-          kind: QueryResultTab.kind,
-          tag: 'analyze_page_query',
-          config: {
+        addQueryResultsTab(
+          {
             query: query,
             title: 'Standalone Query',
             prefetchedResponse: resp,
           },
-        });
+          'analyze_page_query',
+        );
         // We might have started to execute another query. Ignore it in that
         // case.
         if (state.executedQuery !== query) {
diff --git a/ui/src/frontend/query_result_tab.ts b/ui/src/frontend/query_result_tab.ts
index 2f0c903..d42dd48 100644
--- a/ui/src/frontend/query_result_tab.ts
+++ b/ui/src/frontend/query_result_tab.ts
@@ -34,21 +34,12 @@
   closeTab,
   NewBottomTabArgs,
 } from './bottom_tab';
-import {globals} from './globals';
 import {QueryTable} from './query_table';
-
-export function runQueryInNewTab(query: string, title: string, tag?: string) {
-  return addTab({
-    kind: QueryResultTab.kind,
-    tag,
-    config: {
-      query,
-      title,
-    },
-  });
-}
-
-globals.registerOpenQueryHandler(runQueryInNewTab);
+import {TABS_V2_FLAG} from '../core/feature_flags';
+import {globals} from './globals';
+import {Actions} from '../common/actions';
+import {BottomTabToTabAdapter} from '../public/utils';
+import {EngineProxy} from '../public';
 
 interface QueryResultTabConfig {
   readonly query: string;
@@ -58,6 +49,42 @@
   readonly prefetchedResponse?: QueryResponse;
 }
 
+// External interface for adding a new query results tab
+// Automatically decided whether to add v1 or v2 tab
+export function addQueryResultsTab(
+  config: QueryResultTabConfig, tag?: string): void {
+  if (TABS_V2_FLAG.get()) {
+    const queryResultsTab = new QueryResultTab({
+      config,
+      engine: getEngine(),
+      uuid: uuidv4(),
+    });
+
+    const uri = 'queryResults#' + (tag ?? uuidv4());
+
+    globals.tabManager.registerTab({
+      uri,
+      content: new BottomTabToTabAdapter(queryResultsTab),
+      isEphemeral: true,
+    });
+
+    globals.dispatch(Actions.showTab({uri}));
+  } else {
+    return addTab({
+      kind: QueryResultTab.kind,
+      tag,
+      config,
+    });
+  }
+}
+
+// TODO(stevegolton): Find a way to make this more elegant.
+function getEngine(): EngineProxy {
+  const engConfig = globals.getCurrentEngine();
+  const engineId = assertExists(engConfig).id;
+  return assertExists(globals.engines.get(engineId)).getProxy('QueryResult');
+}
+
 export class QueryResultTab extends BottomTab<QueryResultTabConfig> {
   static readonly kind = 'dev.perfetto.QueryResultTab';
 
diff --git a/ui/src/frontend/slice_args.ts b/ui/src/frontend/slice_args.ts
index 9d4c122..341fbe1 100644
--- a/ui/src/frontend/slice_args.ts
+++ b/ui/src/frontend/slice_args.ts
@@ -32,10 +32,9 @@
 import {MenuItem, PopupMenu2} from '../widgets/menu';
 import {TreeNode} from '../widgets/tree';
 
-import {addTab} from './bottom_tab';
 import {globals} from './globals';
 import {Arg} from './sql/args';
-import {SqlTableTab} from './sql_table/tab';
+import {addSqlTableTab} from './sql_table/tab';
 import {SqlTables} from './sql_table/well_known_tables';
 
 // Renders slice arguments (key/value pairs) as a subtree.
@@ -96,17 +95,14 @@
         label: 'Find slices with same arg value',
         icon: 'search',
         onclick: () => {
-          addTab({
-            kind: SqlTableTab.kind,
-            config: {
-              table: SqlTables.slice,
-              filters: [{
-                type: 'arg_filter',
-                argSetIdColumn: 'arg_set_id',
-                argName: fullKey,
-                op: `= ${sqliteString(displayValue)}`,
-              }],
-            },
+          addSqlTableTab({
+            table: SqlTables.slice,
+            filters: [{
+              type: 'arg_filter',
+              argSetIdColumn: 'arg_set_id',
+              argName: fullKey,
+              op: `= ${sqliteString(displayValue)}`,
+            }],
           });
         },
       }),
diff --git a/ui/src/frontend/slice_details.ts b/ui/src/frontend/slice_details.ts
index 282ecc8..c8c97b0 100644
--- a/ui/src/frontend/slice_details.ts
+++ b/ui/src/frontend/slice_details.ts
@@ -24,14 +24,13 @@
 import {SqlRef} from '../widgets/sql_ref';
 import {Tree, TreeNode} from '../widgets/tree';
 
-import {addTab} from './bottom_tab';
 import {globals} from './globals';
 import {SliceDetails} from './sql/slice';
 import {
   BreakdownByThreadState,
   BreakdownByThreadStateTreeNode,
 } from './sql/thread_state';
-import {SqlTableTab} from './sql_table/tab';
+import {addSqlTableTab} from './sql_table/tab';
 import {SqlTables} from './sql_table/well_known_tables';
 import {getProcessName, getThreadName} from './thread_and_process_info';
 import {DurationWidget} from './widgets/duration';
@@ -65,13 +64,10 @@
           m(MenuItem, {
             label: 'Slices with the same name',
             onclick: () => {
-              addTab({
-                kind: SqlTableTab.kind,
-                config: {
-                  table: SqlTables.slice,
-                  displayName: 'slice',
-                  filters: [`name = ${sqliteString(slice.name)}`],
-                },
+              addSqlTableTab({
+                table: SqlTables.slice,
+                displayName: 'slice',
+                filters: [`name = ${sqliteString(slice.name)}`],
               });
             },
           }),
diff --git a/ui/src/frontend/sql_table/tab.ts b/ui/src/frontend/sql_table/tab.ts
index bd704cd..109f398 100644
--- a/ui/src/frontend/sql_table/tab.ts
+++ b/ui/src/frontend/sql_table/tab.ts
@@ -21,16 +21,55 @@
 import {Button} from '../../widgets/button';
 import {DetailsShell} from '../../widgets/details_shell';
 import {Popup, PopupPosition} from '../../widgets/popup';
-import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from '../bottom_tab';
+import {addTab, BottomTab, bottomTabRegistry, NewBottomTabArgs} from '../bottom_tab';
 
-import {SqlTableState} from './state';
+import {Filter, SqlTableState} from './state';
 import {SqlTable} from './table';
 import {SqlTableDescription, tableDisplayName} from './table_description';
+import {TABS_V2_FLAG} from '../../core/feature_flags';
+import {EngineProxy} from '../../public';
+import {globals} from '../globals';
+import {assertExists} from '../../base/logging';
+import {uuidv4} from '../../base/uuid';
+import {BottomTabToTabAdapter} from '../../public/utils';
+import {Actions} from '../../common/actions';
 
 interface SqlTableTabConfig {
   table: SqlTableDescription;
   displayName?: string;
-  filters?: string[];
+  filters?: Filter[];
+}
+
+export function addSqlTableTab(config: SqlTableTabConfig): void {
+  if (TABS_V2_FLAG.get()) {
+    const queryResultsTab = new SqlTableTab({
+      config,
+      engine: getEngine(),
+      uuid: uuidv4(),
+    });
+
+    const uri = 'sqlTable#' + uuidv4();
+
+    globals.tabManager.registerTab({
+      uri,
+      content: new BottomTabToTabAdapter(queryResultsTab),
+      isEphemeral: true,
+    });
+
+    globals.dispatch(Actions.showTab({uri}));
+  } else {
+    return addTab({
+      kind: SqlTableTab.kind,
+      config,
+    });
+  }
+}
+
+// TODO(stevegolton): Find a way to make this more elegant.
+function getEngine(): EngineProxy {
+  const engConfig = globals.getCurrentEngine();
+  const engineId = assertExists(engConfig).id;
+  return assertExists(globals.engines.get(engineId)).getProxy('QueryResult');
 }
 
 export class SqlTableTab extends BottomTab<SqlTableTabConfig> {
diff --git a/ui/src/frontend/tab_panel.ts b/ui/src/frontend/tab_panel.ts
index 46d4380..dd8ae21 100644
--- a/ui/src/frontend/tab_panel.ts
+++ b/ui/src/frontend/tab_panel.ts
@@ -67,10 +67,18 @@
     const tabDropdownEntries =
         globals.tabManager.tabs.filter((tab) => tab.isEphemeral === false)
           .map(({content, uri}): TabDropdownEntry => {
+            // Check if the tab is already open
+            const isOpen = globals.state.tabs.openTabs.find((openTabUri) => {
+              return openTabUri === uri;
+            });
+            const clickAction = isOpen ?
+              Actions.hideTab({uri}) :
+              Actions.showTab({uri});
             return {
               key: uri,
               title: content.getTitle(),
-              onClick: () => globals.dispatch(Actions.showTab({uri})),
+              onClick: () => globals.dispatch(clickAction),
+              checked: isOpen !== undefined,
             };
           });
 
@@ -104,12 +112,12 @@
     if (!exists(cs)) {
       return m(EmptyState, {
         className: 'pf-noselection',
-        header: 'No selection',
-        detail: 'Please select something',
+        header: 'Nothing selected',
+        detail: 'Selection details will appear here',
       });
     }
 
-    const sectionReg = globals.tabManager.currentSelectionSections;
+    const sectionReg = globals.tabManager.detailsPanels;
     const allSections = Array.from(sectionReg.values());
 
     // Get the first "truthy" current selection section
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index 5f918af..b8ff6c6 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -19,7 +19,7 @@
 import {Time} from '../base/time';
 import {Actions} from '../common/actions';
 import {TrackCacheEntry} from '../common/track_cache';
-import {featureFlags} from '../core/feature_flags';
+import {TABS_V2_FLAG, featureFlags} from '../core/feature_flags';
 import {raf} from '../core/raf_scheduler';
 import {TrackTags} from '../public';
 
@@ -47,13 +47,6 @@
   defaultValue: true,
 });
 
-const TABS_V2 = featureFlags.register({
-  id: 'tabsv2',
-  name: 'Tabs V2',
-  description: 'Use Tabs V2',
-  defaultValue: false,
-});
-
 // Checks if the mousePos is within 3px of the start or end of the
 // current selected time range.
 function onTimeRangeBoundary(mousePos: number): 'START'|'END'|null {
@@ -353,7 +346,7 @@
   }
 
   private renderTabPanel() {
-    if (TABS_V2.get()) {
+    if (TABS_V2_FLAG.get()) {
       return m(TabPanel);
     } else {
       return m(DetailsPanel);
diff --git a/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts b/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
index 9625bb1..40ed061 100644
--- a/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
@@ -215,8 +215,6 @@
   USING SPAN_JOIN(RilSignalStrength PARTITIONED track_id, ScreenOn)`;
 
 const MODEM_RIL_CHANNELS_PREAMBLE = `
-  SELECT IMPORT('android.battery_stats');
-
   CREATE OR REPLACE PERFETTO FUNCTION EXTRACT_KEY_VALUE(source STRING, key_name STRING) RETURNS STRING AS
   SELECT SUBSTR(trimmed, INSTR(trimmed, "=")+1, INSTR(trimmed, ",") - INSTR(trimmed, "=") - 1)
   FROM (SELECT SUBSTR($source, INSTR($source, $key_name)) AS trimmed);`;
@@ -943,6 +941,7 @@
             WHERE track_name = "${track}"`,
         groupId);
 
+    await e.query(`SELECT IMPORT('android.battery_stats');`);
     return flatten([
       query('Top App', 'battery_stats.top'),
       this.addSliceTrack(
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index beafacb..e87447a 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -327,7 +327,7 @@
   onShow?(): void;
 }
 
-export interface CurrentSelectionSection {
+export interface DetailsPanel {
   render(selection: Selection): m.Children;
 }
 
@@ -402,8 +402,9 @@
   // is deactivated or when the trace is unloaded.
   registerTab(tab: TabDescriptor): void;
 
-  // Register a current selection handler.
-  registerCurrentSelectionSection(sel: CurrentSelectionSection): void;
+  // Register a hook into the current selection tab rendering logic that allows
+  // customization of the current selection tab content.
+  registerDetailsPanel(sel: DetailsPanel): void;
 
   // Create a store mounted over the top of this plugin's persistent state.
   mountStore<T>(migrate: Migrate<T>): Store<T>;
diff --git a/ui/src/public/utils.ts b/ui/src/public/utils.ts
index 028c3a0..cb3d814 100644
--- a/ui/src/public/utils.ts
+++ b/ui/src/public/utils.ts
@@ -17,7 +17,7 @@
 import {Selection} from '../common/state';
 import {BottomTab} from '../frontend/bottom_tab';
 
-import {CurrentSelectionSection, Tab} from '.';
+import {DetailsPanel, Tab} from '.';
 
 export function getTrackName(args: Partial<{
   name: string | null,
@@ -127,7 +127,7 @@
       },
     })
  */
-export class BottomTabToSCSAdapter implements CurrentSelectionSection {
+export class BottomTabToSCSAdapter implements DetailsPanel {
   private oldSelection?: Selection;
   private bottomTab?: BottomTab;
   private attrs: BottomTabAdapterAttrs;
diff --git a/ui/src/tracks/android_log/index.ts b/ui/src/tracks/android_log/index.ts
index 28e2fe8..860890d 100644
--- a/ui/src/tracks/android_log/index.ts
+++ b/ui/src/tracks/android_log/index.ts
@@ -12,11 +12,14 @@
 // 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 {LIMIT, TrackData} from '../../common/track_data';
 import {TimelineFetcher} from '../../common/track_helper';
 import {checkerboardExcept} from '../../frontend/checkerboard';
 import {globals} from '../../frontend/globals';
+import {LogPanel} from '../../frontend/logs_panel';
 import {PanelSize} from '../../frontend/panel';
 import {
   EngineProxy,
@@ -158,6 +161,16 @@
         track: () => new AndroidLogTrack(ctx.engine),
       });
     }
+
+    // Eternal tabs should always be available even if there is nothing to show
+    ctx.registerTab({
+      isEphemeral: false,
+      uri: 'android_logs',
+      content: {
+        render: () => m(LogPanel),
+        getTitle: () => 'Android Logs',
+      },
+    });
   }
 }
 
diff --git a/ui/src/tracks/chrome_critical_user_interactions/index.ts b/ui/src/tracks/chrome_critical_user_interactions/index.ts
index 29c16ca..1357d5c 100644
--- a/ui/src/tracks/chrome_critical_user_interactions/index.ts
+++ b/ui/src/tracks/chrome_critical_user_interactions/index.ts
@@ -17,13 +17,17 @@
 import {Actions} from '../../common/actions';
 import {SCROLLING_TRACK_GROUP} from '../../common/state';
 import {OnSliceClickArgs} from '../../frontend/base_slice_track';
-import {GenericSliceDetailsTab} from '../../frontend/generic_slice_details_tab';
+import {
+  GenericSliceDetailsTab,
+  GenericSliceDetailsTabConfig,
+} from '../../frontend/generic_slice_details_tab';
 import {globals} from '../../frontend/globals';
 import {
   NAMED_ROW,
   NamedSliceTrackTypes,
 } from '../../frontend/named_slice_track';
 import {
+  BottomTabToSCSAdapter,
   NUM,
   Plugin,
   PluginContext,
@@ -212,6 +216,52 @@
       track: (trackCtx) => new CriticalUserInteractionTrack(
         {engine: ctx.engine, trackKey: trackCtx.trackKey}),
     });
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (selection) => {
+        if (selection.kind === 'GENERIC_SLICE' &&
+            selection.detailsPanelConfig.kind === PageLoadDetailsPanel.kind) {
+          const config = selection.detailsPanelConfig.config;
+          return new PageLoadDetailsPanel({
+            config: config as GenericSliceDetailsTabConfig,
+            engine: ctx.engine,
+            uuid: uuidv4(),
+          });
+        }
+        return undefined;
+      },
+    }));
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (selection) => {
+        if (selection.kind === 'GENERIC_SLICE' &&
+            selection.detailsPanelConfig.kind === StartupDetailsPanel.kind) {
+          const config = selection.detailsPanelConfig.config;
+          return new StartupDetailsPanel({
+            config: config as GenericSliceDetailsTabConfig,
+            engine: ctx.engine,
+            uuid: uuidv4(),
+          });
+        }
+        return undefined;
+      },
+    }));
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (selection) => {
+        if (selection.kind === 'GENERIC_SLICE' &&
+            selection.detailsPanelConfig.kind ===
+                WebContentInteractionPanel.kind) {
+          const config = selection.detailsPanelConfig.config;
+          return new WebContentInteractionPanel({
+            config: config as GenericSliceDetailsTabConfig,
+            engine: ctx.engine,
+            uuid: uuidv4(),
+          });
+        }
+        return undefined;
+      },
+    }));
   }
 
   onActivate(ctx: PluginContext): void {
diff --git a/ui/src/tracks/chrome_scroll_jank/index.ts b/ui/src/tracks/chrome_scroll_jank/index.ts
index 38d41dd..9c8f2c3 100644
--- a/ui/src/tracks/chrome_scroll_jank/index.ts
+++ b/ui/src/tracks/chrome_scroll_jank/index.ts
@@ -21,6 +21,10 @@
 import {ObjectByKey} from '../../common/state';
 import {featureFlags} from '../../core/feature_flags';
 import {
+  GenericSliceDetailsTabConfig,
+} from '../../frontend/generic_slice_details_tab';
+import {
+  BottomTabToSCSAdapter,
   NUM,
   Plugin,
   PluginContext,
@@ -33,12 +37,15 @@
 import {NULL_TRACK_URI} from '../null_track';
 
 import {ChromeTasksScrollJankTrack} from './chrome_tasks_scroll_jank_track';
+import {EventLatencySliceDetailsPanel} from './event_latency_details_panel';
 import {
   addLatencyTracks,
   EventLatencyTrack,
   JANKY_LATENCY_NAME,
 } from './event_latency_track';
+import {ScrollDetailsPanel} from './scroll_details_panel';
 import {ScrollJankCauseMap} from './scroll_jank_cause_map';
+import {ScrollJankV3DetailsPanel} from './scroll_jank_v3_details_panel';
 import {
   addScrollJankV3ScrollTrack,
   ScrollJankV3Track,
@@ -227,6 +234,21 @@
         });
       },
     });
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (selection) => {
+        if (selection.kind === 'GENERIC_SLICE' &&
+            selection.detailsPanelConfig.kind === ScrollDetailsPanel.kind) {
+          const config = selection.detailsPanelConfig.config;
+          return new ScrollDetailsPanel({
+            config: config as GenericSliceDetailsTabConfig,
+            engine: ctx.engine,
+            uuid: uuidv4(),
+          });
+        }
+        return undefined;
+      },
+    }));
   }
 
   private async addEventLatencyTrack(ctx: PluginContextTrace): Promise<void> {
@@ -307,6 +329,22 @@
         return new EventLatencyTrack({engine: ctx.engine, trackKey}, baseTable);
       },
     });
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (selection) => {
+        if (selection.kind === 'GENERIC_SLICE' &&
+            selection.detailsPanelConfig.kind ===
+                EventLatencySliceDetailsPanel.kind) {
+          const config = selection.detailsPanelConfig.config;
+          return new EventLatencySliceDetailsPanel({
+            config: config as GenericSliceDetailsTabConfig,
+            engine: ctx.engine,
+            uuid: uuidv4(),
+          });
+        }
+        return undefined;
+      },
+    }));
   }
 
   private async addScrollJankV3ScrollTrack(ctx: PluginContextTrace):
@@ -325,6 +363,22 @@
         });
       },
     });
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (selection) => {
+        if (selection.kind === 'GENERIC_SLICE' &&
+            selection.detailsPanelConfig.kind ===
+                ScrollJankV3DetailsPanel.kind) {
+          const config = selection.detailsPanelConfig.config;
+          return new ScrollJankV3DetailsPanel({
+            config: config as GenericSliceDetailsTabConfig,
+            engine: ctx.engine,
+            uuid: uuidv4(),
+          });
+        }
+        return undefined;
+      },
+    }));
   }
 }
 
diff --git a/ui/src/tracks/chrome_slices/index.ts b/ui/src/tracks/chrome_slices/index.ts
index f232352..998ebfd 100644
--- a/ui/src/tracks/chrome_slices/index.ts
+++ b/ui/src/tracks/chrome_slices/index.ts
@@ -15,6 +15,8 @@
 import {BigintMath as BIMath} from '../../base/bigint_math';
 import {clamp} from '../../base/math_utils';
 import {Duration, duration, time} from '../../base/time';
+import {uuidv4} from '../../base/uuid';
+import {ChromeSliceDetailsTab} from '../../frontend/chrome_slice_details_tab';
 import {
   NAMED_ROW,
   NamedSliceTrack,
@@ -27,6 +29,7 @@
 } from '../../frontend/slice_track';
 import {NewTrackArgs} from '../../frontend/track';
 import {
+  BottomTabToSCSAdapter,
   EngineProxy,
   Plugin,
   PluginContext,
@@ -292,6 +295,22 @@
         },
       });
     }
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (sel) => {
+        if (sel.kind !== 'CHROME_SLICE') {
+          return undefined;
+        }
+        return new ChromeSliceDetailsTab({
+          config: {
+            table: sel.table ?? 'slice',
+            id: sel.id,
+          },
+          engine: ctx.engine,
+          uuid: uuidv4(),
+        });
+      },
+    }));
   }
 }
 
diff --git a/ui/src/tracks/counter/index.ts b/ui/src/tracks/counter/index.ts
index 4ac8a37..144ece1 100644
--- a/ui/src/tracks/counter/index.ts
+++ b/ui/src/tracks/counter/index.ts
@@ -28,6 +28,7 @@
   TimelineFetcher,
 } from '../../common/track_helper';
 import {checkerboardExcept} from '../../frontend/checkerboard';
+import {CounterDetailsPanel} from '../../frontend/counter_panel';
 import {globals} from '../../frontend/globals';
 import {PanelSize} from '../../frontend/panel';
 import {
@@ -630,6 +631,16 @@
     await this.addCpuPerfCounterTracks(ctx);
     await this.addThreadCounterTracks(ctx);
     await this.addProcessCounterTracks(ctx);
+
+    ctx.registerDetailsPanel({
+      render: (sel) => {
+        if (sel.kind === 'COUNTER') {
+          return m(CounterDetailsPanel);
+        } else {
+          return undefined;
+        }
+      },
+    });
   }
 
   private async addCounterTracks(ctx: PluginContextTrace) {
diff --git a/ui/src/tracks/cpu_profile/index.ts b/ui/src/tracks/cpu_profile/index.ts
index 3c7b2f0..29f703b 100644
--- a/ui/src/tracks/cpu_profile/index.ts
+++ b/ui/src/tracks/cpu_profile/index.ts
@@ -19,6 +19,7 @@
 import {colorForSample} from '../../common/colorizer';
 import {TrackData} from '../../common/track_data';
 import {TimelineFetcher} from '../../common/track_helper';
+import {CpuProfileDetailsPanel} from '../../frontend/cpu_profile_panel';
 import {globals} from '../../frontend/globals';
 import {PanelSize} from '../../frontend/panel';
 import {TimeScale} from '../../frontend/time_scale';
@@ -283,6 +284,16 @@
         track: () => new CpuProfileTrack(ctx.engine, utid),
       });
     }
+
+    ctx.registerDetailsPanel({
+      render: (sel) => {
+        if (sel.kind === 'CPU_PROFILE_SAMPLE') {
+          return m(CpuProfileDetailsPanel);
+        } else {
+          return undefined;
+        }
+      },
+    });
   }
 }
 
diff --git a/ui/src/tracks/cpu_slices/index.ts b/ui/src/tracks/cpu_slices/index.ts
index 0596894..149d014 100644
--- a/ui/src/tracks/cpu_slices/index.ts
+++ b/ui/src/tracks/cpu_slices/index.ts
@@ -33,6 +33,7 @@
 import {checkerboardExcept} from '../../frontend/checkerboard';
 import {globals} from '../../frontend/globals';
 import {PanelSize} from '../../frontend/panel';
+import {SliceDetailsPanel} from '../../frontend/slice_details_panel';
 import {
   EngineProxy,
   Plugin,
@@ -509,6 +510,14 @@
         track: ({trackKey}) => new CpuSliceTrack(ctx.engine, trackKey, cpu),
       });
     }
+
+    ctx.registerDetailsPanel({
+      render: (sel) => {
+        if (sel.kind === 'SLICE') {
+          return m(SliceDetailsPanel);
+        }
+      },
+    });
   }
 
   async guessCpuSizes(engine: EngineProxy): Promise<Map<number, string>> {
diff --git a/ui/src/tracks/debug/index.ts b/ui/src/tracks/debug/index.ts
index 089c69b..7beeb7a 100644
--- a/ui/src/tracks/debug/index.ts
+++ b/ui/src/tracks/debug/index.ts
@@ -13,7 +13,9 @@
 // limitations under the License.
 
 import {DEBUG_SLICE_TRACK_URI} from '../../frontend/debug_tracks';
+import {uuidv4} from '../../base/uuid';
 import {
+  BottomTabToSCSAdapter,
   Plugin,
   PluginContext,
   PluginContextTrace,
@@ -21,7 +23,9 @@
 } from '../../public';
 
 import {DebugCounterTrack} from './counter_track';
+import {DebugSliceDetailsTab} from './details_tab';
 import {DebugTrackV2} from './slice_track';
+import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 
 export const DEBUG_COUNTER_TRACK_URI = 'perfetto.DebugCounter';
 
@@ -33,6 +37,22 @@
       uri: DEBUG_SLICE_TRACK_URI,
       track: (trackCtx) => new DebugTrackV2(ctx.engine, trackCtx),
     });
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (selection) => {
+        if (selection.kind === 'GENERIC_SLICE' &&
+            selection.detailsPanelConfig.kind === DebugSliceDetailsTab.kind) {
+          const config = selection.detailsPanelConfig.config;
+          return new DebugSliceDetailsTab({
+            config: config as GenericSliceDetailsTabConfig,
+            engine: ctx.engine,
+            uuid: uuidv4(),
+          });
+        }
+        return undefined;
+      },
+    }));
+
     ctx.registerTrack({
       uri: DEBUG_COUNTER_TRACK_URI,
       track: (trackCtx) => new DebugCounterTrack(ctx.engine, trackCtx),
diff --git a/ui/src/tracks/flows/index.ts b/ui/src/tracks/flows/index.ts
new file mode 100644
index 0000000..523b854
--- /dev/null
+++ b/ui/src/tracks/flows/index.ts
@@ -0,0 +1,52 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use 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 {
+  FlowEventsAreaSelectedPanel,
+  FlowEventsPanel,
+} from '../../frontend/flow_events_panel';
+import {globals} from '../../frontend/globals';
+import {
+  Plugin,
+  PluginContext,
+  PluginContextTrace,
+  PluginDescriptor,
+} from '../../public';
+
+class FlowsPlugin implements Plugin {
+  onActivate(_ctx: PluginContext): void {}
+
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    ctx.registerTab({
+      isEphemeral: false,
+      uri: 'perfetto.Flows#FlowEvents',
+      content: {
+        render: () => {
+          const selection = globals.state.currentSelection;
+          if (selection?.kind === 'AREA') {
+            return m(FlowEventsAreaSelectedPanel);
+          } else {
+            return m(FlowEventsPanel);
+          }
+        },
+        getTitle: () => 'Flow Events',
+      },
+    });
+  }
+}
+
+export const plugin: PluginDescriptor = {
+  pluginId: 'perfetto.Flows',
+  plugin: FlowsPlugin,
+};
diff --git a/ui/src/tracks/ftrace/index.ts b/ui/src/tracks/ftrace/index.ts
index dd1a2b7..eb5c548 100644
--- a/ui/src/tracks/ftrace/index.ts
+++ b/ui/src/tracks/ftrace/index.ts
@@ -12,11 +12,14 @@
 // 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 {colorForFtrace} from '../../common/colorizer';
 import {LIMIT, TrackData} from '../../common/track_data';
 import {TimelineFetcher} from '../../common/track_helper';
 import {checkerboardExcept} from '../../frontend/checkerboard';
+import {FtracePanel} from '../../frontend/ftrace_panel';
 import {globals} from '../../frontend/globals';
 import {PanelSize} from '../../frontend/panel';
 import {
@@ -150,6 +153,15 @@
         },
       });
     }
+
+    ctx.registerTab({
+      uri: 'perfetto.FtraceRaw#FtraceEventsTab',
+      isEphemeral: false,
+      content: {
+        render: () => m(FtracePanel),
+        getTitle: () => 'Ftrace Events',
+      },
+    });
   }
 
   private async lookupCpuCores(engine: EngineProxy): Promise<number[]> {
diff --git a/ui/src/tracks/heap_profile/index.ts b/ui/src/tracks/heap_profile/index.ts
index 684404d..50c458b 100644
--- a/ui/src/tracks/heap_profile/index.ts
+++ b/ui/src/tracks/heap_profile/index.ts
@@ -22,6 +22,7 @@
   OnSliceClickArgs,
   OnSliceOverArgs,
 } from '../../frontend/base_slice_track';
+import {FlamegraphDetailsPanel} from '../../frontend/flamegraph_panel';
 import {globals} from '../../frontend/globals';
 import {NewTrackArgs} from '../../frontend/track';
 import {
@@ -116,10 +117,10 @@
   onActivate(_ctx: PluginContext): void {}
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     const result = await ctx.engine.query(`
-    select distinct(upid) from heap_profile_allocation
-    union
-    select distinct(upid) from heap_graph_object
-  `);
+      select distinct(upid) from heap_profile_allocation
+      union
+      select distinct(upid) from heap_graph_object
+    `);
     for (const it = result.iter({upid: NUM}); it.valid(); it.next()) {
       const upid = it.upid;
       ctx.registerTrack({
@@ -137,6 +138,16 @@
         },
       });
     }
+
+    ctx.registerDetailsPanel({
+      render: (sel) => {
+        if (sel.kind === 'HEAP_PROFILE') {
+          return m(FlamegraphDetailsPanel);
+        } else {
+          return undefined;
+        }
+      },
+    });
   }
 }
 
diff --git a/ui/src/tracks/perf_samples_profile/index.ts b/ui/src/tracks/perf_samples_profile/index.ts
index 05ed875..a4b57e5 100644
--- a/ui/src/tracks/perf_samples_profile/index.ts
+++ b/ui/src/tracks/perf_samples_profile/index.ts
@@ -19,6 +19,7 @@
 import {TrackData} from '../../common/track_data';
 import {TimelineFetcher} from '../../common/track_helper';
 import {FLAMEGRAPH_HOVERED_COLOR} from '../../frontend/flamegraph';
+import {FlamegraphDetailsPanel} from '../../frontend/flamegraph_panel';
 import {globals} from '../../frontend/globals';
 import {PanelSize} from '../../frontend/panel';
 import {TimeScale} from '../../frontend/time_scale';
@@ -223,7 +224,7 @@
       select distinct upid, pid
       from perf_sample join thread using (utid) join process using (upid)
       where callsite_id is not null
-  `);
+    `);
     for (const it = result.iter({upid: NUM, pid: NUM}); it.valid(); it.next()) {
       const upid = it.upid;
       const pid = it.pid;
@@ -235,6 +236,16 @@
         track: () => new PerfSamplesProfileTrack(ctx.engine, upid),
       });
     }
+
+    ctx.registerDetailsPanel({
+      render: (sel) => {
+        if (sel.kind === 'PERF_SAMPLES') {
+          return m(FlamegraphDetailsPanel);
+        } else {
+          return undefined;
+        }
+      },
+    });
   }
 }
 
diff --git a/ui/src/tracks/pivot_table/index.ts b/ui/src/tracks/pivot_table/index.ts
new file mode 100644
index 0000000..736d2cb
--- /dev/null
+++ b/ui/src/tracks/pivot_table/index.ts
@@ -0,0 +1,45 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {globals} from '../../frontend/globals';
+import {PivotTable} from '../../frontend/pivot_table';
+import {
+  Plugin,
+  PluginContext,
+  PluginContextTrace,
+  PluginDescriptor,
+} from '../../public';
+
+class PivotTablePlugin implements Plugin {
+  onActivate(_ctx: PluginContext): void {}
+
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    ctx.registerTab({
+      isEphemeral: false,
+      uri: 'perfetto.PivotTable#PivotTable',
+      content: {
+        render: () => m(PivotTable, {
+          selectionArea:
+              globals.state.nonSerializableState.pivotTable.selectionArea,
+        }),
+        getTitle: () => 'Pivot Table',
+      },
+    });
+  }
+}
+
+export const plugin: PluginDescriptor = {
+  pluginId: 'perfetto.PivotTable',
+  plugin: PivotTablePlugin,
+};
diff --git a/ui/src/tracks/screenshots/index.ts b/ui/src/tracks/screenshots/index.ts
index 254dadb..fc2c6da 100644
--- a/ui/src/tracks/screenshots/index.ts
+++ b/ui/src/tracks/screenshots/index.ts
@@ -12,11 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {uuidv4} from '../../base/uuid';
 import {AddTrackArgs} from '../../common/actions';
 import {
+  GenericSliceDetailsTabConfig,
+} from '../../frontend/generic_slice_details_tab';
+import {
   NamedSliceTrackTypes,
 } from '../../frontend/named_slice_track';
 import {
+  BottomTabToSCSAdapter,
   NUM,
   Plugin,
   PluginContext,
@@ -106,6 +111,21 @@
           });
         },
       });
+
+      ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+        tabFactory: (selection) => {
+          if (selection.kind === 'GENERIC_SLICE' &&
+              selection.detailsPanelConfig.kind === ScreenshotTab.kind) {
+            const config = selection.detailsPanelConfig.config;
+            return new ScreenshotTab({
+              config: config as GenericSliceDetailsTabConfig,
+              engine: ctx.engine,
+              uuid: uuidv4(),
+            });
+          }
+          return undefined;
+        },
+      }));
     }
   }
 }
diff --git a/ui/src/tracks/thread_state/index.ts b/ui/src/tracks/thread_state/index.ts
index 62ef1fe..7d3f1f0 100644
--- a/ui/src/tracks/thread_state/index.ts
+++ b/ui/src/tracks/thread_state/index.ts
@@ -27,7 +27,10 @@
 import {checkerboardExcept} from '../../frontend/checkerboard';
 import {globals} from '../../frontend/globals';
 import {PanelSize} from '../../frontend/panel';
+import {asThreadStateSqlId} from '../../frontend/sql_types';
+import {ThreadStateTab} from '../../frontend/thread_state_tab';
 import {
+  BottomTabToSCSAdapter,
   EngineProxy,
   Plugin,
   PluginContext,
@@ -365,6 +368,21 @@
         },
       });
     }
+
+    ctx.registerDetailsPanel(new BottomTabToSCSAdapter({
+      tabFactory: (sel) => {
+        if (sel.kind !== 'THREAD_STATE') {
+          return undefined;
+        }
+        return new ThreadStateTab({
+          config: {
+            id: asThreadStateSqlId(sel.id),
+          },
+          engine: ctx.engine,
+          uuid: uuidv4(),
+        });
+      },
+    }));
   }
 }