ui: show sampled stacks beneath callstack instant tracks

Sampled callstacks can be inspected as individual instants or an aggregated flamegraph, but neither shows how stacks evolve over time. Add a sampled-stack child beneath each eligible thread's Callstacks track, retaining standard instant markers and sample selection on the parent.

A CallstackTrack wrapper owns lazy preparation of frame runs and delegates rendering, layout, selection, and tooltips to SliceTrack. Concurrent rendering and restored-selection requests share one initialization promise, including failures. This needs no new initialization, render, mouse, or tooltip hooks in SliceTrack. The shared slice-track change is limited to expanding compressed rows on the first click without selecting an event.

Sampled stacks use ordinary frame rectangles, function-name colors, sampled-duration tooltips, and frame details. There are no sampling-point diamonds, stems, overlays, or extra timestamp queries. The child is named Callstack flamechart. SQL classifies clock, cycle, and instruction timebases as eligible using structured session metadata. Every eligible thread without a slice track reveals its child with compressed rows; threads with slice tracks keep the child hidden initially. Visibility remains stable during pan and zoom. Per-session area selection is preserved.

Code origin is classified once per mapping in SQL, independently of process names and symbol availability. In mapping-color mode, binary, library, kernel, and unknown colors use the same mapping-path shade on sample instants and flamechart frames. Library recognition uses filename conventions because mapping metadata does not identify executable versus shared-object ELF files.

Final open frames use incomplete slices through trace end; details and time-range selection preserve that boundary.

Flamechart frames default to function-name colors. Track settings offer Color by → Function name or Mapping, using the existing track settings and SliceTrack cache-key APIs and supporting bulk updates. Mapping mode matches the origin colors on sample instants.

Integration tests expand track groups only when needed, including groups automatically revealed by flamecharts. Reviewed screenshot baselines cover the default presentation and switching to mapping colors.

Validation: 22 targeted UI unit tests and 3 browser integration tests pass, including a clean screenshot comparison run in the CI browser container. The UI build, TypeScript checking, ESLint, and formatting pass. The underlying stack also passed 7 SQL diff tests and 12 native generator tests. This PR remains a draft.
diff --git a/test/data/ui-screenshots/local_cache_key.test.ts/multiple-traces-via-url-and-local-cache-key/back-to-trace-1.png.sha256 b/test/data/ui-screenshots/local_cache_key.test.ts/multiple-traces-via-url-and-local-cache-key/back-to-trace-1.png.sha256
index 5ccd790..9f7d121 100644
--- a/test/data/ui-screenshots/local_cache_key.test.ts/multiple-traces-via-url-and-local-cache-key/back-to-trace-1.png.sha256
+++ b/test/data/ui-screenshots/local_cache_key.test.ts/multiple-traces-via-url-and-local-cache-key/back-to-trace-1.png.sha256
@@ -1 +1 @@
-ace45fd7fa052f8ce335e304ccc73599630d88103affcf6f96976b4251bb9bb1
\ No newline at end of file
+6a63dd29199a3a75a2f085163bcb3b1ac6b3e5ff24fe0e80ea522d20c79104c3
\ No newline at end of file
diff --git a/test/data/ui-screenshots/local_cache_key.test.ts/multiple-traces-via-url-and-local-cache-key/trace-1.png.sha256 b/test/data/ui-screenshots/local_cache_key.test.ts/multiple-traces-via-url-and-local-cache-key/trace-1.png.sha256
index de9cf03..f3d4094 100644
--- a/test/data/ui-screenshots/local_cache_key.test.ts/multiple-traces-via-url-and-local-cache-key/trace-1.png.sha256
+++ b/test/data/ui-screenshots/local_cache_key.test.ts/multiple-traces-via-url-and-local-cache-key/trace-1.png.sha256
@@ -1 +1 @@
-373556fb0f535212dfc0c6b1ddabcc1a7f2124e81a4f6630f2430ec96382b603
\ No newline at end of file
+35f6af398d9cb1d677a7589812d347ca70b131eaa70e7f46ba011f3b2dff9c5d
\ No newline at end of file
diff --git a/test/data/ui-screenshots/perf_event.test.ts/flamechart-mapping-colors/flamechart-mapping-colors.png.sha256 b/test/data/ui-screenshots/perf_event.test.ts/flamechart-mapping-colors/flamechart-mapping-colors.png.sha256
new file mode 100644
index 0000000..6b01a8e
--- /dev/null
+++ b/test/data/ui-screenshots/perf_event.test.ts/flamechart-mapping-colors/flamechart-mapping-colors.png.sha256
@@ -0,0 +1 @@
+139b79af8a563b8f4e1c228e4177f9a336588bf271b701c625ad2f0f931ebd30
\ No newline at end of file
diff --git a/test/data/ui-screenshots/perf_event.test.ts/multiple-callstack-tracks/perf-event-sf-expanded.png.sha256 b/test/data/ui-screenshots/perf_event.test.ts/multiple-callstack-tracks/perf-event-sf-expanded.png.sha256
index 7caa5e0..c680930 100644
--- a/test/data/ui-screenshots/perf_event.test.ts/multiple-callstack-tracks/perf-event-sf-expanded.png.sha256
+++ b/test/data/ui-screenshots/perf_event.test.ts/multiple-callstack-tracks/perf-event-sf-expanded.png.sha256
@@ -1 +1 @@
-6ab2b16a6df0243d80ac6dea5c6a27ae7a9797c488d75e59a8a0c642170ec3e1
\ No newline at end of file
+7c09b57446340eae6874d812443962655b6b59f4919648fe970d087839d3b246
\ No newline at end of file
diff --git a/test/data/ui-screenshots/perf_event.test.ts/multiple-callstack-tracks/perf-event-sf.png.sha256 b/test/data/ui-screenshots/perf_event.test.ts/multiple-callstack-tracks/perf-event-sf.png.sha256
index 3148c87..0ceacb8 100644
--- a/test/data/ui-screenshots/perf_event.test.ts/multiple-callstack-tracks/perf-event-sf.png.sha256
+++ b/test/data/ui-screenshots/perf_event.test.ts/multiple-callstack-tracks/perf-event-sf.png.sha256
@@ -1 +1 @@
-d4d52cf13063833fad0bad8135aca1fbb925ef85c58e9663bd25bc3150c9c87e
\ No newline at end of file
+9882cdf379e9348db36358999ff2631798494ac3284e6517bd29c181441771f5
\ No newline at end of file
diff --git a/ui/src/components/tracks/slice_track.ts b/ui/src/components/tracks/slice_track.ts
index 6b871dd..f670518 100644
--- a/ui/src/components/tracks/slice_track.ts
+++ b/ui/src/components/tracks/slice_track.ts
@@ -345,6 +345,7 @@
   private readonly hoverMonitor = new Monitor([() => this.hoveredSlice?.id]);
 
   private hoveredSlice?: SliceOrInstant<InferRowType<T>>;
+  private hoveredCompressedRow = false;
   private charWidth = {title: -1, subtitle: -1};
   private computedTrackHeight = 0;
   private currentDataFrame?: DataFrame<InferRowType<T>>;
@@ -1164,6 +1165,9 @@
     if (!this.hoveredSlice) {
       return undefined;
     }
+    if (this.sliceLayout.collapsed && this.hoveredCompressedRow) {
+      return 'Click to expand rows';
+    }
     return (
       this.attrs.tooltip?.(this.hoveredSlice) ??
       renderTooltip(this.trace, this.hoveredSlice)
@@ -1298,6 +1302,7 @@
   onMouseMove(e: TrackMouseEvent): void {
     const prevHoveredSlice = this.hoveredSlice;
     this.hoveredSlice = this.findSlice(e);
+    this.hoveredCompressedRow = this.isCompressedRow(e.y);
     if (this.hoverMonitor.ifStateChanged()) {
       this.trace.timeline.highlightedSliceId = this.hoveredSlice?.id;
       this.trace.timeline.highlightedSliceName = this.hoveredSlice?.title;
@@ -1317,6 +1322,7 @@
   onMouseOut(): void {
     const prevHoveredSlice = this.hoveredSlice;
     this.hoveredSlice = undefined;
+    this.hoveredCompressedRow = false;
     if (this.hoverMonitor.ifStateChanged()) {
       this.trace.timeline.highlightedSliceId = undefined;
       this.trace.timeline.highlightedSliceName = undefined;
@@ -1327,11 +1333,26 @@
     }
   }
 
+  private isCompressedRow(y: number): boolean {
+    const {padding, sliceHeight, rowGap, collapsed} = this.sliceLayout;
+    return (
+      collapsed &&
+      y > padding + sliceHeight &&
+      y >= padding + sliceHeight + rowGap
+    );
+  }
+
   onMouseClick(event: TrackMouseEvent): boolean {
     const slice = this.findSlice(event);
     if (slice === undefined) {
       return false;
     }
+    if (this.isCompressedRow(event.y)) {
+      this.sliceLayout = {...this.sliceLayout, collapsed: false};
+      this.onMouseOut();
+      this.trace.raf.scheduleFullRedraw();
+      return true;
+    }
     if (this.attrs.onSliceClick) {
       this.attrs.onSliceClick({slice});
     } else {
diff --git a/ui/src/components/tracks/slice_track_unittest.ts b/ui/src/components/tracks/slice_track_unittest.ts
index 10443f7..0bd4e41 100644
--- a/ui/src/components/tracks/slice_track_unittest.ts
+++ b/ui/src/components/tracks/slice_track_unittest.ts
@@ -14,7 +14,14 @@
 
 import {SourceDataset} from '../../trace_processor/dataset';
 import {LONG, LONG_NULL, NUM} from '../../trace_processor/query_result';
-import {generateRenderQuery} from './slice_track';
+import {generateRenderQuery, SliceTrack} from './slice_track';
+import {vi} from 'vitest';
+import {Time} from '../../base/time';
+import {HighPrecisionTime} from '../../base/high_precision_time';
+import {HighPrecisionTimeSpan} from '../../base/high_precision_time_span';
+import {TimeScale} from '../../base/time_scale';
+import type {Trace} from '../../public/trace';
+import {GRAY} from '../colorizer';
 
 describe('generateRenderQuery', () => {
   test('minimal query', () => {
@@ -67,3 +74,98 @@
     );
   });
 });
+
+describe('SliceTrack compressed row interactions', () => {
+  const timescale = new TimeScale(
+    new HighPrecisionTimeSpan(new HighPrecisionTime(Time.ZERO), 100),
+    {left: 0, right: 100},
+  );
+
+  function setup(collapsed = true, customClick = false) {
+    const selectTrackEvent = vi.fn();
+    const onSliceClick = vi.fn();
+    const trace = {
+      timeline: {},
+      selection: {selectTrackEvent},
+      raf: {scheduleFullRedraw: vi.fn()},
+    } as unknown as Trace;
+    const track = SliceTrack.create({
+      trace,
+      uri: 'test',
+      dataset: new SourceDataset({src: 'slices', schema: {ts: LONG}}),
+      initialMaxDepth: 1,
+      sliceLayout: {collapsed},
+      ...(customClick && {onSliceClick}),
+    });
+    // Seed fetched data to exercise actual hit testing without a SQL engine.
+    Object.assign(track, {
+      currentDataFrame: {
+        start: Time.ZERO,
+        end: Time.fromRaw(100n),
+        slices: {
+          starts: new Float32Array([0, 10]),
+          ends: new Float32Array([100, 90]),
+          depths: new Uint16Array([0, 1]),
+          patterns: new Uint8Array(2),
+          slices: [0, 1].map((id) => ({
+            id,
+            title: `Frame ${id}`,
+            subtitle: '',
+            count: 1,
+            colorScheme: GRAY,
+            fillRatio: 1,
+            row: {ts: 0n},
+          })),
+          count: 2,
+        },
+        instants: {count: 0},
+      },
+    });
+    track.getHeight();
+    return {track, selectTrackEvent, onSliceClick};
+  }
+
+  test('first compressed-row click expands; next click selects', () => {
+    const {track, selectTrackEvent} = setup();
+    const event = {x: 50, y: 22, timescale};
+    track.onMouseMove(event);
+    expect(track.renderTooltip()).toBe('Click to expand rows');
+    expect(track.onMouseClick(event)).toBe(true);
+    expect(selectTrackEvent).not.toHaveBeenCalled();
+    expect(track.getHeight()).toBe(42);
+    expect(track.getSliceVerticalBounds(1)).toEqual({top: 21, bottom: 39});
+    expect(track.onMouseClick(event)).toBe(true);
+    expect(selectTrackEvent).toHaveBeenCalledWith('test', 1);
+  });
+
+  test.each([10, 21])('top row at y=%s selects without expanding', (y) => {
+    const {track, selectTrackEvent} = setup();
+    track.onMouseClick({x: 50, y, timescale});
+    expect(selectTrackEvent).toHaveBeenCalledWith('test', 0);
+    expect(track.getHeight()).toBe(27);
+  });
+
+  test('empty space in compressed rows does not expand', () => {
+    const {track, selectTrackEvent} = setup();
+    expect(track.onMouseClick({x: 5, y: 22, timescale})).toBe(false);
+    expect(selectTrackEvent).not.toHaveBeenCalled();
+    expect(track.getHeight()).toBe(27);
+  });
+
+  test('expansion precedes custom click callbacks', () => {
+    const {track, onSliceClick} = setup(true, true);
+    const event = {x: 50, y: 22, timescale};
+    track.onMouseClick(event);
+    expect(onSliceClick).not.toHaveBeenCalled();
+    track.getHeight();
+    track.onMouseClick(event);
+    expect(onSliceClick).toHaveBeenCalledOnce();
+  });
+
+  test('expanded rows retain normal click behavior', () => {
+    const {track, selectTrackEvent} = setup(false);
+    track.onMouseClick({x: 50, y: 30, timescale});
+    expect(selectTrackEvent).toHaveBeenCalledWith('test', 1);
+    expect(track.getHeight()).toBe(42);
+  });
+});
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/callstack_track.ts b/ui/src/plugins/dev.perfetto.StackSamples/callstack_track.ts
new file mode 100644
index 0000000..17290a7
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.StackSamples/callstack_track.ts
@@ -0,0 +1,309 @@
+// 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 {valueIfAllEqual} from '../../base/array_utils';
+import {Icons} from '../../base/semantic_icons';
+import {getColorForSlice} from '../../components/colorizer';
+import {MenuItem} from '../../widgets/menu';
+import {AsyncMemo} from '../../base/async_memo';
+import {uuidv4Sql} from '../../base/uuid';
+import {Time, type time} from '../../base/time';
+import {formatDuration} from '../../components/time_utils';
+import {SliceTrack} from '../../components/tracks/slice_track';
+import type {Trace} from '../../public/trace';
+import type {
+  Track,
+  TrackRenderContext,
+  TrackRenderer,
+  TrackMouseEvent,
+  SnapPoint,
+  TrackSetting,
+  TrackSettingDescriptor,
+} from '../../public/track';
+import type {TrackEventSelection} from '../../public/selection';
+import type {TimeScale} from '../../base/time_scale';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {
+  LONG,
+  NUM,
+  STR,
+  type InferRowType,
+} from '../../trace_processor/query_result';
+import {sqlValueToSqliteString} from '../../trace_processor/sql_utils';
+import {FlamechartFrameDetailsPanel} from './frame_details_panel';
+import {sampleColorScheme} from './sample_colors';
+import {
+  STACK_SAMPLE_FLAMECHART_TRACK_KIND,
+  STACK_SAMPLE_TRACK_KIND,
+} from './track_kinds';
+
+type FlamechartColorBy = 'mapping' | 'function';
+
+const colorByDescriptor: TrackSettingDescriptor<FlamechartColorBy> = {
+  name: 'Color by',
+  description: 'Color frames by code mapping or function name.',
+  render(setter, values) {
+    const value = valueIfAllEqual(values);
+    return m(MenuItem, {label: 'Color by'}, [
+      m(MenuItem, {
+        label: 'Mapping',
+        icon: value === 'mapping' ? Icons.RadioChecked : Icons.RadioUnchecked,
+        onclick: () => setter('mapping'),
+      }),
+      m(MenuItem, {
+        label: 'Function name',
+        icon: value === 'function' ? Icons.RadioChecked : Icons.RadioUnchecked,
+        onclick: () => setter('function'),
+      }),
+    ]);
+  },
+};
+
+export interface CallstackTrackConfig {
+  readonly source: string;
+  readonly utid: number;
+  readonly upid: number | undefined;
+  // Undefined means all sessions; null means samples without a session.
+  readonly sessionId?: number | null;
+}
+
+const ROW_SCHEMA = {
+  id: NUM,
+  ts: LONG,
+  dur: LONG,
+  depth: NUM,
+  name: STR,
+  sampleCount: NUM,
+  category: NUM,
+  mappingName: STR,
+  frameId: NUM,
+};
+
+type FlamechartRow = InferRowType<typeof ROW_SCHEMA>;
+
+// Owns sampled-stack preparation, keeping the shared SliceTrack API unchanged.
+export class CallstackTrack implements TrackRenderer {
+  private readonly inner: SliceTrack<typeof ROW_SCHEMA>;
+  private colorBy: FlamechartColorBy = 'function';
+  private readonly initializationSlot = new AsyncMemo<boolean>();
+  private initializationPromise?: Promise<void>;
+  private initialized = false;
+  private readonly tableName = `__flamechart_runs_${uuidv4Sql()}`;
+
+  constructor(
+    private readonly trace: Trace,
+    uri: string,
+    private readonly config: CallstackTrackConfig,
+  ) {
+    this.inner = SliceTrack.create({
+      trace,
+      uri,
+      dataset: new SourceDataset({schema: ROW_SCHEMA, src: this.tableName}),
+      sliceLayout: {collapsed: true},
+      getKey: () => this.colorBy,
+      colorizer: (row: FlamechartRow) =>
+        this.colorBy === 'function'
+          ? getColorForSlice(row.name, {stripTrailingDigits: false})
+          : sampleColorScheme(row.category, row.mappingName),
+      tooltip: (slice) => {
+        return [
+          m('div', slice.row.name),
+          m(
+            'div',
+            slice.row.dur === -1n
+              ? 'Incomplete (sampled)'
+              : `${formatDuration(trace, slice.row.dur)} (sampled)`,
+          ),
+          m('div', `${slice.row.sampleCount} samples`),
+        ];
+      },
+      detailsPanel: (row: FlamechartRow) => {
+        return new FlamechartFrameDetailsPanel(trace, {
+          frameId: row.frameId,
+          name: row.name,
+          ts: Time.fromRaw(row.ts),
+          dur: row.dur,
+          category: row.category,
+          sampleCount: row.sampleCount,
+          trackUri: uri,
+        });
+      },
+    });
+  }
+
+  get settings(): ReadonlyArray<TrackSetting> {
+    const colorBy: TrackSetting<FlamechartColorBy> = {
+      descriptor: colorByDescriptor,
+      value: this.colorBy,
+      update: (value) => {
+        if (value === this.colorBy) return;
+        this.colorBy = value;
+        this.trace.raf.scheduleFullRedraw();
+      },
+    };
+    return [colorBy];
+  }
+
+  private initialize(): Promise<void> {
+    return (this.initializationPromise ??= this.prepare().then(() => {
+      this.initialized = true;
+      this.trace.raf.scheduleFullRedraw();
+    }));
+  }
+
+  render(ctx: TrackRenderContext): void {
+    if (!this.initialized) {
+      this.initializationSlot.use({
+        key: {},
+        compute: async () => {
+          await this.initialize();
+          return true;
+        },
+      });
+      return;
+    }
+    this.inner.render(ctx);
+  }
+
+  getHeight() {
+    return this.inner.getHeight();
+  }
+  getSliceVerticalBounds(depth: number) {
+    return this.inner.getSliceVerticalBounds(depth);
+  }
+  getTrackShellButtons() {
+    return this.inner.getTrackShellButtons();
+  }
+  onMouseMove(event: TrackMouseEvent) {
+    this.inner.onMouseMove(event);
+  }
+  onMouseOut() {
+    this.inner.onMouseOut();
+  }
+  onMouseClick(event: TrackMouseEvent) {
+    return this.inner.onMouseClick(event);
+  }
+  onMouseDoubleClick(event: TrackMouseEvent) {
+    return this.inner.onMouseDoubleClick(event);
+  }
+  renderTooltip() {
+    return this.inner.renderTooltip();
+  }
+  getSnapPoint(
+    targetTime: time,
+    thresholdPx: number,
+    timescale: TimeScale,
+  ): SnapPoint | undefined {
+    return this.inner.getSnapPoint(targetTime, thresholdPx, timescale);
+  }
+  getDataset() {
+    return this.initialized ? this.inner.getDataset() : undefined;
+  }
+  async getSelectionDetails(id: number) {
+    await this.initialize();
+    return this.inner.getSelectionDetails(id);
+  }
+  detailsPanel(selection: TrackEventSelection) {
+    return this.initialized ? this.inner.detailsPanel(selection) : undefined;
+  }
+
+  private sampleConstraint(): string {
+    const {config} = this;
+    const source = sqlValueToSqliteString(config.source);
+    const parts = [`ss.source = ${source}`, `tc.utid = ${config.utid}`];
+    if (config.sessionId === null) {
+      parts.push('ss.session_id is null');
+    } else if (config.sessionId !== undefined) {
+      parts.push(`ss.session_id = ${config.sessionId}`);
+    }
+    return parts.join(' and ');
+  }
+
+  private async prepare(): Promise<void> {
+    const {trace, tableName} = this;
+    const engine = trace.engine;
+    const constraint = this.sampleConstraint();
+    await engine.query('include perfetto module callstacks.stack_profile;');
+    await engine.query('include perfetto module std.trees.table_conversion;');
+    await engine.query('include perfetto module std.stack_sample.flamechart;');
+    await engine.query('include perfetto module std.stack_sample.mapping;');
+    await engine.query(`
+      create perfetto table ${tableName} as
+      select row_number() over (order by ts, depth) as id, *
+      from (
+        select
+          r.ts,
+          r.dur,
+          r.depth as depth,
+          iif(f.name = '', 'unknown', f.name) as name,
+          r.sample_count as sampleCount,
+          coalesce(mp.category, 3) as category,
+          coalesce(mp.name, '') as mappingName,
+          r.id as frameId
+        from _stack_sample_flamechart_runs!(
+          _tree_from_table!(
+            (select id, parent_id, name from _callstack_spc_forest),
+            (name)
+          ),
+          (
+            select ss.ts, fl.id as leaf_id
+            from stack_sample ss
+            join stack_sample_task_context tc on tc.id = ss.task_context_id
+            join _callstack_spc_forest fl
+              on fl.callsite_id = ss.callsite_id
+              and fl.is_leaf_function_in_callsite_frame
+            where ${constraint}
+            order by ss.ts
+          )
+        ) as r
+        join _callstack_spc_forest as f on f.id = r.id
+        left join _stack_sample_mapping_classification mp on mp.id = f.mapping_id
+      )
+    `);
+  }
+}
+
+export function createCallstackTrack(
+  trace: Trace,
+  uri: string,
+  config: CallstackTrackConfig,
+): Track {
+  const renderer = new CallstackTrack(trace, uri, config);
+  const track: Track = {
+    uri,
+    description:
+      'This track shows sampled callstacks. Each row is a stack depth, and ' +
+      'each block represents a frame observed in consecutive samples. ' +
+      'Unlike instrumented slices, block boundaries and durations are inferred ' +
+      'from samples, not recorded function entry and exit times. Calls between ' +
+      'samples may be missed. Frames still open at the last sample extend ' +
+      'to trace end as incomplete.',
+    tags: {
+      // Preserve stack-sample area selection for the child as well.
+      // Automatic sample-track discovery excludes the flamechart kind.
+      kinds: [STACK_SAMPLE_TRACK_KIND, STACK_SAMPLE_FLAMECHART_TRACK_KIND],
+      utid: config.utid,
+      upid: config.upid,
+      stackSampleSource: config.source,
+      ...(config.sessionId !== undefined &&
+        config.sessionId !== null && {
+          stackSampleSessionId: config.sessionId,
+        }),
+      ...(config.sessionId === null && {stackSampleNullSession: true}),
+    },
+    renderer,
+  };
+  return track;
+}
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/callstack_track_unittest.ts b/ui/src/plugins/dev.perfetto.StackSamples/callstack_track_unittest.ts
new file mode 100644
index 0000000..6ce2c95
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.StackSamples/callstack_track_unittest.ts
@@ -0,0 +1,103 @@
+// 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 {vi} from 'vitest';
+import {defer} from '../../base/deferred';
+import {SliceTrack} from '../../components/tracks/slice_track';
+import type {Trace} from '../../public/trace';
+import type {TrackRenderContext} from '../../public/track';
+import {CallstackTrack} from './callstack_track';
+
+describe('CallstackTrack', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  function setup(source = 'linux.perf') {
+    const ready = defer<void>();
+    const query = vi.fn(async (_sql: string) => {
+      await ready;
+      return {iter: () => ({valid: () => false})};
+    });
+    const trace = {
+      engine: {query},
+      raf: {scheduleFullRedraw: vi.fn()},
+    } as unknown as Trace;
+    const track = new CallstackTrack(trace, 'test', {
+      source,
+      utid: 1,
+      upid: 2,
+      sessionId: 3,
+    });
+    const context = {} as TrackRenderContext;
+    return {track, query, ready, context};
+  }
+
+  test('prepares lazily once for concurrent render and selection requests', async () => {
+    const render = vi
+      .spyOn(SliceTrack.prototype, 'render')
+      .mockImplementation(() => {});
+    const {track, query, ready, context} = setup();
+    expect(query).not.toHaveBeenCalled();
+    expect(track.getDataset()).toBeUndefined();
+    track.render(context);
+    track.render(context);
+    const first = track.getSelectionDetails(1);
+    const second = track.getSelectionDetails(2);
+    expect(query).toHaveBeenCalledOnce();
+    expect(render).not.toHaveBeenCalled();
+    ready.resolve();
+    await Promise.all([first, second]);
+    expect(query).toHaveBeenCalledTimes(7); // Four modules, one runs table, two selections.
+    expect(track.getDataset()).toBeDefined();
+    const sql = query.mock.calls.map(([statement]) => statement).join('\n');
+    expect(sql).toContain('ss.session_id = 3');
+    expect(sql).not.toContain('_samples as');
+    track.render(context);
+    expect(render).toHaveBeenCalledWith(context);
+    expect(query).toHaveBeenCalledTimes(7);
+  });
+
+  test('sources with the same sanitized name keep separate run tables', async () => {
+    const first = setup('custom.cpu');
+    const second = setup('custom_cpu');
+    first.ready.resolve();
+    second.ready.resolve();
+    await Promise.all([
+      first.track.getSelectionDetails(1),
+      second.track.getSelectionDetails(1),
+    ]);
+    const firstDataset = first.track.getDataset();
+    const secondDataset = second.track.getDataset();
+    expect(firstDataset).toBeDefined();
+    expect(secondDataset).toBeDefined();
+    expect(firstDataset!.src).not.toBe(secondDataset!.src);
+  });
+
+  test('preparation errors reach selections and the render error path without retrying', async () => {
+    const {track, query, ready, context} = setup();
+    track.render(context);
+    const first = expect(track.getSelectionDetails(1)).rejects.toThrow(
+      'prepare failed',
+    );
+    const second = expect(track.getSelectionDetails(2)).rejects.toThrow(
+      'prepare failed',
+    );
+    ready.reject(new Error('prepare failed'));
+    await Promise.all([first, second]);
+    await vi.waitFor(() =>
+      expect(() => track.render(context)).toThrow('prepare failed'),
+    );
+    expect(query).toHaveBeenCalledOnce();
+    expect(track.getDataset()).toBeUndefined();
+  });
+});
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/frame_details_panel.ts b/ui/src/plugins/dev.perfetto.StackSamples/frame_details_panel.ts
new file mode 100644
index 0000000..929b8cb
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.StackSamples/frame_details_panel.ts
@@ -0,0 +1,125 @@
+// 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 {Time, type time} from '../../base/time';
+import {formatDuration} from '../../components/time_utils';
+import {DurationWidget} from '../../components/widgets/duration';
+import {Timestamp} from '../../components/widgets/timestamp';
+import type {TrackEventDetailsPanel} from '../../public/details_panel';
+import type {Trace} from '../../public/trace';
+import {NUM_NULL, STR_NULL} from '../../trace_processor/query_result';
+import {Button} from '../../widgets/button';
+import {DetailsShell} from '../../widgets/details_shell';
+import {Section} from '../../widgets/section';
+import {Tree, TreeNode} from '../../widgets/tree';
+import {sampleCategoryLabel} from './sample_colors';
+
+export interface FlamechartFrameInfo {
+  // Node id in _callstack_spc_forest.
+  readonly frameId: number;
+  readonly name: string;
+  readonly ts: time;
+  readonly dur: bigint;
+  readonly category: number;
+  readonly sampleCount: number;
+  readonly trackUri: string;
+}
+
+// Details panel for a run of a frame on the sampled-stacks flamechart:
+// identifies the frame (mapping, category, source location) and offers
+// selecting the run's time range for aggregate analysis.
+export class FlamechartFrameDetailsPanel implements TrackEventDetailsPanel {
+  private mapping?: string;
+  private sourceLocation?: string;
+
+  constructor(
+    private readonly trace: Trace,
+    private readonly info: FlamechartFrameInfo,
+  ) {}
+
+  async load(): Promise<void> {
+    const result = await this.trace.engine.query(`
+      select
+        mp.name as mapping,
+        f.source_file as sourceFile,
+        f.line_number as lineNumber
+      from _callstack_spc_forest f
+      left join stack_profile_mapping mp on mp.id = f.mapping_id
+      where f.id = ${this.info.frameId}
+    `);
+    const row = result.maybeFirstRow({
+      mapping: STR_NULL,
+      sourceFile: STR_NULL,
+      lineNumber: NUM_NULL,
+    });
+    if (row === undefined) return;
+    this.mapping = row.mapping ?? undefined;
+    if (row.sourceFile !== null) {
+      this.sourceLocation =
+        row.lineNumber === null
+          ? row.sourceFile
+          : `${row.sourceFile}:${row.lineNumber}`;
+    }
+  }
+
+  render(): m.Children {
+    const {trace, info} = this;
+    return m(
+      DetailsShell,
+      {
+        title: info.name,
+        description: `${info.sampleCount} samples · ${info.dur === -1n ? 'Incomplete' : formatDuration(trace, info.dur)} (sampled)`,
+        buttons: m(Button, {
+          label: 'Select time range',
+          onclick: () => {
+            trace.selection.selectArea({
+              start: info.ts,
+              end:
+                info.dur === -1n
+                  ? trace.traceInfo.end
+                  : Time.fromRaw(info.ts + info.dur),
+              trackUris: [info.trackUri],
+            });
+          },
+        }),
+      },
+      m(
+        Section,
+        {title: 'Frame'},
+        m(
+          Tree,
+          m(TreeNode, {left: 'Name', right: info.name}),
+          this.mapping !== undefined &&
+            m(TreeNode, {left: 'Mapping', right: this.mapping}),
+          m(TreeNode, {
+            left: 'Category',
+            right: sampleCategoryLabel(info.category),
+          }),
+          this.sourceLocation !== undefined &&
+            m(TreeNode, {left: 'Source', right: this.sourceLocation}),
+          m(TreeNode, {
+            left: 'Start',
+            right: m(Timestamp, {trace, ts: info.ts}),
+          }),
+          m(TreeNode, {
+            left: 'Duration (sampled)',
+            right: m(DurationWidget, {trace, dur: info.dur}),
+          }),
+          m(TreeNode, {left: 'Samples', right: `${info.sampleCount}`}),
+        ),
+      ),
+    );
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/frame_details_panel_unittest.ts b/ui/src/plugins/dev.perfetto.StackSamples/frame_details_panel_unittest.ts
new file mode 100644
index 0000000..6cc1740
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.StackSamples/frame_details_panel_unittest.ts
@@ -0,0 +1,54 @@
+// 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 type m from 'mithril';
+import {vi} from 'vitest';
+import {Time} from '../../base/time';
+import type {Trace} from '../../public/trace';
+import {FlamechartFrameDetailsPanel} from './frame_details_panel';
+
+vi.mock('../../components/time_utils', () => ({formatDuration: () => '20 ns'}));
+
+describe('FlamechartFrameDetailsPanel', () => {
+  test.each([
+    {dur: -1n, end: 100n, label: 'Incomplete (sampled)'},
+    {dur: 20n, end: 30n, label: '20 ns (sampled)'},
+  ])('selects the displayed range for dur=$dur', ({dur, end, label}) => {
+    const selectArea = vi.fn();
+    const trace = {
+      traceInfo: {end: Time.fromRaw(100n)},
+      selection: {selectArea},
+    } as unknown as Trace;
+    const panel = new FlamechartFrameDetailsPanel(trace, {
+      frameId: 1,
+      name: 'work',
+      ts: Time.fromRaw(10n),
+      dur,
+      category: 0,
+      sampleCount: 2,
+      trackUri: 'callstacks',
+    });
+    const result = panel.render() as m.Vnode<{
+      description: string;
+      buttons: m.Vnode<{onclick: () => void}>;
+    }>;
+    expect(result.attrs.description).toContain(label);
+    result.attrs.buttons.attrs.onclick();
+    expect(selectArea).toHaveBeenCalledWith({
+      start: Time.fromRaw(10n),
+      end: Time.fromRaw(end),
+      trackUris: ['callstacks'],
+    });
+  });
+});
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/index.ts b/ui/src/plugins/dev.perfetto.StackSamples/index.ts
index 04fe674..027be5f 100644
--- a/ui/src/plugins/dev.perfetto.StackSamples/index.ts
+++ b/ui/src/plugins/dev.perfetto.StackSamples/index.ts
@@ -48,14 +48,20 @@
   updateTreeExplorerState,
   type TreeExplorerState,
 } from '../../widgets/tree_explorer';
+import {SLICE_TRACK_KIND} from '../../public/track_kinds';
 import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
+import {createCallstackTrack} from './callstack_track';
 import {createProfilingTrack} from './profiling_track';
 import {
   getStackSampleSourceSchema,
   type StackSampleSourceSchema,
 } from './stack_sample_sources';
+import {
+  STACK_SAMPLE_TRACK_KIND,
+  STACK_SAMPLE_FLAMECHART_TRACK_KIND,
+} from './track_kinds';
 
-export const STACK_SAMPLE_TRACK_KIND = 'StackSampleTrack';
+export {STACK_SAMPLE_TRACK_KIND} from './track_kinds';
 const LINUX_PERF_SOURCE = 'linux.perf';
 
 const STACK_SAMPLES_PLUGIN_STATE_SCHEMA = z
@@ -84,7 +90,6 @@
 
 export interface StackSampleTrackConfig {
   readonly source: string;
-  readonly title: string;
   readonly upid?: number;
   readonly utid?: number;
   // Undefined means all sessions; null means samples without a session.
@@ -123,6 +128,14 @@
   return sessionId === null ? '_session_none' : `_session_${sessionId}`;
 }
 
+// Appends the qualifiers which disambiguate a name (source when several
+// emit, session when several exist), parenthesized: "Callstacks (Perf,
+// cycles)". Empty/undefined qualifiers are dropped.
+function named(base: string, ...qualifiers: (string | undefined)[]): string {
+  const quals = qualifiers.filter((q) => q !== undefined && q !== '');
+  return quals.length === 0 ? base : `${base} (${quals.join(', ')})`;
+}
+
 // Creates the common stack-sample track definition. Source plugins retain
 // responsibility for deciding which tracks to register and where to place
 // them in the workspace.
@@ -169,12 +182,22 @@
             id: NUM,
             ts: LONG,
             callsiteId: NUM,
+            category: NUM,
+            mappingName: STR,
           },
           src: `
-            select ss.id, ss.ts, ss.callsite_id as callsiteId
+            select
+              ss.id,
+              ss.ts,
+              ss.callsite_id as callsiteId,
+              coalesce(mp.category, 3) as category,
+              coalesce(mp.name, '') as mappingName
             from stack_sample ss
             left join stack_sample_task_context tc on tc.id = ss.task_context_id
             left join thread t on t.utid = tc.utid
+            left join stack_profile_callsite c on c.id = ss.callsite_id
+            left join stack_profile_frame fr on fr.id = c.frame_id
+            left join _stack_sample_mapping_classification mp on mp.id = fr.mapping
             where ${trackConstraints}
             order by ss.ts
           `,
@@ -187,9 +210,9 @@
           where ss.ts = ${ts} and ${trackConstraints}
         `,
         sqlModule: 'callstacks.stack_profile',
-        metricName: `${config.title} Samples`,
-        panelTitle: `${config.title} Samples`,
-        sliceName: `${config.title} Sample`,
+        metricName: 'Samples',
+        panelTitle: 'Callstack',
+        sliceName: 'Sample',
       },
       detailsPanelState,
       onDetailsPanelStateChange,
@@ -208,7 +231,7 @@
 
   return {
     id: `stack_sample_flamegraph_${encodeURIComponent(config.source)}`,
-    name: `${config.title} Sample Flamegraph`,
+    name: named('Callstack Flamegraph', config.title),
     render: (selection: AreaSelection) => {
       const fetcher = fetcherMemo.use({
         key: areaSelectionKey(selection),
@@ -290,7 +313,7 @@
   const metrics: TreeExplorerQueryMetric[] = [];
   for (const counterName of new Set(names)) {
     metrics.push({
-      name: `${config.title} Samples (${counterName})`,
+      name: counterName,
       unit: '',
       nameColumnLabel: 'Symbol',
       dependencySql: 'include perfetto module callstacks.stack_profile;',
@@ -340,7 +363,7 @@
       `,
       tableMetrics: [
         {
-          name: `${config.title} Samples (Sample Count)`,
+          name: 'Sample Count',
           unit: '',
           columnName: 'self_count',
         },
@@ -374,6 +397,7 @@
         tags?.kinds?.includes(STACK_SAMPLE_TRACK_KIND) === true &&
         tags.stackSampleSource === source &&
         tags.stackSampleSummary !== true &&
+        !tags.kinds.includes(STACK_SAMPLE_FLAMECHART_TRACK_KIND) &&
         matchesScope
       );
     })
@@ -408,13 +432,19 @@
     configs.sort(
       (a, b) => a.order - b.order || a.source.localeCompare(b.source),
     );
+    if (configs.length > 0) {
+      await trace.engine.query(
+        'include perfetto module std.stack_sample.mapping;',
+      );
+    }
+    const multiSource = configs.length > 1;
     for (const config of configs) {
-      await this.addTracksForSource(trace, config);
+      await this.addTracksForSource(trace, config, multiSource);
       const store = ensureExists(this.store);
       trace.selection.registerAreaSelectionTab(
         createStackSampleAreaSelectionTab(trace, {
           source: config.source,
-          title: config.title,
+          title: multiSource ? config.title : '',
           counterNames: this.counterNamesBySource.get(config.source) ?? [],
           counterNamesBySession: this.counterNamesBySession,
           getState: () =>
@@ -457,7 +487,11 @@
   private async addTracksForSource(
     trace: Trace,
     config: StackSampleSourceSchema,
+    multiSource: boolean,
   ): Promise<void> {
+    // With a single stack-sample source there is nothing to disambiguate;
+    // only prefix track names with the source when several sources emit.
+    const displayTitle = multiSource ? config.title : '';
     const result = await trace.engine.query(`
       select distinct
         tc.utid,
@@ -475,6 +509,7 @@
 
     const byUtid = new Map<number, SampleGroupInfo>();
     const byUpid = new Map<number, {sessionIds: SessionId[]}>();
+    const processOnlySamples = new Set<number>();
     for (
       const it = result.iter({
         utid: NUM_NULL,
@@ -511,20 +546,53 @@
         if (!info.sessionIds.includes(sessionId)) {
           info.sessionIds.push(sessionId);
         }
+        if (utid === null) {
+          processOnlySamples.add(upid);
+        }
+      }
+    }
+
+    const sampledThreadsByUpid = new Map<number, number>();
+    for (const info of byUtid.values()) {
+      if (info.upid !== undefined) {
+        sampledThreadsByUpid.set(
+          info.upid,
+          (sampledThreadsByUpid.get(info.upid) ?? 0) + 1,
+        );
       }
     }
 
     for (const info of byUtid.values()) this.sortSessions(info.sessionIds);
     for (const info of byUpid.values()) this.sortSessions(info.sessionIds);
 
+    const flamechartSessions = await this.queryFlamechartSessions(
+      trace,
+      config.source,
+    );
+    // Session labels disambiguate the sampling timebase, so they apply
+    // whenever the trace has several kinds of sampling - be it several
+    // sessions of this source or several sources - even on tracks which
+    // only carry one of them.
+    const labelSessions = flamechartSessions.size > 1 || multiSource;
+
     const groups = trace.plugins.getPlugin(ProcessThreadGroupsPlugin);
     for (const [upid, {sessionIds}] of byUpid) {
+      // A process track duplicates the thread track when the process has
+      // exactly one sampled thread and no process-only samples (the common
+      // shape for kernel threads).
+      if (
+        !processOnlySamples.has(upid) &&
+        (sampledThreadsByUpid.get(upid) ?? 0) === 1
+      ) {
+        continue;
+      }
       const node = this.addScopeTracks(trace, config, {
         upid,
         utid: undefined,
         sessionIds,
-        summaryName: `${config.title} Process Callstacks`,
-        leafName: (label) => `${config.title} Process Callstacks ${label}`,
+        labelSessions,
+        summaryName: named('Process Callstacks', displayTitle),
+        leafName: (label) => named('Process Callstacks', displayTitle, label),
         uri: (sessionId) =>
           processStackSampleTrackUri(config.source, upid, sessionId),
         sortOrder: -40,
@@ -532,20 +600,159 @@
       groups.getGroupForProcess(upid)?.addChildInOrder(node);
     }
 
+    const store = ensureExists(this.store);
+    const detailsPanelState = () =>
+      store.state.detailsPanelFlamegraphStates?.[config.source];
+    const onDetailsPanelStateChange = (state: TreeExplorerState) => {
+      store.edit((draft) => {
+        draft.detailsPanelFlamegraphStates ??= {};
+        draft.detailsPanelFlamegraphStates[config.source] = state;
+      });
+    };
+
+    // Keep sample instants on the parent, with a lazy frame-only child for
+    // clock-, cycle-, or instruction-based sessions.
+    const flamechartNodes: {readonly utid: number; readonly node: TrackNode}[] =
+      [];
     for (const [utid, {threadName, tid, upid, sessionIds}] of byUtid) {
-      const title = `${threadName ?? 'Thread'} ${tid} ${config.title} Callstacks`;
-      const node = this.addScopeTracks(trace, config, {
-        upid,
-        utid,
-        sessionIds,
-        summaryName: title,
-        leafName: (label) => `${title} ${label}`,
-        uri: (sessionId) =>
-          threadStackSampleTrackUri(config.source, upid, utid, sessionId),
+      const threadPrefix = `${threadName ?? 'Thread'} ${tid}`;
+      const registerThreadTrack = (
+        uri: string,
+        sessionId: SessionId | undefined,
+        name: string,
+      ): TrackNode => {
+        const trackConfig = {
+          source: config.source,
+          utid,
+          upid,
+          sessionId,
+        };
+        trace.tracks.registerTrack(
+          createStackSampleTrack(
+            trace,
+            uri,
+            trackConfig,
+            detailsPanelState(),
+            onDetailsPanelStateChange,
+          ),
+        );
+        const node = new TrackNode({uri, name, sortOrder: -50});
+        const supported = flamechartSessions.get(sessionId ?? null) ?? false;
+        if (supported) {
+          const childUri = `${uri}/flamechart`;
+          trace.tracks.registerTrack(
+            createCallstackTrack(trace, childUri, trackConfig),
+          );
+          const child = new TrackNode({
+            uri: childUri,
+            name: 'Callstack flamechart',
+          });
+          node.addChildInOrder(child);
+          flamechartNodes.push({utid, node: child});
+        }
+        return node;
+      };
+
+      if (sessionIds.length <= 1) {
+        const uri = threadStackSampleTrackUri(config.source, upid, utid);
+        const sessionId = sessionIds[0];
+        const sessionLabel =
+          labelSessions && sessionId !== undefined && sessionId !== null
+            ? this.getSessionLabel(sessionId)
+            : undefined;
+        const node = registerThreadTrack(
+          uri,
+          sessionId,
+          `${threadPrefix} ${named('Callstacks', displayTitle, sessionLabel)}`,
+        );
+        groups.getGroupForThread(utid)?.addChildInOrder(node);
+        continue;
+      }
+
+      const summaryUri = threadStackSampleTrackUri(config.source, upid, utid);
+      trace.tracks.registerTrack(
+        createStackSampleTrack(
+          trace,
+          summaryUri,
+          {
+            source: config.source,
+            utid,
+            upid,
+            summary: true,
+          },
+          detailsPanelState(),
+          onDetailsPanelStateChange,
+        ),
+      );
+      const summaryNode = new TrackNode({
+        uri: summaryUri,
+        name: `${threadPrefix} ${named('Callstacks', displayTitle)}`,
+        isSummary: true,
         sortOrder: -50,
       });
-      groups.getGroupForThread(utid)?.addChildInOrder(node);
+      for (const sessionId of sessionIds) {
+        const uri = threadStackSampleTrackUri(
+          config.source,
+          upid,
+          utid,
+          sessionId,
+        );
+        summaryNode.addChildInOrder(
+          registerThreadTrack(
+            uri,
+            sessionId,
+            `${threadPrefix} ${named('Callstacks', displayTitle, this.sessionLabel(sessionId))}`,
+          ),
+        );
+      }
+      groups.getGroupForThread(utid)?.addChildInOrder(summaryNode);
     }
+
+    if (flamechartNodes.length > 0) {
+      // Threads with instrumented slices keep their child hidden. Other
+      // threads reveal the child with compressed frame rows. These defaults
+      // run once, never during pan or zoom.
+      trace.onTraceReady.addListener(() => {
+        const utidsWithSlices = new Set<number>();
+        for (const track of trace.tracks.getAllTracks()) {
+          const tags = track.tags;
+          if (
+            tags?.kinds?.includes(SLICE_TRACK_KIND) === true &&
+            tags.utid !== undefined
+          ) {
+            utidsWithSlices.add(tags.utid);
+          }
+        }
+        for (const {utid, node} of flamechartNodes) {
+          if (!utidsWithSlices.has(utid)) node.reveal();
+        }
+      });
+    }
+  }
+
+  private async queryFlamechartSessions(
+    trace: Trace,
+    source: string,
+  ): Promise<Map<SessionId, boolean>> {
+    await trace.engine.query(
+      'include perfetto module std.stack_sample.flamechart;',
+    );
+    const result = await trace.engine.query(`
+      select distinct ss.session_id as sessionId,
+        _stack_sample_flamechart_supported(s.timebase_unit) as supported
+      from stack_sample ss
+      left join stack_sample_session s on s.id = ss.session_id
+      where ss.source = ${sqlValueToSqliteString(source)}
+    `);
+    const sessions = new Map<SessionId, boolean>();
+    for (
+      const it = result.iter({sessionId: NUM_NULL, supported: NUM});
+      it.valid();
+      it.next()
+    ) {
+      sessions.set(it.sessionId, it.supported !== 0);
+    }
+    return sessions;
   }
 
   private addScopeTracks(
@@ -555,6 +762,7 @@
       readonly upid: number | undefined;
       readonly utid: number | undefined;
       readonly sessionIds: SessionId[];
+      readonly labelSessions: boolean;
       readonly summaryName: string;
       readonly leafName: (label: string) => string;
       readonly uri: (sessionId?: SessionId) => string;
@@ -573,7 +781,6 @@
           uri,
           {
             source: config.source,
-            title: config.title,
             upid: args.upid,
             utid: args.utid,
             sessionId,
@@ -590,13 +797,22 @@
       );
     };
 
-    const splitBySession = args.sessionIds.some((id) => id !== null);
+    // Only split into per-session tracks when this scope has several, but
+    // label a lone session whenever the trace as a whole is multi-session.
+    const splitBySession = args.sessionIds.length > 1;
     if (!splitBySession) {
+      // Keep the merged uri but carry the lone session in the track's tags,
+      // so area selection only surfaces the measures it actually has.
       const uri = args.uri();
-      registerTrack(uri, undefined, false);
+      registerTrack(uri, args.sessionIds[0], false);
+      const sessionId = args.sessionIds[0];
+      const name =
+        args.labelSessions && sessionId !== undefined && sessionId !== null
+          ? args.leafName(this.getSessionLabel(sessionId))
+          : args.summaryName;
       return new TrackNode({
         uri,
-        name: args.summaryName,
+        name,
         sortOrder: args.sortOrder,
       });
     }
@@ -613,8 +829,7 @@
     for (const sessionId of args.sessionIds) {
       const uri = args.uri(sessionId);
       registerTrack(uri, sessionId, false);
-      const label =
-        sessionId === null ? 'No session' : this.getSessionLabel(sessionId);
+      const label = this.sessionLabel(sessionId);
       summaryTrack.addChildInOrder(
         new TrackNode({
           uri,
@@ -626,6 +841,10 @@
     return summaryTrack;
   }
 
+  private sessionLabel(sessionId: SessionId): string {
+    return sessionId === null ? 'no session' : this.getSessionLabel(sessionId);
+  }
+
   private getSessionLabel(sessionId: number): string {
     return (
       this.counterNamesBySession.get(sessionId)?.[0] ?? `Session ${sessionId}`
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/profiling_track.ts b/ui/src/plugins/dev.perfetto.StackSamples/profiling_track.ts
index ece1f91..c0e2897 100644
--- a/ui/src/plugins/dev.perfetto.StackSamples/profiling_track.ts
+++ b/ui/src/plugins/dev.perfetto.StackSamples/profiling_track.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {getColorForSample} from '../../components/colorizer';
+import {sampleColorScheme} from './sample_colors';
 import {
   metricsFromTableOrSubquery,
   TreeExplorerFetcher,
@@ -32,7 +32,7 @@
 import type {Trace} from '../../public/trace';
 import {SliceTrack} from '../../components/tracks/slice_track';
 import type {SourceDataset} from '../../trace_processor/dataset';
-import type {LONG, NUM} from '../../trace_processor/query_result';
+import type {LONG, NUM, STR} from '../../trace_processor/query_result';
 
 /**
  * Configuration for creating a profiling track (CPU profile, perf samples, etc)
@@ -40,12 +40,14 @@
 export interface ProfilingTrackConfig {
   /**
    * The SourceDataset that provides the profiling samples.
-   * Must have schema: {id: NUM, ts: LONG, callsiteId: NUM}
+   * Must have schema: {id: NUM, ts: LONG, callsiteId: NUM, category: NUM, mappingName: STR}
    */
   readonly dataset: SourceDataset<{
     id: typeof NUM;
     ts: typeof LONG;
     callsiteId: typeof NUM;
+    category: typeof NUM;
+    mappingName: typeof STR;
   }>;
 
   /**
@@ -119,7 +121,7 @@
     uri,
     dataset: config.dataset,
     sliceName: () => config.sliceName,
-    colorizer: (row) => getColorForSample(row.callsiteId),
+    colorizer: (row) => sampleColorScheme(row.category, row.mappingName),
     detailsPanel: (row) => {
       const ts = Time.fromRaw(row.ts);
       const fetcher = fetcherMemo.use({
@@ -201,7 +203,7 @@
 function renderProfilingDetailsPanel(
   trace: Trace,
   ts: time,
-  config: ProfilingTrackConfig,
+  config: Omit<ProfilingTrackConfig, 'dataset'>,
   state: TreeExplorerState,
   onStateChange: (state: TreeExplorerState) => void,
   fetcher: TreeExplorerFetcher,
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/sample_colors.ts b/ui/src/plugins/dev.perfetto.StackSamples/sample_colors.ts
new file mode 100644
index 0000000..9ee7166
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.StackSamples/sample_colors.ts
@@ -0,0 +1,52 @@
+// 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 {HSLColor} from '../../base/color';
+import type {ColorScheme} from '../../base/color_scheme';
+import {hash} from '../../base/hash';
+import {GRAY, makeColorScheme} from '../../components/colorizer';
+
+// Frames are colored by where they come from, consistently across all
+// instant and flamechart tracks: binary, library, kernel, or unknown mapping.
+// Categories are defined by std.stack_sample.mapping; shades use the mapping path,
+// so symbolization and function names do not change a sample's origin color.
+export const CATEGORY_BINARY = 0;
+export const CATEGORY_LIBRARY = 1;
+export const CATEGORY_KERNEL = 2;
+export const CATEGORY_UNKNOWN = 3;
+
+const CATEGORY_LABELS = ['Binary', 'Library', 'Kernel', 'Unknown'];
+const CATEGORY_HUES = [217, 110, 30];
+const SATURATION = 28;
+const LIGHTNESS_BASE = 48;
+const LIGHTNESS_JITTER = 8;
+
+export function sampleCategoryLabel(category: number): string {
+  return CATEGORY_LABELS[category] ?? 'Unknown';
+}
+
+const cache = new Map<string, ColorScheme>();
+
+export function sampleColorScheme(category: number, name: string): ColorScheme {
+  const hue = CATEGORY_HUES[category];
+  if (hue === undefined) return GRAY;
+  const key = `${category}#${name}`;
+  let scheme = cache.get(key);
+  if (scheme === undefined) {
+    const lightness = LIGHTNESS_BASE + hash(name, LIGHTNESS_JITTER);
+    scheme = makeColorScheme(new HSLColor([hue, SATURATION, lightness]));
+    cache.set(key, scheme);
+  }
+  return scheme;
+}
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/sample_colors_unittest.ts b/ui/src/plugins/dev.perfetto.StackSamples/sample_colors_unittest.ts
new file mode 100644
index 0000000..c72fe16
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.StackSamples/sample_colors_unittest.ts
@@ -0,0 +1,129 @@
+// 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 {vi} from 'vitest';
+import {SliceTrack} from '../../components/tracks/slice_track';
+import type {Trace} from '../../public/trace';
+import {CallstackTrack} from './callstack_track';
+import {createStackSampleTrack} from './index';
+import {GRAY, getColorForSlice} from '../../components/colorizer';
+import {sampleColorScheme} from './sample_colors';
+
+describe('stack sample colors', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  test.each([0, 1, 2, 3])(
+    'instants and frames share mapping colors for category %i',
+    (category) => {
+      const create = vi
+        .spyOn(SliceTrack, 'create')
+        .mockReturnValue({} as ReturnType<typeof SliceTrack.create>);
+      const trace = {raf: {scheduleFullRedraw: vi.fn()}} as unknown as Trace;
+      createStackSampleTrack(
+        trace,
+        'samples',
+        {source: 'linux.perf', utid: 1},
+        undefined,
+        () => {},
+      );
+      const track = new CallstackTrack(trace, 'frames', {
+        source: 'linux.perf',
+        utid: 1,
+        upid: 1,
+      });
+      track.settings[0].update('mapping');
+      const instantColor = create.mock.calls[0][0].colorizer!;
+      const frameColor = create.mock.calls[1][0].colorizer!;
+      const row = {
+        id: 1,
+        ts: 10n,
+        dur: -1n,
+        depth: 0,
+        callsiteId: 2,
+        frameId: 3,
+        category,
+        mappingName: '/out/trace_processor_shell',
+        sampleCount: 1,
+      };
+      const expected = sampleColorScheme(category, row.mappingName);
+      expect(instantColor({...row, name: ''})).toEqual(expected);
+      expect(frameColor({...row, name: 'resolved_function'})).toEqual(expected);
+      if (category === 3) expect(expected).toEqual(GRAY);
+    },
+  );
+  test('function coloring is the default and can switch to mapping and back', () => {
+    const create = vi
+      .spyOn(SliceTrack, 'create')
+      .mockReturnValue({} as ReturnType<typeof SliceTrack.create>);
+    const scheduleFullRedraw = vi.fn();
+    const trace = {raf: {scheduleFullRedraw}} as unknown as Trace;
+    const track = new CallstackTrack(trace, 'frames', {
+      source: 'linux.perf',
+      utid: 1,
+      upid: 1,
+    });
+    const attrs = create.mock.calls[0][0];
+    const row = {
+      id: 1,
+      ts: 10n,
+      dur: -1n,
+      depth: 0,
+      frameId: 1,
+      name: 'work123',
+      category: 0,
+      mappingName: '/bin/program',
+      sampleCount: 1,
+    };
+    const functionKey = attrs.getKey!();
+    expect(track.settings[0].value).toBe('function');
+    const functionColor = getColorForSlice(row.name, {
+      stripTrailingDigits: false,
+    });
+    expect(attrs.colorizer!(row)).toEqual(functionColor);
+    expect(
+      attrs.colorizer!({...row, category: 1, mappingName: '/lib/libc.so'}),
+    ).toEqual(functionColor);
+    expect(scheduleFullRedraw).not.toHaveBeenCalled();
+    track.settings[0].update('mapping');
+    expect(attrs.getKey!()).not.toBe(functionKey);
+    expect(attrs.colorizer!(row)).toEqual(
+      sampleColorScheme(row.category, row.mappingName),
+    );
+    expect(scheduleFullRedraw).toHaveBeenCalledOnce();
+    track.settings[0].update('function');
+    expect(attrs.getKey!()).toBe(functionKey);
+    expect(attrs.colorizer!(row)).toEqual(functionColor);
+    expect(scheduleFullRedraw).toHaveBeenCalledTimes(2);
+  });
+
+  test('flamecharts share a bulk-edit descriptor but keep independent settings', () => {
+    vi.spyOn(SliceTrack, 'create').mockReturnValue(
+      {} as ReturnType<typeof SliceTrack.create>,
+    );
+    const trace = {raf: {scheduleFullRedraw: vi.fn()}} as unknown as Trace;
+    const first = new CallstackTrack(trace, 'first', {
+      source: 'linux.perf',
+      utid: 1,
+      upid: 1,
+    });
+    const second = new CallstackTrack(trace, 'second', {
+      source: 'linux.perf',
+      utid: 2,
+      upid: 1,
+    });
+    expect(first.settings[0].descriptor).toBe(second.settings[0].descriptor);
+    first.settings[0].update('mapping');
+    expect(second.settings[0].value).toBe('function');
+  });
+});
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/track_kinds.ts b/ui/src/plugins/dev.perfetto.StackSamples/track_kinds.ts
new file mode 100644
index 0000000..09b1525
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.StackSamples/track_kinds.ts
@@ -0,0 +1,16 @@
+// 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.
+
+export const STACK_SAMPLE_TRACK_KIND = 'StackSampleTrack';
+export const STACK_SAMPLE_FLAMECHART_TRACK_KIND = 'StackSampleFlamechartTrack';
diff --git a/ui/src/test/perf_event.test.ts b/ui/src/test/perf_event.test.ts
index f04d690..01f9d8f 100644
--- a/ui/src/test/perf_event.test.ts
+++ b/ui/src/test/perf_event.test.ts
@@ -29,26 +29,41 @@
 test('multiple callstack tracks', async () => {
   const grp = pth.locateTrack('surfaceflinger 558');
   await grp.scrollIntoViewIfNeeded();
-  await pth.toggleTrackGroup(grp);
+  await pth.expandTrackGroup(grp);
 
   await pth.waitForIdleAndScreenshot('perf_event_sf.png', {
     locator: page.locator('.pf-timeline-page__timeline'),
   });
 
   const processGrp = pth.locateTrack(
-    'surfaceflinger 558/Perf Process Callstacks',
+    'surfaceflinger 558/Process Callstacks',
     grp,
   );
   await processGrp.scrollIntoViewIfNeeded();
-  await pth.toggleTrackGroup(processGrp);
+  await pth.expandTrackGroup(processGrp);
   const threadGrp = pth.locateTrack(
-    'surfaceflinger 558/Thread 558 Perf Callstacks',
+    'surfaceflinger 558/Thread 558 Callstacks',
     grp,
   );
   await threadGrp.scrollIntoViewIfNeeded();
-  await pth.toggleTrackGroup(threadGrp);
+  await pth.expandTrackGroup(threadGrp);
 
   await pth.waitForIdleAndScreenshot('perf_event_sf_expanded.png', {
     locator: page.locator('.pf-timeline-page__timeline'),
   });
 });
+
+test('flamechart mapping colors', async () => {
+  const grp = pth.locateTrack('surfaceflinger 558');
+  const flamechart = grp
+    .locator('.pf-track[ref$="/Callstack flamechart"]')
+    .first();
+  await flamechart.scrollIntoViewIfNeeded();
+  await flamechart.locator('.pf-track__shell').first().hover();
+  await flamechart.getByTitle('Track options', {exact: true}).click();
+  await page.getByText('Color by', {exact: true}).click();
+  await page.getByText('Mapping', {exact: true}).click();
+  await pth.waitForIdleAndScreenshot('flamechart_mapping_colors.png', {
+    locator: page.locator('.pf-timeline-page__timeline'),
+  });
+});
diff --git a/ui/src/test/perfetto_ui_test_helper.ts b/ui/src/test/perfetto_ui_test_helper.ts
index 0d132cb..e832134 100644
--- a/ui/src/test/perfetto_ui_test_helper.ts
+++ b/ui/src/test/perfetto_ui_test_helper.ts
@@ -120,6 +120,14 @@
     await this.waitForPerfettoIdle();
   }
 
+  async expandTrackGroup(locator: Locator) {
+    const header = locator.locator(':scope > .pf-track__header');
+    const classes = await header.getAttribute('class');
+    if (!classes?.includes('pf-track__header--expanded')) {
+      await this.toggleTrackGroup(locator);
+    }
+  }
+
   locateTrack(name: string, trackGroup?: Locator): Locator {
     return (trackGroup ?? this.page).locator(`.pf-track[ref="${name}"]`);
   }