ui: Consolidate flow events into single plugin and extract connected flows

Merge FlowEventsPanel into FlowEvents, move flow navigation commands
and overlay rendering into the plugin, and remove flow state from core
TraceImpl. Extract connected flow queries into a standalone helper
to decouple ThreadSliceDetailsPanel from core flow state.
diff --git a/ui/src/components/details/connected_flows.ts b/ui/src/components/details/connected_flows.ts
new file mode 100644
index 0000000..a687035
--- /dev/null
+++ b/ui/src/components/details/connected_flows.ts
@@ -0,0 +1,97 @@
+// Copyright (C) 2026 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 {Time, type time} from '../../base/time';
+import type {Engine} from '../../trace_processor/engine';
+import {LONG, NUM, STR_NULL} from '../../trace_processor/query_result';
+import {asSliceSqlId, type SliceSqlId} from '../sql_utils/core_types';
+
+export interface FlowRow {
+  readonly id: number;
+  readonly sliceId: SliceSqlId;
+  readonly sliceName: string;
+  readonly sliceChromeCustomName?: string;
+  readonly sliceStartTs: time;
+  readonly sliceEndTs: time;
+  readonly threadName: string;
+  readonly processName: string;
+}
+
+export interface DirectlyConnectedFlows {
+  readonly preceding: readonly FlowRow[];
+  readonly following: readonly FlowRow[];
+}
+
+export async function getConnectedFlows(
+  engine: Engine,
+  sliceId: number,
+): Promise<DirectlyConnectedFlows> {
+  const query = `
+    INCLUDE PERFETTO MODULE slices.flow;
+
+    select
+      f.id as id,
+      f.slice_out as sliceOut,
+      f.slice_in as sliceIn,
+      t.id as otherSliceId,
+      t.name as otherSliceName,
+      t.ts as otherSliceStartTs,
+      (t.ts + t.dur) as otherSliceEndTs,
+      (thread.name || ' ' || thread.tid) as otherThreadName,
+      (process.name || ' ' || process.pid) as otherProcessName
+    from directly_connected_flow(${sliceId}) f
+    join slice t on (case when f.slice_in = ${sliceId} then f.slice_out else f.slice_in end) = t.id
+    left join thread_track track on track.id = t.track_id
+    left join thread using (utid)
+    left join process using (upid)
+  `;
+
+  const result = await engine.query(query);
+  const preceding: FlowRow[] = [];
+  const following: FlowRow[] = [];
+
+  const it = result.iter({
+    id: NUM,
+    sliceOut: NUM,
+    sliceIn: NUM,
+    otherSliceId: NUM,
+    otherSliceName: STR_NULL,
+    otherSliceStartTs: LONG,
+    otherSliceEndTs: LONG,
+    otherThreadName: STR_NULL,
+    otherProcessName: STR_NULL,
+  });
+
+  const nullToStr = (s: null | string): string => (s === null ? 'NULL' : s);
+
+  for (; it.valid(); it.next()) {
+    const row: FlowRow = {
+      id: it.id,
+      sliceId: asSliceSqlId(it.otherSliceId),
+      sliceName: nullToStr(it.otherSliceName),
+      sliceStartTs: Time.fromRaw(it.otherSliceStartTs),
+      sliceEndTs: Time.fromRaw(it.otherSliceEndTs),
+      threadName: nullToStr(it.otherThreadName),
+      processName: nullToStr(it.otherProcessName),
+    };
+
+    if (it.sliceIn === sliceId) {
+      preceding.push(row);
+    } else {
+      following.push(row);
+    }
+  }
+
+  return {preceding, following};
+}
diff --git a/ui/src/components/details/thread_slice_details_tab.ts b/ui/src/components/details/thread_slice_details_tab.ts
index d29ecba..b15305c 100644
--- a/ui/src/components/details/thread_slice_details_tab.ts
+++ b/ui/src/components/details/thread_slice_details_tab.ts
@@ -23,7 +23,6 @@
 import {MenuItem, PopupMenu} from '../../widgets/menu';
 import {Section} from '../../widgets/section';
 import {Tree} from '../../widgets/tree';
-import type {FlowPoint} from '../../core/flow_types';
 import {hasArgs} from './args';
 import {
   type DistributionScope,
@@ -40,12 +39,10 @@
 import {asSliceSqlId} from '../sql_utils/core_types';
 import {DurationWidget} from '../widgets/duration';
 import {Grid, GridCell, GridHeaderCell} from '../../widgets/grid';
-import {ensureIsInstance} from '../../base/assert';
 import type {Trace} from '../../public/trace';
 import type {TrackEventDetailsPanel} from '../../public/details_panel';
 import type {TrackEventSelection} from '../../public/selection';
 import {extensions} from '../extensions';
-import {TraceImpl} from '../../core/trace_impl';
 import {renderSliceArguments} from './slice_args';
 import {SLICE_TABLE} from '../widgets/sql/table_definitions';
 import {TrackEventRef} from '../widgets/track_event_ref';
@@ -59,6 +56,11 @@
   titleWithHelp,
 } from '../distribution_panel';
 import type {Dataset} from '../../trace_processor/dataset';
+import {
+  type DirectlyConnectedFlows,
+  type FlowRow,
+  getConnectedFlows,
+} from './connected_flows';
 
 interface ContextMenuItem {
   name: string;
@@ -221,17 +223,18 @@
 export class ThreadSliceDetailsPanel implements TrackEventDetailsPanel {
   private sliceDetails?: SliceDetails;
   private breakdownByThreadState?: BreakdownByThreadState;
+  private connectedFlows: DirectlyConnectedFlows = {
+    preceding: [],
+    following: [],
+  };
   private distributionLoaded = false;
   private distributionScope: DistributionScope = 'track';
   private cachedWholeTraceDataset?: {dataset: Dataset | undefined};
-  private readonly trace: TraceImpl;
+  private readonly trace: Trace;
   private readonly attrs: ThreadSliceDetailsPanelAttrs;
 
   constructor(trace: Trace, attrs?: ThreadSliceDetailsPanelAttrs) {
-    // Rationale for the assertIsInstance: ThreadSliceDetailsPanel requires a
-    // TraceImpl (because of flows) but here we must take a Trace interface,
-    // because this track is exposed to plugins (which see only Trace).
-    this.trace = ensureIsInstance(trace, TraceImpl);
+    this.trace = trace;
     this.attrs = attrs ?? {};
   }
 
@@ -240,16 +243,15 @@
     const {eventId} = selection;
     const details = await getSliceDetails(trace.engine, eventId);
 
-    if (
-      details !== undefined &&
-      details.thread !== undefined &&
-      details.dur > 0
-    ) {
-      this.breakdownByThreadState = await breakDownIntervalByThreadState(
-        trace.engine,
-        TimeSpan.fromTimeAndDuration(details.ts, details.dur),
-        details.thread.utid,
-      );
+    if (details !== undefined) {
+      this.connectedFlows = await getConnectedFlows(trace.engine, details.id);
+      if (details.thread !== undefined && details.dur > 0) {
+        this.breakdownByThreadState = await breakDownIntervalByThreadState(
+          trace.engine,
+          TimeSpan.fromTimeAndDuration(details.ts, details.dur),
+          details.thread.utid,
+        );
+      }
     }
 
     this.sliceDetails = details;
@@ -335,8 +337,7 @@
   }
 
   private renderPrecedingFlows(slice: SliceDetails): m.Children {
-    const flows = this.trace.flows.connectedFlows;
-    const inFlows = flows.filter(({end}) => end.sliceId === slice.id);
+    const inFlows = this.connectedFlows.preceding;
 
     if (inFlows.length > 0) {
       const isRunTask =
@@ -358,18 +359,18 @@
               m(TrackEventRef, {
                 trace: this.trace,
                 table: 'slice',
-                id: flow.begin.sliceId,
-                name: flow.begin.sliceChromeCustomName ?? flow.begin.sliceName,
+                id: flow.sliceId,
+                name: flow.sliceChromeCustomName ?? flow.sliceName,
               }),
             ),
             m(
               GridCell,
               m(DurationWidget, {
                 trace: this.trace,
-                dur: flow.end.sliceStartTs - flow.begin.sliceEndTs,
+                dur: slice.ts - flow.sliceEndTs,
               }),
             ),
-            m(GridCell, this.getThreadNameForFlow(flow.begin, !isRunTask)),
+            m(GridCell, this.getThreadNameForFlow(flow, !isRunTask)),
           ]),
         }),
       );
@@ -379,8 +380,7 @@
   }
 
   private renderFollowingFlows(slice: SliceDetails): m.Children {
-    const flows = this.trace.flows.connectedFlows;
-    const outFlows = flows.filter(({begin}) => begin.sliceId === slice.id);
+    const outFlows = this.connectedFlows.following;
 
     if (outFlows.length > 0) {
       const isPostTask =
@@ -402,18 +402,18 @@
               m(TrackEventRef, {
                 trace: this.trace,
                 table: 'slice',
-                id: flow.end.sliceId,
-                name: flow.end.sliceChromeCustomName ?? flow.end.sliceName,
+                id: flow.sliceId,
+                name: flow.sliceChromeCustomName ?? flow.sliceName,
               }),
             ),
             m(
               GridCell,
               m(DurationWidget, {
                 trace: this.trace,
-                dur: flow.end.sliceStartTs - flow.begin.sliceEndTs,
+                dur: flow.sliceStartTs - (slice.ts + slice.dur),
               }),
             ),
-            m(GridCell, this.getThreadNameForFlow(flow.end, !isPostTask)),
+            m(GridCell, this.getThreadNameForFlow(flow, !isPostTask)),
           ]),
         }),
       );
@@ -423,7 +423,7 @@
   }
 
   private getThreadNameForFlow(
-    flow: FlowPoint,
+    flow: FlowRow,
     includeProcessName: boolean,
   ): string {
     return includeProcessName
diff --git a/ui/src/core/embedder/default_plugins.ts b/ui/src/core/embedder/default_plugins.ts
index 0008eac..4370fd8 100644
--- a/ui/src/core/embedder/default_plugins.ts
+++ b/ui/src/core/embedder/default_plugins.ts
@@ -64,7 +64,7 @@
   'dev.perfetto.ExtensionServers',
   'dev.perfetto.FlagsPage',
   'dev.perfetto.FirefoxProfilerMarkers',
-  'dev.perfetto.FlowEventsPanel',
+  'dev.perfetto.FlowEvents',
   'dev.perfetto.Frames',
   'dev.perfetto.Ftrace',
   'dev.perfetto.GlobalGroups',
diff --git a/ui/src/core/trace_impl.ts b/ui/src/core/trace_impl.ts
index c9a3578..3297b91 100644
--- a/ui/src/core/trace_impl.ts
+++ b/ui/src/core/trace_impl.ts
@@ -32,7 +32,6 @@
 import {ScrollHelper} from './scroll_helper';
 import type {Selection, SelectionOpts} from '../public/selection';
 import type {SearchResult} from '../public/search';
-import {FlowManager} from './flow_manager';
 import type {AppImpl, OpenTraceArrayBufArgs} from './app_impl';
 import type {PluginManagerImpl} from './plugin_manager';
 import type {RouteArgs} from '../public/route_schema';
@@ -76,7 +75,6 @@
   readonly tracks = new TrackManagerImpl();
   readonly workspaces = new WorkspaceManagerImpl();
   readonly notes = new NoteManagerImpl();
-  readonly flows: FlowManager;
   readonly scrollHelper: ScrollHelper;
   readonly trash = new DisposableStack();
   readonly onTraceReady = new EvtSource<void>();
@@ -126,12 +124,6 @@
       }
     };
 
-    this.flows = new FlowManager(
-      engine.getProxy('FlowManager'),
-      this.tracks,
-      this.selection,
-    );
-
     this.search = new SearchManagerImpl({
       timeline: this.timeline,
       trackManager: this.tracks,
@@ -209,8 +201,6 @@
     if (switchToCurrentSelectionTab && selection.kind !== 'empty') {
       this.tabs.showCurrentSelectionTab();
     }
-
-    this.flows.updateFlows(selection);
   }
 
   private onResultStep(searchResult: SearchResult) {
diff --git a/ui/src/core_plugins/dev.perfetto.CoreCommands/index.ts b/ui/src/core_plugins/dev.perfetto.CoreCommands/index.ts
index 3c7e62d..5683a64 100644
--- a/ui/src/core_plugins/dev.perfetto.CoreCommands/index.ts
+++ b/ui/src/core_plugins/dev.perfetto.CoreCommands/index.ts
@@ -617,34 +617,6 @@
       defaultHotkey: 'Escape',
     });
 
-    ctx.commands.registerCommand({
-      id: 'dev.perfetto.NextFlow',
-      name: 'Next flow',
-      callback: () => ctx.flows.focusOtherFlow('Forward'),
-      defaultHotkey: 'Mod+]',
-    });
-
-    ctx.commands.registerCommand({
-      id: 'dev.perfetto.PrevFlow',
-      name: 'Prev flow',
-      callback: () => ctx.flows.focusOtherFlow('Backward'),
-      defaultHotkey: 'Mod+[',
-    });
-
-    ctx.commands.registerCommand({
-      id: 'dev.perfetto.MoveNextFlow',
-      name: 'Move next flow',
-      callback: () => ctx.flows.moveByFocusedFlow('Forward'),
-      defaultHotkey: ']',
-    });
-
-    ctx.commands.registerCommand({
-      id: 'dev.perfetto.MovePrevFlow',
-      name: 'Move prev flow',
-      callback: () => ctx.flows.moveByFocusedFlow('Backward'),
-      defaultHotkey: '[',
-    });
-
     // Provides a test bed for resolving events using a SQL table name and ID
     // which is used in deep-linking, amongst other places.
     ctx.commands.registerCommand({
diff --git a/ui/src/core_plugins/dev.perfetto.FlowEventsPanel/flow_events_panel.ts b/ui/src/core_plugins/dev.perfetto.FlowEvents/flow_events_panel.ts
similarity index 95%
rename from ui/src/core_plugins/dev.perfetto.FlowEventsPanel/flow_events_panel.ts
rename to ui/src/core_plugins/dev.perfetto.FlowEvents/flow_events_panel.ts
index 87be6f8..f7edb3b 100644
--- a/ui/src/core_plugins/dev.perfetto.FlowEventsPanel/flow_events_panel.ts
+++ b/ui/src/core_plugins/dev.perfetto.FlowEvents/flow_events_panel.ts
@@ -18,11 +18,13 @@
 import {Intent} from '../../widgets/common';
 import {Icon} from '../../widgets/icon';
 import {Tooltip} from '../../widgets/tooltip';
-import {ALL_CATEGORIES, getFlowCategories} from '../../core/flow_types';
+import {ALL_CATEGORIES, getFlowCategories} from './flow_types';
+import type {FlowManager} from './flow_manager';
 import type {TraceImpl} from '../../core/trace_impl';
 
 export interface FlowEventsAreaSelectedPanelAttrs {
   trace: TraceImpl;
+  flows: FlowManager;
 }
 
 export class FlowEventsAreaSelectedPanel implements m.ClassComponent<FlowEventsAreaSelectedPanelAttrs> {
@@ -32,8 +34,7 @@
       return;
     }
 
-    const {trace} = attrs;
-    const {flows} = trace;
+    const {flows} = attrs;
 
     const categoryToFlowsNum = new Map<string, number>();
 
diff --git a/ui/src/core_plugins/dev.perfetto.Timeline/flow_events_renderer.ts b/ui/src/core_plugins/dev.perfetto.FlowEvents/flow_events_renderer.ts
similarity index 92%
rename from ui/src/core_plugins/dev.perfetto.Timeline/flow_events_renderer.ts
rename to ui/src/core_plugins/dev.perfetto.FlowEvents/flow_events_renderer.ts
index 785bd81..62480c3 100644
--- a/ui/src/core_plugins/dev.perfetto.Timeline/flow_events_renderer.ts
+++ b/ui/src/core_plugins/dev.perfetto.FlowEvents/flow_events_renderer.ts
@@ -25,7 +25,8 @@
   ALL_CATEGORIES,
   type Flow,
   getFlowCategories,
-} from '../../core/flow_types';
+} from './flow_types';
+import type {FlowManager} from './flow_manager';
 import type {TraceImpl} from '../../core/trace_impl';
 import type {TrackNode} from '../../public/workspace';
 
@@ -56,10 +57,11 @@
  * Renders the flows overlay on top of the timeline, given the set of panels and
  * a canvas to draw on.
  *
- * Note: the actual flow data is retrieved from trace.flows, which are produced
+ * Note: the actual flow data is retrieved from flows, which are produced
  * by FlowManager.
  *
- * @param trace - The Trace instance, which holds onto the FlowManager.
+ * @param trace - The Trace instance.
+ * @param flows - The FlowManager instance.
  * @param ctx - The canvas to draw on.
  * @param size - The size of the canvas.
  * @param tracks - A list of tracks and their vertical positions on the canvas.
@@ -71,6 +73,7 @@
  */
 export function renderFlows(
   trace: TraceImpl,
+  flows: FlowManager,
   ctx: CanvasRenderingContext2D,
   size: Size2D,
   tracks: ReadonlyArray<TrackInfo>,
@@ -107,8 +110,8 @@
       flow.end.sliceId === trace.timeline.highlightedSliceId ||
       flow.begin.sliceId === trace.timeline.highlightedSliceId;
     const focused =
-      flow.id === trace.flows.focusedFlowIdLeft ||
-      flow.id === trace.flows.focusedFlowIdRight;
+      flow.id === flows.focusedFlowIdLeft ||
+      flow.id === flows.focusedFlowIdRight;
 
     let intensity = DEFAULT_FLOW_INTENSITY;
     let width = DEFAULT_FLOW_WIDTH;
@@ -188,17 +191,17 @@
   };
 
   // Render the connected flows
-  trace.flows.connectedFlows.forEach((flow) => {
+  flows.connectedFlows.forEach((flow) => {
     drawFlow(flow, CONNECTED_FLOW_HUE);
   });
 
   // Render the selected flows
-  trace.flows.selectedFlows.forEach((flow) => {
+  flows.selectedFlows.forEach((flow) => {
     const categories = getFlowCategories(flow);
     for (const cat of categories) {
       if (
-        trace.flows.visibleCategories.get(cat) ||
-        trace.flows.visibleCategories.get(ALL_CATEGORIES)
+        flows.visibleCategories.get(cat) ||
+        flows.visibleCategories.get(ALL_CATEGORIES)
       ) {
         drawFlow(flow, SELECTED_FLOW_HUE);
         break;
diff --git a/ui/src/core/flow_manager.ts b/ui/src/core_plugins/dev.perfetto.FlowEvents/flow_manager.ts
similarity index 95%
rename from ui/src/core/flow_manager.ts
rename to ui/src/core_plugins/dev.perfetto.FlowEvents/flow_manager.ts
index d13018b..b153ecc 100644
--- a/ui/src/core/flow_manager.ts
+++ b/ui/src/core_plugins/dev.perfetto.FlowEvents/flow_manager.ts
@@ -12,18 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Time} from '../base/time';
-import {featureFlags} from './feature_flags';
+import {Time} from '../../base/time';
+import {featureFlags} from '../../core/feature_flags';
 import type {FlowDirection, Flow} from './flow_types';
-import {asSliceSqlId} from '../components/sql_utils/core_types';
-import {LONG, NUM, STR_NULL} from '../trace_processor/query_result';
-import type {Track, TrackManager} from '../public/track';
+import {asSliceSqlId} from '../../components/sql_utils/core_types';
+import {LONG, NUM, STR_NULL} from '../../trace_processor/query_result';
+import type {Track, TrackManager} from '../../public/track';
 import type {
   AreaSelection,
   Selection,
   SelectionManager,
-} from '../public/selection';
-import type {Engine} from '../trace_processor/engine';
+} from '../../public/selection';
+import type {Engine} from '../../trace_processor/engine';
+import type {Raf} from '../../public/raf';
 
 const SHOW_INDIRECT_PRECEDING_FLOWS_FLAG = featureFlags.register({
   id: 'showIndirectPrecedingFlows',
@@ -47,6 +48,7 @@
     private engine: Engine,
     private trackMgr: TrackManager,
     private selectionMgr: SelectionManager,
+    private raf?: Raf,
   ) {}
 
   // TODO(primiano): the only reason why this is not done in the constructor is
@@ -75,10 +77,10 @@
            when name="ThreadControllerImpl::RunTask" or
                 name="ThreadPool_RunTask" then
             printf("RunTask(posted_from=%s:%s)",
-             EXTRACT_ARG(arg_set_id, "task.posted_from.file_name"),
-             EXTRACT_ARG(arg_set_id, "task.posted_from.function_name"))
-         end
-         from slice where id=$slice_id'
+              EXTRACT_ARG(arg_set_id, "task.posted_from.file_name"),
+              EXTRACT_ARG(arg_set_id, "task.posted_from.function_name"))
+          end
+          from slice where id=$slice_id'
     );`);
   }
 
@@ -448,13 +450,18 @@
         }
       }
     }
+    this.raf?.scheduleCanvasRedraw();
   }
 
   private setSelectedFlows(selectedFlows: Flow[]) {
     this._selectedFlows = selectedFlows;
+    this.raf?.scheduleCanvasRedraw();
   }
 
   updateFlows(selection: Selection) {
+    if (this._curSelection === selection) {
+      return;
+    }
     this.initialize();
     this._curSelection = selection;
 
@@ -477,7 +484,7 @@
     if (selection.kind === 'area') {
       this.areaSelected(selection);
     } else {
-      this.setConnectedFlows([]);
+      this.setSelectedFlows([]);
     }
   }
 
diff --git a/ui/src/core/flow_types.ts b/ui/src/core_plugins/dev.perfetto.FlowEvents/flow_types.ts
similarity index 94%
rename from ui/src/core/flow_types.ts
rename to ui/src/core_plugins/dev.perfetto.FlowEvents/flow_types.ts
index d5f73e2..9f97a93 100644
--- a/ui/src/core/flow_types.ts
+++ b/ui/src/core_plugins/dev.perfetto.FlowEvents/flow_types.ts
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import type {time, duration} from '../base/time';
-import type {SliceSqlId} from '../components/sql_utils/core_types';
+import type {time, duration} from '../../base/time';
+import type {SliceSqlId} from '../../components/sql_utils/core_types';
 
 export interface Flow {
   id: number;
diff --git a/ui/src/core_plugins/dev.perfetto.FlowEvents/index.ts b/ui/src/core_plugins/dev.perfetto.FlowEvents/index.ts
new file mode 100644
index 0000000..9692b55
--- /dev/null
+++ b/ui/src/core_plugins/dev.perfetto.FlowEvents/index.ts
@@ -0,0 +1,99 @@
+// Copyright (C) 2026 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 type {TraceImpl} from '../../core/trace_impl';
+import type {PerfettoPlugin} from '../../public/plugin';
+import {FlowEventsAreaSelectedPanel} from './flow_events_panel';
+import {renderFlows} from './flow_events_renderer';
+import {FlowManager} from './flow_manager';
+
+// Any track that wants to use this plugin to render flows should
+export const FLOWS_SLICE_TRACK = 'flows_slice_track';
+
+export default class FlowEventsPlugin implements PerfettoPlugin {
+  static readonly id = 'dev.perfetto.FlowEvents';
+  static readonly description =
+    'Handles rendering flows on top of slice tracks originating from the ' +
+    'slice table, and for the flows area selection panel.';
+
+  async onTraceLoad(trace: TraceImpl): Promise<void> {
+    const flows = new FlowManager(
+      trace.engine,
+      trace.tracks,
+      trace.selection,
+      trace.raf,
+    );
+
+    trace.tracks.registerOverlay({
+      render(ctx, timescale, size, tracks) {
+        flows.updateFlows(trace.selection.selection);
+        renderFlows(
+          trace,
+          flows,
+          ctx,
+          size,
+          tracks,
+          trace.workspaces.currentWorkspace.tracks,
+          timescale,
+        );
+      },
+    });
+
+    // This type assertion is allowed because we're a core plugin.
+    trace.selection.registerAreaSelectionTab({
+      id: 'flow_events',
+      name: 'Flow Events',
+      priority: -100,
+      render() {
+        if (flows.selectedFlows.length > 0) {
+          return {
+            isLoading: false,
+            content: m(FlowEventsAreaSelectedPanel, {trace, flows}),
+          };
+        } else {
+          return undefined;
+        }
+      },
+    });
+
+    trace.commands.registerCommand({
+      id: 'dev.perfetto.NextFlow',
+      name: 'Next flow',
+      callback: () => flows.focusOtherFlow('Forward'),
+      defaultHotkey: 'Mod+]',
+    });
+
+    trace.commands.registerCommand({
+      id: 'dev.perfetto.PrevFlow',
+      name: 'Prev flow',
+      callback: () => flows.focusOtherFlow('Backward'),
+      defaultHotkey: 'Mod+[',
+    });
+
+    trace.commands.registerCommand({
+      id: 'dev.perfetto.MoveNextFlow',
+      name: 'Move next flow',
+      callback: () => flows.moveByFocusedFlow('Forward'),
+      defaultHotkey: ']',
+    });
+
+    trace.commands.registerCommand({
+      id: 'dev.perfetto.MovePrevFlow',
+      name: 'Move prev flow',
+      callback: () => flows.moveByFocusedFlow('Backward'),
+      defaultHotkey: '[',
+    });
+  }
+}
diff --git a/ui/src/core_plugins/dev.perfetto.FlowEventsPanel/index.ts b/ui/src/core_plugins/dev.perfetto.FlowEventsPanel/index.ts
deleted file mode 100644
index 89d4626..0000000
--- a/ui/src/core_plugins/dev.perfetto.FlowEventsPanel/index.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2025 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 type {PerfettoPlugin} from '../../public/plugin';
-import type {TraceImpl} from '../../core/trace_impl';
-import {FlowEventsAreaSelectedPanel} from './flow_events_panel';
-
-/**
- * This plugin is a core plugin because for now flows are stored in the core and
- * not exposed to plugins. In the future once we normalize how flows should
- * work, we can reassess this and move it into wherever it needs to be.
- */
-export default class implements PerfettoPlugin {
-  static readonly id = 'dev.perfetto.FlowEventsPanel';
-
-  async onTraceLoad(trace: TraceImpl): Promise<void> {
-    // This type assertion is allowed because we're a core plugin.
-    trace.selection.registerAreaSelectionTab({
-      id: 'flow_events',
-      name: 'Flow Events',
-      priority: -100,
-      render() {
-        if (trace.flows.selectedFlows.length > 0) {
-          return {
-            isLoading: false,
-            content: m(FlowEventsAreaSelectedPanel, {trace}),
-          };
-        } else {
-          return undefined;
-        }
-      },
-    });
-  }
-}
diff --git a/ui/src/core_plugins/dev.perfetto.Timeline/track_tree_view.ts b/ui/src/core_plugins/dev.perfetto.Timeline/track_tree_view.ts
index e611d9a..8c85aa1 100644
--- a/ui/src/core_plugins/dev.perfetto.Timeline/track_tree_view.ts
+++ b/ui/src/core_plugins/dev.perfetto.Timeline/track_tree_view.ts
@@ -60,7 +60,6 @@
   COLOR_TIMELINE_OVERLAY,
   TRACK_SHELL_WIDTH,
 } from '../../frontend/css_constants';
-import {renderFlows} from './flow_events_renderer';
 import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper';
 import {
   shiftDragPanInteraction,
@@ -341,7 +340,6 @@
             virtualCanvasSize,
             renderedTracks,
             canvasRect,
-            rootNode,
             renderer,
           );
 
@@ -429,7 +427,6 @@
     size: Size2D,
     renderedTracks: ReadonlyArray<TrackView>,
     floatingCanvasRect: Rect2D,
-    rootNode: TrackNode,
     renderer: Renderer,
   ) {
     const timelineRect = new Rect2D({
@@ -480,7 +477,6 @@
       renderer,
     );
 
-    renderFlows(this.trace, ctx, size, renderedTracks, rootNode, timescale);
     this.drawHoveredNoteVertical(ctx, timescale, size);
     this.drawHoveredCursorVertical(ctx, timescale, size);
     this.drawNoteVerticals(ctx, timescale, size);