You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/04 19:37:52 UTC

(superset) branch master updated: feat(versioning): version-history UI (#41551)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new e103d92b489 feat(versioning): version-history UI (#41551)
e103d92b489 is described below

commit e103d92b4894807b46c975f8574debd2ede4b106
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Tue Aug 4 20:37:46 2026 +0100

    feat(versioning): version-history UI (#41551)
    
    Co-authored-by: Kamil Gabryjelski <ka...@gmail.com>
    Co-authored-by: Claude Opus 4.7 <no...@anthropic.com>
    Co-authored-by: Mike Bridge <mi...@ext.preset.io>
---
 docs/docs/using-superset/version-history.mdx       | 120 ++++
 docs/static/feature-flags.json                     |   6 +
 .../superset-ui-core/src/utils/featureFlags.ts     |   1 +
 .../tests/version-history/activity-log.spec.ts     | 175 +++++
 superset-frontend/spec/helpers/reducerIndex.ts     |   2 +
 superset-frontend/src/constants.ts                 |   6 +
 superset-frontend/src/dashboard/actions/hydrate.ts |  14 +-
 .../DashboardBuilder/DashboardBuilder.test.tsx     | 179 +++++-
 .../DashboardBuilder/DashboardBuilder.tsx          | 235 ++++++-
 .../DashboardBuilder/DashboardWrapper.tsx          |   2 +-
 .../src/dashboard/components/Header/index.tsx      |  19 +-
 .../Header/useHeaderActionsDropdownMenu.tsx        |  20 +-
 .../components/gridComponents/Tab/Tab.tsx          |  11 +-
 superset-frontend/src/dashboard/types.ts           |   3 +
 .../src/explore/actions/exploreActions.ts          |  17 +-
 .../src/explore/components/Control.tsx             |   8 +-
 .../explore/components/ControlPanelsContainer.tsx  |   9 +-
 .../components/ExploreChartHeader/index.tsx        |  27 +-
 .../ExploreViewContainer.test.tsx                  |  36 +-
 .../components/ExploreViewContainer/index.tsx      | 142 +++-
 .../src/explore/components/SaveModal.test.tsx      |  35 +-
 .../src/explore/components/SaveModal.tsx           |  44 +-
 .../controls/AnnotationLayerControl/index.tsx      |   7 +-
 .../DatasourceControl/DatasourceControl.test.tsx   |  48 ++
 .../controls/DatasourceControl/index.tsx           |  17 +-
 .../useExploreAdditionalActionsMenu/index.tsx      |  36 ++
 .../useExploreAdditionalActionsMenu.test.tsx       |  68 ++
 .../explore/exploreUtils/canOverwriteSlice.test.ts | 109 ++++
 .../src/explore/exploreUtils/canOverwriteSlice.ts  |  71 ++
 .../src/features/versionHistory/ActionRow.tsx      | 246 +++++++
 .../versionHistory/ChartVersionPreview.test.tsx    | 247 +++++++
 .../versionHistory/ChartVersionPreview.tsx         | 257 ++++++++
 .../versionHistory/CurrentVersionSection.tsx       | 163 +++++
 .../DashboardVersionHistory.test.tsx               | 255 ++++++++
 .../versionHistory/DashboardVersionHistory.tsx     | 282 ++++++++
 .../versionHistory/ExploreVersionHistory.test.tsx  | 338 ++++++++++
 .../versionHistory/ExploreVersionHistory.tsx       | 361 +++++++++++
 .../features/versionHistory/PreviewBanner.test.tsx | 247 +++++++
 .../src/features/versionHistory/PreviewBanner.tsx  | 129 ++++
 .../features/versionHistory/RelatedUpdateRow.tsx   | 183 ++++++
 .../versionHistory/RestoreConfirmModal.tsx         |  72 +++
 .../src/features/versionHistory/SaveGroupItem.tsx  | 386 +++++++++++
 .../versionHistory/VersionHistoryPanel.test.tsx    | 498 ++++++++++++++
 .../versionHistory/VersionHistoryPanel.tsx         | 343 ++++++++++
 .../src/features/versionHistory/api.test.ts        | 232 +++++++
 .../src/features/versionHistory/api.ts             | 396 ++++++++++++
 .../features/versionHistory/canRestoreDashboard.ts |  41 ++
 .../src/features/versionHistory/display.test.ts    | 421 ++++++++++++
 .../src/features/versionHistory/display.ts         | 398 ++++++++++++
 .../src/features/versionHistory/grouping.test.ts   | 440 +++++++++++++
 .../src/features/versionHistory/grouping.ts        | 276 ++++++++
 .../features/versionHistory/openRelated.test.ts    | 130 ++++
 .../src/features/versionHistory/openRelated.ts     |  64 ++
 .../versionHistory/previewReselect.test.tsx        | 134 ++++
 .../src/features/versionHistory/reducer.test.ts    | 147 +++++
 .../src/features/versionHistory/reducer.ts         | 294 +++++++++
 .../versionHistory/sessionLogMiddleware.test.ts    | 226 +++++++
 .../versionHistory/sessionLogMiddleware.ts         | 155 +++++
 .../src/features/versionHistory/types.ts           | 231 +++++++
 .../useDashboardVersionPreview.test.tsx            | 716 +++++++++++++++++++++
 .../versionHistory/useDashboardVersionPreview.ts   | 470 ++++++++++++++
 .../versionHistory/useVersionActions.test.tsx      | 338 ++++++++++
 .../features/versionHistory/useVersionActions.tsx  | 323 ++++++++++
 .../versionHistory/useVersionActivity.test.ts      | 424 ++++++++++++
 .../features/versionHistory/useVersionActivity.ts  | 281 ++++++++
 .../src/hooks/apiResources/dashboards.ts           |   2 +-
 superset-frontend/src/types/Chart.ts               |   2 +
 superset-frontend/src/utils/navigationUtils.ts     |  38 ++
 superset-frontend/src/views/store.ts               |  18 +-
 superset/config.py                                 |   5 +
 tests/integration_tests/superset_test_config.py    |   3 +
 71 files changed, 11548 insertions(+), 131 deletions(-)

diff --git a/docs/docs/using-superset/version-history.mdx b/docs/docs/using-superset/version-history.mdx
new file mode 100644
index 00000000000..59490e7b984
--- /dev/null
+++ b/docs/docs/using-superset/version-history.mdx
@@ -0,0 +1,120 @@
+---
+title: Version History
+hide_title: true
+sidebar_position: 8
+version: 1
+---
+
+# Version History
+
+Superset can record the history of a chart or dashboard as you edit it, and let
+you look at, restore, or branch from any earlier version.
+
+The panel lists each saved version with its author, timestamp, and a plain
+description of what changed — "Chart renamed to Q3 Revenue", "Added filter on
+'Region'" — rather than a raw diff. You can search the history and filter it
+down to changes on the entity itself or on the things it depends on.
+
+## Enabling it
+
+Two switches are involved, and both matter.
+
+| Setting | Type | Effect |
+| --- | --- | --- |
+| `VERSION_HISTORY` | Feature flag | Shows the version history UI |
+| `ENABLE_VERSIONING_CAPTURE` | Config value | Records versions as entities are saved |
+
+```python
+# superset_config.py
+FEATURE_FLAGS = {"VERSION_HISTORY": True}
+ENABLE_VERSIONING_CAPTURE = True
+```
+
+Both default to off. They are separate because capture is the expensive half:
+an operator may want to start recording history before exposing the UI, so that
+there is something to show when they do.
+
+Turning the UI on without capture gives a panel that reports "No history yet"
+and never fills, so enable capture first — or at the same time. History only
+accrues from the moment capture is switched on; earlier edits are not
+reconstructed.
+
+## Viewing history
+
+On a chart, open **… (actions) → View version history** in Explore. A dashboard
+uses the same entry in its own **… (actions)** menu.
+
+The entry appears for users who may edit the entity — the same rule the server
+applies to overwriting it: administrators, plus anyone whose subjects are among
+the entity's editors. Users who cannot edit do not see the panel.
+
+## Previewing a version
+
+Selecting a version loads it into the page so you can see it before deciding
+anything. While a preview is active:
+
+- A banner across the top marks the page as historical and names the version.
+- The dashboard or chart itself is inert — filters, controls, and chart
+  interactions are disabled, so nothing you click can be mistaken for editing
+  the live entity. You can still scroll, and still move between tabs.
+- Saving is unavailable. Preview never writes.
+
+Close the preview to return to the live version, along with whatever filter
+selections you had before you opened it.
+
+A dashboard preview reconstructs the layout as it was, including charts that
+have since been removed from the dashboard. A chart that has been deleted
+outright cannot be reconstructed; its slot shows a placeholder instead.
+
+Preview is a close approximation of the version, not a pixel-faithful replay.
+The dashboard's own saved properties — title, CSS, metadata, description,
+slug, certification, draft or published state, and theme — are shown as they
+were. Charts, though, render with their current definitions rather than the
+definitions they had at that version, and a dashboard's chart membership is
+reconstructed from the saved layout, so charts that belonged to the dashboard
+without appearing in the layout are not represented. Restore applies the full
+captured state, so a restored dashboard can differ from its preview in these
+respects.
+
+## Restoring a version
+
+**Restore** rewrites the entity back to the selected version. Two things are
+worth knowing:
+
+- **History stays append-only.** A restore is itself recorded as a new version,
+  so restoring never destroys the states you moved away from — you can always
+  restore forward again.
+- **A dashboard restore may be partial.** If the version referenced charts that
+  have since been deleted, the layout is restored without them and Superset
+  tells you how many were not reattached.
+
+Restoring is offered to the same users who may edit the entity, and is withheld
+from externally managed entities, whose source of truth lives outside Superset
+and would overwrite the restore on the next sync.
+
+## Opening a version as something new
+
+**Open as new** creates a brand-new chart or dashboard from the selected
+version and leaves the original untouched. This is the safer option when you
+want an old state back but are not certain you want to give up the current one.
+
+How much of the version comes across differs by entity, because the two copy
+paths do. A new chart is built from the version itself: its name,
+visualization type, dataset, controls, query context, description, and cache
+timeout. A new dashboard is a copy of the *live* dashboard with the version's
+title, CSS, metadata, and layout applied over it — the copy endpoint accepts
+only those fields, so properties such as description, slug, certification, and
+theme come from the dashboard as it stands today, not from the version.
+
+## What gets recorded
+
+Versions capture the entity's own saved state — a chart's name, visualization
+type, and controls; a dashboard's title, layout, filters, and properties.
+
+The timeline also shows **related** activity: changes to things the entity
+depends on, such as the dataset behind a chart. These are shown for context and
+cannot be restored from the entity's own history — restore acts on the entity
+you are looking at.
+
+Changes made outside a save, such as ownership or permission edits, are not part
+of version history.
diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json
index 7a7bcad85e5..1cf3c7fd0dd 100644
--- a/docs/static/feature-flags.json
+++ b/docs/static/feature-flags.json
@@ -104,6 +104,12 @@
         "default": false,
         "lifecycle": "development",
         "description": "Enables the tagging system for organizing assets"
+      },
+      {
+        "name": "VERSION_HISTORY",
+        "default": false,
+        "lifecycle": "development",
+        "description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty."
       }
     ],
     "testing": [
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index 35eb81cd7b4..ebc5ed9a532 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -66,6 +66,7 @@ export enum FeatureFlag {
   TaggingSystem = 'TAGGING_SYSTEM',
   Thumbnails = 'THUMBNAILS',
   UseAnalogousColors = 'USE_ANALOGOUS_COLORS',
+  VersionHistory = 'VERSION_HISTORY',
   ForceSqlLabRunAsync = 'SQLLAB_FORCE_RUN_ASYNC',
   SlackEnableAvatars = 'SLACK_ENABLE_AVATARS',
   EnableDashboardScreenshotEndpoints = 'ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS',
diff --git a/superset-frontend/playwright/tests/version-history/activity-log.spec.ts b/superset-frontend/playwright/tests/version-history/activity-log.spec.ts
new file mode 100644
index 00000000000..c1beff202f4
--- /dev/null
+++ b/superset-frontend/playwright/tests/version-history/activity-log.spec.ts
@@ -0,0 +1,175 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+/**
+ * Activity-log (version history) coverage for charts.
+ *
+ * Requires the running instance to have VERSION_HISTORY +
+ * ENABLE_VERSIONING_CAPTURE enabled (the docker dev stack does).
+ *
+ * The test creates its own chart and renames it twice via the authenticated
+ * REST API to guarantee a deterministic, non-"first tracked save" entry, then
+ * opens the Explore version-history panel and asserts the rendered entries —
+ * exercising the descriptive-row rendering (no raw layout node ids / UUIDs
+ * leaking into the timeline).
+ *
+ * It deliberately does not touch the seeded example charts. Renaming those
+ * made the test order-dependent (it picked the most recently changed charts),
+ * unsafe to run on parallel workers, and left version records behind that no
+ * revert could remove — version history being append-only is the point.
+ */
+import { APIRequestContext, Page } from '@playwright/test';
+import rison from 'rison';
+import { testWithAssets, expect } from '../../helpers/fixtures';
+import { apiGet } from '../../helpers/api/requests';
+import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
+import { getDatasetByName } from '../../helpers/api/dataset';
+import { TIMEOUT } from '../../utils/constants';
+
+const DATASET_NAME = 'birth_names';
+
+// Visible row text must never expose synthetic identifiers (layout node
+// ids like CHART-xyz / ROW-… or bare UUIDs) — the rendering layer maps
+// these to human names or kind-only phrasing.
+const OPAQUE_ID =
+  /\b(CHART|ROW|COLUMN|TAB|TABS|HEADER|MARKDOWN|DIVIDER|GRID)-|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
+
+/**
+ * Resolve the current user's USER-type subject id. Chart `editors` (and the
+ * `can_overwrite` gate derived from them) live in the Subject id space, not
+ * the user id space.
+ */
+async function currentUserSubjectId(
+  request: APIRequestContext,
+): Promise<number> {
+  const meRes = await request.get('/api/v1/me/');
+  expect(meRes.ok(), 'current user request').toBeTruthy();
+  const userId = (await meRes.json()).result.id;
+
+  const q = encodeURIComponent(
+    `(filters:!((col:user_id,opr:eq,value:${userId})))`,
+  );
+  const res = await request.get(`/api/v1/security/subject/?q=${q}`);
+  expect(res.ok(), 'subject lookup request').toBeTruthy();
+  const subjects = (await res.json()).result;
+  expect(
+    subjects.length,
+    `USER subject exists for user ${userId}`,
+  ).toBeGreaterThan(0);
+  return subjects[0].id;
+}
+
+/**
+ * Any dataset will do: the chart exists only to carry version records, and
+ * nothing here asserts on rendered data. Prefer the conventional example so
+ * this reads like its sibling specs, but fall back to whatever the instance
+ * has rather than requiring a particular fixture to be loaded.
+ */
+async function anyDatasetId(page: Page): Promise<number> {
+  const named = await getDatasetByName(page, DATASET_NAME);
+  if (named) {
+    return named.id;
+  }
+  const res = await apiGet(
+    page,
+    `api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
+  );
+  expect(res.ok(), 'dataset list request').toBeTruthy();
+  const [first] = (await res.json()).result;
+  expect(first, 'the instance has at least one dataset').toBeTruthy();
+  return first.id;
+}
+
+/** Open the Explore "Additional actions → View version history" panel. */
+async function openVersionHistory(page: Page): Promise<void> {
+  await page.locator('[data-test="actions-trigger"]').click();
+  await page.getByRole('menuitem', { name: 'View version history' }).click();
+  await expect(
+    page.locator('[aria-label="Version history"]'),
+    'version history panel opens',
+  ).toBeVisible();
+}
+
+testWithAssets(
+  'chart activity log renders deterministic descriptive entries',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+    const datasetId = await anyDatasetId(page);
+
+    const baseName = `version_history_${Date.now()}`;
+    const chartResp = await apiPostChart(page, {
+      slice_name: baseName,
+      viz_type: 'table',
+      datasource_id: datasetId,
+      datasource_type: 'table',
+      // Raw mode with no column references, so the chart is valid against any
+      // dataset.
+      params: JSON.stringify({
+        datasource: `${datasetId}__table`,
+        viz_type: 'table',
+        query_mode: 'raw',
+        all_columns: [],
+        adhoc_filters: [],
+        row_limit: 10,
+      }),
+    });
+    expect(chartResp.ok(), 'chart creation').toBeTruthy();
+    const chartBody = await chartResp.json();
+    const chartId: number = chartBody.result?.id ?? chartBody.id;
+    expect(chartId, 'chart creation should return an id').toBeTruthy();
+    testAssets.trackChart(chartId);
+
+    // Version history is only offered on charts the user can overwrite, i.e.
+    // can edit — so claim editorship. A chart created through the API starts
+    // without editors.
+    // Two renames: the first edit on an as-yet-untracked chart collapses into
+    // "first tracked save"; the second is a normal descriptive save.
+    const adminSubjectId = await currentUserSubjectId(page.request);
+    await apiPutChart(page, chartId, {
+      editors: [adminSubjectId],
+      slice_name: `${baseName} ·vh1`,
+    });
+    await apiPutChart(page, chartId, { slice_name: `${baseName} ·vh2` });
+
+    await page.goto(`explore/?slice_id=${chartId}`);
+    await openVersionHistory(page);
+
+    const panel = page.locator('[aria-label="Version history"]');
+    const groups = panel.locator('[data-test="version-history-save-group"]');
+    await expect(groups.first(), 'shows a save group').toBeVisible();
+
+    // The newest save is the live one and is tagged "Current".
+    await expect(panel.getByText('Current')).toBeVisible();
+
+    // Expand the newest group and confirm the rename rendered as a descriptive
+    // action row — not a raw field path.
+    await groups.first().getByRole('button').first().click();
+    const rows = panel.locator('[data-test="version-history-action-row"]');
+    await expect(rows.first(), 'shows action rows').toBeVisible();
+    await expect(panel.getByText(/Chart renamed to /).first()).toBeVisible();
+
+    // No synthetic identifiers may surface in any visible timeline row.
+    const panelText = (await panel.innerText()) ?? '';
+    expect(
+      OPAQUE_ID.test(panelText),
+      `panel leaks a raw id/uuid:\n${panelText}`,
+    ).toBeFalsy();
+  },
+);
diff --git a/superset-frontend/spec/helpers/reducerIndex.ts b/superset-frontend/spec/helpers/reducerIndex.ts
index a4e0021839a..034305ca5a0 100644
--- a/superset-frontend/spec/helpers/reducerIndex.ts
+++ b/superset-frontend/spec/helpers/reducerIndex.ts
@@ -30,6 +30,7 @@ import saveModal from 'src/explore/reducers/saveModalReducer';
 import explore from 'src/explore/reducers/exploreReducer';
 import sqlLab from 'src/SqlLab/reducers/sqlLab';
 import reports from 'src/features/reports/ReportModal/reducer';
+import versionHistory from 'src/features/versionHistory/reducer';
 import getBootstrapData from 'src/utils/getBootstrapData';
 
 const impressionId = (state = '') => state;
@@ -60,6 +61,7 @@ export default {
   sqlLab,
   localStorageUsageInKilobytes: noopReducer(0),
   reports,
+  versionHistory,
   common: noopReducer(common),
   user: noopReducer(user),
 };
diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts
index 6e2a5ca64a4..271e6890d47 100644
--- a/superset-frontend/src/constants.ts
+++ b/superset-frontend/src/constants.ts
@@ -111,6 +111,10 @@ export const URL_PARAMS = {
     name: 'edit',
     type: 'boolean',
   },
+  versionHistory: {
+    name: 'version_history',
+    type: 'boolean',
+  },
 } as const;
 
 export const RESERVED_CHART_URL_PARAMS: string[] = [
@@ -119,6 +123,7 @@ export const RESERVED_CHART_URL_PARAMS: string[] = [
   URL_PARAMS.datasourceId.name,
   URL_PARAMS.datasourceType.name,
   URL_PARAMS.datasetId.name,
+  URL_PARAMS.versionHistory.name,
 ];
 export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
   URL_PARAMS.nativeFilters.name,
@@ -126,6 +131,7 @@ export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
   URL_PARAMS.permalinkKey.name,
   URL_PARAMS.preselectFilters.name,
   URL_PARAMS.editMode.name,
+  URL_PARAMS.versionHistory.name,
 ];
 
 export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
diff --git a/superset-frontend/src/dashboard/actions/hydrate.ts b/superset-frontend/src/dashboard/actions/hydrate.ts
index 527b754e83c..d943ae5f80b 100644
--- a/superset-frontend/src/dashboard/actions/hydrate.ts
+++ b/superset-frontend/src/dashboard/actions/hydrate.ts
@@ -74,7 +74,7 @@ import {
 export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD';
 type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>;
 
-interface HydrateChartData {
+export interface HydrateChartData {
   slice_id: number;
   slice_url: string;
   slice_name: string;
@@ -86,7 +86,7 @@ interface HydrateChartData {
   changed_on: string;
 }
 
-interface HydrateDashboardData extends Dashboard {
+export interface HydrateDashboardData extends Dashboard {
   metadata: JsonObject;
   position_data: Record<string, LayoutItem> | null;
   [key: string]: unknown;
@@ -99,6 +99,13 @@ interface HydrateDashboardParams {
   dataMask: DataMaskStateWithId;
   activeTabs: string[] | null;
   chartStates: DashboardChartStates | null;
+  /**
+   * Forces edit mode rather than deriving it from the `edit` URL param.
+   * Version preview passes `false`: the param outlives the navigation that
+   * set it, so a dashboard opened with `?edit=true` would otherwise keep an
+   * live Save toolbar over a historical snapshot.
+   */
+  editMode?: boolean;
 }
 
 export const hydrateDashboard =
@@ -109,6 +116,7 @@ export const hydrateDashboard =
     dataMask,
     activeTabs,
     chartStates,
+    editMode: editModeOverride,
   }: HydrateDashboardParams) =>
   (dispatch: AppDispatch, getState: GetState): AnyAction => {
     const { user, common, dashboardState } = getState();
@@ -396,7 +404,7 @@ export const hydrateDashboard =
           css: dashboard.css || '',
           colorNamespace: metadata?.color_namespace || null,
           colorScheme: metadata?.color_scheme || null,
-          editMode: canEdit && editMode,
+          editMode: editModeOverride ?? (canEdit && editMode),
           isPublished: dashboard.published,
           hasUnsavedChanges: false,
           dashboardIsSaving: false,
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
index ef98369be1d..0bbc5850a13 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
@@ -42,7 +42,14 @@ import {
 } from 'spec/fixtures/mockDashboardLayout';
 import { storeWithState } from 'spec/fixtures/mockStore';
 import mockState from 'spec/fixtures/mockState';
-import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
+import {
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+} from 'src/dashboard/util/constants';
+import {
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+} from 'src/dashboard/util/componentTypes';
 import * as useNativeFiltersModule from './state';
 
 fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
@@ -786,3 +793,173 @@ test('should maintain layout when switching between tabs', async () => {
   expect(gridContainer).toBeInTheDocument();
   expect(tabPanels.length).toBeGreaterThan(0);
 });
+
+const dashboardPreviewState = {
+  isPanelOpen: true,
+  entityType: 'dashboard',
+  include: 'all',
+  preview: {
+    entityUuid: 'dash-uuid',
+    versionUuid: 'version-uuid',
+    transactionId: 7,
+    headline: 'Dec 5, 2025, 12:18 PM',
+    issuedAt: '2025-12-05T17:18:00',
+  },
+  sessionLog: [],
+  restoreCount: 0,
+  lastRestoredEntityUuid: null,
+};
+
+test('gates the dashboard grid while a version preview is active', async () => {
+  (useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
+    100,
+    jest.fn(),
+  ]);
+  (fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
+  (setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
+
+  const { findByTestId } = render(<DashboardBuilder />, {
+    useRedux: true,
+    store: storeWithState({
+      ...mockState,
+      dashboardLayout: undoableDashboardLayout,
+      versionHistory: dashboardPreviewState,
+    }),
+    useDnd: true,
+    useRouter: true,
+    useTheme: true,
+  });
+
+  const gate = await findByTestId('dashboard-grid-gate');
+  expect(gate).toHaveAttribute('aria-disabled', 'true');
+  expect(gate).toHaveStyleRule('pointer-events', 'none');
+  // Tab navigation is deliberately carved out of the gate so tabbed
+  // dashboards remain navigable while previewing.
+  expect(gate).toHaveStyleRule('pointer-events', 'auto', {
+    target: '.ant-tabs-nav',
+  });
+});
+
+test('does not gate the dashboard grid without an active preview', async () => {
+  (useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
+    100,
+    jest.fn(),
+  ]);
+  (fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
+  (setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
+
+  const { findByTestId } = render(<DashboardBuilder />, {
+    useRedux: true,
+    store: storeWithState({
+      ...mockState,
+      dashboardLayout: undoableDashboardLayout,
+    }),
+    useDnd: true,
+    useRouter: true,
+    useTheme: true,
+  });
+
+  const gate = await findByTestId('dashboard-grid-gate');
+  expect(gate).toHaveAttribute('aria-disabled', 'false');
+  expect(gate).not.toHaveStyleRule('pointer-events', 'none');
+});
+
+test('lets a keyboard user scroll a gated preview but not activate it', async () => {
+  // pointer-events: none stops the mouse but not the keyboard, so the gate
+  // suppresses keys. Suppressing *every* key made a long previewed dashboard
+  // unreadable without a mouse (WCAG 2.1.1): scrolling is not interaction.
+  (useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
+    100,
+    jest.fn(),
+  ]);
+  (fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
+  (setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
+
+  const { findByTestId } = render(<DashboardBuilder />, {
+    useRedux: true,
+    store: storeWithState({
+      ...mockState,
+      dashboardLayout: undoableDashboardLayout,
+      versionHistory: dashboardPreviewState,
+    }),
+    useDnd: true,
+    useRouter: true,
+    useTheme: true,
+  });
+  const gate = await findByTestId('dashboard-grid-gate');
+
+  ['PageDown', 'ArrowDown', 'Home', 'End'].forEach(key => {
+    expect(fireEvent.keyDown(gate, { key })).toBe(true);
+  });
+
+  // Activation still blocked — the gate's whole purpose.
+  expect(fireEvent.keyDown(gate, { key: 'Enter' })).toBe(false);
+});
+
+// The empty-state call to action renders above DashboardContentWrapper, so the
+// grid gate does not cover it. Previewing a version whose layout is empty would
+// otherwise offer a route into edit mode over a historical snapshot.
+const emptyDashboardLayout = {
+  past: [],
+  future: [],
+  present: {
+    [DASHBOARD_ROOT_ID]: {
+      type: DASHBOARD_ROOT_TYPE,
+      id: DASHBOARD_ROOT_ID,
+      children: [DASHBOARD_GRID_ID],
+    },
+    [DASHBOARD_GRID_ID]: {
+      type: DASHBOARD_GRID_TYPE,
+      id: DASHBOARD_GRID_ID,
+      children: [],
+      meta: {},
+    },
+  },
+};
+
+const renderEmptyDashboard = (
+  versionHistory?: typeof dashboardPreviewState,
+) => {
+  (useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
+    100,
+    jest.fn(),
+  ]);
+  (fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
+  (setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
+
+  return render(<DashboardBuilder />, {
+    useRedux: true,
+    store: storeWithState({
+      ...mockState,
+      dashboardInfo: { ...mockState.dashboardInfo, dash_edit_perm: true },
+      dashboardLayout: emptyDashboardLayout,
+      ...(versionHistory ? { versionHistory } : {}),
+    }),
+    useDnd: true,
+    useRouter: true,
+    useTheme: true,
+  });
+};
+
+test('offers the empty-state edit action to an editor', async () => {
+  const { findByRole } = renderEmptyDashboard();
+
+  expect(
+    await findByRole('button', { name: 'Edit the dashboard' }),
+  ).toBeVisible();
+});
+
+test('withholds the empty-state edit action while previewing a version', async () => {
+  const { findByText, queryByRole } = renderEmptyDashboard(
+    dashboardPreviewState,
+  );
+
+  // The empty-state message itself still describes what is on screen; only the
+  // edit affordance is withdrawn.
+  expect(
+    await findByText('There are no charts added to this dashboard'),
+  ).toBeInTheDocument();
+  expect(
+    queryByRole('button', { name: 'Edit the dashboard' }),
+  ).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
index cc5926f6ac6..e293bac2167 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
@@ -18,9 +18,24 @@
  */
 /* eslint-env browser */
 import cx from 'classnames';
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+  Suspense,
+  lazy,
+  memo,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
 import { t } from '@apache-superset/core/translation';
-import { addAlpha, JsonObject, useElementOnScreen } from '@superset-ui/core';
+import {
+  addAlpha,
+  isFeatureEnabled,
+  FeatureFlag,
+  JsonObject,
+  useElementOnScreen,
+} from '@superset-ui/core';
 import { css, styled, useTheme } from '@apache-superset/core/theme';
 import { useDispatch, useSelector } from 'react-redux';
 import { EmptyState, Loading } from '@superset-ui/core/components';
@@ -69,11 +84,22 @@ import {
   OPEN_FILTER_BAR_WIDTH,
   EMPTY_CONTAINER_Z_INDEX,
 } from 'src/dashboard/constants';
+import { selectCanRestoreDashboard } from 'src/features/versionHistory/canRestoreDashboard';
+import { selectIsDashboardVersionPreviewActive } from 'src/features/versionHistory/reducer';
 import { getRootLevelTabsComponent, shouldFocusTabs } from './utils';
 import DashboardContainer from './DashboardContainer';
 import { useNativeFilters } from './state';
 import DashboardWrapper from './DashboardWrapper';
 
+// Lazy-loaded so deployments with the VersionHistory flag off never pay
+// the bundle cost of the feature's component graph.
+const DashboardVersionHistory = lazy(
+  () => import('src/features/versionHistory/DashboardVersionHistory'),
+);
+const PreviewBanner = lazy(
+  () => import('src/features/versionHistory/PreviewBanner'),
+);
+
 // @z-index-above-dashboard-charts + 1 = 11
 const FiltersPanel = styled.div<{ width: number; hidden: boolean }>`
   background-color: ${({ theme }) => theme.colorBgContainer};
@@ -129,6 +155,17 @@ const StyledContent = styled.div<{
   ${({ fullSizeChartId }) => fullSizeChartId && `z-index: 101;`}
 `;
 
+// Sticks alongside the page scroll so the panel stays fully visible.
+const VersionHistoryColumn = styled.div`
+  grid-column: 3;
+  grid-row: 1 / span 2;
+  position: sticky;
+  top: 0;
+  align-self: start;
+  height: 100vh;
+  z-index: 99;
+`;
+
 const DashboardContentWrapper = styled.div`
   ${({ theme }) => css`
     &.dashboard {
@@ -142,8 +179,9 @@ const DashboardContentWrapper = styled.div`
       & .dashboard-component-tabs {
         box-shadow: 0 ${theme.sizeUnit}px ${theme.sizeUnit}px 0
           ${addAlpha(theme.colorBorderSecondary, 0.1)};
-        padding-left: ${theme.sizeUnit *
-        2}px; /* note this is added to tab-level padding, to match header */
+        padding-left: ${
+          theme.sizeUnit * 2
+        }px; /* note this is added to tab-level padding, to match header */
       }
 
       .dropdown-toggle.btn.btn-primary .caret {
@@ -274,8 +312,9 @@ const DashboardContentWrapper = styled.div`
 const StyledDashboardContent = styled.div<{
   editMode: boolean;
   marginLeft: number;
+  previewGated: boolean;
 }>`
-  ${({ theme, editMode, marginLeft }) => css`
+  ${({ theme, editMode, marginLeft, previewGated }) => css`
     background-color: ${theme.colorBgLayout};
     display: flex;
     flex-direction: row;
@@ -283,6 +322,22 @@ const StyledDashboardContent = styled.div<{
     height: auto;
     flex: 1;
 
+    ${
+      previewGated &&
+      `
+      /* Block chart interactions (context menus, cross-filters, drills)
+         while previewing a historical version. Tab bars deliberately stay
+         clickable: previewing a tabbed dashboard requires navigating its
+         tabs, and tab state is ephemeral — exiting the preview re-hydrates
+         and wipes it. Page scrolling is unaffected because the page, not
+         this subtree, owns the scroll. */
+      pointer-events: none;
+      .ant-tabs-nav {
+        pointer-events: auto;
+      }
+    `
+    }
+
     .grid-container .dashboard-component-tabs {
       box-shadow: none;
       padding-left: 0;
@@ -296,12 +351,14 @@ const StyledDashboardContent = styled.div<{
       margin: ${theme.sizeUnit * 4}px;
       margin-left: ${marginLeft}px;
 
-      ${editMode &&
-      `
+      ${
+        editMode &&
+        `
       max-width: calc(100% - ${
         BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
       }px);
-    `}
+    `
+      }
 
       /* this is the ParentSize wrapper */
       & > div:first-of-type {
@@ -338,8 +395,10 @@ const StyledDashboardContent = styled.div<{
       }
 
       &.fade-out {
-        box-shadow: ${theme.dashboardTileBoxShadow ??
-        `0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`};
+        box-shadow: ${
+          theme.dashboardTileBoxShadow ??
+          `0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`
+        };
       }
 
       & .missing-chart-container {
@@ -359,6 +418,20 @@ const StyledDashboardContent = styled.div<{
   `}
 `;
 
+/** Keys that only move the viewport. A mouse user can still scroll a gated
+ * preview with the wheel, so blocking the keyboard equivalent would leave a
+ * long dashboard unreadable by keyboard alone (WCAG 2.1.1). */
+const SCROLL_KEYS = new Set([
+  'ArrowUp',
+  'ArrowDown',
+  'ArrowLeft',
+  'ArrowRight',
+  'PageUp',
+  'PageDown',
+  'Home',
+  'End',
+]);
+
 const ELEMENT_ON_SCREEN_OPTIONS = {
   threshold: [1],
 };
@@ -380,6 +453,11 @@ const DashboardBuilder = () => {
   const canEdit = useSelector<RootState, boolean>(
     ({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
   );
+  // Deliberately not canEdit: restoring an externally managed dashboard would
+  // be undone by the next sync, and unlike editing there is no server-side
+  // check to fall back on. Shared with the history panel so the two cannot
+  // drift.
+  const canRestoreVersion = useSelector(selectCanRestoreDashboard);
   const dashboardIsSaving = useSelector<RootState, boolean>(
     ({ dashboardState }) => dashboardState.dashboardIsSaving,
   );
@@ -389,6 +467,14 @@ const DashboardBuilder = () => {
   const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
     ({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
   );
+  const isVersionPreviewActive = useSelector(
+    selectIsDashboardVersionPreviewActive,
+  );
+  // The empty-state call to action sits outside the preview gate below, so it
+  // needs its own term: a previewed version whose layout happens to be empty
+  // would otherwise offer "Edit the dashboard" over a historical snapshot and
+  // clear the undo history on the way in. Mirrors userCanEdit in the Header.
+  const canEnterEditMode = canEdit && !isVersionPreviewActive;
 
   const handleChangeTab = useCallback(
     ({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
@@ -529,14 +615,37 @@ const DashboardBuilder = () => {
         {hideDashboardHeader && !isReport && <HeadlessAutoRefresh />}
         {showFilterBar &&
           filterBarOrientation === FilterBarOrientation.Horizontal && (
-            <FilterBar
-              orientation={FilterBarOrientation.Horizontal}
-              hidden={hideFilterBar}
-            />
+            <div
+              data-test="dashboard-filter-bar-gate"
+              aria-disabled={isVersionPreviewActive}
+              css={css`
+                ${
+                  isVersionPreviewActive
+                    ? `
+                    pointer-events: none;
+                    opacity: 0.5;
+                  `
+                    : ''
+                }
+              `}
+              // inert blocks keyboard focus too; React 18 needs the spread form
+              {...(isVersionPreviewActive ? { inert: '' } : {})}
+            >
+              <FilterBar
+                orientation={FilterBarOrientation.Horizontal}
+                hidden={hideFilterBar}
+              />
+            </div>
           )}
       </>
     ),
-    [hideDashboardHeader, showFilterBar, filterBarOrientation, hideFilterBar],
+    [
+      hideDashboardHeader,
+      showFilterBar,
+      filterBarOrientation,
+      hideFilterBar,
+      isVersionPreviewActive,
+    ],
   );
 
   const renderDraggableContent = useCallback(
@@ -603,16 +712,34 @@ const DashboardBuilder = () => {
         >
           <StickyPanel ref={containerRef} width={filterBarWidth}>
             <ErrorBoundary>
-              <FilterBar
-                orientation={FilterBarOrientation.Vertical}
-                verticalConfig={{
-                  filtersOpen: dashboardFiltersOpen,
-                  toggleFiltersBar: toggleDashboardFiltersOpen,
-                  width: filterBarWidth,
-                  height: filterBarHeight,
-                  offset: filterBarOffset,
-                }}
-              />
+              <div
+                data-test="dashboard-filter-bar-gate"
+                aria-disabled={isVersionPreviewActive}
+                css={css`
+                  height: 100%;
+                  ${
+                    isVersionPreviewActive
+                      ? `
+                      pointer-events: none;
+                      opacity: 0.5;
+                    `
+                      : ''
+                  }
+                `}
+                // inert blocks keyboard focus too; React 18 needs the spread form
+                {...(isVersionPreviewActive ? { inert: '' } : {})}
+              >
+                <FilterBar
+                  orientation={FilterBarOrientation.Vertical}
+                  verticalConfig={{
+                    filtersOpen: dashboardFiltersOpen,
+                    toggleFiltersBar: toggleDashboardFiltersOpen,
+                    width: filterBarWidth,
+                    height: filterBarHeight,
+                    offset: filterBarOffset,
+                  }}
+                />
+              </div>
             </ErrorBoundary>
           </StickyPanel>
         </FiltersPanel>
@@ -624,6 +751,7 @@ const DashboardBuilder = () => {
       filterBarHeight,
       filterBarOffset,
       hideFilterBar,
+      isVersionPreviewActive,
     ],
   );
 
@@ -670,6 +798,14 @@ const DashboardBuilder = () => {
         </Droppable>
       </StyledHeader>
       <StyledContent fullSizeChartId={fullSizeChartId}>
+        {isFeatureEnabled(FeatureFlag.VersionHistory) && (
+          <Suspense fallback={null}>
+            <PreviewBanner
+              entityType="dashboard"
+              canRestore={canRestoreVersion}
+            />
+          </Suspense>
+        )}
         {!editMode &&
           !topLevelTabs &&
           dashboardLayout[DASHBOARD_GRID_ID]?.children?.length === 0 && (
@@ -677,12 +813,12 @@ const DashboardBuilder = () => {
               title={t('There are no charts added to this dashboard')}
               size="large"
               description={
-                canEdit &&
+                canEnterEditMode &&
                 t(
                   'Go to the edit mode to configure the dashboard and add charts',
                 )
               }
-              buttonText={canEdit && t('Edit the dashboard')}
+              buttonText={canEnterEditMode && t('Edit the dashboard')}
               buttonAction={() => {
                 dispatch(setEditMode(true));
                 dispatch(clearDashboardHistory());
@@ -698,6 +834,44 @@ const DashboardBuilder = () => {
             className="dashboard-content"
             editMode={editMode}
             marginLeft={dashboardContentMarginLeft}
+            data-test="dashboard-grid-gate"
+            aria-disabled={isVersionPreviewActive}
+            previewGated={isVersionPreviewActive}
+            onKeyDownCapture={event => {
+              if (!isVersionPreviewActive) {
+                return;
+              }
+              // pointer-events: none leaves the subtree tab-focusable, so
+              // focus-navigation keys must pass through — swallowing Tab
+              // would trap keyboard focus inside the gated grid
+              // (WCAG 2.1.2). Activation/typing keys stay blocked outside
+              // the tab navs, which remain interactive during preview.
+              if (event.key === 'Tab' || event.key === 'Escape') {
+                return;
+              }
+              // Scrolling is not interaction. A mouse user can still scroll a
+              // gated preview with the wheel, so blocking the keyboard
+              // equivalent would leave a long dashboard unreadable by
+              // keyboard alone (WCAG 2.1.1).
+              if (SCROLL_KEYS.has(event.key)) {
+                return;
+              }
+              // Space scrolls too, but only where it would not also press
+              // something: the gate exists to stop activation, and
+              // pointer-events does not cover the keyboard.
+              if (
+                event.key === ' ' &&
+                !(event.target as HTMLElement).closest(
+                  'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])',
+                )
+              ) {
+                return;
+              }
+              if (!(event.target as HTMLElement).closest('.ant-tabs-nav')) {
+                event.preventDefault();
+                event.stopPropagation();
+              }
+            }}
           >
             {showDashboard ? (
               missingInitialFilters.length > 0 ? (
@@ -733,6 +907,13 @@ const DashboardBuilder = () => {
           </StyledDashboardContent>
         </DashboardContentWrapper>
       </StyledContent>
+      {isFeatureEnabled(FeatureFlag.VersionHistory) && (
+        <VersionHistoryColumn>
+          <Suspense fallback={null}>
+            <DashboardVersionHistory />
+          </Suspense>
+        </VersionHistoryColumn>
+      )}
       {dashboardIsSaving && (
         <Loading
           css={css`
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx
index 8fd07069aff..f94490c9b15 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx
@@ -31,7 +31,7 @@ const StyledDiv = styled.div`
     background-color: ${theme.colorBgLayout};
     position: relative;
     display: grid;
-    grid-template-columns: auto 1fr;
+    grid-template-columns: auto 1fr auto;
     grid-template-rows: auto 1fr;
     flex: 1;
     /* Special cases */
diff --git a/superset-frontend/src/dashboard/components/Header/index.tsx b/superset-frontend/src/dashboard/components/Header/index.tsx
index 283ff04a112..c0ad92a2e69 100644
--- a/superset-frontend/src/dashboard/components/Header/index.tsx
+++ b/superset-frontend/src/dashboard/components/Header/index.tsx
@@ -60,6 +60,7 @@ import {
 } from 'src/features/reports/ReportModal/actions';
 import { PageHeaderWithActions } from '@superset-ui/core/components/PageHeaderWithActions';
 import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
+import { selectIsDashboardVersionPreviewActive } from 'src/features/versionHistory/reducer';
 import DashboardEmbedModal from '../EmbeddedModal';
 import OverwriteConfirm from '../OverwriteConfirm';
 import {
@@ -542,10 +543,21 @@ const Header = (): JSX.Element => {
 
   const metadataBar = useDashboardMetadataBar(dashboardInfo);
 
+  const isVersionPreviewActive = useSelector(
+    selectIsDashboardVersionPreviewActive,
+  );
   const userCanEdit =
-    dashboardInfo.dash_edit_perm && !dashboardInfo.is_managed_externally;
-  const userCanShare = !!dashboardInfo.dash_share_perm;
-  const userCanSaveAs = !!dashboardInfo.dash_save_perm;
+    dashboardInfo.dash_edit_perm &&
+    !dashboardInfo.is_managed_externally &&
+    !isVersionPreviewActive;
+  const userCanShare =
+    !!dashboardInfo.dash_share_perm && !isVersionPreviewActive;
+  // Gated on preview like userCanEdit above: the Save/Discard toolbar acts on
+  // whatever is currently hydrated, and during a preview that is the
+  // snapshot's layout -- so leaving this ungated is a route to writing a
+  // historical version over the live dashboard.
+  const userCanSaveAs =
+    !!dashboardInfo.dash_save_perm && !isVersionPreviewActive;
   const userCanCurate =
     isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
     findPermission('can_set_embedded', 'Dashboard', user.roles);
@@ -840,6 +852,7 @@ const Header = (): JSX.Element => {
         menuDropdownProps={{
           open: isDropdownVisible,
           onOpenChange: setIsDropdownVisible,
+          disabled: isVersionPreviewActive,
         }}
         additionalActionsMenu={menu}
         showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
index 8a1c09249f2..6aa8ab247ab 100644
--- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
+++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
@@ -18,8 +18,9 @@
  */
 import type { Dispatch, ReactElement, SetStateAction } from 'react';
 import { useState, useEffect, useCallback, useMemo } from 'react';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
 import { useHistory, useLocation } from 'react-router-dom';
+import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
 import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
 import { t } from '@apache-superset/core/translation';
 import { isEmpty } from 'lodash-es';
@@ -37,6 +38,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
 import { MenuKeys, RootState } from 'src/dashboard/types';
 import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
 import { usePermissions } from 'src/hooks/usePermissions';
+import { openVersionHistoryPanel } from 'src/features/versionHistory/reducer';
 
 export const useHeaderActionsMenu = ({
   customCss,
@@ -73,6 +75,7 @@ export const useHeaderActionsMenu = ({
   Dispatch<SetStateAction<boolean>>,
 ] => {
   const [isDropdownVisible, setIsDropdownVisible] = useState(false);
+  const dispatch = useDispatch();
   const { canExportImage } = usePermissions();
   const history = useHistory();
   const location = useLocation();
@@ -117,6 +120,9 @@ export const useHeaderActionsMenu = ({
         case MenuKeys.ManageEmbedded:
           manageEmbedded();
           break;
+        case MenuKeys.VersionHistory:
+          dispatch(openVersionHistoryPanel('dashboard'));
+          break;
         default:
           break;
       }
@@ -128,6 +134,7 @@ export const useHeaderActionsMenu = ({
       showPropertiesModal,
       showRefreshModal,
       manageEmbedded,
+      dispatch,
       history,
       location,
     ],
@@ -317,6 +324,17 @@ export const useHeaderActionsMenu = ({
       );
     }
 
+    if (
+      isFeatureEnabled(FeatureFlag.VersionHistory) &&
+      userCanEdit &&
+      !editMode
+    ) {
+      menuItems.push({
+        key: MenuKeys.VersionHistory,
+        label: t('View version history'),
+      });
+    }
+
     return (
       <Menu
         selectable={false}
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
index 04d6bb537e4..403f5145d1f 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
@@ -46,6 +46,7 @@ import {
   Droppable,
 } from 'src/dashboard/components/dnd/DragDroppable';
 import { TAB_TYPE } from 'src/dashboard/util/componentTypes';
+import { selectIsDashboardVersionPreviewActive } from 'src/features/versionHistory/reducer';
 import type { LayoutItem, RootState } from 'src/dashboard/types';
 import type {
   DropResult,
@@ -155,6 +156,14 @@ const Tab = (props: TabProps): ReactElement => {
   const canEdit = useSelector(
     (state: RootState) => state.dashboardInfo.dash_edit_perm,
   );
+  // The empty-state call to action offers a route into edit mode; during a
+  // version preview the grid gate blocks its activation, but it would still
+  // render as a live-looking link that silently does nothing. Withhold the
+  // affordance instead, as DashboardBuilder's empty state does.
+  const isVersionPreviewActive = useSelector(
+    selectIsDashboardVersionPreviewActive,
+  );
+  const canEnterEditMode = canEdit && !isVersionPreviewActive;
   const dashboardLayout = useSelector(
     (state: RootState) => state.dashboardLayout.present,
   );
@@ -329,7 +338,7 @@ const Tab = (props: TabProps): ReactElement => {
                       : t('There are no components added to this tab')
                   }
                   description={
-                    canEdit &&
+                    canEnterEditMode &&
                     (editMode ? (
                       <span>
                         {t('You can')}{' '}
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 06310d0fe77..084e557bbf8 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -123,6 +123,7 @@ export type DashboardState = {
   isFiltersRefreshing: boolean;
   hasUnsavedChanges: boolean;
   dashboardIsSaving: boolean;
+  lastModifiedTime?: number;
   colorScheme: string;
   sliceIds: number[];
   directPathLastUpdated: number;
@@ -169,6 +170,7 @@ export type DashboardState = {
 };
 export type DashboardInfo = {
   id: number;
+  uuid?: string;
   common: {
     conf: JsonObject;
   };
@@ -403,4 +405,5 @@ export enum MenuKeys {
   ManageEmailReports = 'manage_email_reports',
   ExportPivotXlsx = 'export_pivot_xlsx',
   EmbedCode = 'embed_code',
+  VersionHistory = 'version_history',
 }
diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts
index f1c2dda2818..5dc9098a554 100644
--- a/superset-frontend/src/explore/actions/exploreActions.ts
+++ b/superset-frontend/src/explore/actions/exploreActions.ts
@@ -98,8 +98,23 @@ export function setControlValue(
   controlName: string,
   value: any,
   validationErrors?: any[],
+  options?: {
+    /**
+     * Marks a dispatch that no user gesture produced — effects rewriting
+     * transferred controls, derived values set alongside another control,
+     * and similar. The version-history session log skips these so an
+     * untouched chart never reports unsaved changes the user didn't make.
+     */
+    programmatic?: boolean;
+  },
 ) {
-  return { type: SET_FIELD_VALUE, controlName, value, validationErrors };
+  return {
+    type: SET_FIELD_VALUE,
+    controlName,
+    value,
+    validationErrors,
+    programmatic: options?.programmatic ?? false,
+  };
 }
 
 export const SET_EXPLORE_CONTROLS = 'UPDATE_EXPLORE_CONTROLS';
diff --git a/superset-frontend/src/explore/components/Control.tsx b/superset-frontend/src/explore/components/Control.tsx
index eeb2be1ee91..4046da337e0 100644
--- a/superset-frontend/src/explore/components/Control.tsx
+++ b/superset-frontend/src/explore/components/Control.tsx
@@ -84,8 +84,12 @@ export default function Control(props: ControlProps) {
       !isEqual(props.value, props.default) &&
       resetOnHide
     ) {
-      // reset control value if setting to invisible
-      setControlValue?.(name, props.default);
+      // reset control value if setting to invisible. Programmatic: the
+      // user's gesture was whatever hid this control, not an edit of it —
+      // the version-history session log must not attribute it to them.
+      setControlValue?.(name, props.default, undefined, {
+        programmatic: true,
+      });
     }
   }, [
     name,
diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
index df15a4a1ac1..22618cd821d 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
@@ -403,7 +403,14 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
               props.controls[controlName].value,
             );
         if (shouldUpdateControls) {
-          props.actions.setControlValue(controlName, alteredControls);
+          props.actions.setControlValue(
+            controlName,
+            alteredControls,
+            undefined,
+            {
+              programmatic: true,
+            },
+          );
         }
       });
     }
diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
index 7f3c734d255..4cd3a4ceab1 100644
--- a/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
@@ -19,7 +19,7 @@
 import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
 import { useHistory } from 'react-router-dom';
 import type { Location } from 'history';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
 import {
   Tooltip,
   Button,
@@ -58,6 +58,7 @@ import { Slice } from 'src/types/Chart';
 import { ReportObject } from 'src/features/reports/types';
 import { User } from 'src/types/bootstrapTypes';
 import getBootstrapData from 'src/utils/getBootstrapData';
+import { selectIsChartVersionPreviewActive } from 'src/features/versionHistory/reducer';
 import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu';
 import { useExploreMetadataBar } from './useExploreMetadataBar';
 
@@ -125,6 +126,7 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
   isSaveModalVisible,
 }) => {
   const dispatch = useDispatch();
+  const isVersionPreviewActive = useSelector(selectIsChartVersionPreviewActive);
   const { latestQueryFormData, sliceFormData } = chart;
   const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
   const [isReportModalOpen, setIsReportModalOpen] = useState(false);
@@ -298,14 +300,22 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
     () => ({
       title: sliceName ?? '',
       canEdit:
-        !slice ||
-        canOverwrite ||
-        Boolean(slice?.editors?.some(editor => userSubjects.has(editor))),
+        !isVersionPreviewActive &&
+        (!slice ||
+          canOverwrite ||
+          Boolean(slice?.editors?.some(editor => userSubjects.has(editor)))),
       onSave: actions.updateChartTitle,
       placeholder: t('Add the name of the chart'),
       label: t('Chart title'),
     }),
-    [actions.updateChartTitle, canOverwrite, slice, sliceName, userSubjects],
+    [
+      actions.updateChartTitle,
+      canOverwrite,
+      isVersionPreviewActive,
+      slice,
+      sliceName,
+      userSubjects,
+    ],
   );
 
   const certificatiedBadgeProps = useMemo(
@@ -366,7 +376,7 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
           <Button
             buttonStyle="secondary"
             onClick={showModal}
-            disabled={saveDisabled}
+            disabled={saveDisabled || isVersionPreviewActive}
             data-test="query-save-button"
             css={saveButtonStyles}
             icon={<Icons.SaveOutlined />}
@@ -376,15 +386,16 @@ const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
         </div>
       </Tooltip>
     ),
-    [saveDisabled, showModal],
+    [isVersionPreviewActive, saveDisabled, showModal],
   );
 
   const menuDropdownProps = useMemo(
     () => ({
       open: isDropdownVisible,
       onOpenChange: setIsDropdownVisible,
+      disabled: isVersionPreviewActive,
     }),
-    [isDropdownVisible, setIsDropdownVisible],
+    [isDropdownVisible, isVersionPreviewActive, setIsDropdownVisible],
   );
 
   return (
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
index 65378889e34..f32f3b2ee5b 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
@@ -842,6 +842,8 @@ test('automatic axis title margin adjustment sets X axis margin to 30 when title
       expect(setControlValueSpy).toHaveBeenCalledWith(
         'x_axis_title_margin',
         30,
+        undefined,
+        { programmatic: true },
       );
     });
   } finally {
@@ -887,6 +889,8 @@ test('automatic axis title margin adjustment sets Y axis margin to 30 when title
       expect(setControlValueSpy).toHaveBeenCalledWith(
         'y_axis_title_margin',
         30,
+        undefined,
+        { programmatic: true },
       );
     });
   } finally {
@@ -927,7 +931,12 @@ test('automatic axis title margin adjustment resets X axis margin to 0 when titl
     store.dispatch(exploreActions.setControlValue('x_axis_title', ''));
 
     await waitFor(() => {
-      expect(setControlValueSpy).toHaveBeenCalledWith('x_axis_title_margin', 0);
+      expect(setControlValueSpy).toHaveBeenCalledWith(
+        'x_axis_title_margin',
+        0,
+        undefined,
+        { programmatic: true },
+      );
     });
   } finally {
     getChartControlPanelRegistry().remove('table');
@@ -967,7 +976,12 @@ test('automatic axis title margin adjustment resets Y axis margin to 0 when titl
     store.dispatch(exploreActions.setControlValue('y_axis_title', ''));
 
     await waitFor(() => {
-      expect(setControlValueSpy).toHaveBeenCalledWith('y_axis_title_margin', 0);
+      expect(setControlValueSpy).toHaveBeenCalledWith(
+        'y_axis_title_margin',
+        0,
+        undefined,
+        { programmatic: true },
+      );
     });
   } finally {
     getChartControlPanelRegistry().remove('table');
@@ -1067,7 +1081,12 @@ test('automatic axis title margin adjustment changes X axis margin when title is
     });
 
     // Should call setControlValue since margin is less than 30
-    expect(setControlValueSpy).toHaveBeenCalledWith('x_axis_title_margin', 30);
+    expect(setControlValueSpy).toHaveBeenCalledWith(
+      'x_axis_title_margin',
+      30,
+      undefined,
+      { programmatic: true },
+    );
   } finally {
     getChartControlPanelRegistry().remove('table');
     jest.restoreAllMocks();
@@ -1166,7 +1185,12 @@ test('automatic axis title margin adjustment changes Y axis margin when title is
     });
 
     // Should call setControlValue since margin is less than 30
-    expect(setControlValueSpy).toHaveBeenCalledWith('y_axis_title_margin', 30);
+    expect(setControlValueSpy).toHaveBeenCalledWith(
+      'y_axis_title_margin',
+      30,
+      undefined,
+      { programmatic: true },
+    );
   } finally {
     getChartControlPanelRegistry().remove('table');
     jest.restoreAllMocks();
@@ -1215,10 +1239,14 @@ test('automatic axis title margin adjustment handles both X and Y axis titles be
       expect(setControlValueSpy).toHaveBeenCalledWith(
         'x_axis_title_margin',
         30,
+        undefined,
+        { programmatic: true },
       );
       expect(setControlValueSpy).toHaveBeenCalledWith(
         'y_axis_title_margin',
         30,
+        undefined,
+        { programmatic: true },
       );
     });
   } finally {
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
index fa52e944e58..ce1a2d77fae 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
@@ -19,6 +19,8 @@
 /* eslint camelcase: 0 */
 import {
   ComponentType,
+  Suspense,
+  lazy,
   memo,
   useCallback,
   useEffect,
@@ -33,6 +35,8 @@ import {
   useComponentDidMount,
   usePrevious,
   isMatrixifyEnabled,
+  isFeatureEnabled,
+  FeatureFlag,
   QueryFormData,
   JsonObject,
   MatrixifyFormData,
@@ -96,6 +100,7 @@ import {
 } from 'src/explore/types';
 import { Slice } from 'src/types/Chart';
 import { User } from 'src/types/bootstrapTypes';
+import { selectIsChartVersionPreviewActive } from 'src/features/versionHistory/reducer';
 import ExploreChartPanel from '../ExploreChartPanel';
 import ConnectedControlPanelsContainer from '../ControlPanelsContainer';
 import SaveModal from '../SaveModal';
@@ -103,6 +108,15 @@ import DataSourcePanel from '../DatasourcePanel';
 import ConnectedExploreChartHeader from '../ExploreChartHeader';
 import ExploreContainer from '../ExploreContainer';
 
+// Lazy-loaded so deployments with the VersionHistory flag off never pay
+// the bundle cost of the feature's component graph.
+const ExploreVersionHistory = lazy(
+  () => import('src/features/versionHistory/ExploreVersionHistory'),
+);
+const ChartVersionPreview = lazy(
+  () => import('src/features/versionHistory/ChartVersionPreview'),
+);
+
 const ExplorePanelContainer = styled.div`
   ${({ theme }) => css`
     text-align: left;
@@ -414,6 +428,9 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
   );
   const tabId = useTabId();
   const history = useHistory();
+  const isChartVersionPreviewActive = useSelector(
+    selectIsChartVersionPreviewActive,
+  );
 
   const theme = useTheme();
 
@@ -521,6 +538,9 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
   );
 
   const onQuery = useCallback(() => {
+    if (isChartVersionPreviewActive) {
+      return;
+    }
     props.actions.setForceQuery(false);
 
     // Skip main query if Matrixify is enabled
@@ -545,6 +565,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
     props.actions,
     props.chart.id,
     props.form_data,
+    isChartVersionPreviewActive,
   ]);
 
   const handleKeydown = useCallback(
@@ -748,7 +769,12 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
               (currentTemplate ? ' ' : '') +
               newVariables.join(' ');
 
-            props.actions.setControlValue('tooltip_template', updatedTemplate);
+            props.actions.setControlValue(
+              'tooltip_template',
+              updatedTemplate,
+              undefined,
+              { programmatic: true },
+            );
           }
         }
       }
@@ -761,9 +787,13 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
         );
 
         if (xAxisTitle && currentMargin < 30) {
-          props.actions.setControlValue('x_axis_title_margin', 30);
+          props.actions.setControlValue('x_axis_title_margin', 30, undefined, {
+            programmatic: true,
+          });
         } else if (!xAxisTitle && currentMargin !== 0) {
-          props.actions.setControlValue('x_axis_title_margin', 0);
+          props.actions.setControlValue('x_axis_title_margin', 0, undefined, {
+            programmatic: true,
+          });
         }
       }
 
@@ -774,9 +804,13 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
         );
 
         if (yAxisTitle && currentMargin < 30) {
-          props.actions.setControlValue('y_axis_title_margin', 30);
+          props.actions.setControlValue('y_axis_title_margin', 30, undefined, {
+            programmatic: true,
+          });
         } else if (!yAxisTitle && currentMargin !== 0) {
-          props.actions.setControlValue('y_axis_title_margin', 0);
+          props.actions.setControlValue('y_axis_title_margin', 0, undefined, {
+            programmatic: true,
+          });
         }
       }
 
@@ -1072,15 +1106,32 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
               onClick={toggleCollapse}
             />
           </div>
-          {/* eslint-disable @typescript-eslint/no-explicit-any -- DataSourcePanel uses narrower types that are compatible at runtime */}
-          <DataSourcePanel
-            formData={props.form_data}
-            datasource={props.datasource as any}
-            controls={props.controls as any}
-            actions={props.actions as any}
-            width={width}
-          />
-          {/* eslint-enable @typescript-eslint/no-explicit-any */}
+          <div
+            data-test="explore-datasource-gate"
+            aria-disabled={isChartVersionPreviewActive}
+            css={css`
+              height: 100%;
+              ${
+                isChartVersionPreviewActive
+                  ? `
+                  pointer-events: none;
+                  opacity: 0.5;
+                `
+                  : ''
+              }
+            `}
+            {...(isChartVersionPreviewActive ? { inert: '' } : {})}
+          >
+            {/* eslint-disable @typescript-eslint/no-explicit-any -- DataSourcePanel uses narrower types that are compatible at runtime */}
+            <DataSourcePanel
+              formData={props.form_data}
+              datasource={props.datasource as any}
+              controls={props.controls as any}
+              actions={props.actions as any}
+              width={width}
+            />
+            {/* eslint-enable @typescript-eslint/no-explicit-any */}
+          </div>
         </Resizable>
         {isCollapsed ? (
           <button
@@ -1125,22 +1176,40 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
           enable={{ right: true }}
           className="col-sm-3 explore-column controls-column"
         >
-          <ConnectedControlPanelsContainer
-            exploreState={props.exploreState}
-            // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Combined actions type is compatible at runtime
-            actions={props.actions as any}
-            form_data={props.form_data}
-            controls={props.controls}
-            chart={props.chart}
-            datasource_type={props.datasource_type}
-            isDatasourceMetaLoading={props.isDatasourceMetaLoading}
-            onQuery={onQuery}
-            onStop={onStop}
-            canStopQuery={props.can_add || props.can_overwrite}
-            errorMessage={dataTabErrorMessage}
-            buttonErrorMessage={errorMessage}
-            chartIsStale={chartIsStale}
-          />
+          <div
+            data-test="explore-controls-gate"
+            aria-disabled={isChartVersionPreviewActive}
+            css={css`
+              height: 100%;
+              ${
+                isChartVersionPreviewActive
+                  ? `
+                  pointer-events: none;
+                  opacity: 0.5;
+                `
+                  : ''
+              }
+            `}
+            // inert blocks keyboard focus too; React 18 needs the spread form
+            {...(isChartVersionPreviewActive ? { inert: '' } : {})}
+          >
+            <ConnectedControlPanelsContainer
+              exploreState={props.exploreState}
+              // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Combined actions type is compatible at runtime
+              actions={props.actions as any}
+              form_data={props.form_data}
+              controls={props.controls}
+              chart={props.chart}
+              datasource_type={props.datasource_type}
+              isDatasourceMetaLoading={props.isDatasourceMetaLoading}
+              onQuery={onQuery}
+              onStop={onStop}
+              canStopQuery={props.can_add || props.can_overwrite}
+              errorMessage={dataTabErrorMessage}
+              buttonErrorMessage={errorMessage}
+              chartIsStale={chartIsStale}
+            />
+          </div>
         </Resizable>
         <div
           className={cx(
@@ -1148,8 +1217,19 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
             isCollapsed ? 'col-sm-9' : 'col-sm-7',
           )}
         >
-          {renderChartContainer()}
+          {isChartVersionPreviewActive ? (
+            <Suspense fallback={null}>
+              <ChartVersionPreview />
+            </Suspense>
+          ) : (
+            renderChartContainer()
+          )}
         </div>
+        {isFeatureEnabled(FeatureFlag.VersionHistory) && (
+          <Suspense fallback={null}>
+            <ExploreVersionHistory />
+          </Suspense>
+        )}
       </ExplorePanelContainer>
       {props.isSaveModalVisible && (
         <SaveModal
diff --git a/superset-frontend/src/explore/components/SaveModal.test.tsx b/superset-frontend/src/explore/components/SaveModal.test.tsx
index 20c3c7977dd..74f1b4da840 100644
--- a/superset-frontend/src/explore/components/SaveModal.test.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.test.tsx
@@ -358,10 +358,17 @@ test('enables overwrite option for admin non-editor', () => {
   expect(getByRole('radio', { name: 'Save (Overwrite)' })).toBeEnabled();
 });
 
-test('enables overwrite option for owner when owners are objects', () => {
-  // The Slice type declares `owners` as `number[]`, but the explore
-  // bootstrap can deliver owners as `{ id: number }` objects at runtime.
-  // A chart owner must still be able to overwrite their own chart in that case.
+test('disables overwrite for an owner who is not an editor', () => {
+  // Ownership alone does not confer overwrite. UpdateChartCommand gates on
+  // raise_for_editorship, and is_editor resolves to admin or the user's
+  // subjects intersected with editors/extra_editors -- owners are not
+  // consulted -- so an owner who is not an editor is answered 403 by the API.
+  // The modal previously enabled Save (Overwrite) for them anyway and only
+  // surfaced the refusal after the request.
+  //
+  // editors is cleared explicitly: the shared fixture grants editorship to
+  // subject 1, which would satisfy the gate by a different route and leave
+  // this assertion passing whatever the owner handling did.
   const { getByRole } = setup(
     {},
     mockStore({
@@ -370,6 +377,26 @@ test('enables overwrite option for owner when owners are objects', () => {
         ...initialState.explore,
         slice: {
           ...initialState.explore.slice,
+          editors: [],
+          owners: [{ id: 1 }],
+        },
+      },
+      user: { userId: 1 },
+    }),
+  );
+  expect(getByRole('radio', { name: 'Save (Overwrite)' })).toBeDisabled();
+});
+
+test('enables overwrite for an owner who is also an editor', () => {
+  const { getByRole } = setup(
+    {},
+    mockStore({
+      ...initialState,
+      explore: {
+        ...initialState.explore,
+        slice: {
+          ...initialState.explore.slice,
+          editors: [{ id: 1 }],
           owners: [{ id: 1 }],
         },
       },
diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx
index 1d0bf9e2740..aaa80a4472c 100755
--- a/superset-frontend/src/explore/components/SaveModal.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.tsx
@@ -52,10 +52,7 @@ import { css, styled, useTheme } from '@apache-superset/core/theme';
 import { Alert } from '@apache-superset/core/components';
 import { Radio } from '@superset-ui/core/components/Radio';
 import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants';
-import {
-  canUserEditDashboard,
-  isUserAdmin,
-} from 'src/dashboard/util/permissionUtils';
+import { canUserEditDashboard } from 'src/dashboard/util/permissionUtils';
 import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions';
 import {
   SaveActionType,
@@ -68,7 +65,7 @@ import {
   updateChartState,
 } from 'src/dashboard/actions/dashboardState';
 import { Dashboard } from 'src/types/Dashboard';
-import getBootstrapData from 'src/utils/getBootstrapData';
+import { canOverwriteSlice as canOverwriteSliceFor } from 'src/explore/exploreUtils/canOverwriteSlice';
 import { TabNode, TabTreeNode } from '../types';
 import { CHART_WIDTH, CHART_HEIGHT } from 'src/dashboard/constants';
 
@@ -228,38 +225,11 @@ const SaveModal = ({
   const history = useHistory();
   const theme = useTheme();
 
-  const isCurrentUserOwner = useCallback((): boolean => {
-    const userId = user?.userId;
-    if (userId === undefined) {
-      return false;
-    }
-    // The Slice type declares `owners` as `number[]`, but the explore
-    // bootstrap payload can carry owners as API `Owner` objects (`{ id }`)
-    // instead, so normalize to the numeric id before comparing.
-    return Boolean(
-      slice?.owners?.some((owner: number | { id?: number }) =>
-        typeof owner === 'number' ? owner === userId : owner?.id === userId,
-      ),
-    );
-  }, [slice, user]);
-
-  const canOverwriteSlice = useCallback((): boolean => {
-    const userSubjects = getBootstrapData()?.common?.user_subjects ?? [];
-    const canEditSlice = Boolean(
-      slice?.editors?.some((editor: { id: number } | number) =>
-        userSubjects.includes(typeof editor === 'number' ? editor : editor.id),
-      ),
-    );
-
-    return (
-      !!slice &&
-      (can_overwrite ||
-        isUserAdmin(user) ||
-        canEditSlice ||
-        isCurrentUserOwner()) &&
-      !slice?.is_managed_externally
-    );
-  }, [can_overwrite, slice, user, isCurrentUserOwner]);
+  const canOverwriteSlice = useCallback(
+    (): boolean =>
+      canOverwriteSliceFor({ slice, user, canOverwrite: can_overwrite }),
+    [can_overwrite, slice, user],
+  );
 
   const [newSliceName, setNewSliceName] = useState<string | undefined>(
     sliceName,
diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx
index 880f97af682..dffdd8e62f9 100644
--- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx
@@ -111,7 +111,12 @@ function AnnotationLayerControl({
       (Object.keys(annotationError).length && !validationErrors.length) ||
       (!Object.keys(annotationError).length && validationErrors.length)
     ) {
-      actions.setControlValue(name, value, Object.keys(annotationError));
+      actions.setControlValue(name, value, Object.keys(annotationError), {
+        // Re-publishes the same `value` with fresh validation errors after an
+        // annotation query resolves; no user gesture produced it, so it must
+        // not report an unsaved edit on an untouched chart.
+        programmatic: true,
+      });
     }
   }, [annotationError, validationErrors, value, actions, name]);
 
diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
index 41335f43d7b..98debbb906e 100644
--- a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
+++ b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
@@ -462,6 +462,8 @@ test('should set the default temporal column', async () => {
     expect(props.actions.setControlValue).toHaveBeenCalledWith(
       'granularity_sqla',
       'test-default',
+      undefined,
+      { programmatic: true },
     );
   });
 });
@@ -498,6 +500,8 @@ test('should set the first available temporal column', async () => {
     expect(props.actions.setControlValue).toHaveBeenCalledWith(
       'granularity_sqla',
       'test-first',
+      undefined,
+      { programmatic: true },
     );
   });
 });
@@ -534,10 +538,54 @@ test('should not set the temporal column', async () => {
     expect(props.actions.setControlValue).toHaveBeenCalledWith(
       'granularity_sqla',
       null,
+      undefined,
+      { programmatic: true },
     );
   });
 });
 
+test('editing a dataset still emits a dirty signal for the restore gate', async () => {
+  // The derived granularity_sqla write is programmatic, so it is invisible to
+  // the version-history session log. On the *swap* route that is fine —
+  // ChangeDatasourceModal's own onChange emits a recorded control change. The
+  // Edit Dataset route has no such change, so the reconciliation dispatched by
+  // changeDatasource is the only thing standing between an edited chart and a
+  // restore that silently discards the reconciled value.
+  const props = createProps();
+  const overrideProps = {
+    ...props,
+    form_data: { granularity_sqla: 'test-col' },
+    datasource: {
+      ...props.datasource,
+      main_dttm_col: 'test-default',
+      columns: [
+        { column_name: 'test-col', is_dttm: false },
+        { column_name: 'test-default', is_dttm: true },
+      ],
+    },
+  };
+  render(<DatasourceControl {...props} {...overrideProps} />, {
+    useRedux: true,
+    useRouter: true,
+  });
+
+  await openAndSaveChanges(overrideProps.datasource);
+
+  await waitFor(() => {
+    expect(props.actions.setControlValue).toHaveBeenCalledWith(
+      'granularity_sqla',
+      'test-default',
+      undefined,
+      { programmatic: true },
+    );
+  });
+  // changeDatasource dispatches UPDATE_FORM_DATA_BY_DATASOURCE (pinned in
+  // datasourcesActions.test.ts), which the session-log middleware records.
+  expect(props.actions.changeDatasource).toHaveBeenCalledWith(
+    expect.objectContaining({ id: overrideProps.datasource.id }),
+  );
+});
+
 test('should show missing params state', () => {
   const props = createProps({ datasource: fallbackExploreInitialData.dataset });
   render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
index 1f0a75f5a87..bd0468134f0 100644
--- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
@@ -85,7 +85,12 @@ interface User {
 
 interface DatasourceControlActions {
   changeDatasource: (datasource: ExtendedDatasource) => void;
-  setControlValue: (name: string, value: unknown) => void;
+  setControlValue: (
+    name: string,
+    value: unknown,
+    validationErrors?: unknown[],
+    options?: { programmatic?: boolean },
+  ) => void;
 }
 
 interface FormData {
@@ -262,7 +267,15 @@ export default function DatasourceControl({
         const temporalColumn = isDefaultTemporal
           ? defaultTemporalColumn
           : temporalColumns?.[0];
-        actions.setControlValue('granularity_sqla', temporalColumn || null);
+        actions.setControlValue(
+          'granularity_sqla',
+          temporalColumn || null,
+          undefined,
+          {
+            // Derived alongside the datasource change, not a gesture of its own.
+            programmatic: true,
+          },
+        );
       }
 
       if (onDatasourceSave) {
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index a40bc890917..b182d0bfeaa 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -54,6 +54,9 @@ import copyTextToClipboard from 'src/utils/copy';
 import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
 import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip';
 import { logEvent } from 'src/logger/actions';
+import { openVersionHistoryPanel } from 'src/features/versionHistory/reducer';
+import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { canOverwriteSlice } from 'src/explore/exploreUtils/canOverwriteSlice';
 import {
   LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
   LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG,
@@ -109,6 +112,7 @@ const MENU_KEYS = {
   DELETE_REPORT: 'delete_report',
   VIEW_QUERY: 'view_query',
   RUN_IN_SQL_LAB: 'run_in_sql_lab',
+  VERSION_HISTORY: 'version_history',
 };
 
 const VIZ_TYPES_PIVOTABLE = [VizType.PivotTable];
@@ -274,12 +278,14 @@ interface ExploreState {
   explore?: ExploreSlice & {
     chartStates?: Record<number, JsonObject>;
     can_export_image?: boolean;
+    can_overwrite?: boolean;
   };
   common?: {
     conf?: {
       CSV_STREAMING_ROW_THRESHOLD?: number;
     };
   };
+  user?: UserWithPermissionsAndRoles;
 }
 
 export type UseExploreAdditionalActionsMenuReturn = [
@@ -326,6 +332,20 @@ export const useExploreAdditionalActionsMenu = (
   const canExportImage = useSelector<ExploreState, boolean>(
     state => state.explore?.can_export_image ?? false,
   );
+  const canOverwrite = useSelector<ExploreState, boolean>(
+    state => state.explore?.can_overwrite ?? false,
+  );
+  const user = useSelector<
+    ExploreState,
+    UserWithPermissionsAndRoles | undefined
+  >(state => state.user);
+  // `can_overwrite` alone hides version history on any chart without explicit
+  // editors — every seeded chart — even from admins. Same predicate SaveModal
+  // uses, so a user who can save a chart can also see its history.
+  const canModifySlice = useMemo(
+    () => canOverwriteSlice({ slice, user, canOverwrite }),
+    [slice, user, canOverwrite],
+  );
 
   const dataExportDisabled = !canDownloadCSV;
   const imageExportDisabled = !canExportImage;
@@ -1007,6 +1027,21 @@ export const useExploreAdditionalActionsMenu = (
       menuItems.push(reportMenuItem);
     }
 
+    if (
+      isFeatureEnabled(FeatureFlag.VersionHistory) &&
+      canModifySlice &&
+      slice?.slice_id
+    ) {
+      menuItems.push({
+        key: MENU_KEYS.VERSION_HISTORY,
+        label: t('View version history'),
+        onClick: () => {
+          dispatch(openVersionHistoryPanel('chart'));
+          setIsDropdownVisible(false);
+        },
+      });
+    }
+
     // View query
     menuItems.push({
       key: MENU_KEYS.VIEW_QUERY,
@@ -1049,6 +1084,7 @@ export const useExploreAdditionalActionsMenu = (
   }, [
     addDangerToast,
     canDownloadCSV,
+    canModifySlice,
     copyLink,
     dashboards,
     dashboardMenuItems,
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
index d4d57c7a043..34f0efd2c7f 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
@@ -17,6 +17,7 @@
  * under the License.
  */
 import { ComponentType } from 'react';
+import { FeatureFlag } from '@superset-ui/core';
 import { render, screen, waitFor } from 'spec/helpers/testing-library';
 import userEvent from '@testing-library/user-event';
 import downloadAsImage from 'src/utils/downloadAsImage';
@@ -249,3 +250,70 @@ test('getExportScreenshotMenuItems PDF option calls downloadAsPdf and dispatches
   expect(setIsDropdownVisible).toHaveBeenCalledWith(false);
   expect(dispatch).toHaveBeenCalledTimes(1);
 });
+
+/**
+ * The version-history gate, exercised against the state shape hydrateExplore
+ * actually produces rather than a mocked can_overwrite. Every seeded chart
+ * ships with an empty editors list, so a membership-only gate hid the action
+ * from everyone on a fresh install.
+ */
+const renderMenuFor = (
+  slice: Record<string, unknown>,
+  user: Record<string, unknown>,
+) =>
+  render(<TestComponent {...defaultProps} slice={slice as never} />, {
+    useRedux: true,
+    initialState: {
+      user,
+      explore: { can_overwrite: false, slice },
+    },
+  });
+
+const adminUser = {
+  userId: 1,
+  username: 'admin',
+  permissions: {},
+  roles: { Admin: [] },
+};
+
+const gammaUser = {
+  userId: 2,
+  username: 'gamma',
+  permissions: {},
+  roles: { Gamma: [] },
+};
+
+test('an admin sees version history on a chart that has no editors', async () => {
+  window.featureFlags = { [FeatureFlag.VersionHistory]: true };
+
+  renderMenuFor(
+    { slice_id: 1, slice_name: 'Test Chart', editors: [] },
+    adminUser,
+  );
+
+  expect(await screen.findByText('View version history')).toBeInTheDocument();
+});
+
+test('a non-editor without the admin role does not', async () => {
+  window.featureFlags = { [FeatureFlag.VersionHistory]: true };
+
+  renderMenuFor(
+    { slice_id: 1, slice_name: 'Test Chart', editors: [] },
+    gammaUser,
+  );
+
+  await screen.findByText('View query');
+  expect(screen.queryByText('View version history')).not.toBeInTheDocument();
+});
+
+test('the item stays hidden while the feature flag is off', async () => {
+  window.featureFlags = { [FeatureFlag.VersionHistory]: false };
+
+  renderMenuFor(
+    { slice_id: 1, slice_name: 'Test Chart', editors: [] },
+    adminUser,
+  );
+
+  await screen.findByText('View query');
+  expect(screen.queryByText('View version history')).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.test.ts b/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.test.ts
new file mode 100644
index 00000000000..3eaff8ee3da
--- /dev/null
+++ b/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.test.ts
@@ -0,0 +1,109 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { canOverwriteSlice } from './canOverwriteSlice';
+
+// The viewer's subject ids: subject 7 is them, 8 is a role or group they hold.
+jest.mock('src/utils/getBootstrapData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({ common: { user_subjects: [7, 8] } })),
+}));
+
+// isUserAdmin only recognises a fully-shaped bootstrap user: username,
+// permissions and roles must all be present or the type guard rejects it.
+const admin = {
+  userId: 1,
+  username: 'admin',
+  permissions: {},
+  roles: { Admin: [] },
+} as unknown as UserWithPermissionsAndRoles;
+
+const gamma = {
+  userId: 2,
+  username: 'gamma',
+  permissions: {},
+  roles: { Gamma: [] },
+} as unknown as UserWithPermissionsAndRoles;
+
+test('an admin may act on a chart that has no explicit editors', () => {
+  // The regression this guards: every seeded and example chart ships with an
+  // empty editors list, so a membership-only check hid the action from
+  // everyone, admins included, on a fresh install.
+  expect(canOverwriteSlice({ slice: { editors: [] }, user: admin })).toBe(true);
+});
+
+test('a non-editor without the admin role may not', () => {
+  expect(canOverwriteSlice({ slice: { editors: [] }, user: gamma })).toBe(
+    false,
+  );
+});
+
+test('a subject listed among the editors may, whether by id or object', () => {
+  expect(canOverwriteSlice({ slice: { editors: [7] }, user: gamma })).toBe(
+    true,
+  );
+  expect(
+    canOverwriteSlice({ slice: { editors: [{ id: 8 }] }, user: gamma }),
+  ).toBe(true);
+});
+
+test('editorship granted indirectly through extra_editors counts', () => {
+  // The server's is_editor unions extra_editors into the editor set, so the
+  // UI must too or it hides an action the API would allow.
+  expect(
+    canOverwriteSlice({
+      slice: { editors: [], extra_editors: [7] },
+      user: gamma,
+    }),
+  ).toBe(true);
+});
+
+test('a subject that matches neither list may not', () => {
+  expect(
+    canOverwriteSlice({
+      slice: { editors: [99], extra_editors: [98] },
+      user: gamma,
+    }),
+  ).toBe(false);
+});
+
+test('the store value alone is sufficient', () => {
+  expect(
+    canOverwriteSlice({
+      slice: { editors: [] },
+      user: gamma,
+      canOverwrite: true,
+    }),
+  ).toBe(true);
+});
+
+test('an externally managed chart is excluded even from an admin', () => {
+  expect(
+    canOverwriteSlice({
+      slice: { editors: [], is_managed_externally: true },
+      user: admin,
+      canOverwrite: true,
+    }),
+  ).toBe(false);
+});
+
+test('a missing slice is not modifiable', () => {
+  expect(canOverwriteSlice({ slice: undefined, user: admin })).toBe(false);
+  expect(canOverwriteSlice({ slice: null, user: admin })).toBe(false);
+});
diff --git a/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.ts b/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.ts
new file mode 100644
index 00000000000..f3088078232
--- /dev/null
+++ b/superset-frontend/src/explore/exploreUtils/canOverwriteSlice.ts
@@ -0,0 +1,71 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 {
+  isUserAdmin,
+  isUserInSubjects,
+  type SubjectRef,
+} from 'src/dashboard/util/permissionUtils';
+import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+
+interface SliceLike {
+  editors?: SubjectRef[] | null;
+  extra_editors?: SubjectRef[] | null;
+  is_managed_externally?: boolean | null;
+}
+
+/**
+ * Whether *user* may modify *slice*, in the sense that governs both saving
+ * over a chart and acting on its version history.
+ *
+ * `canOverwrite` is the value the explore store holds, which `hydrateExplore`
+ * computes purely as "the current user is among the slice's editors". That is
+ * necessary but not sufficient: a chart with no explicit editors — which
+ * includes every seeded and example chart — yields false for everyone,
+ * administrators included. So the store value is treated as one of several
+ * routes to permission rather than the whole answer.
+ *
+ * Externally managed slices are excluded: their source of truth lives outside
+ * Superset, so overwriting or reverting one would be overwritten again by the
+ * next sync.
+ */
+export function canOverwriteSlice({
+  slice,
+  user,
+  canOverwrite = false,
+}: {
+  slice?: SliceLike | null;
+  user?: UserWithPermissionsAndRoles;
+  canOverwrite?: boolean;
+}): boolean {
+  if (!slice || slice.is_managed_externally) {
+    return false;
+  }
+  if (canOverwrite || isUserAdmin(user)) {
+    return true;
+  }
+
+  // Editors are Subjects — the viewer's user, roles and groups — so compare
+  // against subject ids rather than the user id. `extra_editors` carries
+  // editorship granted indirectly by a deployment's EXTRA_EDITORS_RESOLVER;
+  // the server counts it, so leaving it out here would hide the action from
+  // someone the API would happily let through.
+  return isUserInSubjects(slice.editors, slice.extra_editors);
+}
+
+export default canOverwriteSlice;
diff --git a/superset-frontend/src/features/versionHistory/ActionRow.tsx b/superset-frontend/src/features/versionHistory/ActionRow.tsx
new file mode 100644
index 00000000000..a89468779cc
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ActionRow.tsx
@@ -0,0 +1,246 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { KeyboardEvent } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { Button, Dropdown, Icons } from '@superset-ui/core/components';
+import type { ActivityRecord, VersionedEntityType } from './types';
+import {
+  describeRecord,
+  formatAuthor,
+  formatVersionDateTimeShort,
+} from './display';
+
+const Row = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: stretch;
+    gap: ${theme.sizeUnit * 4}px;
+    cursor: pointer;
+    border-radius: ${theme.borderRadius}px;
+    &:hover {
+      background-color: ${theme.colorBgTextHover};
+    }
+  `}
+`;
+
+const Rail = styled.div`
+  ${({ theme }) => `
+    width: ${theme.sizeUnit * 4}px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding-top: ${theme.sizeUnit * 1.5}px;
+  `}
+`;
+
+const Dot = styled.span<{ isActive: boolean }>`
+  ${({ theme, isActive }) => `
+    width: ${theme.sizeUnit * 2.5}px;
+    height: ${theme.sizeUnit * 2.5}px;
+    flex-shrink: 0;
+    border-radius: 50%;
+    border: 2px solid ${isActive ? theme.colorPrimary : theme.colorBorder};
+    background-color: ${theme.colorBgContainer};
+  `}
+`;
+
+const Connector = styled.span`
+  ${({ theme }) => `
+    flex: 1;
+    width: 1px;
+    margin-top: ${theme.sizeUnit / 2}px;
+    background-color: ${theme.colorSplit};
+  `}
+`;
+
+const Content = styled.div`
+  ${({ theme }) => `
+    flex: 1;
+    min-width: 0;
+    padding-bottom: ${theme.sizeUnit * 5}px;
+  `}
+`;
+
+const Title = styled.div`
+  ${({ theme }) => `
+    font-size: ${theme.fontSize}px;
+    line-height: ${theme.lineHeight};
+    color: ${theme.colorText};
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  `}
+`;
+
+const Meta = styled.div`
+  ${({ theme }) => `
+    color: ${theme.colorTextTertiary};
+    font-size: ${theme.fontSizeSM}px;
+    line-height: ${theme.lineHeightSM};
+  `}
+`;
+
+// The kebab centers within the first text line (one line-height tall)
+// so it tracks the title, not the middle of the two-line row.
+const KebabWrapper = styled.div`
+  ${({ theme }) => `
+    align-self: flex-start;
+    display: flex;
+    align-items: center;
+    height: ${theme.fontSize * theme.lineHeight}px;
+  `}
+`;
+
+// Icon-only trigger: neutral icon color instead of the link-button blue.
+const KebabButton = styled(Button)`
+  ${({ theme }) => `
+    && {
+      color: ${theme.colorTextTertiary};
+    }
+    &&:hover,
+    &&:focus {
+      color: ${theme.colorText};
+    }
+  `}
+`;
+
+export interface ActionRowProps {
+  entityType: VersionedEntityType;
+  record: ActivityRecord;
+  /** False for the current (live) version, where restoring is a no-op. */
+  showRestore: boolean;
+  /**
+   * False when the enclosing group has no versionUuid to act on — both
+   * kebab actions name a specific version, so the menu would be dead.
+   */
+  showActions?: boolean;
+  isPreviewed: boolean;
+  isLast: boolean;
+  onPreview: () => void;
+  onRestore: () => void;
+  onOpenAsNew: () => void;
+}
+
+export default function ActionRow({
+  entityType,
+  record,
+  showRestore,
+  showActions = true,
+  isPreviewed,
+  isLast,
+  onPreview,
+  onRestore,
+  onOpenAsNew,
+}: ActionRowProps) {
+  const theme = useTheme();
+  const label = describeRecord(record);
+  const meta = `${formatAuthor(record.changed_by)} · ${formatVersionDateTimeShort(
+    record.issued_at,
+  )}`;
+  const itemStyle = {
+    height: theme.controlHeightLG,
+    paddingLeft: theme.sizeUnit * 6,
+    paddingRight: theme.sizeUnit * 6,
+    display: 'flex',
+    alignItems: 'center',
+  };
+  const menuItems = [
+    ...(showRestore
+      ? [
+          {
+            key: 'restore',
+            label: t('Restore this version'),
+            style: itemStyle,
+            onClick: ({
+              domEvent,
+            }: {
+              domEvent: { stopPropagation: () => void };
+            }) => {
+              domEvent.stopPropagation();
+              onRestore();
+            },
+          },
+        ]
+      : []),
+    {
+      key: 'open-as-new',
+      label:
+        entityType === 'chart'
+          ? t('Open as new chart')
+          : t('Open as new dashboard'),
+      style: itemStyle,
+      onClick: ({
+        domEvent,
+      }: {
+        domEvent: { stopPropagation: () => void };
+      }) => {
+        domEvent.stopPropagation();
+        onOpenAsNew();
+      },
+    },
+  ];
+
+  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
+    // Keydowns from the nested kebab <button> bubble up here; only
+    // activate when the row itself has focus, so Enter/Space on the kebab
+    // opens its menu instead of triggering a preview.
+    if (event.target !== event.currentTarget) {
+      return;
+    }
+    if (event.key === 'Enter' || event.key === ' ') {
+      event.preventDefault();
+      onPreview();
+    }
+  };
+
+  return (
+    <Row
+      // eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- row contains a nested kebab <button>, which a real <button> cannot
+      role="button"
+      tabIndex={0}
+      onClick={onPreview}
+      onKeyDown={handleKeyDown}
+      data-test="version-history-action-row"
+    >
+      <Rail>
+        <Dot isActive={isPreviewed} />
+        {!isLast && <Connector />}
+      </Rail>
+      <Content>
+        <Title title={label}>{label}</Title>
+        <Meta>{meta}</Meta>
+      </Content>
+      <KebabWrapper>
+        {showActions && (
+          <Dropdown menu={{ items: menuItems }} trigger={['click']}>
+            <KebabButton
+              buttonSize="xsmall"
+              buttonStyle="link"
+              aria-label={t('More actions')}
+              onClick={event => event.stopPropagation()}
+            >
+              <Icons.MoreOutlined iconSize="m" />
+            </KebabButton>
+          </Dropdown>
+        )}
+      </KebabWrapper>
+    </Row>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/ChartVersionPreview.test.tsx b/superset-frontend/src/features/versionHistory/ChartVersionPreview.test.tsx
new file mode 100644
index 00000000000..c554f05065e
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ChartVersionPreview.test.tsx
@@ -0,0 +1,247 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 {
+  createStore,
+  render,
+  screen,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import type { VersionHistoryState } from './types';
+import type { VersionHistoryRootState } from './reducer';
+import { fetchDatasourceMetadata, fetchVersionSnapshot } from './api';
+import ChartVersionPreview from './ChartVersionPreview';
+
+jest.mock('./api', () => ({
+  fetchDatasourceMetadata: jest.fn(),
+  fetchVersionSnapshot: jest.fn(),
+}));
+jest.mock('./PreviewBanner', () => ({
+  __esModule: true,
+  default: () => null,
+}));
+jest.mock('src/components/Chart/chartAction', () => ({
+  getChartDataRequest: jest.fn(),
+  handleChartDataResponse: jest.fn(),
+}));
+jest.mock('src/explore/exploreUtils', () => ({
+  ...jest.requireActual('src/explore/exploreUtils'),
+  getQuerySettings: () => [false],
+}));
+// SuperChart would need a registered viz plugin; the assertions here are
+// about what the effect resolves to, not how it renders.
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  SuperChart: ({ formData }: { formData: { datasource: string } }) => (
+    <div data-test="super-chart">{formData.datasource}</div>
+  ),
+}));
+
+const { handleChartDataResponse } = jest.requireMock(
+  'src/components/Chart/chartAction',
+);
+
+const previewState = (): VersionHistoryState => ({
+  isPanelOpen: true,
+  entityType: 'chart',
+  include: 'all',
+  // SET_VERSION_PREVIEW always enters the applying state; starting from the
+  // real post-dispatch shape keeps these tests honest about the lifecycle —
+  // the component itself must announce completion.
+  isPreviewApplying: true,
+  preview: {
+    entityUuid: 'chart-uuid',
+    versionUuid: 'version-uuid',
+    transactionId: 1,
+    headline: 'A save',
+    issuedAt: '2026-07-01T00:00:00Z',
+  },
+  sessionLog: [],
+  restoreCount: 0,
+  lastRestoredEntityUuid: null,
+});
+
+const LIVE_DATASOURCE = { id: 5, type: 'table', columns: [], metrics: [] };
+
+const renderPreview = (liveDatasource: unknown = LIVE_DATASOURCE) => {
+  const store = createStore(
+    {
+      versionHistory: previewState(),
+      explore: { datasource: liveDatasource, slice: { editors: [{ id: 1 }] } },
+      user: { userId: 1, roles: {} },
+    },
+    reducerIndex,
+  );
+  render(<ChartVersionPreview />, { useTheme: true, store });
+  return store;
+};
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+    params: '{"metric":"count"}',
+    viz_type: 'table',
+    datasource_id: 5,
+    datasource_type: 'table',
+  });
+  (getChartDataRequest as jest.Mock).mockResolvedValue({
+    response: {},
+    json: {},
+  });
+  (handleChartDataResponse as jest.Mock).mockResolvedValue([{ data: [] }]);
+});
+
+test('renders the snapshot against the datasource the version was built on', async () => {
+  renderPreview();
+
+  await waitFor(() => {
+    expect(screen.getByTestId('super-chart')).toHaveTextContent('5__table');
+  });
+  // The live datasource already matches, so no extra metadata round trip.
+  expect(fetchDatasourceMetadata).not.toHaveBeenCalled();
+});
+
+test('announces the applied preview so the banner can offer Restore', async () => {
+  // The store enters isPreviewApplying on SET_VERSION_PREVIEW and only this
+  // component's announcement leaves it; without it the banner reads "Loading
+  // historical version" forever and never renders the Restore button.
+  const store = renderPreview();
+
+  await waitFor(() => {
+    expect(
+      (store.getState() as unknown as VersionHistoryRootState).versionHistory
+        .isPreviewApplying,
+    ).toBe(false);
+  });
+});
+
+test('announces completion even when the preview fails to load', async () => {
+  // A failed load shows the error alert in place of the chart; the applying
+  // state must still clear so the banner is not stuck reporting a load that
+  // has already settled.
+  (getChartDataRequest as jest.Mock).mockRejectedValue(
+    new Response(JSON.stringify({ message: 'boom' }), { status: 500 }),
+  );
+  const store = renderPreview();
+
+  await waitFor(() => {
+    expect(
+      (store.getState() as unknown as VersionHistoryRootState).versionHistory
+        .isPreviewApplying,
+    ).toBe(false);
+  });
+});
+
+test('fetches metadata when the version used a different datasource', async () => {
+  // A chart repointed at another dataset since the snapshot: the preview must
+  // describe itself with the dataset the version actually used, not the
+  // current one.
+  (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+    params: '{}',
+    viz_type: 'table',
+    datasource_id: 9,
+    datasource_type: 'table',
+  });
+  (fetchDatasourceMetadata as jest.Mock).mockResolvedValue({
+    id: 9,
+    type: 'table',
+    columns: [],
+    metrics: [],
+  });
+  renderPreview();
+
+  await waitFor(() => {
+    expect(fetchDatasourceMetadata).toHaveBeenCalledWith(9, 'table');
+  });
+  expect(screen.getByTestId('super-chart')).toHaveTextContent('9__table');
+});
+
+test('explains a version whose datasource is gone rather than failing generically', async () => {
+  (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+    params: '{}',
+    viz_type: 'table',
+    datasource_id: 9,
+    datasource_type: 'table',
+  });
+  (fetchDatasourceMetadata as jest.Mock).mockRejectedValue(new Error('404'));
+  renderPreview();
+
+  await waitFor(() => {
+    expect(
+      screen.getByText(/data this version was built on is no longer available/),
+    ).toBeInTheDocument();
+  });
+  expect(screen.queryByTestId('super-chart')).not.toBeInTheDocument();
+});
+
+test('explains a version that records no viz type or dataset', async () => {
+  // Every version-table column is nullable — a row written for a delete
+  // carries nulls throughout. Without the guard the preview requested
+  // `undefined__undefined` and surfaced whatever the API said about it.
+  (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+    params: '{}',
+    viz_type: null,
+    datasource_id: null,
+    datasource_type: null,
+  });
+  renderPreview();
+
+  await waitFor(() => {
+    expect(
+      screen.getByText(
+        /does not record a visualization type and dataset, so it cannot be previewed/,
+      ),
+    ).toBeInTheDocument();
+  });
+  expect(fetchDatasourceMetadata).not.toHaveBeenCalled();
+  expect(getChartDataRequest).not.toHaveBeenCalled();
+  expect(screen.queryByTestId('super-chart')).not.toBeInTheDocument();
+});
+
+test('surfaces a chart-data failure instead of rendering an empty chart', async () => {
+  (getChartDataRequest as jest.Mock).mockRejectedValue(
+    new Response(JSON.stringify({ message: 'Query timed out' }), {
+      status: 422,
+    }),
+  );
+  renderPreview();
+
+  await waitFor(() => {
+    expect(screen.getByText(/Query timed out/)).toBeInTheDocument();
+  });
+  expect(screen.queryByTestId('super-chart')).not.toBeInTheDocument();
+});
+
+test('malformed snapshot params surface as an error, not a crash', async () => {
+  // params is free-form JSON stored per version; a snapshot written by an
+  // older or hand-edited record must not take the page down.
+  (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+    params: '{not json',
+    viz_type: 'table',
+    datasource_id: 5,
+    datasource_type: 'table',
+  });
+  renderPreview();
+
+  await waitFor(() => {
+    expect(screen.queryByTestId('super-chart')).not.toBeInTheDocument();
+  });
+  expect(screen.getByRole('alert')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/features/versionHistory/ChartVersionPreview.tsx b/superset-frontend/src/features/versionHistory/ChartVersionPreview.tsx
new file mode 100644
index 00000000000..b226cc92203
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ChartVersionPreview.tsx
@@ -0,0 +1,257 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { useEffect, useMemo, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { canOverwriteSlice } from 'src/explore/exploreUtils/canOverwriteSlice';
+import { Slice } from 'src/types/Chart';
+import {
+  getClientErrorObject,
+  QueryData,
+  QueryFormData,
+  SuperChart,
+} from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Alert } from '@apache-superset/core/components';
+import { Loading } from '@superset-ui/core/components';
+import {
+  getChartDataRequest,
+  handleChartDataResponse,
+} from 'src/components/Chart/chartAction';
+import { getQuerySettings } from 'src/explore/exploreUtils';
+import type { Dataset } from 'src/components/Chart/types';
+import type { ExplorePageState } from 'src/explore/types';
+import { selectVersionPreview, versionPreviewApplied } from './reducer';
+import { fetchDatasourceMetadata, fetchVersionSnapshot } from './api';
+import PreviewBanner from './PreviewBanner';
+
+const Container = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit * 2}px;
+    height: 100%;
+    padding: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+const ChartArea = styled.div`
+  flex: 1;
+  min-height: 0;
+`;
+
+function buildPreviewFormData(
+  params: string | null,
+  vizType: string,
+  datasourceId: number,
+  datasourceType: string,
+): QueryFormData {
+  const parsed = params ? JSON.parse(params) : {};
+  return {
+    ...parsed,
+    viz_type: vizType,
+    datasource: `${datasourceId}__${datasourceType}`,
+    // Use an unsaved-chart id so the preview never collides with the
+    // live chart's cached queries.
+    slice_id: 0,
+  } as QueryFormData;
+}
+
+export default function ChartVersionPreview() {
+  const dispatch = useDispatch();
+  const preview = useSelector(selectVersionPreview);
+  const slice = useSelector<ExplorePageState, Slice | undefined>(
+    state => state.explore?.slice ?? undefined,
+  );
+  const user = useSelector<ExplorePageState, ExplorePageState['user']>(
+    state => state.user,
+  );
+  const canOverwrite = useSelector<ExplorePageState, boolean>(
+    state => state.explore?.can_overwrite ?? false,
+  );
+  // Same predicate as the panel and the menu, so an admin who can open the
+  // history can also restore from it.
+  const canRestore = useMemo(
+    () => canOverwriteSlice({ slice, user, canOverwrite }),
+    [slice, user, canOverwrite],
+  );
+  const datasource = useSelector<ExplorePageState, Dataset | undefined>(
+    state => state.explore?.datasource as unknown as Dataset | undefined,
+  );
+  const [formData, setFormData] = useState<QueryFormData | null>(null);
+  const [queriesData, setQueriesData] = useState<QueryData[] | null>(null);
+  const [previewDatasource, setPreviewDatasource] = useState<
+    Dataset | undefined
+  >(undefined);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const fetchIdRef = useRef(0);
+  // Read the live datasource through a ref so re-hydrations don't
+  // retrigger the preview fetch. Synced in an effect — render-phase ref
+  // writes are unsafe under concurrent rendering.
+  const liveDatasourceRef = useRef(datasource);
+  useEffect(() => {
+    liveDatasourceRef.current = datasource;
+  }, [datasource]);
+
+  const entityUuid = preview?.entityUuid;
+  const versionUuid = preview?.versionUuid;
+
+  useEffect(() => {
+    if (!entityUuid || !versionUuid) {
+      return;
+    }
+    fetchIdRef.current += 1;
+    const fetchId = fetchIdRef.current;
+    setIsLoading(true);
+    setError(null);
+    setQueriesData(null);
+    setFormData(null);
+    setPreviewDatasource(undefined);
+
+    const load = async () => {
+      const snapshot = await fetchVersionSnapshot(
+        'chart',
+        entityUuid,
+        versionUuid,
+      );
+      // Every version-table column is nullable, so a version that records no
+      // visualization type or dataset cannot be rendered at all — say so
+      // rather than requesting `undefined__undefined`.
+      const { viz_type: vizType, datasource_id: datasourceId } = snapshot;
+      const { datasource_type: datasourceType } = snapshot;
+      if (vizType == null || datasourceId == null || datasourceType == null) {
+        if (fetchId === fetchIdRef.current) {
+          setError(
+            t(
+              'This version does not record a visualization type and dataset, so it cannot be previewed',
+            ),
+          );
+        }
+        return;
+      }
+      // The snapshot may reference a different datasource than the live
+      // chart (e.g. the chart was switched to another dataset since);
+      // fetch the metadata the preview should label itself with.
+      const live = liveDatasourceRef.current as
+        | (Dataset & { id?: number; type?: string })
+        | undefined;
+      let datasourceMeta: Dataset | undefined = live;
+      if (!live || live.id !== datasourceId || live.type !== datasourceType) {
+        try {
+          datasourceMeta = (await fetchDatasourceMetadata(
+            datasourceId,
+            datasourceType,
+          )) as unknown as Dataset;
+        } catch {
+          if (fetchId === fetchIdRef.current) {
+            setError(
+              t(
+                'The data this version was built on is no longer available, so it cannot be previewed',
+              ),
+            );
+          }
+          return;
+        }
+      }
+      const previewFormData = buildPreviewFormData(
+        snapshot.params,
+        vizType,
+        datasourceId,
+        datasourceType,
+      );
+      const [useLegacyApi] = getQuerySettings(previewFormData);
+      const { response, json } = await getChartDataRequest({
+        formData: previewFormData,
+      });
+      const result = await handleChartDataResponse(
+        response,
+        json,
+        useLegacyApi,
+      );
+      if (fetchId !== fetchIdRef.current) {
+        return;
+      }
+      setFormData(previewFormData);
+      setQueriesData(result);
+      setPreviewDatasource(datasourceMeta);
+    };
+
+    load()
+      .catch(async response => {
+        if (fetchId !== fetchIdRef.current) {
+          return;
+        }
+        const { error: clientError, message } = await getClientErrorObject(
+          response as Parameters<typeof getClientErrorObject>[0],
+        );
+        setError(clientError || message || t('Failed to load version preview'));
+      })
+      .finally(() => {
+        if (fetchId === fetchIdRef.current) {
+          setIsLoading(false);
+          // SET_VERSION_PREVIEW put the store into the applying state; only
+          // this announcement leaves it, and the banner withholds Restore
+          // until it fires. Announce on failure too: the error alert replaces
+          // the chart, so the page is not mislabeling live content as
+          // historical, and Restore stays available exactly as it is from the
+          // panel kebab — a version whose data can't render is still a valid
+          // restore target.
+          dispatch(versionPreviewApplied());
+        }
+      });
+
+    // Invalidate the in-flight request when the preview closes or the
+    // component unmounts, so a late /chart/data response cannot set state
+    // afterwards.
+    return () => {
+      fetchIdRef.current += 1;
+    };
+  }, [dispatch, entityUuid, versionUuid]);
+
+  if (!preview) {
+    return null;
+  }
+
+  return (
+    <Container data-test="chart-version-preview">
+      <PreviewBanner entityType="chart" canRestore={canRestore} />
+      {isLoading && <Loading />}
+      {!isLoading && error && (
+        <Alert type="error" closable={false} message={error} />
+      )}
+      {!isLoading && !error && formData && queriesData && (
+        <ChartArea>
+          {/* keep SuperChart's own error boundary so a render failure on
+              stale metadata degrades to a contained error, not a broken
+              page */}
+          <SuperChart
+            chartType={formData.viz_type}
+            enableNoResults
+            datasource={previewDatasource}
+            formData={formData}
+            queriesData={queriesData}
+            height="100%"
+            width="100%"
+          />
+        </ChartArea>
+      )}
+    </Container>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/CurrentVersionSection.tsx b/superset-frontend/src/features/versionHistory/CurrentVersionSection.tsx
new file mode 100644
index 00000000000..2c672bb7c50
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/CurrentVersionSection.tsx
@@ -0,0 +1,163 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { useState } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { extendedDayjs } from '@superset-ui/core/utils/dates';
+import { Icons } from '@superset-ui/core/components';
+import type { SessionLogEntry } from './types';
+import { SHORT_DATETIME_FORMAT } from './display';
+
+const Container = styled.div`
+  ${({ theme }) => `
+    border-bottom: 1px solid ${theme.colorBorderSecondary};
+    padding: ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px;
+  `}
+`;
+
+const Header = styled.button`
+  ${({ theme }) => `
+    display: flex;
+    align-items: flex-start;
+    gap: ${theme.sizeUnit * 2}px;
+    padding: ${theme.sizeUnit * 3}px 0;
+    cursor: pointer;
+    border: none;
+    background: none;
+    width: 100%;
+    text-align: left;
+    font: inherit;
+    color: inherit;
+  `}
+`;
+
+const Title = styled.div`
+  ${({ theme }) => `
+    flex: 1;
+    font-size: ${theme.fontSize}px;
+    line-height: ${theme.lineHeight};
+    color: ${theme.colorText};
+  `}
+`;
+
+// Icons center within the first text line (one line-height tall) so
+// they track the title, consistent with the other timeline rows.
+const IconWrapper = styled.span`
+  ${({ theme }) => `
+    color: ${theme.colorTextSecondary};
+    display: flex;
+    align-items: center;
+    height: ${theme.fontSize * theme.lineHeight}px;
+  `}
+`;
+
+const ChevronWrapper = styled.span`
+  ${({ theme }) => `
+    color: ${theme.colorTextTertiary};
+    display: flex;
+    align-items: center;
+    height: ${theme.fontSize * theme.lineHeight}px;
+  `}
+`;
+
+const EntriesBlock = styled.div`
+  ${({ theme }) => `
+    background-color: ${theme.colorPrimaryBg};
+    border-radius: ${theme.borderRadius}px;
+    padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+const EntryLabel = styled.div`
+  ${({ theme }) => `
+    font-size: ${theme.fontSize}px;
+    line-height: ${theme.lineHeight};
+    color: ${theme.colorText};
+  `}
+`;
+
+const EntryMeta = styled.div`
+  ${({ theme }) => `
+    color: ${theme.colorTextTertiary};
+    font-size: ${theme.fontSizeSM}px;
+    line-height: ${theme.lineHeightSM};
+  `}
+`;
+
+export interface CurrentVersionSectionProps {
+  entries: SessionLogEntry[];
+  /** e.g. "Restored version · Dec 5, 2025, 5:18 PM" */
+  restoreNotice: string | null;
+}
+
+export default function CurrentVersionSection({
+  entries,
+  restoreNotice,
+}: CurrentVersionSectionProps) {
+  const [expanded, setExpanded] = useState(false);
+
+  if (!restoreNotice && entries.length === 0) {
+    return null;
+  }
+
+  const toggle = () => setExpanded(value => !value);
+
+  return (
+    <Container data-test="version-history-current-version">
+      <Header
+        type="button"
+        onClick={toggle}
+        aria-expanded={expanded}
+        aria-label={t('Current version')}
+      >
+        <IconWrapper>
+          <Icons.CheckCircleOutlined iconSize="l" />
+        </IconWrapper>
+        <Title>{t('Current version')}</Title>
+        <ChevronWrapper>
+          {expanded ? (
+            <Icons.UpOutlined iconSize="m" />
+          ) : (
+            <Icons.DownOutlined iconSize="m" />
+          )}
+        </ChevronWrapper>
+      </Header>
+      {expanded && (
+        <EntriesBlock>
+          {restoreNotice && <EntryLabel>{restoreNotice}</EntryLabel>}
+          {entries.map(entry => {
+            // Session log timestamps are local epoch milliseconds.
+            const time = extendedDayjs(entry.ts).format(SHORT_DATETIME_FORMAT);
+            return (
+              <div key={`${entry.controlName}-${entry.ts}`}>
+                <EntryLabel>{entry.label}</EntryLabel>
+                <EntryMeta>
+                  {entry.user ? `${entry.user} · ${time}` : time}
+                </EntryMeta>
+              </div>
+            );
+          })}
+        </EntriesBlock>
+      )}
+    </Container>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx
new file mode 100644
index 00000000000..9080ad8e27e
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.test.tsx
@@ -0,0 +1,255 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { AnyAction, Store } from 'redux';
+import { act, render } from 'spec/helpers/testing-library';
+import type { VersionHistoryState } from './types';
+import { useVersionActivity } from './useVersionActivity';
+import DashboardVersionHistory from './DashboardVersionHistory';
+
+const mockPanelProps = jest.fn();
+jest.mock('./VersionHistoryPanel', () => ({
+  __esModule: true,
+  default: (props: unknown) => {
+    mockPanelProps(props);
+    return null;
+  },
+}));
+jest.mock('./useDashboardVersionPreview', () => ({
+  useDashboardVersionPreview: jest.fn(),
+}));
+jest.mock('./useVersionActions', () => ({
+  useVersionActions: () => ({
+    requestRestore: jest.fn(),
+    openAsNew: jest.fn(),
+    restoreModal: null,
+  }),
+}));
+jest.mock('./useVersionActivity', () => ({
+  useVersionActivity: jest.fn(),
+}));
+const mockAddDangerToast = jest.fn();
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+  useToasts: () => ({ addDangerToast: mockAddDangerToast }),
+}));
+
+const mockedUseVersionActivity = useVersionActivity as jest.Mock;
+const refresh = jest.fn();
+
+const versionHistoryState = (
+  overrides: Partial<VersionHistoryState> = {},
+): VersionHistoryState => ({
+  isPanelOpen: true,
+  entityType: 'dashboard',
+  include: 'all',
+  preview: null,
+  isPreviewApplying: false,
+  sessionLog: [],
+  restoreCount: 0,
+  lastRestoredEntityUuid: null,
+  ...overrides,
+});
+
+interface TestState {
+  versionHistory: VersionHistoryState;
+  dashboardInfo: {
+    uuid: string;
+    last_modified_time: number;
+    dash_edit_perm?: boolean;
+    is_managed_externally?: boolean;
+  };
+  dashboardState: { hasUnsavedChanges: boolean; lastModifiedTime: number };
+}
+
+/** Minimal recording store: dispatched actions are captured, never reduced,
+ * so tests drive state transitions explicitly via setState. */
+function makeTestStore(initial: TestState) {
+  let state = initial;
+  const actions: AnyAction[] = [];
+  const listeners = new Set<() => void>();
+  return {
+    actions,
+    getState: () => state,
+    setState(partial: Partial<TestState>) {
+      state = { ...state, ...partial };
+      listeners.forEach(listener => listener());
+    },
+    dispatch(action: AnyAction) {
+      actions.push(action);
+      return action;
+    },
+    subscribe(listener: () => void) {
+      listeners.add(listener);
+      return () => {
+        listeners.delete(listener);
+      };
+    },
+  };
+}
+
+const makeStore = () =>
+  makeTestStore({
+    versionHistory: versionHistoryState(),
+    dashboardInfo: {
+      uuid: 'dash-uuid',
+      last_modified_time: 100,
+      dash_edit_perm: true,
+    },
+    dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 500 },
+  });
+
+const renderAdapter = (store: ReturnType<typeof makeTestStore>) =>
+  render(<DashboardVersionHistory />, { store: store as unknown as Store });
+
+beforeEach(() => {
+  mockedUseVersionActivity.mockReturnValue({
+    records: [],
+    timeline: [],
+    newestGroup: null,
+    currentVersionStatus: 'empty',
+    count: 0,
+    isLoading: false,
+    error: null,
+    hasMore: false,
+    truncated: false,
+    loadMore: jest.fn(),
+    refresh,
+  });
+});
+
+afterEach(() => {
+  jest.clearAllMocks();
+});
+
+test('refreshes the timeline when an edit-mode save bumps lastModifiedTime', () => {
+  const store = makeStore();
+  renderAdapter(store);
+  expect(refresh).not.toHaveBeenCalled();
+
+  act(() => {
+    store.setState({
+      dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 600 },
+    });
+  });
+
+  expect(refresh).toHaveBeenCalledTimes(1);
+});
+
+test('refreshes the timeline when a filter or properties save bumps last_modified_time', () => {
+  const store = makeStore();
+  renderAdapter(store);
+
+  act(() => {
+    store.setState({
+      dashboardInfo: { uuid: 'dash-uuid', last_modified_time: 200 },
+    });
+  });
+
+  expect(refresh).toHaveBeenCalledTimes(1);
+});
+
+test('does not refresh when unrelated state changes leave the save signals untouched', () => {
+  const store = makeStore();
+  renderAdapter(store);
+
+  act(() => {
+    store.setState({
+      dashboardState: { hasUnsavedChanges: true, lastModifiedTime: 500 },
+      dashboardInfo: { uuid: 'dash-uuid', last_modified_time: 100 },
+    });
+  });
+
+  expect(refresh).not.toHaveBeenCalled();
+});
+
+test('a restore that also moves the save signal refreshes exactly once', () => {
+  const store = makeStore();
+  renderAdapter(store);
+
+  act(() => {
+    store.setState({
+      versionHistory: versionHistoryState({
+        restoreCount: 1,
+        lastRestoredEntityUuid: 'dash-uuid',
+      }),
+      dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 700 },
+    });
+  });
+
+  expect(refresh).toHaveBeenCalledTimes(1);
+});
+
+test('preview is blocked while the dashboard has unsaved edit-mode changes', () => {
+  const store = makeTestStore({
+    versionHistory: versionHistoryState(),
+    dashboardInfo: { uuid: 'dash-uuid', last_modified_time: 100 },
+    dashboardState: { hasUnsavedChanges: true, lastModifiedTime: 500 },
+  });
+  renderAdapter(store);
+
+  const { onPreview } = mockPanelProps.mock.lastCall[0];
+  act(() => {
+    onPreview({
+      type: 'group',
+      transactionId: 10,
+      versionUuid: 'v-1',
+      issuedAt: '2025-12-05T17:18:00',
+      changedBy: null,
+      actionKind: null,
+      records: [],
+    });
+  });
+
+  // Previewing would rehydrate the dashboard and wipe the unsaved work;
+  // the user is told to save or discard instead.
+  expect(mockAddDangerToast).toHaveBeenCalled();
+  expect(
+    store.actions.some(action => action.type === 'SET_VERSION_PREVIEW'),
+  ).toBe(false);
+});
+
+test('offers restore to an editor of a normally managed dashboard', () => {
+  const store = makeStore();
+  renderAdapter(store);
+
+  expect(mockPanelProps).toHaveBeenCalledWith(
+    expect.objectContaining({ canRestore: true }),
+  );
+});
+
+test('withholds restore on an externally managed dashboard', () => {
+  // The header menu hides the entry for these, but the panel also opens from
+  // ?version_history=true and the restore endpoint has no
+  // is_managed_externally check -- so this gate is the only thing standing
+  // between a URL param and a restore that the next sync would undo.
+  const store = makeTestStore({
+    versionHistory: versionHistoryState(),
+    dashboardInfo: {
+      uuid: 'dash-uuid',
+      last_modified_time: 100,
+      dash_edit_perm: true,
+      is_managed_externally: true,
+    },
+    dashboardState: { hasUnsavedChanges: false, lastModifiedTime: 500 },
+  });
+  renderAdapter(store);
+
+  expect(mockPanelProps).toHaveBeenCalledWith(
+    expect.objectContaining({ canRestore: false }),
+  );
+});
diff --git a/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx
new file mode 100644
index 00000000000..7b67ec011dd
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/DashboardVersionHistory.tsx
@@ -0,0 +1,282 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useDebounceValue } from 'src/hooks/useDebounceValue';
+import { t } from '@apache-superset/core/translation';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { getUrlParam } from 'src/utils/urlUtils';
+import { URL_PARAMS } from 'src/constants';
+import type { RootState } from 'src/dashboard/types';
+import type {
+  ActivityInclude,
+  ActivityRecord,
+  SaveGroup,
+  SessionLogEntry,
+} from './types';
+import {
+  clearVersionPreview,
+  closeVersionHistoryPanel,
+  openVersionHistoryPanel,
+  selectIsVersionHistoryPanelOpen,
+  selectVersionHistoryInclude,
+  selectVersionPreview,
+  selectVersionLastRestoredUuid,
+  selectVersionRestoreCount,
+  setVersionHistoryInclude,
+  setVersionPreview,
+} from './reducer';
+import { selectCanRestoreDashboard } from './canRestoreDashboard';
+import { openRelatedEntity } from './openRelated';
+import { useVersionActivity } from './useVersionActivity';
+import { useVersionActions } from './useVersionActions';
+import { useDashboardVersionPreview } from './useDashboardVersionPreview';
+import { groupHeadline } from './display';
+import VersionHistoryPanel from './VersionHistoryPanel';
+
+export default function DashboardVersionHistory() {
+  const dispatch = useDispatch();
+  const { addDangerToast } = useToasts();
+  const uuid = useSelector<RootState, string | undefined>(
+    state => state.dashboardInfo?.uuid,
+  );
+  const canRestore = useSelector(selectCanRestoreDashboard);
+  const isPanelOpen = useSelector(selectIsVersionHistoryPanelOpen);
+  const include = useSelector(selectVersionHistoryInclude);
+  const preview = useSelector(selectVersionPreview);
+  const hasUnsavedChanges = useSelector<RootState, boolean>(
+    state => !!state.dashboardState?.hasUnsavedChanges,
+  );
+  // Dashboard edits are tracked coarsely (no per-control log like
+  // explore): a single "unsaved edits" entry while edit mode is dirty.
+  // The timestamp is captured when the dashboard first turns dirty —
+  // stamping it during render would drift on every re-render.
+  const [dirtySince, setDirtySince] = useState<number | null>(null);
+  useEffect(() => {
+    if (hasUnsavedChanges) {
+      setDirtySince(current => current ?? Date.now());
+    } else {
+      setDirtySince(null);
+    }
+  }, [hasUnsavedChanges]);
+  const sessionEntries = useMemo<SessionLogEntry[]>(
+    () =>
+      hasUnsavedChanges && dirtySince !== null
+        ? [
+            {
+              label: t('Unsaved dashboard edits'),
+              controlName: 'dashboard',
+              ts: dirtySince,
+              user: null,
+            },
+          ]
+        : [],
+    [dirtySince, hasUnsavedChanges],
+  );
+
+  // The URL param is honoured once per mount. It persists for the whole
+  // visit, and this effect re-runs whenever `canRestore` moves — a late
+  // false→true flip (dashboardInfo refetch after a properties save) would
+  // otherwise re-open a panel the user explicitly closed.
+  const urlParamHandledRef = useRef(false);
+  useEffect(() => {
+    // Match the menu entry's gating: version history is only offered to
+    // users who could restore (sc-107604) — the URL param must not open
+    // it for read-only viewers.
+    if (
+      !urlParamHandledRef.current &&
+      getUrlParam(URL_PARAMS.versionHistory) &&
+      canRestore
+    ) {
+      urlParamHandledRef.current = true;
+      dispatch(openVersionHistoryPanel('dashboard'));
+    }
+  }, [canRestore, dispatch]);
+
+  // Leaving the page should not carry panel/preview state to other pages.
+  useEffect(
+    () => () => {
+      dispatch(closeVersionHistoryPanel());
+    },
+    [dispatch],
+  );
+
+  // Server-side search over the full history; debounce so each keystroke
+  // doesn't refetch.
+  const [searchTerm, setSearchTerm] = useState('');
+  const debouncedSearch = useDebounceValue(searchTerm);
+  const activity = useVersionActivity(
+    'dashboard',
+    isPanelOpen ? uuid : undefined,
+    include,
+    debouncedSearch,
+  );
+
+  useDashboardVersionPreview(uuid);
+
+  const { requestRestore, openAsNew, restoreModal } = useVersionActions(
+    'dashboard',
+    uuid,
+  );
+
+  // Page rehydration after a restore happens in useDashboardVersionPreview;
+  // here only the activity timeline needs a refresh so the new
+  // "Restored version" entry shows up.
+  const restoreCount = useSelector(selectVersionRestoreCount);
+  const lastRestoredUuid = useSelector(selectVersionLastRestoredUuid);
+  // Saves made while the panel is open must surface as new timeline
+  // entries without reopening it. Saves bump one of two redux signals
+  // depending on the path: edit-mode saves round-trip through ON_SAVE
+  // (dashboardState.lastModifiedTime), while native-filter and
+  // properties saves bump dashboardInfo.last_modified_time.
+  const saveSignal = useSelector<RootState, string>(state =>
+    [
+      state.dashboardState?.lastModifiedTime ?? '',
+      state.dashboardInfo?.last_modified_time ?? '',
+    ].join('|'),
+  );
+  const lastRestoreCountRef = useRef(restoreCount);
+  const lastSaveSignalRef = useRef(saveSignal);
+  const refreshActivity = activity.refresh;
+  useEffect(() => {
+    if (restoreCount !== lastRestoreCountRef.current) {
+      lastRestoreCountRef.current = restoreCount;
+      if (lastRestoredUuid !== uuid) {
+        // A restore of some other entity, resolving after navigation. This
+        // dashboard's timeline gained nothing; don't refetch it.
+        return;
+      }
+      // The restore refresh covers any save-signal movement caused by
+      // the same change; sync it so it does not refetch again.
+      lastSaveSignalRef.current = saveSignal;
+      refreshActivity();
+      return;
+    }
+    if (saveSignal !== lastSaveSignalRef.current) {
+      // A signal appearing where none existed is the page's initial
+      // hydration, not a save.
+      const isInitialHydration = lastSaveSignalRef.current === '|';
+      lastSaveSignalRef.current = saveSignal;
+      if (!isInitialHydration) {
+        refreshActivity();
+      }
+    }
+  }, [lastRestoredUuid, refreshActivity, restoreCount, saveSignal, uuid]);
+
+  const handleClose = useCallback(() => {
+    dispatch(closeVersionHistoryPanel());
+  }, [dispatch]);
+
+  const handleIncludeChange = useCallback(
+    (value: ActivityInclude) => {
+      dispatch(setVersionHistoryInclude(value));
+    },
+    [dispatch],
+  );
+
+  const handlePreview = useCallback(
+    (group: SaveGroup) => {
+      if (!group.versionUuid || !uuid) {
+        return;
+      }
+      if (hasUnsavedChanges) {
+        // Previewing rehydrates the whole dashboard state, which would
+        // silently wipe in-progress edit-mode work and its undo history.
+        addDangerToast(
+          t('Save or discard your unsaved changes to preview a version.'),
+        );
+        return;
+      }
+      dispatch(
+        setVersionPreview({
+          entityUuid: uuid,
+          versionUuid: group.versionUuid,
+          transactionId: group.transactionId,
+          headline: groupHeadline(group),
+          issuedAt: group.issuedAt,
+        }),
+      );
+    },
+    [addDangerToast, dispatch, hasUnsavedChanges, uuid],
+  );
+
+  const handleExitPreview = useCallback(() => {
+    dispatch(clearVersionPreview(uuid));
+  }, [dispatch, uuid]);
+
+  const handleOpenRelated = useCallback(
+    (record: ActivityRecord) => {
+      openRelatedEntity(record, addDangerToast);
+    },
+    [addDangerToast],
+  );
+
+  const handleRestore = useCallback(
+    (group: SaveGroup) => {
+      if (group.versionUuid) {
+        requestRestore({
+          versionUuid: group.versionUuid,
+          headline: groupHeadline(group),
+          issuedAt: group.issuedAt,
+        });
+      }
+    },
+    [requestRestore],
+  );
+
+  const handleOpenAsNew = useCallback(
+    (group: SaveGroup) => {
+      if (group.versionUuid) {
+        openAsNew({
+          versionUuid: group.versionUuid,
+          headline: groupHeadline(group),
+          issuedAt: group.issuedAt,
+        });
+      }
+    },
+    [openAsNew],
+  );
+
+  if (!isPanelOpen) {
+    return restoreModal;
+  }
+
+  return (
+    <>
+      <VersionHistoryPanel
+        entityType="dashboard"
+        canRestore={canRestore}
+        activity={activity}
+        include={include}
+        onIncludeChange={handleIncludeChange}
+        searchTerm={searchTerm}
+        onSearchChange={setSearchTerm}
+        previewedTransactionId={preview?.transactionId ?? null}
+        onClose={handleClose}
+        onPreview={handlePreview}
+        onExitPreview={handleExitPreview}
+        onRestore={handleRestore}
+        onOpenAsNew={handleOpenAsNew}
+        onOpenRelated={handleOpenRelated}
+        sessionEntries={sessionEntries}
+      />
+      {restoreModal}
+    </>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx
new file mode 100644
index 00000000000..922d9807ce2
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.test.tsx
@@ -0,0 +1,338 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { AnyAction, Store } from 'redux';
+import { act, render, waitFor } from 'spec/helpers/testing-library';
+import { hydrateExplore } from 'src/explore/actions/hydrateExplore';
+import type { VersionHistoryState } from './types';
+import { fetchExploreRehydrationData } from './api';
+import { useVersionActivity } from './useVersionActivity';
+import ExploreVersionHistory from './ExploreVersionHistory';
+
+jest.mock('./VersionHistoryPanel', () => ({
+  __esModule: true,
+  default: () => null,
+}));
+jest.mock('./useVersionActions', () => ({
+  useVersionActions: () => ({
+    requestRestore: jest.fn(),
+    openAsNew: jest.fn(),
+    restoreModal: null,
+  }),
+}));
+jest.mock('./useVersionActivity', () => ({
+  useVersionActivity: jest.fn(),
+}));
+jest.mock('./api', () => ({
+  ...jest.requireActual('./api'),
+  fetchChartUuid: jest.fn(),
+  fetchExploreRehydrationData: jest.fn(),
+}));
+jest.mock('src/explore/actions/hydrateExplore', () => ({
+  hydrateExplore: jest.fn(),
+}));
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+  useToasts: () => ({ addDangerToast: jest.fn() }),
+}));
+
+const HYDRATE_EXPLORE_TEST = 'HYDRATE_EXPLORE_TEST_ACTION';
+
+const mockedUseVersionActivity = useVersionActivity as jest.Mock;
+const mockedHydrateExplore = hydrateExplore as unknown as jest.Mock;
+const mockedFetchRehydration =
+  fetchExploreRehydrationData as unknown as jest.Mock;
+const refresh = jest.fn();
+
+const versionHistoryState = (
+  overrides: Partial<VersionHistoryState> = {},
+): VersionHistoryState => ({
+  isPanelOpen: true,
+  entityType: 'chart',
+  include: 'all',
+  preview: null,
+  isPreviewApplying: false,
+  sessionLog: [],
+  restoreCount: 0,
+  lastRestoredEntityUuid: null,
+  ...overrides,
+});
+
+interface TestSlice {
+  slice_id: number;
+  uuid: string;
+  changed_on: string;
+}
+
+interface TestState {
+  versionHistory: VersionHistoryState;
+  explore: { slice?: TestSlice };
+}
+
+const slice = (changedOn: string): TestSlice => ({
+  slice_id: 1,
+  uuid: 'chart-uuid',
+  changed_on: changedOn,
+});
+
+/** Minimal recording store: dispatched actions are captured, never reduced,
+ * so tests drive state transitions explicitly via setState. */
+function makeTestStore(initial: TestState) {
+  let state = initial;
+  const actions: AnyAction[] = [];
+  const listeners = new Set<() => void>();
+  return {
+    actions,
+    getState: () => state,
+    setState(partial: Partial<TestState>) {
+      state = { ...state, ...partial };
+      listeners.forEach(listener => listener());
+    },
+    dispatch(action: AnyAction) {
+      actions.push(action);
+      return action;
+    },
+    subscribe(listener: () => void) {
+      listeners.add(listener);
+      return () => {
+        listeners.delete(listener);
+      };
+    },
+  };
+}
+
+const makeStore = () =>
+  makeTestStore({
+    versionHistory: versionHistoryState(),
+    explore: { slice: slice('2025-12-08T17:18:00') },
+  });
+
+const renderAdapter = (store: ReturnType<typeof makeTestStore>) =>
+  render(<ExploreVersionHistory />, { store: store as unknown as Store });
+
+const activityResult = {
+  records: [],
+  timeline: [],
+  newestGroup: null,
+  currentVersionStatus: 'empty',
+  count: 0,
+  isLoading: false,
+  error: null,
+  hasMore: false,
+  truncated: false,
+  loadMore: jest.fn(),
+  refresh,
+};
+
+beforeEach(() => {
+  mockedUseVersionActivity.mockReturnValue(activityResult);
+  mockedFetchRehydration.mockResolvedValue({});
+  mockedHydrateExplore.mockImplementation(payload => ({
+    type: HYDRATE_EXPLORE_TEST,
+    payload,
+  }));
+});
+
+afterEach(() => {
+  jest.clearAllMocks();
+});
+
+test('refreshes the timeline when an overwrite save replaces the slice', () => {
+  const store = makeStore();
+  renderAdapter(store);
+  expect(refresh).not.toHaveBeenCalled();
+
+  act(() => {
+    store.setState({ explore: { slice: slice('2025-12-08T18:00:00') } });
+  });
+
+  expect(refresh).toHaveBeenCalledTimes(1);
+  expect(mockedHydrateExplore).not.toHaveBeenCalled();
+});
+
+test('a restore refreshes exactly once even when it also moves the slice', async () => {
+  const store = makeStore();
+  renderAdapter(store);
+
+  act(() => {
+    store.setState({
+      versionHistory: versionHistoryState({
+        restoreCount: 1,
+        lastRestoredEntityUuid: 'chart-uuid',
+      }),
+      explore: { slice: slice('2025-12-08T18:00:00') },
+    });
+  });
+
+  expect(refresh).toHaveBeenCalledTimes(1);
+  // The restore branch also reloads the explore page state in place.
+  await waitFor(() =>
+    expect(
+      store.actions.some(action => action.type === HYDRATE_EXPLORE_TEST),
+    ).toBe(true),
+  );
+  expect(mockedHydrateExplore).toHaveBeenCalledWith(
+    expect.objectContaining({ saveAction: 'overwrite' }),
+  );
+});
+
+test('a restore rehydration resolving after unmount is discarded', async () => {
+  // hydrateExplore rewrites the whole explore store; a rehydration fetch
+  // resolving after the user navigated away would overwrite the next page's
+  // chart with this one's payload.
+  let resolveRehydration: (value: object) => void = () => {};
+  mockedFetchRehydration.mockReturnValue(
+    new Promise(resolve => {
+      resolveRehydration = resolve;
+    }),
+  );
+  const store = makeStore();
+  const { unmount } = renderAdapter(store);
+
+  act(() => {
+    store.setState({
+      versionHistory: versionHistoryState({
+        restoreCount: 1,
+        lastRestoredEntityUuid: 'chart-uuid',
+      }),
+    });
+  });
+  await waitFor(() => expect(mockedFetchRehydration).toHaveBeenCalled());
+
+  unmount();
+  await act(async () => {
+    resolveRehydration({});
+  });
+
+  expect(mockedHydrateExplore).not.toHaveBeenCalled();
+  expect(
+    store.actions.some(action => action.type === HYDRATE_EXPLORE_TEST),
+  ).toBe(false);
+});
+
+test('a search keystroke mid-rehydration does not drop the restored version', async () => {
+  // `refreshActivity` is `useVersionActivity`'s `refresh`, whose identity
+  // moves whenever the debounced search term or the include filter does.
+  // A guard bound to the effect run would cancel the in-flight rehydration
+  // on that re-run and never re-issue it — the timeline would show the
+  // restore while the chart still rendered its pre-restore state, with the
+  // failure toast suppressed too.
+  let resolveRehydration: (value: object) => void = () => {};
+  mockedFetchRehydration.mockReturnValue(
+    new Promise(resolve => {
+      resolveRehydration = resolve;
+    }),
+  );
+  mockedUseVersionActivity.mockImplementation(() => ({
+    ...activityResult,
+    // A fresh identity per render, as the real hook produces when `q` moves.
+    refresh: () => refresh(),
+  }));
+  const store = makeStore();
+  renderAdapter(store);
+
+  act(() => {
+    store.setState({
+      versionHistory: versionHistoryState({
+        restoreCount: 1,
+        lastRestoredEntityUuid: 'chart-uuid',
+      }),
+    });
+  });
+  await waitFor(() => expect(mockedFetchRehydration).toHaveBeenCalled());
+
+  // The debounce fires mid-flight and re-renders the panel.
+  act(() => {
+    store.setState({
+      versionHistory: versionHistoryState({
+        restoreCount: 1,
+        lastRestoredEntityUuid: 'chart-uuid',
+        include: 'self',
+      }),
+    });
+  });
+
+  await act(async () => {
+    resolveRehydration({});
+  });
+
+  expect(mockedHydrateExplore).toHaveBeenCalledWith(
+    expect.objectContaining({ saveAction: 'overwrite' }),
+  );
+  // ...and it was not re-issued to compensate.
+  expect(mockedFetchRehydration).toHaveBeenCalledTimes(1);
+});
+
+test('the initial slice hydration does not trigger a refresh', () => {
+  const store = makeTestStore({
+    versionHistory: versionHistoryState(),
+    explore: {},
+  });
+  renderAdapter(store);
+
+  act(() => {
+    store.setState({ explore: { slice: slice('2025-12-08T17:18:00') } });
+  });
+
+  expect(refresh).not.toHaveBeenCalled();
+});
+
+test('does not refresh when state changes leave changed_on untouched', () => {
+  const store = makeStore();
+  renderAdapter(store);
+
+  act(() => {
+    store.setState({ explore: { slice: slice('2025-12-08T17:18:00') } });
+  });
+
+  expect(refresh).not.toHaveBeenCalled();
+});
+
+test('a save landing mid-rehydration wins over the older restore payload', async () => {
+  // The restore payload was fetched before the save committed, so hydrating
+  // it would roll the chart back over the newer state. The save's own
+  // in-place hydration is already correct; the stale payload is dropped.
+  let resolveRehydration: (value: object) => void = () => {};
+  mockedFetchRehydration.mockReturnValue(
+    new Promise(resolve => {
+      resolveRehydration = resolve;
+    }),
+  );
+  const store = makeStore();
+  renderAdapter(store);
+
+  act(() => {
+    store.setState({
+      versionHistory: versionHistoryState({
+        restoreCount: 1,
+        lastRestoredEntityUuid: 'chart-uuid',
+      }),
+    });
+  });
+  await waitFor(() => expect(mockedFetchRehydration).toHaveBeenCalled());
+
+  // An overwrite save commits while the rehydration is still in flight.
+  act(() => {
+    store.setState({ explore: { slice: slice('2025-12-09T09:00:00') } });
+  });
+
+  await act(async () => {
+    resolveRehydration({});
+  });
+
+  expect(mockedHydrateExplore).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx
new file mode 100644
index 00000000000..6e7e9359690
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/ExploreVersionHistory.tsx
@@ -0,0 +1,361 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useDebounceValue } from 'src/hooks/useDebounceValue';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { getUrlParam } from 'src/utils/urlUtils';
+import { canOverwriteSlice } from 'src/explore/exploreUtils/canOverwriteSlice';
+import { URL_PARAMS } from 'src/constants';
+import { hydrateExplore } from 'src/explore/actions/hydrateExplore';
+import type { Slice } from 'src/types/Chart';
+import type { ExplorePageState } from 'src/explore/types';
+import type { ActivityInclude, ActivityRecord, SaveGroup } from './types';
+import {
+  clearVersionPreview,
+  closeVersionHistoryPanel,
+  openVersionHistoryPanel,
+  selectIsVersionHistoryPanelOpen,
+  selectVersionHistoryInclude,
+  selectVersionPreview,
+  selectVersionLastRestoredUuid,
+  selectVersionRestoreCount,
+  selectVersionSessionLog,
+  setVersionHistoryInclude,
+  setVersionPreview,
+} from './reducer';
+import { fetchChartUuid, fetchExploreRehydrationData } from './api';
+import { openRelatedEntity } from './openRelated';
+import { useVersionActivity } from './useVersionActivity';
+import { useVersionActions } from './useVersionActions';
+import { groupHeadline } from './display';
+import VersionHistoryPanel from './VersionHistoryPanel';
+
+/**
+ * The explore flex row (datasource rail + control rail + chart) cannot give
+ * up enough width for the panel on narrow viewports; below the XL breakpoint
+ * the panel overlays the page (anchored to the relatively-positioned explore
+ * container) instead of being pushed past the viewport edge.
+ */
+const PanelHost = styled.div`
+  ${({ theme }) => `
+    height: 100%;
+    flex-shrink: 0;
+    @media (max-width: ${theme.screenXL}px) {
+      position: absolute;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      height: auto;
+      z-index: 20;
+      box-shadow: ${theme.boxShadow};
+    }
+  `}
+`;
+
+export default function ExploreVersionHistory() {
+  const dispatch = useDispatch();
+  const { addDangerToast } = useToasts();
+  const slice = useSelector<ExplorePageState, Slice | undefined>(
+    state => state.explore?.slice ?? undefined,
+  );
+  const user = useSelector<ExplorePageState, ExplorePageState['user']>(
+    state => state.user,
+  );
+  const canOverwrite = useSelector<ExplorePageState, boolean>(
+    state => state.explore?.can_overwrite ?? false,
+  );
+  // Same predicate as the menu that opens this panel: can_overwrite alone
+  // excludes admins and extra editors on charts with no explicit editors.
+  const canRestore = useMemo(
+    () => canOverwriteSlice({ slice, user, canOverwrite }),
+    [slice, user, canOverwrite],
+  );
+  const isPanelOpen = useSelector(selectIsVersionHistoryPanelOpen);
+  const include = useSelector(selectVersionHistoryInclude);
+  const preview = useSelector(selectVersionPreview);
+  const sessionLog = useSelector(selectVersionSessionLog);
+  const sliceId = slice?.slice_id;
+  // Key the fetched uuid by slice id so a "save as" (which swaps the slice
+  // in place) invalidates it instead of keeping the old chart's uuid.
+  const [fetchedUuid, setFetchedUuid] = useState<{
+    sliceId: number;
+    uuid: string;
+  } | null>(null);
+  const uuid =
+    slice?.uuid ??
+    (fetchedUuid && fetchedUuid.sliceId === sliceId
+      ? fetchedUuid.uuid
+      : undefined);
+
+  // The URL param is honoured once per mount. It persists for the whole
+  // visit, and this effect re-runs whenever `canRestore` moves — a late
+  // false→true flip (slice metadata refetch, an editors change landing)
+  // would otherwise re-open a panel the user explicitly closed.
+  const urlParamHandledRef = useRef(false);
+  useEffect(() => {
+    // Match the menu entry's gating: version history is only offered to
+    // users who could restore (sc-107604) — the URL param must not open
+    // it for read-only viewers.
+    if (
+      !urlParamHandledRef.current &&
+      getUrlParam(URL_PARAMS.versionHistory) &&
+      canRestore
+    ) {
+      urlParamHandledRef.current = true;
+      dispatch(openVersionHistoryPanel('chart'));
+    }
+  }, [canRestore, dispatch]);
+
+  // Leaving the page should not carry panel/preview state to other pages.
+  useEffect(
+    () => () => {
+      dispatch(closeVersionHistoryPanel());
+    },
+    [dispatch],
+  );
+
+  useEffect(() => {
+    if (uuid || !isPanelOpen || !sliceId) {
+      return undefined;
+    }
+    let cancelled = false;
+    fetchChartUuid(sliceId)
+      .then(value => {
+        if (!cancelled) {
+          setFetchedUuid({ sliceId, uuid: value });
+        }
+      })
+      .catch(() => {
+        if (!cancelled) {
+          addDangerToast(t('Failed to load version history'));
+          // Without a uuid the panel would sit on a misleading
+          // "No history yet" empty state; close it instead.
+          dispatch(closeVersionHistoryPanel());
+        }
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [uuid, isPanelOpen, sliceId, addDangerToast, dispatch]);
+
+  // Server-side search over the full history; debounce so each keystroke
+  // doesn't refetch.
+  const [searchTerm, setSearchTerm] = useState('');
+  const debouncedSearch = useDebounceValue(searchTerm);
+  const activity = useVersionActivity(
+    'chart',
+    isPanelOpen ? uuid : undefined,
+    include,
+    debouncedSearch,
+  );
+
+  const { requestRestore, openAsNew, restoreModal } = useVersionActions(
+    'chart',
+    uuid,
+  );
+
+  // After a restore the server-side chart changed; reload the explore
+  // page state in place (same payload the page hydrates from) and
+  // refresh the activity timeline so the new "Restored version" entry
+  // shows up.
+  const restoreCount = useSelector(selectVersionRestoreCount);
+  const lastRestoredUuid = useSelector(selectVersionLastRestoredUuid);
+  // An overwrite save re-hydrates explore in place (no remount), which
+  // replaces the slice with a fresh server copy; watch its changed_on
+  // so the save surfaces as a new timeline entry while the panel is
+  // open. A "save as" navigates with PUSH and reloads the page, so it
+  // needs no signal.
+  const saveSignal = useSelector<ExplorePageState, string | undefined>(
+    state => state.explore?.slice?.changed_on,
+  );
+  const lastRestoreCountRef = useRef(restoreCount);
+  const lastSaveSignalRef = useRef(saveSignal);
+  const refreshActivity = activity.refresh;
+  // Invalidation token for the in-flight post-restore rehydration.
+  // hydrateExplore rewrites the whole explore store, so a fetch resolving
+  // after the page unmounted — or after a save-as swapped the slice in
+  // place — would overwrite the newly loaded chart's state with the old
+  // chart's payload. The token belongs to the fetch, not to the effect:
+  // the effect's identity moves whenever the debounced search term or the
+  // include filter does (both feed `refreshActivity`), and cancelling on
+  // those re-runs would drop a live rehydration that nothing re-issues.
+  const restoreHydrationIdRef = useRef(0);
+  // Only unmount and a slice/uuid swap supersede a rehydration here; a
+  // newer restore bumps the token where it starts its own fetch.
+  useEffect(
+    () => () => {
+      restoreHydrationIdRef.current += 1;
+    },
+    [sliceId, uuid],
+  );
+  useEffect(() => {
+    if (restoreCount !== lastRestoreCountRef.current) {
+      lastRestoreCountRef.current = restoreCount;
+      // Guard: a restore of some other entity, resolving after navigation.
+      // This chart did not change on the server; rehydrating would discard
+      // its state for someone else's restore.
+      if (lastRestoredUuid === uuid) {
+        // The restore refresh covers any save-signal movement caused by
+        // the same change; sync it so it does not refetch again.
+        lastSaveSignalRef.current = saveSignal;
+        refreshActivity();
+        if (sliceId) {
+          restoreHydrationIdRef.current += 1;
+          const hydrationId = restoreHydrationIdRef.current;
+          // A save landing while this fetch is in flight leaves the payload
+          // in hand older than the store: hydrating it would roll the chart
+          // back over the newer save. The save's own in-place hydration is
+          // already correct, so the stale restore payload is simply dropped.
+          const saveSignalAtStart = lastSaveSignalRef.current;
+          const isCurrent = () =>
+            restoreHydrationIdRef.current === hydrationId &&
+            lastSaveSignalRef.current === saveSignalAtStart;
+          fetchExploreRehydrationData(sliceId)
+            .then(result => {
+              if (isCurrent()) {
+                dispatch(
+                  hydrateExplore({ ...result, saveAction: 'overwrite' }),
+                );
+              }
+            })
+            .catch(() => {
+              if (isCurrent()) {
+                addDangerToast(t('Failed to reload the restored version'));
+              }
+            });
+        }
+      }
+    } else if (saveSignal !== lastSaveSignalRef.current) {
+      // A signal appearing where none existed is the page's initial
+      // hydration, not a save.
+      const isInitialHydration = lastSaveSignalRef.current === undefined;
+      lastSaveSignalRef.current = saveSignal;
+      if (!isInitialHydration) {
+        refreshActivity();
+      }
+    }
+  }, [
+    addDangerToast,
+    dispatch,
+    lastRestoredUuid,
+    refreshActivity,
+    restoreCount,
+    saveSignal,
+    sliceId,
+    uuid,
+  ]);
+
+  const handleClose = useCallback(() => {
+    dispatch(closeVersionHistoryPanel());
+  }, [dispatch]);
+
+  const handleIncludeChange = useCallback(
+    (value: ActivityInclude) => {
+      dispatch(setVersionHistoryInclude(value));
+    },
+    [dispatch],
+  );
+
+  const handlePreview = useCallback(
+    (group: SaveGroup) => {
+      if (!group.versionUuid || !uuid) {
+        return;
+      }
+      dispatch(
+        setVersionPreview({
+          entityUuid: uuid,
+          versionUuid: group.versionUuid,
+          transactionId: group.transactionId,
+          headline: groupHeadline(group),
+          issuedAt: group.issuedAt,
+        }),
+      );
+    },
+    [dispatch, uuid],
+  );
+
+  const handleExitPreview = useCallback(() => {
+    dispatch(clearVersionPreview(uuid));
+  }, [dispatch, uuid]);
+
+  const handleOpenRelated = useCallback(
+    (record: ActivityRecord) => {
+      openRelatedEntity(record, addDangerToast);
+    },
+    [addDangerToast],
+  );
+
+  const handleRestore = useCallback(
+    (group: SaveGroup) => {
+      if (group.versionUuid) {
+        requestRestore({
+          versionUuid: group.versionUuid,
+          headline: groupHeadline(group),
+          issuedAt: group.issuedAt,
+        });
+      }
+    },
+    [requestRestore],
+  );
+
+  const handleOpenAsNew = useCallback(
+    (group: SaveGroup) => {
+      if (group.versionUuid) {
+        openAsNew({
+          versionUuid: group.versionUuid,
+          headline: groupHeadline(group),
+          issuedAt: group.issuedAt,
+        });
+      }
+    },
+    [openAsNew],
+  );
+
+  if (!isPanelOpen) {
+    return restoreModal;
+  }
+
+  return (
+    <>
+      <PanelHost>
+        <VersionHistoryPanel
+          entityType="chart"
+          canRestore={canRestore}
+          activity={activity}
+          include={include}
+          onIncludeChange={handleIncludeChange}
+          searchTerm={searchTerm}
+          onSearchChange={setSearchTerm}
+          previewedTransactionId={preview?.transactionId ?? null}
+          onClose={handleClose}
+          onPreview={handlePreview}
+          onExitPreview={handleExitPreview}
+          onRestore={handleRestore}
+          onOpenAsNew={handleOpenAsNew}
+          onOpenRelated={handleOpenRelated}
+          sessionEntries={sessionLog}
+        />
+      </PanelHost>
+      {restoreModal}
+    </>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/PreviewBanner.test.tsx b/superset-frontend/src/features/versionHistory/PreviewBanner.test.tsx
new file mode 100644
index 00000000000..714110bea0f
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/PreviewBanner.test.tsx
@@ -0,0 +1,247 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 fetchMock from 'fetch-mock';
+import {
+  createStore,
+  render,
+  screen,
+  userEvent,
+  waitFor,
+  within,
+} from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
+import type { VersionHistoryState } from './types';
+import type { VersionHistoryRootState } from './reducer';
+import PreviewBanner from './PreviewBanner';
+
+// the test-helper store is untyped; recover the slice type for assertions
+const versionHistoryOf = (store: { getState: () => unknown }) =>
+  (store.getState() as VersionHistoryRootState).versionHistory;
+
+const versionHistoryState = (
+  overrides: Partial<VersionHistoryState> = {},
+): VersionHistoryState => ({
+  isPanelOpen: true,
+  entityType: 'chart',
+  include: 'all',
+  isPreviewApplying: false,
+  preview: {
+    entityUuid: 'entity-uuid',
+    versionUuid: 'version-uuid',
+    transactionId: 7,
+    headline: 'Dec 5, 2025, 12:18 PM',
+    issuedAt: '2025-12-05T17:18:00',
+  },
+  sessionLog: [],
+  restoreCount: 0,
+  lastRestoredEntityUuid: null,
+  ...overrides,
+});
+
+const renderBanner = (
+  entityType: 'chart' | 'dashboard' = 'chart',
+  state: VersionHistoryState = versionHistoryState(),
+  canRestore = true,
+) => {
+  const store = createStore({ versionHistory: state }, reducerIndex);
+  render(<PreviewBanner entityType={entityType} canRestore={canRestore} />, {
+    store,
+  });
+  return store;
+};
+
+const RESTORE_ENDPOINT =
+  'glob:*/api/v1/chart/entity-uuid/versions/version-uuid/restore';
+const ACTIVITY_ENDPOINT = 'glob:*/api/v1/chart/entity-uuid/activity/*';
+
+test('shows the preview date once when the headline is the save date', () => {
+  renderBanner();
+  const banner = screen.getByTestId('version-preview-banner');
+  expect(banner).toHaveTextContent(
+    'Previewing historical version · Dec 5, 2025, 12:18 PM',
+  );
+  expect(banner).not.toHaveTextContent(
+    'Dec 5, 2025, 12:18 PM · Dec 5, 2025, 12:18 PM',
+  );
+  expect(
+    screen.getByRole('button', { name: 'Restore this version' }),
+  ).toBeInTheDocument();
+  expect(
+    screen.getByRole('button', { name: 'Open as new chart' }),
+  ).toBeInTheDocument();
+});
+
+test('hides restore when the user cannot edit the entity', () => {
+  renderBanner('chart', versionHistoryState(), false);
+
+  expect(
+    screen.queryByRole('button', { name: 'Restore this version' }),
+  ).not.toBeInTheDocument();
+  expect(
+    screen.getByRole('button', { name: 'Open as new chart' }),
+  ).toBeInTheDocument();
+});
+
+test('omitting canRestore hides restore rather than offering it', () => {
+  // canRestore is a permission term, so the default has to fail closed: a call
+  // site that forgets it should lose the affordance, not gain an unguarded one.
+  const store = createStore(
+    { versionHistory: versionHistoryState() },
+    reducerIndex,
+  );
+  render(<PreviewBanner entityType="chart" />, { store });
+
+  expect(
+    screen.queryByRole('button', { name: 'Restore this version' }),
+  ).not.toBeInTheDocument();
+});
+
+test('shows headline and date when the headline carries its own label', () => {
+  renderBanner(
+    'chart',
+    versionHistoryState({
+      preview: {
+        entityUuid: 'entity-uuid',
+        versionUuid: 'version-uuid',
+        transactionId: 7,
+        headline: 'Restored version',
+        issuedAt: '2025-12-05T17:18:00',
+      },
+    }),
+  );
+  expect(screen.getByTestId('version-preview-banner')).toHaveTextContent(
+    'Previewing historical version · Restored version · Dec 5, 2025, 12:18 PM',
+  );
+});
+
+test('renders nothing when the preview belongs to another entity type', () => {
+  renderBanner('dashboard');
+  expect(
+    screen.queryByTestId('version-preview-banner'),
+  ).not.toBeInTheDocument();
+});
+
+test('renders nothing when no preview is active', () => {
+  renderBanner('chart', versionHistoryState({ preview: null }));
+  expect(
+    screen.queryByTestId('version-preview-banner'),
+  ).not.toBeInTheDocument();
+});
+
+test('close preview clears the preview from the store', async () => {
+  const store = renderBanner();
+  await userEvent.click(screen.getByRole('button', { name: 'Close preview' }));
+  expect(versionHistoryOf(store).preview).toBeNull();
+  expect(
+    screen.queryByTestId('version-preview-banner'),
+  ).not.toBeInTheDocument();
+});
+
+test('restoring from the banner confirms, posts, and exits the preview', async () => {
+  fetchMock.post(
+    RESTORE_ENDPOINT,
+    { result: { version_uuid: 'version-uuid' } },
+    { name: 'post-restore' },
+  );
+  // The restore flow probes the newest transaction before/after to
+  // detect no-op restores.
+  fetchMock.get(
+    ACTIVITY_ENDPOINT,
+    { result: [], count: 0 },
+    {
+      name: 'get-activity',
+    },
+  );
+  const store = renderBanner();
+
+  await userEvent.click(
+    screen.getByRole('button', { name: 'Restore this version' }),
+  );
+  const dialog = await screen.findByRole('dialog');
+  expect(within(dialog).getByText('Restore this version?')).toBeInTheDocument();
+  expect(within(dialog).getByText('Dec 5, 2025, 12:18 PM')).toBeInTheDocument();
+
+  await userEvent.click(
+    within(dialog).getByRole('button', { name: 'Restore this version' }),
+  );
+
+  await waitFor(() =>
+    expect(fetchMock.callHistory.calls('post-restore')).toHaveLength(1),
+  );
+  await waitFor(() => {
+    expect(versionHistoryOf(store).restoreCount).toBe(1);
+    expect(versionHistoryOf(store).preview).toBeNull();
+  });
+  expect(
+    screen.queryByTestId('version-preview-banner'),
+  ).not.toBeInTheDocument();
+
+  fetchMock.removeRoute('post-restore');
+  fetchMock.removeRoute('get-activity');
+});
+
+test('a failed restore keeps the preview active', async () => {
+  fetchMock.post(RESTORE_ENDPOINT, 500, { name: 'post-restore-fail' });
+  fetchMock.get(
+    ACTIVITY_ENDPOINT,
+    { result: [], count: 0 },
+    {
+      name: 'get-activity-fail',
+    },
+  );
+  const store = renderBanner();
+
+  await userEvent.click(
+    screen.getByRole('button', { name: 'Restore this version' }),
+  );
+  const dialog = await screen.findByRole('dialog');
+  await userEvent.click(
+    within(dialog).getByRole('button', { name: 'Restore this version' }),
+  );
+
+  await waitFor(() =>
+    expect(fetchMock.callHistory.calls('post-restore-fail')).toHaveLength(1),
+  );
+  await waitFor(() => expect(versionHistoryOf(store).restoreCount).toBe(0));
+  expect(versionHistoryOf(store).preview).not.toBeNull();
+
+  fetchMock.removeRoute('post-restore-fail');
+  fetchMock.removeRoute('get-activity-fail');
+});
+
+test('does not call the page historical while the snapshot is still loading', () => {
+  // Applying a snapshot takes several round trips; until they land the page
+  // under this banner is the live dashboard. Labelling it "Previewing
+  // historical version" would tell the user they are looking at the past
+  // when they are looking at the present -- and restore acts on what they
+  // believe is on screen.
+  renderBanner('chart', versionHistoryState({ isPreviewApplying: true }));
+  const banner = screen.getByTestId('version-preview-banner');
+
+  expect(banner).toHaveTextContent('Loading historical version');
+  expect(banner).not.toHaveTextContent('Previewing historical version');
+});
+
+test('withholds restore until the previewed version is actually on screen', () => {
+  renderBanner('chart', versionHistoryState({ isPreviewApplying: true }));
+
+  expect(
+    screen.queryByRole('button', { name: 'Restore this version' }),
+  ).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/features/versionHistory/PreviewBanner.tsx b/superset-frontend/src/features/versionHistory/PreviewBanner.tsx
new file mode 100644
index 00000000000..e2a43fc7409
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/PreviewBanner.tsx
@@ -0,0 +1,129 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { useCallback } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Alert } from '@apache-superset/core/components';
+import { Button } from '@superset-ui/core/components';
+import type { VersionedEntityType } from './types';
+import { clearVersionPreview, selectVersionHistory } from './reducer';
+import { useVersionActions } from './useVersionActions';
+import { formatVersionDateTime } from './display';
+
+const Actions = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+export interface PreviewBannerProps {
+  entityType: VersionedEntityType;
+  /**
+   * Whether to offer Restore. Defaults to false: this is a permission term,
+   * and a call site that forgets to pass it should lose the affordance rather
+   * than gain an unguarded one.
+   */
+  canRestore?: boolean;
+}
+
+export default function PreviewBanner({
+  entityType,
+  canRestore = false,
+}: PreviewBannerProps) {
+  const dispatch = useDispatch();
+  const {
+    entityType: activeEntityType,
+    preview,
+    isPreviewApplying: isApplying,
+  } = useSelector(selectVersionHistory);
+  const { requestRestore, openAsNew, isCreating, restoreModal } =
+    useVersionActions(entityType, preview?.entityUuid);
+
+  const handleClose = useCallback(() => {
+    dispatch(clearVersionPreview(preview?.entityUuid));
+  }, [dispatch, preview?.entityUuid]);
+
+  if (!preview || activeEntityType !== entityType) {
+    return null;
+  }
+
+  const target = {
+    versionUuid: preview.versionUuid,
+    headline: preview.headline,
+    issuedAt: preview.issuedAt,
+  };
+
+  // Chart save headlines are themselves the save datetime; avoid
+  // rendering the same timestamp twice.
+  const issuedAtLabel = formatVersionDateTime(preview.issuedAt);
+  // Applying a snapshot takes several round trips, and until they land the
+  // page below this banner is still the *live* dashboard. Saying "Previewing"
+  // then would label live data as historical -- the one reading a user must
+  // not be given, since restore acts on what they think they are looking at.
+  const message = isApplying
+    ? t('Loading historical version · %s', issuedAtLabel)
+    : preview.headline === issuedAtLabel
+      ? t('Previewing historical version · %s', issuedAtLabel)
+      : t(
+          'Previewing historical version · %s · %s',
+          preview.headline,
+          issuedAtLabel,
+        );
+
+  return (
+    <>
+      <Alert
+        type="info"
+        closable={false}
+        data-test="version-preview-banner"
+        message={message}
+        action={
+          <Actions>
+            {canRestore && !isApplying && (
+              <Button
+                buttonSize="small"
+                buttonStyle="primary"
+                onClick={() => requestRestore(target)}
+              >
+                {t('Restore this version')}
+              </Button>
+            )}
+            <Button
+              buttonSize="small"
+              buttonStyle="secondary"
+              loading={isCreating}
+              onClick={() => openAsNew(target)}
+            >
+              {entityType === 'chart'
+                ? t('Open as new chart')
+                : t('Open as new dashboard')}
+            </Button>
+            <Button buttonSize="small" buttonStyle="link" onClick={handleClose}>
+              {t('Close preview')}
+            </Button>
+          </Actions>
+        }
+      />
+      {restoreModal}
+    </>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/RelatedUpdateRow.tsx b/superset-frontend/src/features/versionHistory/RelatedUpdateRow.tsx
new file mode 100644
index 00000000000..e5417cfaea3
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/RelatedUpdateRow.tsx
@@ -0,0 +1,183 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { ComponentType } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Icons, Tooltip } from '@superset-ui/core/components';
+import type { IconType } from '@superset-ui/core/components/Icons/types';
+import type { ActivityEntityKind, ActivityRecord } from './types';
+import {
+  entityDisplayName,
+  formatAuthor,
+  formatVersionDateTimeShort,
+  relatedHeadline,
+  relatedRollupHeadline,
+} from './display';
+
+const ENTITY_ICON: Record<ActivityEntityKind, ComponentType<IconType>> = {
+  chart: Icons.BarChartOutlined,
+  dashboard: Icons.DashboardOutlined,
+  dataset: Icons.TableOutlined,
+};
+
+const Row = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: flex-start;
+    gap: ${theme.sizeUnit * 2}px;
+    padding: ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px;
+    border-bottom: 1px solid ${theme.colorBorderSecondary};
+  `}
+`;
+
+// The icon centers within the first text line (one line-height tall)
+// so it tracks the headline, not the middle of the two-line block.
+const IconWrapper = styled.span`
+  ${({ theme }) => `
+    color: ${theme.colorTextSecondary};
+    display: flex;
+    align-items: center;
+    height: ${theme.fontSize * theme.lineHeight}px;
+  `}
+`;
+
+const Content = styled.div`
+  ${({ theme }) => `
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+const Headline = styled.div`
+  ${({ theme }) => `
+    font-size: ${theme.fontSize}px;
+    line-height: ${theme.lineHeight};
+    color: ${theme.colorText};
+    overflow-wrap: anywhere;
+  `}
+`;
+
+const NameLink = styled.button`
+  ${({ theme }) => `
+    background: none;
+    border: none;
+    padding: 0;
+    cursor: pointer;
+    font: inherit;
+    color: ${theme.colorPrimary};
+    &:hover {
+      text-decoration: underline;
+    }
+  `}
+`;
+
+const Meta = styled.div`
+  ${({ theme }) => `
+    color: ${theme.colorTextTertiary};
+    font-size: ${theme.fontSizeSM}px;
+    line-height: ${theme.lineHeightSM};
+  `}
+`;
+
+export interface RelatedUpdateRowProps {
+  record: ActivityRecord;
+  /** Entity names when several same-kind entities rolled into this row. */
+  rollupEntityNames?: string[];
+  onOpen?: (record: ActivityRecord) => void;
+}
+
+export default function RelatedUpdateRow({
+  record,
+  rollupEntityNames,
+  onOpen,
+}: RelatedUpdateRowProps) {
+  const Icon = ENTITY_ICON[record.entity_kind] ?? Icons.FileOutlined;
+
+  if (rollupEntityNames && rollupEntityNames.length > 1) {
+    // No single target to link to; a tooltip lists the rolled-up names.
+    return (
+      <Row data-test="version-history-related-row">
+        <IconWrapper>
+          <Icon iconSize="l" />
+        </IconWrapper>
+        <Content>
+          <Tooltip
+            title={rollupEntityNames.map((name, index) => (
+              // eslint-disable-next-line react/no-array-index-key
+              <div key={index}>{name || t('Untitled')}</div>
+            ))}
+          >
+            <Headline>
+              {relatedRollupHeadline(
+                record.entity_kind,
+                rollupEntityNames.length,
+              )}
+            </Headline>
+          </Tooltip>
+          <Meta>
+            {formatAuthor(record.changed_by)} ·{' '}
+            {formatVersionDateTimeShort(record.issued_at)}
+          </Meta>
+        </Content>
+      </Row>
+    );
+  }
+
+  const headline = relatedHeadline(record);
+  const entityName = entityDisplayName(record);
+  const linkable = !record.entity_deleted && Boolean(onOpen);
+  // Both the server summary and the impact-aware phrasing end with the
+  // entity name; split it out so the name can render as a link. Records
+  // without a name can't be split (the empty string matches anywhere).
+  const nameIndex =
+    linkable && record.entity_name
+      ? headline.lastIndexOf(record.entity_name)
+      : -1;
+
+  return (
+    <Row data-test="version-history-related-row">
+      <IconWrapper>
+        <Icon iconSize="l" />
+      </IconWrapper>
+      <Content>
+        <Headline>
+          {nameIndex >= 0 ? (
+            <>
+              {headline.slice(0, nameIndex)}
+              <NameLink type="button" onClick={() => onOpen?.(record)}>
+                {entityName}
+              </NameLink>
+              {headline.slice(nameIndex + record.entity_name.length)}
+            </>
+          ) : (
+            headline
+          )}
+          {record.entity_deleted && ` (${t('deleted')})`}
+        </Headline>
+        <Meta>
+          {formatAuthor(record.changed_by)} ·{' '}
+          {formatVersionDateTimeShort(record.issued_at)}
+        </Meta>
+      </Content>
+    </Row>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/RestoreConfirmModal.tsx b/superset-frontend/src/features/versionHistory/RestoreConfirmModal.tsx
new file mode 100644
index 00000000000..562a3e7015f
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/RestoreConfirmModal.tsx
@@ -0,0 +1,72 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { t } from '@apache-superset/core/translation';
+import { Modal } from '@superset-ui/core/components';
+import type { VersionedEntityType } from './types';
+import type { VersionActionTarget } from './useVersionActions';
+import { formatVersionDateTime } from './display';
+
+export interface RestoreConfirmModalProps {
+  entityType: VersionedEntityType;
+  target: VersionActionTarget | null;
+  isRestoring: boolean;
+  onCancel: () => void;
+  onConfirm: () => void;
+}
+
+export default function RestoreConfirmModal({
+  entityType,
+  target,
+  isRestoring,
+  onCancel,
+  onConfirm,
+}: RestoreConfirmModalProps) {
+  if (!target) {
+    return null;
+  }
+  const dateLabel = formatVersionDateTime(target.issuedAt);
+  return (
+    <Modal
+      show
+      onHide={onCancel}
+      title={t('Restore this version?')}
+      width="480px"
+      responsive={false}
+      primaryButtonName={t('Restore this version')}
+      primaryButtonLoading={isRestoring}
+      onHandledPrimaryAction={onConfirm}
+      name="restore-version-modal"
+    >
+      <p>
+        {entityType === 'chart'
+          ? t('This chart will be restored to the following version:')
+          : t('This dashboard will be restored to the following version:')}
+      </p>
+      <p data-test="restore-version-name">
+        <strong>{target.headline}</strong>
+        {dateLabel !== target.headline && <> · {dateLabel}</>}
+      </p>
+      <p>
+        {t(
+          'Restoring creates a new version. Your existing version history will not be lost.',
+        )}
+      </p>
+    </Modal>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx b/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx
new file mode 100644
index 00000000000..f3d4e4b0f8b
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/SaveGroupItem.tsx
@@ -0,0 +1,386 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { KeyboardEvent, useState } from 'react';
+import { t, tn } from '@apache-superset/core/translation';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { Button, Dropdown, Icons, Tag } from '@superset-ui/core/components';
+import type { SaveGroup, VersionedEntityType } from './types';
+import {
+  formatAuthor,
+  formatVersionDateTimeShort,
+  groupHeadline,
+} from './display';
+import ActionRow from './ActionRow';
+
+/**
+ * The first chart save serializes the full form_data and can fan out
+ * into dozens of records; cap the initially visible rows per group.
+ */
+// TODO(version-history): backend workaround — remove when upstream stops
+// exploding the full form_data into per-field records on the first save.
+const VISIBLE_RECORD_LIMIT = 10;
+
+// The highlighted container gains inner padding but extends outward by
+// the same amount (negative margin) so its text stays column-aligned
+// with non-highlighted neighbors.
+const Container = styled.div<{ isPreviewed: boolean }>`
+  ${({ theme, isPreviewed }) => {
+    const inset = isPreviewed ? theme.sizeUnit * 3 : 0;
+    return `
+      border-bottom: 1px solid ${theme.colorBorderSecondary};
+      background-color: ${isPreviewed ? theme.colorPrimaryBg : 'transparent'};
+      border-radius: ${isPreviewed ? theme.borderRadius : 0}px;
+      padding: ${theme.sizeUnit * 2}px ${inset}px ${theme.sizeUnit * 4}px;
+      margin: 0 ${-inset}px;
+    `;
+  }}
+`;
+
+const Header = styled.div<{ hasRecords: boolean }>`
+  ${({ theme, hasRecords }) => `
+    display: flex;
+    align-items: flex-start;
+    gap: ${theme.sizeUnit * 2}px;
+    padding: ${theme.sizeUnit * 3}px 0;
+    cursor: ${hasRecords ? 'pointer' : 'default'};
+  `}
+`;
+
+const HeaderText = styled.div`
+  ${({ theme }) => `
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+const HeadlineRow = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    min-width: 0;
+  `}
+`;
+
+const Headline = styled.div`
+  ${({ theme }) => `
+    font-size: ${theme.fontSize}px;
+    line-height: ${theme.lineHeight};
+    color: ${theme.colorText};
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  `}
+`;
+
+const Meta = styled.div`
+  ${({ theme }) => `
+    color: ${theme.colorTextTertiary};
+    font-size: ${theme.fontSizeSM}px;
+    line-height: ${theme.lineHeightSM};
+  `}
+`;
+
+// Icons and trailing controls center within the first text line (one
+// line-height tall) so they track the headline, not the middle of a
+// two-line header block.
+const IconWrapper = styled.span`
+  ${({ theme }) => `
+    color: ${theme.colorTextSecondary};
+    display: flex;
+    align-items: center;
+    height: ${theme.fontSize * theme.lineHeight}px;
+  `}
+`;
+
+const ChevronWrapper = styled.span`
+  ${({ theme }) => `
+    color: ${theme.colorTextTertiary};
+    display: flex;
+    align-items: center;
+    height: ${theme.fontSize * theme.lineHeight}px;
+  `}
+`;
+
+const KebabSlot = styled.span`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    height: ${theme.fontSize * theme.lineHeight}px;
+  `}
+`;
+
+// Icon-only trigger: neutral icon color instead of the link-button blue.
+const KebabButton = styled(Button)`
+  ${({ theme }) => `
+    && {
+      color: ${theme.colorTextTertiary};
+    }
+    &&:hover,
+    &&:focus {
+      color: ${theme.colorText};
+    }
+  `}
+`;
+
+const ExpanderRow = styled.div`
+  ${({ theme }) => `
+    padding-left: ${theme.sizeUnit * 8}px;
+  `}
+`;
+
+export interface SaveGroupItemProps {
+  entityType: VersionedEntityType;
+  group: SaveGroup;
+  /** The newest self save: it IS the live state, not a historical one. */
+  isCurrent: boolean;
+  canRestore: boolean;
+  isPreviewed: boolean;
+  onPreview: (group: SaveGroup) => void;
+  /** Leave an active historical preview (back to the live version). */
+  onExitPreview?: () => void;
+  onRestore: (group: SaveGroup) => void;
+  onOpenAsNew: (group: SaveGroup) => void;
+}
+
+function GroupKebab({
+  entityType,
+  group,
+  isCurrent,
+  canRestore,
+  onRestore,
+  onOpenAsNew,
+}: Pick<
+  SaveGroupItemProps,
+  | 'entityType'
+  | 'group'
+  | 'isCurrent'
+  | 'canRestore'
+  | 'onRestore'
+  | 'onOpenAsNew'
+>) {
+  const theme = useTheme();
+  if (group.versionUuid == null) {
+    // Both actions name a specific version; a group the server returned
+    // without one has nothing for them to act on, and the container
+    // handlers would silently no-op. No kebab beats a dead menu.
+    return null;
+  }
+  const itemStyle = {
+    height: theme.controlHeightLG,
+    paddingLeft: theme.sizeUnit * 6,
+    paddingRight: theme.sizeUnit * 6,
+    display: 'flex',
+    alignItems: 'center',
+  };
+  const menuItems = [
+    // Restoring the live version is a no-op; offer it only on history.
+    ...(isCurrent || !canRestore
+      ? []
+      : [
+          {
+            key: 'restore',
+            label: t('Restore this version'),
+            style: itemStyle,
+            onClick: ({
+              domEvent,
+            }: {
+              domEvent: { stopPropagation: () => void };
+            }) => {
+              domEvent.stopPropagation();
+              onRestore(group);
+            },
+          },
+        ]),
+    {
+      key: 'open-as-new',
+      label:
+        entityType === 'chart'
+          ? t('Open as new chart')
+          : t('Open as new dashboard'),
+      style: itemStyle,
+      onClick: ({
+        domEvent,
+      }: {
+        domEvent: { stopPropagation: () => void };
+      }) => {
+        domEvent.stopPropagation();
+        onOpenAsNew(group);
+      },
+    },
+  ];
+  return (
+    <KebabSlot>
+      <Dropdown menu={{ items: menuItems }} trigger={['click']}>
+        <KebabButton
+          buttonSize="xsmall"
+          buttonStyle="link"
+          aria-label={t('More actions')}
+          onClick={event => event.stopPropagation()}
+        >
+          <Icons.MoreOutlined iconSize="m" />
+        </KebabButton>
+      </Dropdown>
+    </KebabSlot>
+  );
+}
+
+export default function SaveGroupItem({
+  entityType,
+  group,
+  isCurrent,
+  canRestore,
+  isPreviewed,
+  onPreview,
+  onExitPreview,
+  onRestore,
+  onOpenAsNew,
+}: SaveGroupItemProps) {
+  const [expanded, setExpanded] = useState(false);
+  const [showAll, setShowAll] = useState(false);
+  const headline = groupHeadline(group);
+  const meta = `${formatAuthor(group.changedBy)} · ${formatVersionDateTimeShort(
+    group.issuedAt,
+  )}`;
+
+  // The current version is the live state: there is nothing to preview,
+  // and selecting it while previewing an older version exits the preview.
+  const previewIntent = () => {
+    if (isCurrent) {
+      onExitPreview?.();
+    } else {
+      onPreview(group);
+    }
+  };
+
+  const activate =
+    (handler: () => void) => (event: KeyboardEvent<HTMLDivElement>) => {
+      // Keydowns from the nested kebab <button> bubble up here; only
+      // activate when the header itself has focus, so Enter/Space on the
+      // kebab opens its menu instead of toggling the group.
+      if (event.target !== event.currentTarget) {
+        return;
+      }
+      if (event.key === 'Enter' || event.key === ' ') {
+        event.preventDefault();
+        handler();
+      }
+    };
+
+  const currentTag = isCurrent ? <Tag>{t('Current')}</Tag> : null;
+
+  const hasRecords = group.records.length > 0;
+  const toggleExpanded = () => {
+    if (hasRecords) {
+      setExpanded(value => !value);
+    }
+  };
+
+  const visibleRecords = showAll
+    ? group.records
+    : group.records.slice(0, VISIBLE_RECORD_LIMIT);
+  const hiddenCount = group.records.length - visibleRecords.length;
+
+  return (
+    <Container isPreviewed={isPreviewed} data-test="version-history-save-group">
+      <Header
+        // A group with no records has nothing to expand — render it as a
+        // plain container instead of a focusable no-op button.
+        // eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- header contains a nested kebab <button>, which a real <button> cannot
+        role={hasRecords ? 'button' : undefined}
+        tabIndex={hasRecords ? 0 : undefined}
+        onClick={hasRecords ? toggleExpanded : undefined}
+        onKeyDown={hasRecords ? activate(toggleExpanded) : undefined}
+        aria-expanded={hasRecords ? expanded : undefined}
+        aria-label={hasRecords ? headline : undefined}
+        hasRecords={hasRecords}
+      >
+        <IconWrapper>
+          <Icons.CalendarOutlined iconSize="l" />
+        </IconWrapper>
+        <HeaderText>
+          <HeadlineRow>
+            <Headline title={headline}>{headline}</Headline>
+            {currentTag}
+          </HeadlineRow>
+          <Meta>{meta}</Meta>
+        </HeaderText>
+        <GroupKebab
+          entityType={entityType}
+          group={group}
+          isCurrent={isCurrent}
+          canRestore={canRestore}
+          onRestore={onRestore}
+          onOpenAsNew={onOpenAsNew}
+        />
+        {hasRecords && (
+          <ChevronWrapper>
+            {expanded ? (
+              <Icons.UpOutlined iconSize="m" />
+            ) : (
+              <Icons.DownOutlined iconSize="m" />
+            )}
+          </ChevronWrapper>
+        )}
+      </Header>
+      {expanded && (
+        <>
+          {visibleRecords.map((record, index) => (
+            <ActionRow
+              // kind/operation/path alone can repeat within one save — the
+              // same collision grouping's recordKey guards against with the
+              // record values; the index breaks any remaining ties here.
+              key={`${record.kind}-${record.operation}-${JSON.stringify(
+                record.path,
+              )}-${index}`}
+              entityType={entityType}
+              record={record}
+              showRestore={canRestore && !isCurrent}
+              showActions={group.versionUuid != null}
+              isPreviewed={isPreviewed}
+              isLast={index === visibleRecords.length - 1 && hiddenCount === 0}
+              onPreview={previewIntent}
+              onRestore={() => onRestore(group)}
+              onOpenAsNew={() => onOpenAsNew(group)}
+            />
+          ))}
+          {hiddenCount > 0 && (
+            <ExpanderRow>
+              <Button
+                buttonSize="xsmall"
+                buttonStyle="link"
+                onClick={() => setShowAll(true)}
+              >
+                {tn(
+                  'Show %s more change',
+                  'Show %s more changes',
+                  hiddenCount,
+                  hiddenCount,
+                )}
+              </Button>
+            </ExpanderRow>
+          )}
+        </>
+      )}
+    </Container>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx
new file mode 100644
index 00000000000..36c57e2419c
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.test.tsx
@@ -0,0 +1,498 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 {
+  render,
+  screen,
+  selectOption,
+  userEvent,
+} from 'spec/helpers/testing-library';
+import type {
+  ActivityRecord,
+  SaveGroup,
+  TimelineEntry,
+  VersionedEntityType,
+} from './types';
+import { buildTimeline } from './grouping';
+import type { UseVersionActivityResult } from './useVersionActivity';
+import VersionHistoryPanel, {
+  VersionHistoryPanelProps,
+} from './VersionHistoryPanel';
+
+const record = (overrides: Partial<ActivityRecord> = {}): ActivityRecord => ({
+  version_uuid: 'v-1',
+  entity_kind: 'chart',
+  entity_uuid: 'e-1',
+  entity_name: 'My chart',
+  entity_deleted: false,
+  entity_deletion_state: null,
+  source: 'self',
+  transaction_id: 10,
+  action_kind: null,
+  issued_at: '2025-12-05T17:18:00',
+  changed_by: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+  kind: 'metric',
+  operation: 'add',
+  path: ['params', 'metrics', 'Revenue'],
+  from_value: null,
+  to_value: { label: 'Revenue' },
+  summary: '',
+  impact: null,
+  ...overrides,
+});
+
+const group = (overrides: Partial<SaveGroup> = {}): SaveGroup => ({
+  type: 'group',
+  transactionId: 10,
+  versionUuid: 'v-1',
+  issuedAt: '2025-12-05T17:18:00',
+  changedBy: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+  actionKind: null,
+  records: [record()],
+  ...overrides,
+});
+
+const activity = (
+  timeline: TimelineEntry[],
+  overrides: Partial<UseVersionActivityResult> = {},
+): UseVersionActivityResult => ({
+  records: [],
+  timeline,
+  // Mirrors the hook's default: the newest group of an unfiltered timeline.
+  newestGroup:
+    (timeline.find(entry => entry.type === 'group') as SaveGroup | undefined) ??
+    null,
+  currentVersionStatus: timeline.some(entry => entry.type === 'group')
+    ? 'known'
+    : 'empty',
+  count: timeline.length,
+  isLoading: false,
+  error: null,
+  hasMore: false,
+  truncated: false,
+  loadMore: jest.fn(),
+  refresh: jest.fn(),
+  ...overrides,
+});
+
+const defaultProps = (
+  timeline: TimelineEntry[],
+  entityType: VersionedEntityType = 'chart',
+): VersionHistoryPanelProps => ({
+  entityType,
+  activity: activity(timeline),
+  include: 'all',
+  // Explicit rather than relying on the default, which fails closed.
+  canRestore: true,
+  onIncludeChange: jest.fn(),
+  searchTerm: '',
+  onSearchChange: jest.fn(),
+  previewedTransactionId: null,
+  onClose: jest.fn(),
+  onPreview: jest.fn(),
+  onExitPreview: jest.fn(),
+  onRestore: jest.fn(),
+  onOpenAsNew: jest.fn(),
+});
+
+/** A pair of dashboard saves: the newest (current) and an older one. */
+const dashboardPair = () => {
+  const older = group({
+    records: [record({ entity_kind: 'dashboard', kind: 'chart' })],
+  });
+  const newest = group({
+    transactionId: 20,
+    versionUuid: 'v-2',
+    issuedAt: '2025-12-08T17:18:00',
+    records: [
+      record({
+        entity_kind: 'dashboard',
+        kind: 'chart',
+        transaction_id: 20,
+        issued_at: '2025-12-08T17:18:00',
+      }),
+    ],
+  });
+  return { newest, older };
+};
+
+test('chart groups expand to show granular action rows', async () => {
+  const props = defaultProps([group()]);
+  render(<VersionHistoryPanel {...props} />);
+
+  const header = screen.getByRole('button', {
+    name: 'Dec 5, 2025, 12:18 PM',
+  });
+  expect(header).toHaveAttribute('aria-expanded', 'false');
+  expect(
+    screen.queryByText("Applied 'Revenue' metric"),
+  ).not.toBeInTheDocument();
+
+  await userEvent.click(header);
+  expect(header).toHaveAttribute('aria-expanded', 'true');
+  expect(screen.getByText("Applied 'Revenue' metric")).toBeInTheDocument();
+});
+
+test('long groups cap visible rows behind a show-more expander', async () => {
+  const records = Array.from({ length: 12 }, (_, i) =>
+    record({
+      kind: 'field',
+      operation: 'edit',
+      path: ['params', `field_${i}`],
+    }),
+  );
+  const props = defaultProps([group({ records })]);
+  render(<VersionHistoryPanel {...props} />);
+
+  await userEvent.click(
+    screen.getByRole('button', { name: 'Dec 5, 2025, 12:18 PM' }),
+  );
+  expect(screen.getAllByTestId('version-history-action-row')).toHaveLength(10);
+
+  await userEvent.click(
+    screen.getByRole('button', { name: 'Show 2 more changes' }),
+  );
+  expect(screen.getAllByTestId('version-history-action-row')).toHaveLength(12);
+});
+
+// Dashboards share the chart container model (sc-107283 guide, 2026-06-12):
+// the save's date/time heads the group and expands to descriptive per-change
+// rows — no compact "Filters"/"Edit mode" category headline.
+test('dashboard groups head with the save date and expand to descriptive rows', async () => {
+  const props = defaultProps(
+    [
+      group({
+        records: [
+          record({
+            entity_kind: 'dashboard',
+            kind: 'filter',
+            path: ['json_metadata', 'native_filter_configuration', 'NATIVE_1'],
+          }),
+        ],
+      }),
+    ],
+    'dashboard',
+  );
+  render(<VersionHistoryPanel {...props} />);
+
+  const header = screen.getByRole('button', { name: 'Dec 5, 2025, 12:18 PM' });
+  expect(header).toBeInTheDocument();
+  expect(
+    screen.queryByText("Added filter on 'Revenue'"),
+  ).not.toBeInTheDocument();
+
+  await userEvent.click(header);
+  expect(screen.getByText("Added filter on 'Revenue'")).toBeInTheDocument();
+});
+
+test('a group without a versionUuid offers no kebab at all', async () => {
+  // Restore and open-as-new both name a specific version; the container
+  // handlers silently no-op on a null versionUuid, so offering the menu
+  // reads as breakage. No kebab beats a dead menu — and the expanded
+  // action rows hide theirs for the same reason.
+  const versionless = group({ transactionId: 3, versionUuid: null });
+  const props = defaultProps([versionless], 'dashboard');
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(
+    screen.queryByRole('button', { name: 'More actions' }),
+  ).not.toBeInTheDocument();
+
+  // Expanding the group must not surface per-row kebabs either.
+  await userEvent.click(screen.getByText('Ada Lovelace', { exact: false }));
+  expect(
+    screen.queryByRole('button', { name: 'More actions' }),
+  ).not.toBeInTheDocument();
+});
+
+test('the group kebab restores and forks the version', async () => {
+  const { newest, older } = dashboardPair();
+  const props = defaultProps([newest, older], 'dashboard');
+  render(<VersionHistoryPanel {...props} />);
+
+  // The newest save is the live one; act on the older save's kebab.
+  const olderKebab = () =>
+    screen.getAllByRole('button', { name: 'More actions' })[1];
+  await userEvent.click(olderKebab());
+  await userEvent.click(await screen.findByText('Restore this version'));
+  expect(props.onRestore).toHaveBeenCalledWith(older);
+
+  await userEvent.click(olderKebab());
+  await userEvent.click(await screen.findByText('Open as new dashboard'));
+  expect(props.onOpenAsNew).toHaveBeenCalledWith(older);
+});
+
+test('previewing a change row in an older dashboard group calls onPreview', async () => {
+  const { newest, older } = dashboardPair();
+  const props = defaultProps([newest, older], 'dashboard');
+  render(<VersionHistoryPanel {...props} />);
+
+  // Expand the older save (the newest is the live one), then click one of its
+  // change rows: the row, not the header, drives the preview.
+  await userEvent.click(
+    screen.getByRole('button', { name: 'Dec 5, 2025, 12:18 PM' }),
+  );
+  await userEvent.click(screen.getByTestId('version-history-action-row'));
+  expect(props.onPreview).toHaveBeenCalledWith(older);
+});
+
+test('an active search never mis-tags the newest match as Current', () => {
+  // The search filtered out the true newest save; the visible newest match
+  // is an older version and must not be treated as Current.
+  const trueNewest = group({ transactionId: 99, versionUuid: 'v-99' });
+  const olderMatch = group({ transactionId: 5, versionUuid: 'v-5' });
+  const props = defaultProps([olderMatch]);
+  props.searchTerm = 'rename';
+  props.activity = activity([olderMatch], { newestGroup: trueNewest });
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(screen.queryByText('Current')).not.toBeInTheDocument();
+});
+
+test('the newest save shows a Current tag and older saves do not', () => {
+  const { newest, older } = dashboardPair();
+  const props = defaultProps([newest, older], 'dashboard');
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(screen.getAllByText('Current')).toHaveLength(1);
+  const groups = screen.getAllByTestId('version-history-save-group');
+  expect(groups[0]).toHaveTextContent('Current');
+  expect(groups[1]).not.toHaveTextContent('Current');
+});
+
+test('previewing a change row in the current version exits an active preview', async () => {
+  const { newest, older } = dashboardPair();
+  const props = defaultProps([newest, older], 'dashboard');
+  props.previewedTransactionId = older.transactionId;
+  render(<VersionHistoryPanel {...props} />);
+
+  // Expand the current (newest) save and click a change row: the live version
+  // has nothing to preview, so it exits the active preview instead.
+  await userEvent.click(
+    screen.getByRole('button', { name: 'Dec 8, 2025, 12:18 PM' }),
+  );
+  await userEvent.click(screen.getByTestId('version-history-action-row'));
+  expect(props.onPreview).not.toHaveBeenCalled();
+  expect(props.onExitPreview).toHaveBeenCalled();
+});
+
+test('the current version kebab omits restore but keeps open as new', async () => {
+  const { newest, older } = dashboardPair();
+  const props = defaultProps([newest, older], 'dashboard');
+  render(<VersionHistoryPanel {...props} />);
+
+  await userEvent.click(
+    screen.getAllByRole('button', { name: 'More actions' })[0],
+  );
+  expect(await screen.findByText('Open as new dashboard')).toBeInTheDocument();
+  expect(screen.queryByText('Restore this version')).not.toBeInTheDocument();
+});
+
+test('an unavailable current-version probe explains and withholds restore', async () => {
+  const { newest, older } = dashboardPair();
+  const props = defaultProps([newest, older], 'dashboard');
+  props.activity = activity([newest, older], {
+    newestGroup: null,
+    currentVersionStatus: 'unavailable',
+  });
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(
+    screen.getByText(
+      'The current version could not be identified. Restore is temporarily unavailable.',
+    ),
+  ).toBeInTheDocument();
+
+  await userEvent.click(
+    screen.getAllByRole('button', { name: 'More actions' })[1],
+  );
+  expect(await screen.findByText('Open as new dashboard')).toBeInTheDocument();
+  expect(screen.queryByText('Restore this version')).not.toBeInTheDocument();
+});
+
+test('a loading current-version probe withholds restore without an error', async () => {
+  const { newest, older } = dashboardPair();
+  const props = defaultProps([newest, older], 'dashboard');
+  props.activity = activity([newest, older], {
+    newestGroup: null,
+    currentVersionStatus: 'loading',
+  });
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(
+    screen.queryByText(/current version could not be identified/i),
+  ).not.toBeInTheDocument();
+  await userEvent.click(
+    screen.getAllByRole('button', { name: 'More actions' })[1],
+  );
+  expect(screen.queryByText('Restore this version')).not.toBeInTheDocument();
+});
+
+// Search is server-side: the panel forwards the term to the container, which
+// debounces it and refetches; the panel renders whatever matches come back
+// (sc-107283 guide, 2026-06-12).
+test('typing in the search box forwards the term to the container', async () => {
+  const props = defaultProps([group()]);
+  render(<VersionHistoryPanel {...props} />);
+
+  await userEvent.type(
+    screen.getByRole('textbox', { name: 'Search actions' }),
+    'z',
+  );
+  expect(props.onSearchChange).toHaveBeenCalledWith('z');
+});
+
+test('an empty result while searching reports no matches', () => {
+  const props = defaultProps([]);
+  props.searchTerm = 'zzz';
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(screen.getByText('No actions found')).toBeInTheDocument();
+  expect(screen.getByText('Try a different search term')).toBeInTheDocument();
+});
+
+test('the include filter calls back with the selected scope', async () => {
+  const props = defaultProps([group()]);
+  render(<VersionHistoryPanel {...props} />);
+
+  await selectOption('Related items only', 'Filter version history');
+  expect(props.onIncludeChange).toHaveBeenCalledWith('related');
+});
+
+test('an empty timeline shows the no-history empty state', () => {
+  const props = defaultProps([]);
+  render(<VersionHistoryPanel {...props} />);
+  expect(screen.getByText('No history yet')).toBeInTheDocument();
+  expect(
+    screen.getByText('Saved changes will appear here'),
+  ).toBeInTheDocument();
+});
+
+const relatedEntry = (overrides: Partial<ActivityRecord>): TimelineEntry => {
+  const relatedRecord = record({ source: 'related', ...overrides });
+  return { type: 'related', record: relatedRecord, records: [relatedRecord] };
+};
+
+test('related rows link to the entity unless it was deleted', () => {
+  const props = defaultProps([
+    relatedEntry({
+      entity_kind: 'dataset',
+      entity_name: 'Sales',
+      entity_uuid: 'ds-1',
+      transaction_id: 11,
+      summary: 'Dataset updated: Sales',
+    }),
+    relatedEntry({
+      entity_kind: 'chart',
+      entity_name: 'Trend',
+      entity_uuid: 'c-2',
+      entity_deleted: true,
+      transaction_id: 12,
+      summary: 'Chart updated: Trend',
+    }),
+  ]);
+  props.onOpenRelated = jest.fn();
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(screen.getByRole('button', { name: 'Sales' })).toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: 'Trend' }),
+  ).not.toBeInTheDocument();
+  expect(screen.getByText(/\(deleted\)/)).toBeInTheDocument();
+});
+
+test('same-transaction related cascades roll up into one pluralized row', async () => {
+  const timeline = buildTimeline(
+    Array.from({ length: 10 }, (_, i) =>
+      record({
+        source: 'related',
+        entity_kind: 'chart',
+        entity_uuid: `c-${i}`,
+        entity_name: `Chart ${i}`,
+        transaction_id: 30,
+        summary: `Chart updated: Chart ${i}`,
+      }),
+    ),
+  );
+  const props = defaultProps(timeline);
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(screen.getAllByTestId('version-history-related-row')).toHaveLength(1);
+  const headline = screen.getByText('10 charts updated');
+
+  // The rolled-up entity names are listed in a tooltip.
+  await userEvent.hover(headline);
+  expect(await screen.findByText('Chart 7')).toBeInTheDocument();
+});
+
+test('a clipped history says so once the last page is reached', () => {
+  const props = defaultProps([group()]);
+  props.activity = activity([group()], { hasMore: false, truncated: true });
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(
+    screen.getByText(/Older history exists but is beyond/),
+  ).toBeInTheDocument();
+});
+
+test('a complete history shows no truncation notice', () => {
+  const props = defaultProps([group()]);
+  render(<VersionHistoryPanel {...props} />);
+
+  expect(
+    screen.queryByText(/Older history exists but is beyond/),
+  ).not.toBeInTheDocument();
+});
+
+test('load more requests the next page', async () => {
+  const loadMore = jest.fn();
+  const props = defaultProps([group()]);
+  props.activity = activity([group()], { hasMore: true, loadMore });
+  render(<VersionHistoryPanel {...props} />);
+
+  await userEvent.click(screen.getByRole('button', { name: 'Load more' }));
+  expect(loadMore).toHaveBeenCalled();
+});
+
+test('a restore at the head of the timeline surfaces a notice', async () => {
+  const props = defaultProps([
+    group({
+      transactionId: 20,
+      actionKind: 'restore',
+      issuedAt: '2025-12-08T17:18:00',
+    }),
+    group(),
+  ]);
+  render(<VersionHistoryPanel {...props} />);
+
+  await userEvent.click(
+    screen.getByRole('button', { name: 'Current version' }),
+  );
+  expect(
+    screen.getByText('Restored version · Dec 8, 2025, 12:18 PM'),
+  ).toBeInTheDocument();
+});
+
+test('the close button dismisses the panel', async () => {
+  const props = defaultProps([group()]);
+  render(<VersionHistoryPanel {...props} />);
+  await userEvent.click(
+    screen.getByRole('button', { name: 'Close version history' }),
+  );
+  expect(props.onClose).toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/features/versionHistory/VersionHistoryPanel.tsx b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.tsx
new file mode 100644
index 00000000000..fa638bdfe65
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/VersionHistoryPanel.tsx
@@ -0,0 +1,343 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { useMemo } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Alert } from '@apache-superset/core/components';
+import {
+  Button,
+  EmptyState,
+  Icons,
+  Input,
+  Select,
+  Skeleton,
+} from '@superset-ui/core/components';
+import type {
+  ActivityInclude,
+  ActivityRecord,
+  SaveGroup,
+  SessionLogEntry,
+  VersionedEntityType,
+} from './types';
+import type { UseVersionActivityResult } from './useVersionActivity';
+import { relatedEntryKey } from './grouping';
+import { formatVersionDateTime } from './display';
+import SaveGroupItem from './SaveGroupItem';
+import RelatedUpdateRow from './RelatedUpdateRow';
+import CurrentVersionSection from './CurrentVersionSection';
+
+const VERSION_HISTORY_PANEL_WIDTH = 320;
+
+const Panel = styled.aside`
+  ${({ theme }) => `
+    width: ${VERSION_HISTORY_PANEL_WIDTH}px;
+    min-width: ${VERSION_HISTORY_PANEL_WIDTH}px;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    border-left: 1px solid ${theme.colorSplit};
+    background-color: ${theme.colorBgContainer};
+  `}
+`;
+
+const PanelHeader = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px;
+    border-bottom: 1px solid ${theme.colorSplit};
+  `}
+`;
+
+const PanelTitle = styled.span`
+  ${({ theme }) => `
+    font-size: ${theme.fontSize}px;
+    line-height: ${theme.lineHeight};
+    color: ${theme.colorText};
+  `}
+`;
+
+const Controls = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px;
+    > * {
+      flex: 1 1 0;
+      min-width: 0;
+    }
+  `}
+`;
+
+const Body = styled.div`
+  ${({ theme }) => `
+    flex: 1;
+    overflow-y: auto;
+    padding: 0 ${theme.sizeUnit * 4}px;
+  `}
+`;
+
+// Icon-only trigger: neutral icon color instead of the link-button blue.
+const CloseButton = styled(Button)`
+  ${({ theme }) => `
+    && {
+      color: ${theme.colorText};
+    }
+    &&:hover,
+    &&:focus {
+      color: ${theme.colorTextSecondary};
+    }
+  `}
+`;
+
+const Footer = styled.div`
+  ${({ theme }) => `
+    padding: ${theme.sizeUnit * 2}px;
+    text-align: center;
+  `}
+`;
+
+const TruncationNotice = styled.div`
+  ${({ theme }) => `
+    padding: ${theme.sizeUnit * 2}px;
+    text-align: center;
+    color: ${theme.colorTextTertiary};
+    font-size: ${theme.fontSizeSM}px;
+  `}
+`;
+
+const PaddedContent = styled.div`
+  ${({ theme }) => `
+    padding: ${theme.sizeUnit * 4}px;
+  `}
+`;
+
+export interface VersionHistoryPanelProps {
+  entityType: VersionedEntityType;
+  activity: UseVersionActivityResult;
+  include: ActivityInclude;
+  /**
+   * Whether to offer Restore. Defaults to false: this is a permission term,
+   * and a call site that forgets to pass it should lose the affordance rather
+   * than gain an unguarded one.
+   */
+  canRestore?: boolean;
+  onIncludeChange: (include: ActivityInclude) => void;
+  /**
+   * Free-text search is controlled by the container, which debounces it
+   * and feeds it to the activity hook so the server filters the full
+   * history (not just the loaded pages). The panel renders the matches
+   * verbatim.
+   */
+  searchTerm: string;
+  onSearchChange: (value: string) => void;
+  previewedTransactionId: number | null;
+  onClose: () => void;
+  onPreview: (group: SaveGroup) => void;
+  /** Leave an active historical preview (back to the live version). */
+  onExitPreview?: () => void;
+  onRestore: (group: SaveGroup) => void;
+  onOpenAsNew: (group: SaveGroup) => void;
+  onOpenRelated?: (record: ActivityRecord) => void;
+  sessionEntries?: SessionLogEntry[];
+}
+
+export default function VersionHistoryPanel({
+  entityType,
+  activity,
+  include,
+  canRestore = false,
+  onIncludeChange,
+  searchTerm,
+  onSearchChange,
+  previewedTransactionId,
+  onClose,
+  onPreview,
+  onExitPreview,
+  onRestore,
+  onOpenAsNew,
+  onOpenRelated,
+  sessionEntries = [],
+}: VersionHistoryPanelProps) {
+  const {
+    timeline,
+    newestGroup,
+    currentVersionStatus,
+    isLoading,
+    error,
+    hasMore,
+    truncated,
+    loadMore,
+  } = activity;
+
+  const includeOptions = useMemo(
+    () => [
+      { value: 'all', label: t('All changes') },
+      {
+        value: 'self',
+        label:
+          entityType === 'chart'
+            ? t('This chart only')
+            : t('This dashboard only'),
+      },
+      { value: 'related', label: t('Related items only') },
+    ],
+    [entityType],
+  );
+
+  const hasSearch = searchTerm.trim().length > 0;
+
+  // The newest self save IS the live state: it gets a "Current" tag,
+  // no preview affordances, and no restore action. The newest save
+  // being a restore additionally means the live entity matches an
+  // older version; surface that in the "Current version" section.
+  // newestGroup comes from the hook's last unfiltered fetch — the visible
+  // timeline is search-filtered, so its first group may be an older save.
+  const currentTransactionId = newestGroup?.transactionId ?? null;
+  const canRestoreKnownVersion = canRestore && currentVersionStatus === 'known';
+  const restoreNotice =
+    newestGroup?.actionKind === 'restore'
+      ? t('Restored version · %s', formatVersionDateTime(newestGroup.issuedAt))
+      : null;
+
+  const isInitialLoading = isLoading && timeline.length === 0;
+
+  return (
+    <Panel aria-label={t('Version history')}>
+      <PanelHeader>
+        <PanelTitle>{t('Version history')}</PanelTitle>
+        <CloseButton
+          buttonSize="xsmall"
+          buttonStyle="link"
+          aria-label={t('Close version history')}
+          onClick={onClose}
+        >
+          <Icons.CloseOutlined iconSize="xl" />
+        </CloseButton>
+      </PanelHeader>
+      <Controls>
+        <Input
+          allowClear
+          placeholder={t('Search actions')}
+          suffix={<Icons.SearchOutlined iconSize="l" />}
+          value={searchTerm}
+          onChange={event => onSearchChange(event.target.value)}
+          aria-label={t('Search actions')}
+        />
+        <Select
+          ariaLabel={t('Filter version history')}
+          value={include}
+          onChange={value => onIncludeChange(value as ActivityInclude)}
+          options={includeOptions}
+        />
+      </Controls>
+      <Body>
+        <CurrentVersionSection
+          entries={sessionEntries}
+          restoreNotice={restoreNotice}
+        />
+        {error && (
+          <PaddedContent>
+            <Alert type="error" message={error} closable={false} />
+          </PaddedContent>
+        )}
+        {currentVersionStatus === 'unavailable' && timeline.length > 0 && (
+          <PaddedContent>
+            <Alert
+              type="warning"
+              message={t(
+                'The current version could not be identified. Restore is temporarily unavailable.',
+              )}
+              closable={false}
+            />
+          </PaddedContent>
+        )}
+        {isInitialLoading && (
+          <PaddedContent>
+            <Skeleton active />
+          </PaddedContent>
+        )}
+        {!isInitialLoading && !error && timeline.length === 0 && (
+          <EmptyState
+            image="empty.svg"
+            size="small"
+            title={hasSearch ? t('No actions found') : t('No history yet')}
+            description={
+              hasSearch
+                ? t('Try a different search term')
+                : t('Saved changes will appear here')
+            }
+          />
+        )}
+        {timeline.map(entry =>
+          entry.type === 'group' ? (
+            <SaveGroupItem
+              key={`group-${entry.transactionId}`}
+              entityType={entityType}
+              group={entry}
+              isCurrent={entry.transactionId === currentTransactionId}
+              canRestore={canRestoreKnownVersion}
+              isPreviewed={
+                entry.transactionId === previewedTransactionId &&
+                entry.transactionId !== currentTransactionId
+              }
+              onPreview={onPreview}
+              onExitPreview={onExitPreview}
+              onRestore={onRestore}
+              onOpenAsNew={onOpenAsNew}
+            />
+          ) : (
+            <RelatedUpdateRow
+              key={
+                entry.rollupEntityNames
+                  ? `related-rollup-${entry.record.transaction_id}-${entry.record.entity_kind}`
+                  : `related-${relatedEntryKey(entry.record)}`
+              }
+              record={entry.record}
+              rollupEntityNames={entry.rollupEntityNames}
+              onOpen={onOpenRelated}
+            />
+          ),
+        )}
+        {hasMore && (
+          <Footer>
+            <Button
+              buttonStyle="link"
+              onClick={loadMore}
+              loading={isLoading && timeline.length > 0}
+            >
+              {t('Load more')}
+            </Button>
+          </Footer>
+        )}
+        {!hasMore &&
+          truncated &&
+          timeline.length > 0 && (
+            // The server clips very long histories at a fetch ceiling; without
+            // saying so, the last loaded page reads as the beginning of time.
+            <TruncationNotice role="note">
+              {t('Older history exists but is beyond what can be shown here.')}
+            </TruncationNotice>
+          )}
+      </Body>
+    </Panel>
+  );
+}
diff --git a/superset-frontend/src/features/versionHistory/api.test.ts b/superset-frontend/src/features/versionHistory/api.test.ts
new file mode 100644
index 00000000000..9372e1a82a1
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/api.test.ts
@@ -0,0 +1,232 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 fetchMock from 'fetch-mock';
+import {
+  createChartFromSnapshot,
+  createDashboardFromSnapshot,
+  fetchActivity,
+} from './api';
+import type {
+  ChartVersionSnapshot,
+  DashboardVersionSnapshot,
+  VersionMeta,
+} from './types';
+
+const versionMeta: VersionMeta = {
+  version_uuid: 'version-uuid',
+  version_number: 3,
+  transaction_id: 12,
+  operation_type: 'update',
+  issued_at: '2025-12-05T17:18:00',
+  changed_by: null,
+  changes: [],
+};
+
+const positions = {
+  ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
+  'CHART-abc': {
+    type: 'CHART',
+    id: 'CHART-abc',
+    children: [],
+    meta: { chartId: 101, uuid: 'chart-101-uuid', width: 4, height: 50 },
+  },
+};
+
+const snapshot: DashboardVersionSnapshot = {
+  dashboard_title: 'Sales overview',
+  position_json: JSON.stringify(positions),
+  json_metadata: JSON.stringify({ color_scheme: 'supersetColors' }),
+  css: '.dashboard { color: red; }',
+  description: null,
+  slug: null,
+  certified_by: null,
+  certification_details: null,
+  published: true,
+  theme_id: null,
+  uuid: 'dash-uuid',
+  _version: versionMeta,
+};
+
+const LIST_ENDPOINT = 'glob:*/api/v1/dashboard/?q=*';
+const CHART_LIST_ENDPOINT = 'glob:*/api/v1/chart/?q=*';
+const COPY_ENDPOINT = 'glob:*/api/v1/dashboard/42/copy/';
+
+afterEach(() => {
+  fetchMock.removeRoutes();
+  fetchMock.clearHistory();
+});
+
+const ACTIVITY_ENDPOINT = 'glob:*/api/v1/chart/chart-uuid/activity/?*';
+
+test('fetchActivity sends the base pagination params and omits empty filters', async () => {
+  fetchMock.get(ACTIVITY_ENDPOINT, { result: [], count: 0 });
+
+  await fetchActivity('chart', 'chart-uuid', { include: 'self', page: 2 });
+
+  const url = new URL(fetchMock.callHistory.calls(ACTIVITY_ENDPOINT)[0].url);
+  expect(url.searchParams.get('include')).toBe('self');
+  expect(url.searchParams.get('page')).toBe('2');
+  expect(url.searchParams.get('page_size')).toBe('25');
+  // No search filter was supplied, so it must not be sent.
+  expect(url.searchParams.has('q')).toBe(false);
+});
+
+test('fetchActivity forwards a trimmed q filter', async () => {
+  fetchMock.get(ACTIVITY_ENDPOINT, { result: [], count: 0 });
+
+  await fetchActivity('chart', 'chart-uuid', { q: '  revenue  ' });
+
+  const url = new URL(fetchMock.callHistory.calls(ACTIVITY_ENDPOINT)[0].url);
+  expect(url.searchParams.get('q')).toBe('revenue');
+});
+
+test('fetchActivity omits q when it is whitespace only', async () => {
+  fetchMock.get(ACTIVITY_ENDPOINT, { result: [], count: 0 });
+
+  await fetchActivity('chart', 'chart-uuid', { q: '   ' });
+
+  const url = new URL(fetchMock.callHistory.calls(ACTIVITY_ENDPOINT)[0].url);
+  expect(url.searchParams.has('q')).toBe(false);
+});
+
+test('createDashboardFromSnapshot copies via the source dashboard with snapshot layout', async () => {
+  fetchMock.get(LIST_ENDPOINT, { result: [{ id: 42 }], count: 1 });
+  fetchMock.get(CHART_LIST_ENDPOINT, { result: [{ id: 101 }], count: 1 });
+  fetchMock.post(
+    COPY_ENDPOINT,
+    { result: { id: 77, last_modified_time: 1 } },
+    { name: 'post-copy' },
+  );
+
+  const newId = await createDashboardFromSnapshot(
+    'dash-uuid',
+    snapshot,
+    'Sales overview (copy from Dec 5)',
+  );
+
+  expect(newId).toBe(77);
+  const calls = fetchMock.callHistory.calls('post-copy');
+  expect(calls).toHaveLength(1);
+  const payload = JSON.parse(calls[0].options.body as string);
+  expect(payload.dashboard_title).toBe('Sales overview (copy from Dec 5)');
+  expect(payload.css).toBe('.dashboard { color: red; }');
+  // The copy endpoint derives the new dashboard's chart associations
+  // from the `positions` key of json_metadata; the snapshot layout must
+  // be embedded there so the fork references the snapshot's charts.
+  const metadata = JSON.parse(payload.json_metadata);
+  expect(metadata.color_scheme).toBe('supersetColors');
+  expect(metadata.positions).toEqual(positions);
+});
+
+test('createDashboardFromSnapshot swaps deleted-chart slots for placeholders', async () => {
+  const positionsWithDeleted = {
+    ...positions,
+    'CHART-dead': {
+      type: 'CHART',
+      id: 'CHART-dead',
+      children: [],
+      meta: { chartId: 999, uuid: 'chart-999-uuid', width: 4, height: 50 },
+    },
+  };
+  fetchMock.get(LIST_ENDPOINT, { result: [{ id: 42 }], count: 1 });
+  // Only chart 101 still resolves; 999 was deleted since the snapshot.
+  fetchMock.get(CHART_LIST_ENDPOINT, { result: [{ id: 101 }], count: 1 });
+  fetchMock.post(
+    COPY_ENDPOINT,
+    { result: { id: 79, last_modified_time: 1 } },
+    { name: 'post-copy-deleted' },
+  );
+
+  await createDashboardFromSnapshot(
+    'dash-uuid',
+    { ...snapshot, position_json: JSON.stringify(positionsWithDeleted) },
+    'Copy with deleted chart',
+  );
+
+  const calls = fetchMock.callHistory.calls('post-copy-deleted');
+  const metadata = JSON.parse(
+    JSON.parse(calls[0].options.body as string).json_metadata,
+  );
+  // The copy endpoint silently skips chart associations it cannot
+  // resolve, which would leave a dead slot in the forked layout; the
+  // payload must carry the same placeholder the preview renders instead
+  // of the dead chart reference.
+  expect(metadata.positions['CHART-dead']).toEqual({
+    type: 'MARKDOWN',
+    id: 'CHART-dead',
+    children: [],
+    meta: { width: 4, height: 50, code: 'This chart no longer exists.' },
+  });
+  expect(metadata.positions['CHART-abc']).toEqual(positions['CHART-abc']);
+});
+
+test('createDashboardFromSnapshot sends an explicit empty layout, not no layout', async () => {
+  fetchMock.get(LIST_ENDPOINT, { result: [{ id: 42 }], count: 1 });
+  fetchMock.post(
+    COPY_ENDPOINT,
+    { result: { id: 78, last_modified_time: 1 } },
+    { name: 'post-copy-empty' },
+  );
+
+  await createDashboardFromSnapshot(
+    'dash-uuid',
+    { ...snapshot, position_json: null, json_metadata: null, css: null },
+    'Bare copy',
+  );
+
+  const calls = fetchMock.callHistory.calls('post-copy-empty');
+  const payload = JSON.parse(calls[0].options.body as string);
+  expect(payload.css).toBe('');
+  // `positions` must be present even when the snapshot had no layout: the
+  // copy endpoint rebuilds layout and chart associations from this key, so
+  // omitting it leaves the fork carrying the *source's current* charts —
+  // today's dashboard under a historical name.
+  expect(JSON.parse(payload.json_metadata)).toEqual({ positions: {} });
+});
+
+test('createDashboardFromSnapshot rejects when the source dashboard is gone', async () => {
+  fetchMock.get(LIST_ENDPOINT, { result: [], count: 0 });
+
+  await expect(
+    createDashboardFromSnapshot('dash-uuid', snapshot, 'Orphan copy'),
+  ).rejects.toThrow('No dashboard found for uuid dash-uuid');
+});
+
+test('createChartFromSnapshot rejects a version with no viz type or dataset', async () => {
+  // Every version-table column is nullable — a row written for a delete
+  // carries nulls throughout. Posting it would fail validation server-side
+  // with a message the user cannot act on.
+  const chartSnapshot = {
+    slice_name: 'Sales',
+    params: null,
+    viz_type: null,
+    query_context: null,
+    description: null,
+    cache_timeout: null,
+    datasource_id: null,
+    datasource_type: null,
+    uuid: 'chart-uuid',
+    _version: versionMeta,
+  } as ChartVersionSnapshot;
+
+  await expect(
+    createChartFromSnapshot(chartSnapshot, 'Sales (copy)'),
+  ).rejects.toThrow(/does not record a visualization type and dataset/);
+  expect(fetchMock.callHistory.calls('glob:*/api/v1/chart/')).toHaveLength(0);
+});
diff --git a/superset-frontend/src/features/versionHistory/api.ts b/superset-frontend/src/features/versionHistory/api.ts
new file mode 100644
index 00000000000..1971cf5298f
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/api.ts
@@ -0,0 +1,396 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { JsonObject, SupersetClient } from '@superset-ui/core';
+import rison from 'rison';
+import { t } from '@apache-superset/core/translation';
+import { DASHBOARD_GET_COLUMNS } from 'src/hooks/apiResources/dashboards';
+import { CHART_TYPE, MARKDOWN_TYPE } from 'src/dashboard/util/componentTypes';
+import type { ExploreResponsePayload } from 'src/explore/types';
+import type {
+  HydrateChartData,
+  HydrateDashboardData,
+} from 'src/dashboard/actions/hydrate';
+import type { Dashboard } from 'src/types/Dashboard';
+import type {
+  ActivityEntityKind,
+  ActivityInclude,
+  ActivityResponse,
+  ChartVersionSnapshot,
+  DashboardVersionSnapshot,
+  VersionedEntityType,
+  VersionSnapshot,
+} from './types';
+
+const API_RESOURCE: Record<VersionedEntityType, string> = {
+  chart: 'chart',
+  dashboard: 'dashboard',
+};
+
+export interface FetchActivityOptions {
+  include?: ActivityInclude;
+  page?: number;
+  pageSize?: number;
+  /**
+   * Case-insensitive free-text search over the full history (not just the
+   * loaded pages) — the server filters before paginating, so `count`
+   * reflects matches. Debounced upstream.
+   */
+  q?: string;
+}
+
+export async function fetchActivity(
+  entityType: VersionedEntityType,
+  uuid: string,
+  { include = 'all', page = 0, pageSize = 25, q }: FetchActivityOptions = {},
+): Promise<ActivityResponse> {
+  const params = new URLSearchParams({
+    include,
+    page: String(page),
+    page_size: String(pageSize),
+  });
+  const trimmedQ = q?.trim();
+  if (trimmedQ) {
+    params.set('q', trimmedQ);
+  }
+  const { json } = await SupersetClient.get({
+    endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/activity/?${params.toString()}`,
+  });
+  return json as ActivityResponse;
+}
+
+export async function fetchVersionSnapshot(
+  entityType: 'chart',
+  uuid: string,
+  versionUuid: string,
+): Promise<ChartVersionSnapshot>;
+export async function fetchVersionSnapshot(
+  entityType: 'dashboard',
+  uuid: string,
+  versionUuid: string,
+): Promise<DashboardVersionSnapshot>;
+export async function fetchVersionSnapshot(
+  entityType: VersionedEntityType,
+  uuid: string,
+  versionUuid: string,
+): Promise<VersionSnapshot>;
+export async function fetchVersionSnapshot(
+  entityType: VersionedEntityType,
+  uuid: string,
+  versionUuid: string,
+): Promise<VersionSnapshot> {
+  const { json } = await SupersetClient.get({
+    endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/versions/${encodeURIComponent(versionUuid)}/`,
+  });
+  return (json as { result: VersionSnapshot }).result;
+}
+
+export async function restoreVersion(
+  entityType: VersionedEntityType,
+  uuid: string,
+  versionUuid: string,
+): Promise<{ message: string }> {
+  const { json } = await SupersetClient.post({
+    endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/versions/${encodeURIComponent(versionUuid)}/restore`,
+  });
+  return json as { message: string };
+}
+
+/** Creates a new chart from a version snapshot; returns the new chart id. */
+export async function createChartFromSnapshot(
+  snapshot: ChartVersionSnapshot,
+  name: string,
+): Promise<number> {
+  // The chart POST requires all three; the version table allows null for each
+  // (a delete version carries nulls throughout). Fail here with something the
+  // caller can turn into a toast rather than sending a payload the API will
+  // reject with a validation error the user cannot act on.
+  if (
+    snapshot.viz_type == null ||
+    snapshot.datasource_id == null ||
+    snapshot.datasource_type == null
+  ) {
+    throw new Error(
+      'This version does not record a visualization type and dataset, so a new chart cannot be built from it',
+    );
+  }
+  const { json } = await SupersetClient.post({
+    endpoint: '/api/v1/chart/',
+    jsonPayload: {
+      slice_name: name,
+      viz_type: snapshot.viz_type,
+      datasource_id: snapshot.datasource_id,
+      datasource_type: snapshot.datasource_type,
+      ...(snapshot.params != null && { params: snapshot.params }),
+      ...(snapshot.query_context != null && {
+        query_context: snapshot.query_context,
+      }),
+      ...(snapshot.description != null && {
+        description: snapshot.description,
+      }),
+      ...(snapshot.cache_timeout != null && {
+        cache_timeout: snapshot.cache_timeout,
+      }),
+    },
+  });
+  return (json as { id: number }).id;
+}
+
+/** The theme shape `dashboardInfo` holds, keyed by id in the snapshot. */
+export type DashboardTheme = NonNullable<Dashboard['theme']>;
+
+/**
+ * Resolves a snapshot's `theme_id` to the theme object hydration expects.
+ * The version table stores the foreign key, not the theme, so a snapshot
+ * taken under a different theme than the live dashboard needs one lookup.
+ */
+export async function fetchDashboardTheme(
+  themeId: number,
+): Promise<DashboardTheme> {
+  const { json } = await SupersetClient.get({
+    endpoint: `/api/v1/theme/${themeId}`,
+  });
+  const { id, result } = json as {
+    id: number;
+    result: Omit<DashboardTheme, 'id'>;
+  };
+  return { ...result, id };
+}
+
+/**
+ * Activity records identify related entities by uuid only; resolve the
+ * numeric id (needed for page urls) at click time via the list API.
+ */
+export async function resolveEntityId(
+  kind: ActivityEntityKind,
+  uuid: string,
+): Promise<number | null> {
+  const resource: Record<ActivityEntityKind, string> = {
+    chart: 'chart',
+    dashboard: 'dashboard',
+    dataset: 'dataset',
+  };
+  const q = rison.encode({
+    columns: ['id'],
+    filters: [{ col: 'uuid', opr: 'eq', value: uuid }],
+    page_size: 1,
+  });
+  const { json } = await SupersetClient.get({
+    endpoint: `/api/v1/${resource[kind]}/?q=${q}`,
+  });
+  const { result } = json as { result: Array<{ id: number }> };
+  return result.length > 0 ? result[0].id : null;
+}
+
+/** The chart id a layout slot references, or null for non-chart slots. */
+export const layoutChartId = (item: JsonObject): number | null => {
+  const meta = item?.meta as JsonObject | undefined;
+  return item?.type === CHART_TYPE && typeof meta?.chartId === 'number'
+    ? (meta.chartId as number)
+    : null;
+};
+
+/**
+ * Swaps layout slots whose chart is unreachable (deleted, or not visible
+ * to the current user) for a markdown placeholder, preserving the slot's
+ * footprint so the rest of the layout is unaffected.
+ */
+export function swapUnreachableChartSlots(
+  positionData: JsonObject,
+  unreachableIds: Set<number>,
+): JsonObject {
+  if (unreachableIds.size === 0) {
+    return positionData;
+  }
+  const layout: JsonObject = { ...positionData };
+  Object.entries(layout).forEach(([key, item]) => {
+    const chartId = layoutChartId(item as JsonObject);
+    if (chartId !== null && unreachableIds.has(chartId)) {
+      const meta = (item as JsonObject).meta as JsonObject;
+      layout[key] = {
+        ...(item as JsonObject),
+        type: MARKDOWN_TYPE,
+        meta: {
+          width: meta?.width,
+          height: meta?.height,
+          code: t('This chart no longer exists.'),
+        },
+      };
+    }
+  });
+  return layout;
+}
+
+// FAB list endpoints clamp page_size server-side; batches must stay under
+// that cap or reachable charts past it would silently be reported missing.
+const REACHABLE_CHART_BATCH_SIZE = 100;
+
+/** The subset of the given chart ids that the list API can resolve. */
+async function fetchReachableChartIds(
+  chartIds: number[],
+): Promise<Set<number>> {
+  const batches: number[][] = [];
+  for (let i = 0; i < chartIds.length; i += REACHABLE_CHART_BATCH_SIZE) {
+    batches.push(chartIds.slice(i, i + REACHABLE_CHART_BATCH_SIZE));
+  }
+  const results = await Promise.all(
+    batches.map(async batch => {
+      const q = rison.encode({
+        columns: ['id'],
+        filters: [{ col: 'id', opr: 'in', value: batch }],
+        page_size: batch.length,
+      });
+      const { json } = await SupersetClient.get({
+        endpoint: `/api/v1/chart/?q=${q}`,
+      });
+      const { result } = json as { result: Array<{ id: number }> };
+      return result.map(({ id }) => id);
+    }),
+  );
+  return new Set(results.flat());
+}
+
+/**
+ * Forks a dashboard version into a new dashboard via the copy endpoint;
+ * returns the new dashboard id. The copy endpoint derives the new
+ * dashboard's chart associations from the `positions` key of
+ * `json_metadata`, so the fork references (shares, not duplicates)
+ * exactly the charts present in the snapshot's layout. Slots whose chart
+ * no longer resolves are swapped for the same markdown placeholder the
+ * preview renders — the copy endpoint would silently skip their chart
+ * associations, leaving dead slots in the forked layout.
+ */
+export async function createDashboardFromSnapshot(
+  sourceUuid: string,
+  snapshot: DashboardVersionSnapshot,
+  name: string,
+): Promise<number> {
+  const sourceId = await resolveEntityId('dashboard', sourceUuid);
+  if (sourceId === null) {
+    throw new Error(`No dashboard found for uuid ${sourceUuid}`);
+  }
+  const metadata: JsonObject = snapshot.json_metadata
+    ? JSON.parse(snapshot.json_metadata)
+    : {};
+  // Always send `positions`, even empty. The copy endpoint rebuilds the new
+  // dashboard's layout and chart associations from this key; omitting it
+  // leaves the copy with the *source's current* charts, so forking a version
+  // that had no layout would produce today's dashboard under a historical
+  // name.
+  metadata.positions = {};
+  if (snapshot.position_json) {
+    let positions: JsonObject = JSON.parse(snapshot.position_json);
+    const chartIds = new Set<number>();
+    Object.values(positions).forEach(item => {
+      const chartId = layoutChartId(item as JsonObject);
+      if (chartId !== null) {
+        chartIds.add(chartId);
+      }
+    });
+    if (chartIds.size > 0) {
+      const reachable = await fetchReachableChartIds([...chartIds]);
+      const unreachable = new Set(
+        [...chartIds].filter(id => !reachable.has(id)),
+      );
+      positions = swapUnreachableChartSlots(positions, unreachable);
+    }
+    metadata.positions = positions;
+  }
+  const { json } = await SupersetClient.post({
+    endpoint: `/api/v1/dashboard/${encodeURIComponent(sourceId)}/copy/`,
+    jsonPayload: {
+      dashboard_title: name,
+      css: snapshot.css ?? '',
+      json_metadata: JSON.stringify(metadata),
+    },
+  });
+  return (json as { result: { id: number } }).result.id;
+}
+
+/**
+ * Fetches the same explore payload the chart page uses to hydrate, so a
+ * restored chart can be reloaded in place without a full page refresh.
+ */
+export async function fetchExploreRehydrationData(
+  sliceId: number,
+): Promise<ExploreResponsePayload['result']> {
+  const { json } = await SupersetClient.get({
+    endpoint: `/api/v1/explore/?slice_id=${sliceId}`,
+  });
+  return (json as ExploreResponsePayload).result;
+}
+
+/**
+ * Fetches datasource metadata (same shape the explore page hydrates with)
+ * for the datasource a chart version snapshot references, which may no
+ * longer match the live chart's datasource.
+ */
+export async function fetchDatasourceMetadata(
+  datasourceId: number,
+  datasourceType: string,
+): Promise<ExploreResponsePayload['result']['dataset']> {
+  const { json } = await SupersetClient.get({
+    endpoint: `/api/v1/explore/?datasource_id=${datasourceId}&datasource_type=${encodeURIComponent(datasourceType)}`,
+  });
+  return (json as ExploreResponsePayload).result.dataset;
+}
+
+export interface DashboardHydrationData {
+  dashboard: HydrateDashboardData;
+  charts: HydrateChartData[];
+}
+
+/**
+ * Fetches the same dashboard + charts payload the dashboard page uses to
+ * hydrate, so a version preview can re-hydrate (and later un-hydrate)
+ * without unmounting the page.
+ */
+export async function fetchDashboardHydrationData(
+  id: number,
+): Promise<DashboardHydrationData> {
+  const q = rison.encode({ columns: DASHBOARD_GET_COLUMNS });
+  const [dashboardResponse, chartsResponse] = await Promise.all([
+    SupersetClient.get({ endpoint: `/api/v1/dashboard/${id}?q=${q}` }),
+    SupersetClient.get({ endpoint: `/api/v1/dashboard/${id}/charts` }),
+  ]);
+  const dashboard = (dashboardResponse.json as { result: JsonObject }).result;
+  return {
+    dashboard: {
+      ...dashboard,
+      metadata: dashboard.json_metadata
+        ? JSON.parse(dashboard.json_metadata as string)
+        : {},
+      position_data: dashboard.position_json
+        ? JSON.parse(dashboard.position_json as string)
+        : null,
+    } as unknown as HydrateDashboardData,
+    charts: (chartsResponse.json as { result: HydrateChartData[] }).result,
+  };
+}
+
+/**
+ * The explore redux state only carries the chart's numeric id; resolve
+ * its uuid lazily when the version history panel first opens.
+ */
+export async function fetchChartUuid(sliceId: number): Promise<string> {
+  const q = rison.encode({ columns: ['uuid'] });
+  const { json } = await SupersetClient.get({
+    endpoint: `/api/v1/chart/${sliceId}?q=${q}`,
+  });
+  return (json as { result: { uuid: string } }).result.uuid;
+}
diff --git a/superset-frontend/src/features/versionHistory/canRestoreDashboard.ts b/superset-frontend/src/features/versionHistory/canRestoreDashboard.ts
new file mode 100644
index 00000000000..1d76833ebe6
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/canRestoreDashboard.ts
@@ -0,0 +1,41 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { RootState } from 'src/dashboard/types';
+
+/**
+ * Whether the current user may restore a version of this dashboard.
+ *
+ * The dashboard counterpart of `canOverwriteSlice`, and it excludes
+ * externally managed dashboards for the same reason: their source of truth
+ * lives outside Superset, so a restore would be overwritten again by the next
+ * sync.
+ *
+ * Exported as one selector because the gate has more than one consumer — the
+ * history panel and the preview banner — and the header menu's own
+ * `is_managed_externally` check already showed how easily those drift apart.
+ * The panel is reachable directly via `?version_history=true`, so a gate
+ * applied only where the menu entry renders is not applied at all. The restore
+ * endpoint checks editorship but not external management, which leaves this as
+ * the only guard.
+ */
+export const selectCanRestoreDashboard = (state: RootState): boolean =>
+  (state.dashboardInfo?.dash_edit_perm ?? false) &&
+  !state.dashboardInfo?.is_managed_externally;
+
+export default selectCanRestoreDashboard;
diff --git a/superset-frontend/src/features/versionHistory/display.test.ts b/superset-frontend/src/features/versionHistory/display.test.ts
new file mode 100644
index 00000000000..541af9da7b6
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/display.test.ts
@@ -0,0 +1,421 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 {
+  describeRecord,
+  formatAuthor,
+  formatVersionDateTime,
+  formatVersionDateTimeShort,
+  formatVersionMonthDay,
+  groupHeadline,
+  relatedHeadline,
+} from './display';
+import type { ActivityRecord, SaveGroup } from './types';
+
+const record = (overrides: Partial<ActivityRecord> = {}): ActivityRecord => ({
+  version_uuid: 'v-1',
+  entity_kind: 'chart',
+  entity_uuid: 'e-1',
+  entity_name: 'My chart',
+  entity_deleted: false,
+  entity_deletion_state: null,
+  source: 'self',
+  transaction_id: 10,
+  action_kind: null,
+  issued_at: '2025-12-05T17:18:00',
+  changed_by: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+  kind: 'metric',
+  operation: 'add',
+  path: ['params', 'metrics', 'Revenue'],
+  from_value: null,
+  to_value: { label: 'Revenue' },
+  summary: '',
+  impact: null,
+  ...overrides,
+});
+
+const group = (overrides: Partial<SaveGroup> = {}): SaveGroup => ({
+  type: 'group',
+  transactionId: 10,
+  versionUuid: 'v-1',
+  issuedAt: '2025-12-05T17:18:00',
+  changedBy: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+  actionKind: null,
+  records: [record()],
+  ...overrides,
+});
+
+test('timestamps are parsed as UTC and rendered in the local timezone', () => {
+  // jest pins TZ=America/New_York (UTC-5 in December)
+  expect(formatVersionDateTime('2025-12-05T17:18:00')).toBe(
+    'Dec 5, 2025, 12:18 PM',
+  );
+  expect(formatVersionMonthDay('2025-12-05T17:18:00')).toBe('Dec 5');
+  expect(formatVersionDateTimeShort('2025-12-05T17:18:00')).toBe(
+    '12/5/2025 12:18PM',
+  );
+});
+
+test('describeRecord labels metric changes with the metric name', () => {
+  expect(describeRecord(record())).toBe("Applied 'Revenue' metric");
+  expect(describeRecord(record({ operation: 'remove' }))).toBe(
+    "Removed 'Revenue' metric",
+  );
+});
+
+test('describeRecord labels dimension changes', () => {
+  const dimension = record({
+    kind: 'dimension',
+    path: ['params', 'groupby', 'country'],
+    to_value: { column_name: 'country' },
+  });
+  expect(describeRecord(dimension)).toBe("Added 'country' dimension");
+  expect(describeRecord({ ...dimension, operation: 'remove' })).toBe(
+    "Removed 'country' dimension",
+  );
+});
+
+test('describeRecord labels filter changes with their subject', () => {
+  const filter = record({
+    kind: 'filter',
+    path: ['params', 'adhoc_filters', 'country'],
+    to_value: { subject: 'country' },
+  });
+  expect(describeRecord(filter)).toBe("Added filter on 'country'");
+});
+
+test('describeRecord labels time range and color palette changes', () => {
+  expect(
+    describeRecord(
+      record({
+        kind: 'time_range',
+        operation: 'edit',
+        path: ['params', 'time_range'],
+        to_value: 'Last month',
+      }),
+    ),
+  ).toBe("Changed time range to 'Last month'");
+  expect(
+    describeRecord(
+      record({
+        kind: 'color_palette',
+        operation: 'edit',
+        path: ['params', 'color_scheme'],
+        to_value: 'supersetColors',
+      }),
+    ),
+  ).toBe('Changed color palette');
+});
+
+test('describeRecord labels dashboard layout changes', () => {
+  expect(
+    describeRecord(
+      record({
+        kind: 'chart',
+        operation: 'move',
+        path: ['position_json', 'CHART-1'],
+        to_value: null,
+        from_value: null,
+      }),
+    ),
+  ).toBe('Moved a chart');
+  expect(
+    describeRecord(
+      record({
+        kind: 'tab',
+        operation: 'add',
+        path: ['position_json', 'TAB-1'],
+        to_value: { label: 'Overview' },
+      }),
+    ),
+  ).toBe("Added tab 'Overview'");
+});
+
+test('describeRecord names a layout chart from its node payload', () => {
+  // Dashboard layout chart-add records carry the chart title as `name`;
+  // the path holds only the opaque node id.
+  expect(
+    describeRecord(
+      record({
+        kind: 'chart',
+        operation: 'add',
+        path: ['CHART-hyUmCvEiZE223-o-fCcQC'],
+        to_value: {
+          id: 'CHART-hyUmCvEiZE223-o-fCcQC',
+          type: 'CHART',
+          name: 'Deck.gl Grid',
+          chartId: 11,
+        },
+      }),
+    ),
+  ).toBe("Added chart 'Deck.gl Grid'");
+});
+
+test('describeRecord drops opaque node ids in favour of kind-only phrasing', () => {
+  // ROW / COLUMN nodes have no name; never surface the raw node id.
+  expect(
+    describeRecord(
+      record({
+        kind: 'row',
+        operation: 'add',
+        path: ['ROW-s61oGzKbowiZcjMnNPuAd'],
+        to_value: { id: 'ROW-s61oGzKbowiZcjMnNPuAd', type: 'ROW' },
+      }),
+    ),
+  ).toBe('Added a row');
+  expect(
+    describeRecord(
+      record({
+        kind: 'column',
+        operation: 'add',
+        path: ['COLUMN-OfJojKvt2OeeVc_IREkIK'],
+        to_value: { id: 'COLUMN-OfJojKvt2OeeVc_IREkIK', type: 'COLUMN' },
+      }),
+    ),
+  ).toBe('Added a column');
+});
+
+test('describeRecord drops a bare uuid value (M2M slice membership)', () => {
+  expect(
+    describeRecord(
+      record({
+        kind: 'chart',
+        operation: 'add',
+        path: ['slices', '33f0c99f-66fa-40f3-919b-1617f5ee60fa'],
+        to_value: '33f0c99f-66fa-40f3-919b-1617f5ee60fa',
+      }),
+    ),
+  ).toBe('Added a chart');
+});
+
+test('describeRecord falls back to a humanized field name', () => {
+  expect(
+    describeRecord(
+      record({
+        kind: 'field',
+        operation: 'edit',
+        path: ['params', 'row_limit'],
+        to_value: 100,
+        from_value: 10,
+      }),
+    ),
+  ).toBe("Changed 'row limit'");
+  expect(
+    describeRecord(
+      record({
+        kind: 'field',
+        operation: 'remove',
+        path: ['description'],
+        to_value: null,
+        from_value: 'old',
+      }),
+    ),
+  ).toBe("Cleared 'description'");
+});
+
+test('describeRecord frames a name/title change as a rename', () => {
+  expect(
+    describeRecord(
+      record({
+        entity_kind: 'chart',
+        kind: 'field',
+        operation: 'edit',
+        path: ['slice_name'],
+        from_value: 'Old name',
+        to_value: 'Quarterly Revenue',
+      }),
+    ),
+  ).toBe("Chart renamed to 'Quarterly Revenue'");
+  expect(
+    describeRecord(
+      record({
+        entity_kind: 'dashboard',
+        kind: 'field',
+        operation: 'edit',
+        path: ['dashboard_title'],
+        to_value: 'Exec Overview',
+      }),
+    ),
+  ).toBe("Dashboard renamed to 'Exec Overview'");
+  // A name field nested deeper than the top level is a different field,
+  // not a rename of the entity itself.
+  expect(
+    describeRecord(
+      record({
+        kind: 'field',
+        operation: 'edit',
+        path: ['params', 'slice_name'],
+        to_value: 'x',
+      }),
+    ),
+  ).toBe("Changed 'slice name'");
+});
+
+test('describeRecord humanizes camelCase field names', () => {
+  expect(
+    describeRecord(
+      record({
+        kind: 'field',
+        operation: 'edit',
+        path: ['params', 'markerSize'],
+        to_value: 8,
+        from_value: 4,
+      }),
+    ),
+  ).toBe("Changed 'marker size'");
+});
+
+test('groupHeadline prefers the transaction action kind', () => {
+  expect(groupHeadline(group({ actionKind: 'restore' }))).toBe(
+    'Restored version',
+  );
+  expect(
+    groupHeadline(group({ actionKind: 'restore', restoredToVersion: 3 })),
+  ).toBe('Restored to version 3');
+  expect(groupHeadline(group({ actionKind: 'import' }))).toBe('Imported');
+  expect(groupHeadline(group({ actionKind: 'clone' }))).toBe('Cloned');
+});
+
+test('groupHeadline uses the save date for charts', () => {
+  expect(groupHeadline(group())).toBe('Dec 5, 2025, 12:18 PM');
+});
+
+// Dashboards share the chart headline model (sc-107283 guide, 2026-06-12):
+// the save's date/time heads the group and descriptive per-change rows render
+// beneath it — no entity-specific "Filters"/"Edit mode" category headline.
+test('groupHeadline heads dashboard saves with the save date', () => {
+  const filterRecord = record({
+    entity_kind: 'dashboard',
+    kind: 'filter',
+    path: ['json_metadata', 'native_filter_configuration', 'NATIVE_1'],
+  });
+  expect(groupHeadline(group({ records: [filterRecord, filterRecord] }))).toBe(
+    'Dec 5, 2025, 12:18 PM',
+  );
+  expect(
+    groupHeadline(
+      group({
+        records: [
+          filterRecord,
+          record({ entity_kind: 'dashboard', kind: 'chart' }),
+        ],
+      }),
+    ),
+  ).toBe('Dec 5, 2025, 12:18 PM');
+});
+
+test('groupHeadline labels the first tracked save', () => {
+  expect(groupHeadline(group({ firstTrackedSave: true }))).toBe(
+    'First tracked save',
+  );
+});
+
+test('groupHeadline falls back to a label when a save has no records', () => {
+  expect(groupHeadline(group({ records: [] }))).toBe('Properties updated');
+});
+
+test('groupHeadline labels a scaffolding-only save as a layout rearrangement', () => {
+  expect(groupHeadline(group({ records: [], hasSuppressedLayout: true }))).toBe(
+    'Rearranged layout',
+  );
+});
+
+test('relatedHeadline prefers impact-aware phrasing for shared datasets', () => {
+  expect(
+    relatedHeadline(
+      record({
+        source: 'related',
+        entity_kind: 'dataset',
+        entity_name: 'Sales',
+        summary: 'Dataset updated: Sales',
+        impact: { charts: 4 },
+      }),
+    ),
+  ).toBe('Dataset used by 4 charts updated: Sales');
+  expect(
+    relatedHeadline(
+      record({
+        source: 'related',
+        entity_kind: 'dataset',
+        entity_name: 'Sales',
+        summary: 'Dataset updated: Sales',
+        impact: { charts: 1 },
+      }),
+    ),
+  ).toBe('Dataset updated: Sales');
+});
+
+test('relatedHeadline falls back when the server summary is empty', () => {
+  expect(
+    relatedHeadline(
+      record({
+        source: 'related',
+        entity_kind: 'chart',
+        entity_name: 'Trend',
+        summary: '',
+      }),
+    ),
+  ).toBe('Item updated: Trend');
+});
+
+test('formatAuthor handles system, named, and nameless users', () => {
+  expect(formatAuthor(null)).toBe('System');
+  expect(
+    formatAuthor({ id: 1, first_name: 'Ada', last_name: 'Lovelace' }),
+  ).toBe('Ada Lovelace');
+  expect(formatAuthor({ id: 2, first_name: 'Ada', last_name: null })).toBe(
+    'Ada',
+  );
+  expect(formatAuthor({ id: 3, first_name: null, last_name: null })).toBe(
+    'Unknown user',
+  );
+});
+
+test('the shared-dataset headline goes through the plural machinery', () => {
+  // English resolves this branch identically for every count above one, so a
+  // hardcoded plural passes any English assertion. It is routed through tn so
+  // a locale with several plural forms can pick the right one; asserting both
+  // the count and the substituted name pins that the plural call is the one
+  // producing the string.
+  expect(
+    relatedHeadline(
+      record({
+        source: 'related',
+        entity_kind: 'dataset',
+        entity_name: 'Sales',
+        summary: 'Dataset updated: Sales',
+        impact: { charts: 2 },
+      }),
+    ),
+  ).toBe('Dataset used by 2 charts updated: Sales');
+});
+
+test('an unlabelled related record does not assemble its sentence from fragments', () => {
+  // The noun and the frame used to be translated separately and concatenated,
+  // which leaves a translator unable to inflect the noun for the sentence.
+  expect(
+    relatedHeadline(
+      record({
+        source: 'related',
+        entity_kind: 'chart',
+        entity_name: 'Revenue',
+        summary: '',
+      }),
+    ),
+  ).toBe('Item updated: Revenue');
+});
diff --git a/superset-frontend/src/features/versionHistory/display.ts b/superset-frontend/src/features/versionHistory/display.ts
new file mode 100644
index 00000000000..19113c6b95b
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/display.ts
@@ -0,0 +1,398 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { t, tn } from '@apache-superset/core/translation';
+import { extendedDayjs } from '@superset-ui/core/utils/dates';
+import type {
+  ActivityChangedBy,
+  ActivityEntityKind,
+  ActivityRecord,
+  SaveGroup,
+} from './types';
+
+/** Activity timestamps are naive UTC; parse as UTC, render local. */
+export function parseIssuedAt(issuedAt: string) {
+  return extendedDayjs.utc(issuedAt).local();
+}
+
+/** e.g. "Dec 5, 2025, 5:18 PM" */
+export function formatVersionDateTime(issuedAt: string): string {
+  return parseIssuedAt(issuedAt).format('MMM D, YYYY, h:mm A');
+}
+
+/** e.g. "Dec 5" — used for fork (copy) names. */
+export function formatVersionMonthDay(issuedAt: string): string {
+  return parseIssuedAt(issuedAt).format('MMM D');
+}
+
+/** Compact datetime for row meta lines, per design spec. */
+export const SHORT_DATETIME_FORMAT = 'M/D/YYYY h:mmA';
+
+/** e.g. "12/5/2025 2:35PM" */
+export function formatVersionDateTimeShort(issuedAt: string): string {
+  return parseIssuedAt(issuedAt).format(SHORT_DATETIME_FORMAT);
+}
+
+export function formatAuthor(changedBy: ActivityChangedBy | null): string {
+  if (!changedBy) {
+    return t('System');
+  }
+  const name = [changedBy.first_name, changedBy.last_name]
+    .filter(Boolean)
+    .join(' ');
+  return name || t('Unknown user');
+}
+
+/**
+ * Synthetic identifiers that carry no meaning for a reader: dashboard
+ * layout node ids (`CHART-xyz`, `ROW-abc`, …) and bare UUIDs (e.g. an
+ * M2M slice-membership record whose value is just the chart's uuid).
+ * These must never surface as a record's subject.
+ */
+const OPAQUE_ID =
+  /^(CHART|ROW|COLUMN|TAB|TABS|HEADER|MARKDOWN|DIVIDER|GRID|ROOT)-|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+function isOpaqueId(value: string): boolean {
+  return OPAQUE_ID.test(value);
+}
+
+/** A human name nested in a layout node's `meta`, if any. */
+function metaLabel(meta: unknown): string | null {
+  if (!meta || typeof meta !== 'object') {
+    return null;
+  }
+  const candidate = meta as Record<string, unknown>;
+  for (const key of ['sliceName', 'text', 'code']) {
+    const value = candidate[key];
+    if (typeof value === 'string' && value) {
+      return value;
+    }
+  }
+  return null;
+}
+
+function valueLabel(value: unknown): string | null {
+  if (typeof value === 'string' && value) {
+    return value;
+  }
+  if (typeof value === 'number' || typeof value === 'boolean') {
+    return String(value);
+  }
+  if (value && typeof value === 'object') {
+    const candidate = value as Record<string, unknown>;
+    if (typeof candidate.label === 'string' && candidate.label) {
+      return candidate.label;
+    }
+    // Dashboard layout chart nodes carry the chart title as `name`.
+    if (typeof candidate.name === 'string' && candidate.name) {
+      return candidate.name;
+    }
+    if (
+      typeof candidate.column_name === 'string' &&
+      candidate.column_name !== ''
+    ) {
+      return candidate.column_name;
+    }
+    if (typeof candidate.subject === 'string' && candidate.subject !== '') {
+      return candidate.subject;
+    }
+    return metaLabel(candidate.meta);
+  }
+  return null;
+}
+
+/**
+ * The natural key of the changed item is the last path segment for
+ * list-diff records (e.g. ['params', 'metrics', 'Revenue']). Opaque
+ * identifiers (layout node ids, bare UUIDs) are rejected so the caller
+ * falls back to the kind-only phrasing ("Added a chart") rather than
+ * rendering "Added chart 'CHART-hyUmCv…'".
+ */
+function recordSubject(record: ActivityRecord): string | null {
+  const fromValues =
+    valueLabel(record.to_value) ?? valueLabel(record.from_value);
+  if (fromValues && !isOpaqueId(fromValues)) {
+    return fromValues;
+  }
+  const last = record.path[record.path.length - 1];
+  if (typeof last === 'string' && last !== '' && !isOpaqueId(last)) {
+    return last;
+  }
+  return null;
+}
+
+/** e.g. "color_scheme" → "color scheme", "markerSize" → "marker size". */
+function humanizeFieldName(field: string): string {
+  return field
+    .replace(/_/g, ' ')
+    .replace(
+      /([a-z\d])([A-Z])/g,
+      (_, before, upper) => `${before} ${upper.toLowerCase()}`,
+    );
+}
+
+function fieldSubject(record: ActivityRecord): string {
+  const path = record.path.filter(
+    (segment): segment is string => typeof segment === 'string',
+  );
+  const meaningful =
+    path[0] === 'params' || path[0] === 'json_metadata' ? path[1] : path[0];
+  return humanizeFieldName(String(meaningful ?? t('setting')));
+}
+
+function layoutKindLabel(kind: string): string | null {
+  switch (kind) {
+    case 'chart':
+      return t('chart');
+    case 'row':
+      return t('row');
+    case 'column':
+      return t('column');
+    case 'tab':
+      return t('tab');
+    case 'tabs':
+      return t('tabs');
+    case 'header':
+      return t('header');
+    case 'markdown':
+      return t('markdown');
+    case 'divider':
+      return t('divider');
+    default:
+      return null;
+  }
+}
+
+/**
+ * A change to the entity's own name/title reads better as a rename than as
+ * a generic field edit ("Changed 'slice name'"). Returns the friendly
+ * headline, or null when the record isn't a top-level name change.
+ */
+function renameHeadline(record: ActivityRecord): string | null {
+  const field = record.path.length === 1 ? record.path[0] : null;
+  const to = valueLabel(record.to_value);
+  switch (field) {
+    case 'slice_name':
+      return to ? t("Chart renamed to '%s'", to) : t('Chart renamed');
+    case 'dashboard_title':
+      return to ? t("Dashboard renamed to '%s'", to) : t('Dashboard renamed');
+    case 'table_name':
+      return to ? t("Dataset renamed to '%s'", to) : t('Dataset renamed');
+    default:
+      return null;
+  }
+}
+
+/**
+ * Human-readable label for a `source='self'` activity record. The
+ * backend leaves `summary` empty for self records, so the client
+ * renders one from kind / operation / path / values.
+ */
+export function describeRecord(record: ActivityRecord): string {
+  const { kind, operation } = record;
+  const subject = recordSubject(record);
+
+  const renamed = renameHeadline(record);
+  if (renamed) {
+    return renamed;
+  }
+
+  if (kind === 'metric') {
+    if (operation === 'add') {
+      return subject
+        ? t("Applied '%s' metric", subject)
+        : t('Applied a metric');
+    }
+    if (operation === 'remove') {
+      return subject
+        ? t("Removed '%s' metric", subject)
+        : t('Removed a metric');
+    }
+    return subject ? t("Changed '%s' metric", subject) : t('Changed a metric');
+  }
+
+  if (kind === 'dimension') {
+    if (operation === 'add') {
+      return subject
+        ? t("Added '%s' dimension", subject)
+        : t('Added a dimension');
+    }
+    if (operation === 'remove') {
+      return subject
+        ? t("Removed '%s' dimension", subject)
+        : t('Removed a dimension');
+    }
+    return subject
+      ? t("Changed '%s' dimension", subject)
+      : t('Changed a dimension');
+  }
+
+  if (kind === 'filter') {
+    if (operation === 'add') {
+      return subject ? t("Added filter on '%s'", subject) : t('Added a filter');
+    }
+    if (operation === 'remove') {
+      return subject
+        ? t("Removed filter on '%s'", subject)
+        : t('Removed a filter');
+    }
+    return subject
+      ? t("Changed filter on '%s'", subject)
+      : t('Changed a filter');
+  }
+
+  if (kind === 'time_range') {
+    const to = valueLabel(record.to_value);
+    return to
+      ? t("Changed time range to '%s'", to)
+      : t('Changed the time range');
+  }
+
+  if (kind === 'color_palette') {
+    return t('Changed color palette');
+  }
+
+  const label = layoutKindLabel(kind);
+  if (label) {
+    if (operation === 'add') {
+      return subject
+        ? t("Added %s '%s'", label, subject)
+        : t('Added a %s', label);
+    }
+    if (operation === 'remove') {
+      return subject
+        ? t("Removed %s '%s'", label, subject)
+        : t('Removed a %s', label);
+    }
+    if (operation === 'move') {
+      return subject
+        ? t("Moved %s '%s'", label, subject)
+        : t('Moved a %s', label);
+    }
+    return subject
+      ? t("Changed %s '%s'", label, subject)
+      : t('Changed a %s', label);
+  }
+
+  // Generic scalar ("field") changes and unknown kinds.
+  const field = fieldSubject(record);
+  if (operation === 'add') {
+    return t("Set '%s'", field);
+  }
+  if (operation === 'remove') {
+    return t("Cleared '%s'", field);
+  }
+  return t("Changed '%s'", field);
+}
+
+/**
+ * Headline for a save container, identical across entity types. The
+ * transaction `action_kind` drives an action-specific headline; otherwise
+ * the save's date/time heads the group and the descriptive per-change rows
+ * render beneath it.
+ */
+export function groupHeadline(group: SaveGroup): string {
+  if (group.actionKind === 'restore') {
+    return group.restoredToVersion != null
+      ? t('Restored to version %s', group.restoredToVersion)
+      : t('Restored version');
+  }
+  if (group.actionKind === 'import') {
+    return t('Imported');
+  }
+  if (group.actionKind === 'clone') {
+    return t('Cloned');
+  }
+  // The entity's first tracked save replays a params-normalization flood
+  // that isn't meaningful user edits — collapse it under one label.
+  if (group.firstTrackedSave) {
+    return t('First tracked save');
+  }
+  // A save whose only changes were layout-container scaffolding (dropped
+  // from the change list) reads as a layout rearrangement.
+  if (group.records.length === 0 && group.hasSuppressedLayout) {
+    return t('Rearranged layout');
+  }
+  // Defensive: a save that produced no renderable change records
+  // (e.g. properties-only metadata edits) still deserves a label.
+  if (group.records.length === 0) {
+    return t('Properties updated');
+  }
+  return formatVersionDateTime(group.issuedAt);
+}
+
+/** Entity name with a fallback for records that arrive without one. */
+export function entityDisplayName(record: ActivityRecord): string {
+  if (record.entity_name) {
+    return record.entity_name;
+  }
+  switch (record.entity_kind) {
+    case 'chart':
+      return t('Untitled chart');
+    case 'dashboard':
+      return t('Untitled dashboard');
+    case 'dataset':
+      return t('Untitled dataset');
+    default:
+      return t('Untitled');
+  }
+}
+
+/**
+ * Headline for a related row that rolled up several distinct entities
+ * of one kind from a single transaction, e.g. "10 charts updated".
+ */
+export function relatedRollupHeadline(
+  kind: ActivityEntityKind,
+  count: number,
+): string {
+  switch (kind) {
+    case 'chart':
+      return tn('%s chart updated', '%s charts updated', count, count);
+    case 'dashboard':
+      return tn('%s dashboard updated', '%s dashboards updated', count, count);
+    case 'dataset':
+    default:
+      return tn('%s dataset updated', '%s datasets updated', count, count);
+  }
+}
+
+/**
+ * Text for a `source='related'` row. The server renders `summary`
+ * ("Dataset metric changed: Sales"); when a dashboard-path dataset
+ * change affects multiple charts, prefer the impact-aware phrasing.
+ */
+export function relatedHeadline(record: ActivityRecord): string {
+  const chartCount = record.impact?.charts ?? 0;
+  if (record.entity_kind === 'dataset' && chartCount > 1) {
+    // tn even though this branch only runs for counts above one: English has
+    // a single plural form, but languages with several (2-4 vs 5+ and so on)
+    // still need the count to pick between them.
+    return tn(
+      'Dataset used by %s chart updated: %s',
+      'Dataset used by %s charts updated: %s',
+      chartCount,
+      chartCount,
+      entityDisplayName(record),
+    );
+  }
+  // One whole sentence rather than t('Item') interpolated into t('%s updated'):
+  // a translator given the fragments separately cannot inflect the noun for
+  // the sentence it lands in, and many languages require that.
+  return record.summary || t('Item updated: %s', entityDisplayName(record));
+}
diff --git a/superset-frontend/src/features/versionHistory/grouping.test.ts b/superset-frontend/src/features/versionHistory/grouping.test.ts
new file mode 100644
index 00000000000..a1e933196a9
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/grouping.test.ts
@@ -0,0 +1,440 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 {
+  buildTimeline,
+  mergeActivityPages,
+  recordKey,
+  relatedEntryKey,
+} from './grouping';
+import type { ActivityRecord, RelatedEntry, SaveGroup } from './types';
+
+const baseRecord: ActivityRecord = {
+  version_uuid: 'v-1',
+  entity_kind: 'chart',
+  entity_uuid: 'e-1',
+  entity_name: 'My chart',
+  entity_deleted: false,
+  entity_deletion_state: null,
+  source: 'self',
+  transaction_id: 10,
+  action_kind: null,
+  issued_at: '2025-12-05T17:18:00',
+  changed_by: { id: 1, first_name: 'Ada', last_name: 'Lovelace' },
+  kind: 'metric',
+  operation: 'add',
+  path: ['params', 'metrics', 'Revenue'],
+  from_value: null,
+  to_value: { label: 'Revenue' },
+  summary: '',
+  impact: null,
+};
+
+const record = (overrides: Partial<ActivityRecord>): ActivityRecord => ({
+  ...baseRecord,
+  ...overrides,
+});
+
+test('buildTimeline groups self records by transaction id', () => {
+  const entries = buildTimeline([
+    record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+    record({
+      transaction_id: 12,
+      issued_at: '2025-12-06T10:00:00',
+      kind: 'filter',
+      path: ['params', 'adhoc_filters', 'country'],
+    }),
+    record({ transaction_id: 10 }),
+  ]);
+
+  expect(entries).toHaveLength(2);
+  expect(entries[0]).toMatchObject({
+    type: 'group',
+    transactionId: 12,
+  });
+  expect((entries[0] as SaveGroup).records).toHaveLength(2);
+  expect(entries[1]).toMatchObject({ type: 'group', transactionId: 10 });
+});
+
+test('buildTimeline interleaves related records chronologically', () => {
+  const entries = buildTimeline([
+    record({ transaction_id: 14, issued_at: '2025-12-07T09:00:00' }),
+    record({
+      source: 'related',
+      entity_kind: 'dataset',
+      entity_name: 'Sales',
+      transaction_id: 13,
+      issued_at: '2025-12-06T12:00:00',
+      summary: 'Dataset updated: Sales',
+    }),
+    record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+  ]);
+
+  expect(entries.map(entry => entry.type)).toEqual([
+    'group',
+    'related',
+    'group',
+  ]);
+});
+
+test('buildTimeline orders newest first even with shuffled input', () => {
+  const entries = buildTimeline([
+    record({ transaction_id: 10, issued_at: '2025-12-05T17:18:00' }),
+    record({ transaction_id: 14, issued_at: '2025-12-07T09:00:00' }),
+    record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+  ]);
+
+  expect(entries.map(entry => (entry as SaveGroup).transactionId)).toEqual([
+    14, 12, 10,
+  ]);
+});
+
+test('buildTimeline propagates restore action_kind to the group', () => {
+  const entries = buildTimeline([
+    record({
+      transaction_id: 15,
+      action_kind: 'restore',
+      issued_at: '2025-12-08T09:00:00',
+    }),
+    record({
+      transaction_id: 15,
+      action_kind: 'restore',
+      issued_at: '2025-12-08T09:00:00',
+      kind: 'field',
+      path: ['slice_name'],
+    }),
+  ]);
+
+  expect(entries).toHaveLength(1);
+  expect((entries[0] as SaveGroup).actionKind).toBe('restore');
+});
+
+test('buildTimeline drops layout-container scaffolding but keeps chart changes', () => {
+  // Dragging a chart to a new spot spawns a new ROW: the backend emits a
+  // row add + the chart's own move. Only the chart change should surface.
+  const [group] = buildTimeline([
+    record({
+      transaction_id: 9,
+      entity_kind: 'dashboard',
+      kind: 'row',
+      operation: 'add',
+      path: ['ROW-lwk_58lo6fSWnbv6CIKnV'],
+      to_value: { id: 'ROW-lwk_58lo6fSWnbv6CIKnV', type: 'ROW' },
+    }),
+    record({
+      transaction_id: 9,
+      entity_kind: 'dashboard',
+      kind: 'chart',
+      operation: 'move',
+      path: ['CHART-GPLeOv026-Vh0mve_tyh9'],
+      to_value: { id: 'CHART-GPLeOv026-Vh0mve_tyh9', name: 'Trend' },
+    }),
+  ]) as SaveGroup[];
+
+  expect(group.records).toHaveLength(1);
+  expect(group.records[0].kind).toBe('chart');
+  expect(group.hasSuppressedLayout).toBe(true);
+});
+
+test('buildTimeline flags a scaffolding-only save without dropping it', () => {
+  const [group] = buildTimeline([
+    record({
+      transaction_id: 12,
+      entity_kind: 'dashboard',
+      kind: 'column',
+      operation: 'add',
+      path: ['COLUMN-OfJojKvt2OeeVc_IREkIK'],
+      to_value: { id: 'COLUMN-OfJojKvt2OeeVc_IREkIK', type: 'COLUMN' },
+    }),
+  ]) as SaveGroup[];
+
+  expect(group.records).toHaveLength(0);
+  expect(group.hasSuppressedLayout).toBe(true);
+});
+
+test('buildTimeline collapses one related save into a single entry', () => {
+  // One dataset save can fan out into many change records; the panel
+  // should show one row per (transaction, entity), upgraded with the
+  // most informative summary/impact across its records.
+  const entries = buildTimeline(
+    Array.from({ length: 5 }, (_, index) =>
+      record({
+        source: 'related',
+        entity_kind: 'dataset',
+        entity_uuid: 'ds-1',
+        entity_name: 'Sales',
+        transaction_id: 13,
+        issued_at: `2025-12-06T12:00:0${index}`,
+        kind: 'metric',
+        path: ['metrics', `metric_${index}`],
+        summary: index === 2 ? 'Dataset metric changed: Sales' : '',
+        impact: index === 3 ? { charts: 4 } : null,
+      }),
+    ),
+  );
+
+  expect(entries).toHaveLength(1);
+  const [entry] = entries as RelatedEntry[];
+  expect(entry.type).toBe('related');
+  expect(entry.record.summary).toBe('Dataset metric changed: Sales');
+  expect(entry.record.impact).toEqual({ charts: 4 });
+  expect(entry.record.issued_at).toBe('2025-12-06T12:00:04');
+  // every collapsed record is retained so search can match summaries
+  // that only appear on non-representative records
+  expect(entry.records).toHaveLength(5);
+});
+
+test('buildTimeline keeps distinct related entities apart within one transaction', () => {
+  const entries = buildTimeline([
+    record({
+      source: 'related',
+      entity_kind: 'dataset',
+      entity_uuid: 'ds-1',
+      entity_name: 'Sales',
+      transaction_id: 13,
+      issued_at: '2025-12-06T12:00:00',
+    }),
+    record({
+      source: 'related',
+      entity_kind: 'chart',
+      entity_uuid: 'ch-1',
+      entity_name: 'Revenue trend',
+      transaction_id: 13,
+      issued_at: '2025-12-06T12:00:00',
+    }),
+  ]) as RelatedEntry[];
+
+  expect(entries).toHaveLength(2);
+  const keys = entries.map(entry => relatedEntryKey(entry.record));
+  expect(new Set(keys).size).toBe(2);
+});
+
+test('buildTimeline merges adjacent related entries from one split save', () => {
+  // The backend split one logical dataset save into two transactions
+  // with the same timestamp; the panel must show a single row whose
+  // records union both saves (so search keeps matching all of them).
+  const related = (transactionId: number, field: string) =>
+    record({
+      source: 'related',
+      entity_kind: 'dataset',
+      entity_uuid: 'ds-1',
+      entity_name: 'birth_names',
+      transaction_id: transactionId,
+      issued_at: '2026-06-12T14:50:35',
+      kind: 'metric',
+      path: ['metrics', field],
+      summary: 'Dataset used by 11 charts updated: birth_names',
+    });
+
+  const entries = buildTimeline([
+    related(50, 'a'),
+    related(50, 'b'),
+    related(49, 'c'),
+    related(49, 'd'),
+  ]) as RelatedEntry[];
+
+  expect(entries).toHaveLength(1);
+  expect(entries[0].type).toBe('related');
+  // the newer transaction stays representative (stable React key)
+  expect(entries[0].record.transaction_id).toBe(50);
+  expect(entries[0].records).toHaveLength(4);
+});
+
+test('buildTimeline keeps related entries for the same entity apart when issued far apart', () => {
+  const related = (transactionId: number, issuedAt: string) =>
+    record({
+      source: 'related',
+      entity_kind: 'dataset',
+      entity_uuid: 'ds-1',
+      entity_name: 'birth_names',
+      transaction_id: transactionId,
+      issued_at: issuedAt,
+    });
+
+  const entries = buildTimeline([
+    related(50, '2026-06-12T16:50:35'),
+    related(49, '2026-06-12T14:50:35'),
+  ]);
+
+  expect(entries).toHaveLength(2);
+});
+
+test('buildTimeline keeps simultaneous related entries for different entities apart', () => {
+  const entries = buildTimeline([
+    record({
+      source: 'related',
+      entity_kind: 'dataset',
+      entity_uuid: 'ds-1',
+      entity_name: 'birth_names',
+      transaction_id: 50,
+      issued_at: '2026-06-12T14:50:35',
+    }),
+    record({
+      source: 'related',
+      entity_kind: 'dataset',
+      entity_uuid: 'ds-2',
+      entity_name: 'cleaned_sales',
+      transaction_id: 49,
+      issued_at: '2026-06-12T14:50:35',
+    }),
+  ]);
+
+  expect(entries).toHaveLength(2);
+});
+
+test('buildTimeline rolls up same-transaction related entries per entity kind', () => {
+  // One dataset save cascades into a related record for every chart
+  // built on it; the panel should show one pluralized row, not ten.
+  const entries = buildTimeline(
+    Array.from({ length: 10 }, (_, i) =>
+      record({
+        source: 'related',
+        entity_kind: 'chart',
+        entity_uuid: `c-${i}`,
+        entity_name: `Chart ${i}`,
+        transaction_id: 53,
+        issued_at: '2026-06-12T15:00:00',
+        summary: `Chart updated: Chart ${i}`,
+      }),
+    ),
+  ) as RelatedEntry[];
+
+  expect(entries).toHaveLength(1);
+  expect(entries[0].type).toBe('related');
+  expect(entries[0].rollupEntityNames).toHaveLength(10);
+  expect(entries[0].rollupEntityNames).toContain('Chart 0');
+  expect(entries[0].rollupEntityNames).toContain('Chart 9');
+  // every absorbed entry's records are retained for search
+  expect(entries[0].records).toHaveLength(10);
+});
+
+test('buildTimeline leaves single-entity related transactions un-rolled', () => {
+  const entries = buildTimeline([
+    record({
+      source: 'related',
+      entity_kind: 'dataset',
+      entity_uuid: 'ds-1',
+      entity_name: 'Sales',
+      transaction_id: 53,
+      issued_at: '2026-06-12T15:00:00',
+      summary: 'Dataset updated: Sales',
+    }),
+  ]) as RelatedEntry[];
+
+  expect(entries).toHaveLength(1);
+  expect(entries[0].rollupEntityNames).toBeUndefined();
+});
+
+test('buildTimeline rolls up mixed kinds in one transaction per kind', () => {
+  const related = (
+    kind: 'chart' | 'dataset',
+    uuid: string,
+    name: string,
+  ): ActivityRecord =>
+    record({
+      source: 'related',
+      entity_kind: kind,
+      entity_uuid: uuid,
+      entity_name: name,
+      transaction_id: 53,
+      issued_at: '2026-06-12T15:00:00',
+    });
+
+  const entries = buildTimeline([
+    related('chart', 'c-1', 'Trend'),
+    related('chart', 'c-2', 'Breakdown'),
+    related('dataset', 'ds-1', 'Sales'),
+    related('dataset', 'ds-2', 'Costs'),
+  ]) as RelatedEntry[];
+
+  expect(entries).toHaveLength(2);
+  const byKind = new Map(
+    entries.map(entry => [entry.record.entity_kind, entry]),
+  );
+  expect(byKind.get('chart')?.rollupEntityNames).toEqual([
+    'Trend',
+    'Breakdown',
+  ]);
+  expect(byKind.get('dataset')?.rollupEntityNames).toEqual(['Sales', 'Costs']);
+});
+
+test('a self save between two related entries blocks their merge', () => {
+  const related = (transactionId: number, issuedAt: string) =>
+    record({
+      source: 'related',
+      entity_kind: 'dataset',
+      entity_uuid: 'ds-1',
+      entity_name: 'birth_names',
+      transaction_id: transactionId,
+      issued_at: issuedAt,
+    });
+
+  const entries = buildTimeline([
+    related(30, '2026-06-12T10:00:50'),
+    record({ transaction_id: 29, issued_at: '2026-06-12T10:00:30' }),
+    related(28, '2026-06-12T10:00:10'),
+  ]);
+
+  expect(entries.map(entry => entry.type)).toEqual([
+    'related',
+    'group',
+    'related',
+  ]);
+});
+
+test('mergeActivityPages appends new rows and drops duplicates', () => {
+  const pageOne = [
+    record({ transaction_id: 14, issued_at: '2025-12-07T09:00:00' }),
+    record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+  ];
+  const pageTwo = [
+    // duplicate of the last row of page one (offset shifted by a new save)
+    record({ transaction_id: 12, issued_at: '2025-12-06T10:00:00' }),
+    record({ transaction_id: 10 }),
+  ];
+
+  const merged = mergeActivityPages(pageOne, pageTwo);
+
+  expect(merged).toHaveLength(3);
+  expect(new Set(merged.map(recordKey)).size).toBe(3);
+});
+
+test('recordKey distinguishes records within one transaction', () => {
+  const a = record({ path: ['params', 'metrics', 'Revenue'] });
+  const b = record({ path: ['params', 'metrics', 'Profit'] });
+  expect(recordKey(a)).not.toBe(recordKey(b));
+});
+
+test('recordKey separates same-path records that differ only by value', () => {
+  // One save touching the same path twice, or two entries under one collection
+  // path, share kind/operation/path. Keyed on those alone the second row reads
+  // as a re-served copy of the first and is dropped when pages merge.
+  const first = record({
+    path: ['params', 'adhoc_filters'],
+    from_value: null,
+    to_value: 'region = US',
+  });
+  const second = record({
+    path: ['params', 'adhoc_filters'],
+    from_value: null,
+    to_value: 'channel = web',
+  });
+
+  expect(recordKey(first)).not.toBe(recordKey(second));
+  expect(mergeActivityPages([first], [second])).toHaveLength(2);
+});
diff --git a/superset-frontend/src/features/versionHistory/grouping.ts b/superset-frontend/src/features/versionHistory/grouping.ts
new file mode 100644
index 00000000000..87c0e6a7336
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/grouping.ts
@@ -0,0 +1,276 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 {
+  ActivityRecord,
+  RelatedEntry,
+  SaveGroup,
+  TimelineEntry,
+} from './types';
+
+/**
+ * Stable identity for one activity record, used to deduplicate rows
+ * when merging pages (offset pagination can re-serve rows if a new
+ * save lands between page fetches).
+ */
+export function recordKey(record: ActivityRecord): string {
+  return [
+    record.transaction_id,
+    record.entity_kind,
+    record.entity_uuid ?? record.entity_name,
+    record.source,
+    record.kind,
+    record.operation,
+    JSON.stringify(record.path),
+    // kind/operation/path can repeat within one transaction — the same field
+    // touched twice, or two entries under one collection path. Without the
+    // values, the second record is taken for a re-served copy of the first and
+    // dropped on page merge, so the timeline silently under-reports the save.
+    JSON.stringify(record.from_value ?? null),
+    JSON.stringify(record.to_value ?? null),
+  ].join('|');
+}
+
+/** Merge a newly fetched page into already loaded records, deduplicated. */
+export function mergeActivityPages(
+  existing: ActivityRecord[],
+  incoming: ActivityRecord[],
+): ActivityRecord[] {
+  const seen = new Set(existing.map(recordKey));
+  const merged = [...existing];
+  incoming.forEach(record => {
+    const key = recordKey(record);
+    if (!seen.has(key)) {
+      seen.add(key);
+      merged.push(record);
+    }
+  });
+  return merged;
+}
+
+/** One related row per save of a related entity, regardless of how many
+ * change records that save produced. */
+export function relatedEntryKey(record: ActivityRecord): string {
+  return [
+    record.transaction_id,
+    record.entity_kind,
+    record.entity_uuid ?? record.entity_name,
+  ].join('|');
+}
+
+/** Identity of the entity a related record belongs to, ignoring the
+ * transaction. */
+function relatedEntityKey(record: ActivityRecord): string {
+  return [record.entity_kind, record.entity_uuid ?? record.entity_name].join(
+    '|',
+  );
+}
+
+const RELATED_MERGE_WINDOW_MS = 60_000;
+
+/**
+ * The backend can split one logical save of a related entity into
+ * several transactions issued at (nearly) the same instant, which
+ * would render as duplicate-looking rows. Collapse adjacent related
+ * entries — no self save between them — for the same entity issued
+ * within a short window into the newer entry: its representative
+ * record is kept and the older save's records are absorbed so search
+ * over collapsed records keeps working.
+ */
+// TODO(version-history): backend workaround — remove when upstream emits
+// one transaction per logical save.
+function mergeAdjacentRelatedEntries(
+  entries: TimelineEntry[],
+): TimelineEntry[] {
+  const merged: TimelineEntry[] = [];
+  entries.forEach(entry => {
+    const previous = merged[merged.length - 1];
+    if (
+      entry.type === 'related' &&
+      previous?.type === 'related' &&
+      relatedEntityKey(previous.record) === relatedEntityKey(entry.record) &&
+      Math.abs(
+        Date.parse(previous.record.issued_at) -
+          Date.parse(entry.record.issued_at),
+      ) <= RELATED_MERGE_WINDOW_MS
+    ) {
+      previous.records.push(...entry.records);
+    } else {
+      merged.push(entry);
+    }
+  });
+  return merged;
+}
+
+/**
+ * One save can cascade into related records for many distinct entities
+ * of the same kind within a single transaction (e.g. a dataset edit
+ * touching every chart built on it), which would render one row per
+ * entity. Roll those entries up into one row per (transaction, kind):
+ * the newest entry keeps the row, absorbed entries contribute their
+ * entity names (for the tooltip) and records (so search keeps
+ * matching). Single-entity transactions are left untouched.
+ */
+// TODO(version-history): backend workaround — remove when upstream emits
+// a dataset-level impact record instead of per-chart cascade records.
+function rollupSameTransactionRelated(
+  entries: TimelineEntry[],
+): TimelineEntry[] {
+  const buckets = new Map<string, RelatedEntry[]>();
+  entries.forEach(entry => {
+    if (entry.type !== 'related') {
+      return;
+    }
+    const key = `${entry.record.transaction_id}|${entry.record.entity_kind}`;
+    const bucket = buckets.get(key);
+    if (bucket) {
+      bucket.push(entry);
+    } else {
+      buckets.set(key, [entry]);
+    }
+  });
+
+  const absorbed = new Set<RelatedEntry>();
+  buckets.forEach(bucket => {
+    if (bucket.length < 2) {
+      return;
+    }
+    // Entries are ordered newest-first; the first one keeps the row.
+    const [head, ...rest] = bucket;
+    head.rollupEntityNames = bucket.map(entry => entry.record.entity_name);
+    rest.forEach(entry => {
+      head.records.push(...entry.records);
+      absorbed.add(entry);
+    });
+  });
+
+  return absorbed.size > 0
+    ? entries.filter(entry => entry.type !== 'related' || !absorbed.has(entry))
+    : entries;
+}
+
+/**
+ * Dashboard layout containers — rows, columns, and the tab strip — are
+ * scaffolding Superset creates and destroys automatically as charts are
+ * dragged around (a single chart move can spawn a fresh ROW). They carry
+ * no user-meaningful name, so they're kept out of the descriptive change
+ * list; the chart add / move records tell the real story.
+ */
+const LAYOUT_SCAFFOLDING_KINDS = new Set(['row', 'column', 'tabs']);
+
+function isLayoutScaffolding(record: ActivityRecord): boolean {
+  return LAYOUT_SCAFFOLDING_KINDS.has(record.kind);
+}
+
+/**
+ * Build the timeline from a flat newest-first activity stream:
+ * `source='self'` records are grouped into one save container per
+ * transaction, `source='related'` records collapse into a single entry
+ * per (transaction, entity), adjacent related entries from one split
+ * save merge into a single row, and multi-entity cascades roll up into
+ * one row per (transaction, kind). The result is ordered newest first.
+ */
+export function buildTimeline(records: ActivityRecord[]): TimelineEntry[] {
+  const groupsByTransaction = new Map<number, SaveGroup>();
+  const relatedByKey = new Map<string, RelatedEntry>();
+  const entries: TimelineEntry[] = [];
+
+  records.forEach(record => {
+    if (record.source === 'self') {
+      let group = groupsByTransaction.get(record.transaction_id);
+      if (!group) {
+        group = {
+          type: 'group',
+          transactionId: record.transaction_id,
+          versionUuid: record.version_uuid,
+          issuedAt: record.issued_at,
+          changedBy: record.changed_by,
+          actionKind: record.action_kind,
+          records: [],
+        };
+        groupsByTransaction.set(record.transaction_id, group);
+        entries.push(group);
+      }
+      // The synthetic __meta__ headline (operation 'announce') is an
+      // annotation, not a user edit: keep it out of the change list. For a
+      // restore it carries the target version number for the headline.
+      if (record.kind === '__meta__' || record.operation === 'announce') {
+        const meta = record.to_value as { version_number?: number } | null;
+        if (meta && typeof meta.version_number === 'number') {
+          group.restoredToVersion = meta.version_number;
+        }
+      } else if (isLayoutScaffolding(record)) {
+        // Dropping a chart into empty space makes Superset spawn a new ROW;
+        // such container churn isn't a user action. Hide it — the chart's
+        // own add/move record carries the meaning — but remember it so a
+        // scaffolding-only save can still head with "Rearranged layout".
+        group.hasSuppressedLayout = true;
+      } else {
+        group.records.push(record);
+      }
+      if (record.first_tracked_save) {
+        group.firstTrackedSave = true;
+      }
+      if (record.issued_at > group.issuedAt) {
+        group.issuedAt = record.issued_at;
+      }
+      group.versionUuid = group.versionUuid ?? record.version_uuid;
+      group.changedBy = group.changedBy ?? record.changed_by;
+      group.actionKind = group.actionKind ?? record.action_kind;
+    } else {
+      const key = relatedEntryKey(record);
+      const existing = relatedByKey.get(key);
+      if (!existing) {
+        const entry: RelatedEntry = {
+          type: 'related',
+          record,
+          records: [record],
+        };
+        relatedByKey.set(key, entry);
+        entries.push(entry);
+      } else {
+        existing.records.push(record);
+        // Keep the row, upgrading it with the most informative fields
+        // seen across the save's records.
+        const current = existing.record;
+        existing.record = {
+          ...current,
+          summary: current.summary || record.summary,
+          impact: current.impact ?? record.impact,
+          issued_at:
+            record.issued_at > current.issued_at
+              ? record.issued_at
+              : current.issued_at,
+        };
+      }
+    }
+  });
+
+  entries.sort((a, b) => {
+    const issuedA = a.type === 'group' ? a.issuedAt : a.record.issued_at;
+    const issuedB = b.type === 'group' ? b.issuedAt : b.record.issued_at;
+    if (issuedA !== issuedB) {
+      return issuedA < issuedB ? 1 : -1;
+    }
+    const txA = a.type === 'group' ? a.transactionId : a.record.transaction_id;
+    const txB = b.type === 'group' ? b.transactionId : b.record.transaction_id;
+    return txB - txA;
+  });
+
+  return rollupSameTransactionRelated(mergeAdjacentRelatedEntries(entries));
+}
diff --git a/superset-frontend/src/features/versionHistory/openRelated.test.ts b/superset-frontend/src/features/versionHistory/openRelated.test.ts
new file mode 100644
index 00000000000..9886a59b6b1
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/openRelated.test.ts
@@ -0,0 +1,130 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 {
+  closeOpenedTab,
+  navigateOpenedTab,
+  openBlankTab,
+} from 'src/utils/navigationUtils';
+import { resolveEntityId } from './api';
+import { openRelatedEntity } from './openRelated';
+import type { ActivityRecord } from './types';
+
+jest.mock('src/utils/navigationUtils', () => ({
+  openBlankTab: jest.fn(),
+  navigateOpenedTab: jest.fn(),
+  closeOpenedTab: jest.fn(),
+}));
+jest.mock('./api', () => ({
+  resolveEntityId: jest.fn(),
+}));
+
+const mockedResolveEntityId = resolveEntityId as jest.Mock;
+const mockedOpenBlankTab = openBlankTab as jest.Mock;
+const mockedNavigateOpenedTab = navigateOpenedTab as jest.Mock;
+const mockedCloseOpenedTab = closeOpenedTab as jest.Mock;
+
+const claimedTab = { location: { replace: jest.fn() } } as unknown as Window;
+
+const record = (overrides: Partial<ActivityRecord> = {}): ActivityRecord =>
+  ({
+    entity_kind: 'chart',
+    entity_uuid: 'uuid-1',
+    entity_name: 'My chart',
+    ...overrides,
+  }) as ActivityRecord;
+
+beforeEach(() => {
+  mockedOpenBlankTab.mockReturnValue(claimedTab);
+});
+
+afterEach(() => {
+  jest.clearAllMocks();
+});
+
+test('claims the tab before the id resolves, then navigates it', async () => {
+  // window.open after the resolve await is silently refused on Safari (and
+  // on any browser once transient activation lapses) — the tab must be
+  // claimed synchronously in the click's task.
+  let resolveId: (value: number) => void = () => {};
+  mockedResolveEntityId.mockReturnValue(
+    new Promise(resolve => {
+      resolveId = resolve;
+    }),
+  );
+  const onError = jest.fn();
+
+  const pending = openRelatedEntity(record(), onError);
+  expect(mockedOpenBlankTab).toHaveBeenCalledTimes(1);
+  expect(mockedNavigateOpenedTab).not.toHaveBeenCalled();
+
+  resolveId(42);
+  await pending;
+
+  expect(mockedNavigateOpenedTab).toHaveBeenCalledWith(
+    claimedTab,
+    '/explore/?slice_id=42',
+  );
+  expect(onError).not.toHaveBeenCalled();
+});
+
+test('opens the resolved dashboard with a post-route_base path', async () => {
+  mockedResolveEntityId.mockResolvedValue(7);
+  const onError = jest.fn();
+
+  await openRelatedEntity(record({ entity_kind: 'dashboard' }), onError);
+
+  expect(mockedNavigateOpenedTab).toHaveBeenCalledWith(
+    claimedTab,
+    '/dashboard/7/',
+  );
+});
+
+test('reports an error when the record has no uuid', async () => {
+  const onError = jest.fn();
+
+  await openRelatedEntity(record({ entity_uuid: null }), onError);
+
+  expect(onError).toHaveBeenCalledWith('Could not find My chart');
+  expect(mockedResolveEntityId).not.toHaveBeenCalled();
+  // Nothing was claimed, so nothing to close.
+  expect(mockedOpenBlankTab).not.toHaveBeenCalled();
+});
+
+test('closes the claimed tab when the uuid does not resolve to an id', async () => {
+  // Leaving it open would strand the user on about:blank.
+  mockedResolveEntityId.mockResolvedValue(null);
+  const onError = jest.fn();
+
+  await openRelatedEntity(record(), onError);
+
+  expect(onError).toHaveBeenCalledWith('Could not find My chart');
+  expect(mockedNavigateOpenedTab).not.toHaveBeenCalled();
+  expect(mockedCloseOpenedTab).toHaveBeenCalledWith(claimedTab);
+});
+
+test('closes the claimed tab when the id lookup fails', async () => {
+  mockedResolveEntityId.mockRejectedValue(new Error('boom'));
+  const onError = jest.fn();
+
+  await openRelatedEntity(record(), onError);
+
+  expect(onError).toHaveBeenCalledWith('Could not find My chart');
+  expect(mockedNavigateOpenedTab).not.toHaveBeenCalled();
+  expect(mockedCloseOpenedTab).toHaveBeenCalledWith(claimedTab);
+});
diff --git a/superset-frontend/src/features/versionHistory/openRelated.ts b/superset-frontend/src/features/versionHistory/openRelated.ts
new file mode 100644
index 00000000000..c585e73d765
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/openRelated.ts
@@ -0,0 +1,64 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { t } from '@apache-superset/core/translation';
+import {
+  closeOpenedTab,
+  navigateOpenedTab,
+  openBlankTab,
+} from 'src/utils/navigationUtils';
+import type { ActivityEntityKind, ActivityRecord } from './types';
+import { resolveEntityId } from './api';
+
+function entityUrl(kind: ActivityEntityKind, id: number): string {
+  if (kind === 'chart') {
+    return `/explore/?slice_id=${id}`;
+  }
+  if (kind === 'dashboard') {
+    return `/dashboard/${id}/`;
+  }
+  return `/explore/?datasource_type=table&datasource_id=${id}`;
+}
+
+/** Open a related entity's page in a new tab, resolving its uuid to an id. */
+export async function openRelatedEntity(
+  record: ActivityRecord,
+  onError: (message: string) => void,
+): Promise<void> {
+  if (!record.entity_uuid) {
+    onError(t('Could not find %s', record.entity_name));
+    return;
+  }
+  // Claim the tab now, while the click's transient activation is still live:
+  // window.open after the resolve await is silently refused on Safari (and on
+  // any browser once activation lapses) — the same pitfall openAsNew handles.
+  const tab = openBlankTab();
+  try {
+    const id = await resolveEntityId(record.entity_kind, record.entity_uuid);
+    if (id === null) {
+      closeOpenedTab(tab);
+      onError(t('Could not find %s', record.entity_name));
+      return;
+    }
+    navigateOpenedTab(tab, entityUrl(record.entity_kind, id));
+  } catch {
+    // Leaving the claimed tab open would strand the user on about:blank.
+    closeOpenedTab(tab);
+    onError(t('Could not find %s', record.entity_name));
+  }
+}
diff --git a/superset-frontend/src/features/versionHistory/previewReselect.test.tsx b/superset-frontend/src/features/versionHistory/previewReselect.test.tsx
new file mode 100644
index 00000000000..a7f16ab59a3
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/previewReselect.test.tsx
@@ -0,0 +1,134 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+/**
+ * Regression coverage for re-selecting the version already being previewed
+ * (every change row is clickable, including rows of the previewed group).
+ * SET_VERSION_PREVIEW used to re-enter the applying state unconditionally,
+ * but the appliers' effects key on versionUuid and never re-run for an
+ * identical value — so nothing announced completion again and the banner
+ * reported "Loading historical version" forever, with Restore withheld.
+ *
+ * Test contributed by @kgabryje in review; assertions flipped to pin the
+ * fixed behaviour (the reducer treats the re-selection as a no-op).
+ */
+import { act } from 'react';
+import { createStore, render, waitFor } from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import type { VersionHistoryState } from './types';
+import { setVersionPreview, type VersionHistoryRootState } from './reducer';
+import { fetchDatasourceMetadata, fetchVersionSnapshot } from './api';
+import ChartVersionPreview from './ChartVersionPreview';
+
+jest.mock('./api', () => ({
+  fetchDatasourceMetadata: jest.fn(),
+  fetchVersionSnapshot: jest.fn(),
+}));
+jest.mock('./PreviewBanner', () => ({ __esModule: true, default: () => null }));
+jest.mock('src/components/Chart/chartAction', () => ({
+  getChartDataRequest: jest.fn(),
+  handleChartDataResponse: jest.fn(),
+}));
+jest.mock('src/explore/exploreUtils', () => ({
+  ...jest.requireActual('src/explore/exploreUtils'),
+  getQuerySettings: () => [false],
+}));
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  SuperChart: () => <div data-test="super-chart" />,
+}));
+
+const { handleChartDataResponse } = jest.requireMock(
+  'src/components/Chart/chartAction',
+);
+
+const PREVIEW = {
+  entityUuid: 'chart-uuid',
+  versionUuid: 'version-uuid',
+  transactionId: 1,
+  headline: 'A save',
+  issuedAt: '2026-07-01T00:00:00Z',
+};
+
+const previewState = (): VersionHistoryState => ({
+  isPanelOpen: true,
+  entityType: 'chart',
+  include: 'all',
+  isPreviewApplying: true,
+  preview: PREVIEW,
+  sessionLog: [],
+  restoreCount: 0,
+  lastRestoredEntityUuid: null,
+});
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  (fetchVersionSnapshot as jest.Mock).mockResolvedValue({
+    params: '{"metric":"count"}',
+    viz_type: 'table',
+    datasource_id: 5,
+    datasource_type: 'table',
+  });
+  (fetchDatasourceMetadata as jest.Mock).mockResolvedValue({});
+  (getChartDataRequest as jest.Mock).mockResolvedValue({
+    response: {},
+    json: {},
+  });
+  (handleChartDataResponse as jest.Mock).mockResolvedValue([{ data: [] }]);
+});
+
+test('re-selecting the already-previewed version does not re-enter applying', async () => {
+  const store = createStore(
+    {
+      versionHistory: previewState(),
+      explore: {
+        datasource: { id: 5, type: 'table', columns: [], metrics: [] },
+        slice: { editors: [{ id: 1 }] },
+      },
+      user: { userId: 1, roles: {} },
+    },
+    reducerIndex,
+  );
+  render(<ChartVersionPreview />, { useTheme: true, store });
+
+  const applying = () =>
+    (store.getState() as unknown as VersionHistoryRootState).versionHistory
+      .isPreviewApplying;
+
+  await waitFor(() => expect(applying()).toBe(false)); // first apply settles
+
+  // The user clicks a change row inside the group they are already
+  // previewing (every row is clickable, including the previewed group's).
+  act(() => {
+    store.dispatch(setVersionPreview({ ...PREVIEW }));
+  });
+  // The reducer treats it as a no-op: the applier effects key on versionUuid
+  // and would never re-run for an identical value, so re-entering the
+  // applying state here could never be left again.
+  expect(applying()).toBe(false);
+
+  // Flush pending microtasks to show nothing re-enters it later either.
+  await act(async () => {
+    await new Promise(resolve => {
+      setTimeout(resolve, 50);
+    });
+  });
+  expect(applying()).toBe(false);
+});
diff --git a/superset-frontend/src/features/versionHistory/reducer.test.ts b/superset-frontend/src/features/versionHistory/reducer.test.ts
new file mode 100644
index 00000000000..8e434cbccfc
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/reducer.test.ts
@@ -0,0 +1,147 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 versionHistoryReducer, {
+  appendVersionSessionLog,
+  clearVersionPreview,
+  clearVersionSessionLog,
+  closeVersionHistoryPanel,
+  openVersionHistoryPanel,
+  selectIsChartVersionPreviewActive,
+  selectIsDashboardVersionPreviewActive,
+  selectVersionHistory,
+  setVersionHistoryInclude,
+  setVersionPreview,
+  versionRestored,
+  VersionHistoryRootState,
+} from './reducer';
+import type { SessionLogEntry, VersionPreviewState } from './types';
+
+const initial = versionHistoryReducer(undefined, { type: 'INIT' } as never);
+
+const preview: VersionPreviewState = {
+  entityUuid: 'entity-uuid',
+  versionUuid: 'version-uuid',
+  transactionId: 7,
+  headline: 'Dec 5, 2025, 5:18 PM',
+  issuedAt: '2025-12-05T17:18:00',
+};
+
+const entry = (overrides: Partial<SessionLogEntry> = {}): SessionLogEntry => ({
+  label: "Changed 'Metrics'",
+  controlName: 'metrics',
+  ts: 1,
+  user: 'Ada Lovelace',
+  ...overrides,
+});
+
+test('opening the panel records the entity type', () => {
+  const state = versionHistoryReducer(
+    initial,
+    openVersionHistoryPanel('dashboard'),
+  );
+  expect(state.isPanelOpen).toBe(true);
+  expect(state.entityType).toBe('dashboard');
+});
+
+test('closing the panel also exits any active preview', () => {
+  let state = versionHistoryReducer(initial, openVersionHistoryPanel('chart'));
+  state = versionHistoryReducer(state, setVersionPreview(preview));
+  state = versionHistoryReducer(state, closeVersionHistoryPanel());
+  expect(state.isPanelOpen).toBe(false);
+  expect(state.preview).toBeNull();
+});
+
+test('set and clear preview', () => {
+  let state = versionHistoryReducer(initial, setVersionPreview(preview));
+  expect(state.preview).toEqual(preview);
+  state = versionHistoryReducer(state, clearVersionPreview(preview.entityUuid));
+  expect(state.preview).toBeNull();
+});
+
+test("another entity's clear leaves this preview alone", () => {
+  // The slice is global and outlives any one page. A restore (or a failed
+  // apply) settling after the user moved to another entity and previewed a
+  // version there must not clear the preview they just opened — the guard
+  // lives here so every dispatcher inherits it, rather than at each of the
+  // five call sites.
+  let state = versionHistoryReducer(initial, setVersionPreview(preview));
+  state = versionHistoryReducer(state, clearVersionPreview('other-entity'));
+  expect(state.preview).toEqual(preview);
+
+  // ...and the owning entity still clears it.
+  state = versionHistoryReducer(state, clearVersionPreview(preview.entityUuid));
+  expect(state.preview).toBeNull();
+});
+
+test('a clear with no entity cannot exit another entity’s preview', () => {
+  // An undefined uuid identifies nothing, so it must not act as a wildcard.
+  let state = versionHistoryReducer(initial, setVersionPreview(preview));
+  state = versionHistoryReducer(state, clearVersionPreview(undefined));
+  expect(state.preview).toEqual(preview);
+});
+
+test('include filter persists', () => {
+  const state = versionHistoryReducer(
+    initial,
+    setVersionHistoryInclude('related'),
+  );
+  expect(state.include).toBe('related');
+});
+
+test('versionRestored increments the counter and records the entity', () => {
+  let state = versionHistoryReducer(initial, versionRestored('uuid-a'));
+  state = versionHistoryReducer(state, versionRestored('uuid-b'));
+  expect(state.restoreCount).toBe(2);
+  // Consumers use this to ignore restores of entities other than the one
+  // their page shows — the store outlives SPA navigation.
+  expect(state.lastRestoredEntityUuid).toBe('uuid-b');
+});
+
+test('session log collapses consecutive edits of the same control', () => {
+  let state = versionHistoryReducer(
+    initial,
+    appendVersionSessionLog(entry({ ts: 1 })),
+  );
+  state = versionHistoryReducer(
+    state,
+    appendVersionSessionLog(entry({ ts: 2 })),
+  );
+  state = versionHistoryReducer(
+    state,
+    appendVersionSessionLog(entry({ controlName: 'row_limit', ts: 3 })),
+  );
+  expect(state.sessionLog).toHaveLength(2);
+  expect(state.sessionLog[0].ts).toBe(2);
+  state = versionHistoryReducer(state, clearVersionSessionLog());
+  expect(state.sessionLog).toHaveLength(0);
+});
+
+test('selectors fall back to the initial state when the slice is missing', () => {
+  const state = {} as VersionHistoryRootState;
+  expect(selectVersionHistory(state).isPanelOpen).toBe(false);
+  expect(selectIsChartVersionPreviewActive(state)).toBe(false);
+});
+
+test('per-entity preview selectors only match their own entity type', () => {
+  let slice = versionHistoryReducer(initial, openVersionHistoryPanel('chart'));
+  slice = versionHistoryReducer(slice, setVersionPreview(preview));
+  const state: VersionHistoryRootState = { versionHistory: slice };
+  expect(selectIsChartVersionPreviewActive(state)).toBe(true);
+  expect(selectIsDashboardVersionPreviewActive(state)).toBe(false);
+});
diff --git a/superset-frontend/src/features/versionHistory/reducer.ts b/superset-frontend/src/features/versionHistory/reducer.ts
new file mode 100644
index 00000000000..b1e2931c7a4
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/reducer.ts
@@ -0,0 +1,294 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 {
+  ActivityInclude,
+  SessionLogEntry,
+  VersionedEntityType,
+  VersionHistoryState,
+  VersionPreviewState,
+} from './types';
+
+export const OPEN_VERSION_HISTORY_PANEL = 'OPEN_VERSION_HISTORY_PANEL';
+export const CLOSE_VERSION_HISTORY_PANEL = 'CLOSE_VERSION_HISTORY_PANEL';
+export const SET_VERSION_HISTORY_INCLUDE = 'SET_VERSION_HISTORY_INCLUDE';
+export const SET_VERSION_PREVIEW = 'SET_VERSION_PREVIEW';
+export const CLEAR_VERSION_PREVIEW = 'CLEAR_VERSION_PREVIEW';
+export const VERSION_PREVIEW_APPLIED = 'VERSION_PREVIEW_APPLIED';
+export const VERSION_RESTORED = 'VERSION_RESTORED';
+export const APPEND_VERSION_SESSION_LOG = 'APPEND_VERSION_SESSION_LOG';
+export const CLEAR_VERSION_SESSION_LOG = 'CLEAR_VERSION_SESSION_LOG';
+
+/** Upper bound on retained unsaved-edit entries; older ones drop off. */
+export const MAX_SESSION_LOG_ENTRIES = 50;
+
+interface OpenPanelAction {
+  type: typeof OPEN_VERSION_HISTORY_PANEL;
+  entityType: VersionedEntityType;
+}
+
+interface ClosePanelAction {
+  type: typeof CLOSE_VERSION_HISTORY_PANEL;
+}
+
+interface SetIncludeAction {
+  type: typeof SET_VERSION_HISTORY_INCLUDE;
+  include: ActivityInclude;
+}
+
+interface SetPreviewAction {
+  type: typeof SET_VERSION_PREVIEW;
+  preview: VersionPreviewState;
+}
+
+interface ClearPreviewAction {
+  type: typeof CLEAR_VERSION_PREVIEW;
+  entityUuid: string | undefined;
+}
+
+interface PreviewAppliedAction {
+  type: typeof VERSION_PREVIEW_APPLIED;
+}
+
+interface VersionRestoredAction {
+  type: typeof VERSION_RESTORED;
+  /**
+   * The entity the restore happened to. The store outlives SPA navigation
+   * and a restore is a multi-request sequence, so an unscoped broadcast
+   * resolving after the user moved on would make the *next* page rehydrate —
+   * clearing its filters and edits for a restore that happened elsewhere.
+   * Consumers compare this against their own entity before reacting.
+   */
+  entityUuid: string;
+}
+
+interface AppendSessionLogAction {
+  type: typeof APPEND_VERSION_SESSION_LOG;
+  entry: SessionLogEntry;
+}
+
+interface ClearSessionLogAction {
+  type: typeof CLEAR_VERSION_SESSION_LOG;
+}
+
+export type VersionHistoryAction =
+  | OpenPanelAction
+  | ClosePanelAction
+  | SetIncludeAction
+  | SetPreviewAction
+  | ClearPreviewAction
+  | PreviewAppliedAction
+  | VersionRestoredAction
+  | AppendSessionLogAction
+  | ClearSessionLogAction;
+
+export const openVersionHistoryPanel = (
+  entityType: VersionedEntityType,
+): OpenPanelAction => ({
+  type: OPEN_VERSION_HISTORY_PANEL,
+  entityType,
+});
+
+export const closeVersionHistoryPanel = (): ClosePanelAction => ({
+  type: CLOSE_VERSION_HISTORY_PANEL,
+});
+
+export const setVersionHistoryInclude = (
+  include: ActivityInclude,
+): SetIncludeAction => ({
+  type: SET_VERSION_HISTORY_INCLUDE,
+  include,
+});
+
+export const setVersionPreview = (
+  preview: VersionPreviewState,
+): SetPreviewAction => ({
+  type: SET_VERSION_PREVIEW,
+  preview,
+});
+
+/**
+ * Exits the preview of *entityUuid*. The uuid is required rather than
+ * optional so an asynchronous dispatcher has to think about identity: the
+ * slice is global and outlives any one page, so a restore or a failed apply
+ * settling after the user moved to another entity would otherwise clear the
+ * preview they just opened there. The reducer no-ops on a mismatch.
+ */
+export const clearVersionPreview = (
+  entityUuid: string | undefined,
+): ClearPreviewAction => ({
+  type: CLEAR_VERSION_PREVIEW,
+  entityUuid,
+});
+
+/**
+ * Marks the requested preview as fully applied. Dispatched when the snapshot
+ * has been hydrated, or when applying it failed -- either way the page has
+ * stopped being in-between states.
+ */
+export const versionPreviewApplied = (): PreviewAppliedAction => ({
+  type: VERSION_PREVIEW_APPLIED,
+});
+
+export const versionRestored = (entityUuid: string): VersionRestoredAction => ({
+  type: VERSION_RESTORED,
+  entityUuid,
+});
+
+export const appendVersionSessionLog = (
+  entry: SessionLogEntry,
+): AppendSessionLogAction => ({
+  type: APPEND_VERSION_SESSION_LOG,
+  entry,
+});
+
+export const clearVersionSessionLog = (): ClearSessionLogAction => ({
+  type: CLEAR_VERSION_SESSION_LOG,
+});
+
+const initialState: VersionHistoryState = {
+  isPanelOpen: false,
+  entityType: null,
+  include: 'all',
+  preview: null,
+  isPreviewApplying: false,
+  sessionLog: [],
+  restoreCount: 0,
+  lastRestoredEntityUuid: null,
+};
+
+export default function versionHistoryReducer(
+  state: VersionHistoryState = initialState,
+  action: VersionHistoryAction,
+): VersionHistoryState {
+  switch (action.type) {
+    case OPEN_VERSION_HISTORY_PANEL:
+      return { ...state, isPanelOpen: true, entityType: action.entityType };
+    case CLOSE_VERSION_HISTORY_PANEL:
+      // Closing the panel also exits any active preview.
+      return {
+        ...state,
+        isPanelOpen: false,
+        preview: null,
+        isPreviewApplying: false,
+      };
+    case SET_VERSION_HISTORY_INCLUDE:
+      return { ...state, include: action.include };
+    case SET_VERSION_PREVIEW:
+      // Re-selecting the version already being previewed must be a no-op.
+      // The appliers' effects key on versionUuid and would never re-run for
+      // an identical value, so re-entering the applying state here would
+      // leave it stuck: the banner reporting a load forever and Restore
+      // withheld until the preview is exited.
+      if (action.preview.versionUuid === state.preview?.versionUuid) {
+        return state;
+      }
+      // A newly requested preview is by definition not on screen yet: the
+      // snapshot takes several round trips to fetch and hydrate, and until it
+      // lands the page still shows live data. Starting in the applying state
+      // means no caller can forget to announce it.
+      return { ...state, preview: action.preview, isPreviewApplying: true };
+    case VERSION_PREVIEW_APPLIED:
+      return { ...state, isPreviewApplying: false };
+    case CLEAR_VERSION_PREVIEW:
+      // Only the entity whose preview this is may exit it. A restore (or a
+      // failed apply) resolving after the user moved to another entity and
+      // opened a preview there must leave that preview alone.
+      if (state.preview && state.preview.entityUuid !== action.entityUuid) {
+        return state;
+      }
+      return { ...state, preview: null, isPreviewApplying: false };
+    case VERSION_RESTORED:
+      return {
+        ...state,
+        restoreCount: state.restoreCount + 1,
+        lastRestoredEntityUuid: action.entityUuid,
+      };
+    case APPEND_VERSION_SESSION_LOG: {
+      const last = state.sessionLog[state.sessionLog.length - 1];
+      // Collapse consecutive edits of the same control into one entry.
+      if (last && last.controlName === action.entry.controlName) {
+        return {
+          ...state,
+          sessionLog: [...state.sessionLog.slice(0, -1), action.entry],
+        };
+      }
+      // Alternating edits never collapse, so cap the log — the middleware
+      // appends on every control change for the lifetime of the page, and
+      // only the most recent entries are meaningful in the panel.
+      return {
+        ...state,
+        sessionLog: [...state.sessionLog, action.entry].slice(
+          -MAX_SESSION_LOG_ENTRIES,
+        ),
+      };
+    }
+    case CLEAR_VERSION_SESSION_LOG:
+      return { ...state, sessionLog: [] };
+    default:
+      return state;
+  }
+}
+
+export interface VersionHistoryRootState {
+  versionHistory: VersionHistoryState;
+}
+
+export const selectVersionHistory = (state: VersionHistoryRootState) =>
+  state.versionHistory ?? initialState;
+
+export const selectIsVersionHistoryPanelOpen = (
+  state: VersionHistoryRootState,
+) => selectVersionHistory(state).isPanelOpen;
+
+export const selectVersionHistoryInclude = (state: VersionHistoryRootState) =>
+  selectVersionHistory(state).include;
+
+export const selectVersionPreview = (state: VersionHistoryRootState) =>
+  selectVersionHistory(state).preview;
+
+/**
+ * True while a requested preview is still being fetched and hydrated -- the
+ * window in which the banner is up but the page below it is still live data.
+ */
+export const selectIsVersionPreviewApplying = (
+  state: VersionHistoryRootState,
+) => selectVersionHistory(state).isPreviewApplying;
+
+export const selectIsChartVersionPreviewActive = (
+  state: VersionHistoryRootState,
+) => {
+  const { entityType, preview } = selectVersionHistory(state);
+  return entityType === 'chart' && preview !== null;
+};
+
+export const selectIsDashboardVersionPreviewActive = (
+  state: VersionHistoryRootState,
+) => {
+  const { entityType, preview } = selectVersionHistory(state);
+  return entityType === 'dashboard' && preview !== null;
+};
+
+export const selectVersionRestoreCount = (state: VersionHistoryRootState) =>
+  selectVersionHistory(state).restoreCount;
+
+export const selectVersionLastRestoredUuid = (state: VersionHistoryRootState) =>
+  selectVersionHistory(state).lastRestoredEntityUuid;
+
+export const selectVersionSessionLog = (state: VersionHistoryRootState) =>
+  selectVersionHistory(state).sessionLog;
diff --git a/superset-frontend/src/features/versionHistory/sessionLogMiddleware.test.ts b/superset-frontend/src/features/versionHistory/sessionLogMiddleware.test.ts
new file mode 100644
index 00000000000..fb05297003f
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/sessionLogMiddleware.test.ts
@@ -0,0 +1,226 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { isFeatureEnabled } from '@superset-ui/core';
+import { versionSessionLogMiddleware } from './sessionLogMiddleware';
+import {
+  APPEND_VERSION_SESSION_LOG,
+  CLEAR_VERSION_SESSION_LOG,
+} from './reducer';
+
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  isFeatureEnabled: jest.fn(),
+}));
+
+const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
+
+const buildStore = (state: object = {}) => ({
+  getState: jest.fn(() => state),
+  dispatch: jest.fn(),
+});
+
+const run = (store: ReturnType<typeof buildStore>, action: object) => {
+  const next = jest.fn(value => value);
+  versionSessionLogMiddleware(store)(next)(action);
+  return next;
+};
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  mockedIsFeatureEnabled.mockReturnValue(true);
+});
+
+test('logs a session entry with the control label on SET_FIELD_VALUE', () => {
+  const store = buildStore({
+    user: { firstName: 'Ada', lastName: 'Lovelace' },
+    explore: { controls: { metrics: { label: 'Metrics' } } },
+  });
+  run(store, { type: 'SET_FIELD_VALUE', controlName: 'metrics', value: [] });
+  expect(store.dispatch).toHaveBeenCalledWith(
+    expect.objectContaining({
+      type: APPEND_VERSION_SESSION_LOG,
+      entry: expect.objectContaining({
+        label: "Changed 'Metrics'",
+        controlName: 'metrics',
+        user: 'Ada Lovelace',
+      }),
+    }),
+  );
+});
+
+test('falls back to a humanized control name when no label exists', () => {
+  const store = buildStore({ explore: { controls: {} } });
+  run(store, { type: 'SET_FIELD_VALUE', controlName: 'row_limit', value: 10 });
+  expect(store.dispatch).toHaveBeenCalledWith(
+    expect.objectContaining({
+      entry: expect.objectContaining({
+        label: "Changed 'row limit'",
+        user: null,
+      }),
+    }),
+  );
+});
+
+test('skips programmatic control writes so untouched charts stay clean', async () => {
+  // Effects rewrite controls with no user gesture (transferred-control
+  // cleanup after load, derived margins); logging them would report unsaved
+  // edits the user never made. Built with the REAL action creator so the
+  // `programmatic` key is pinned across modules — a rename on either side
+  // fails here rather than silently reviving phantom entries.
+  const { setControlValue } =
+    await import('src/explore/actions/exploreActions');
+  const store = buildStore({
+    explore: { controls: { metrics: { label: 'Metrics' } } },
+  });
+  run(store, setControlValue('metrics', [], undefined, { programmatic: true }));
+  expect(store.dispatch).not.toHaveBeenCalled();
+
+  // The same creator without the mark still logs.
+  run(store, setControlValue('metrics', []));
+  expect(store.dispatch).toHaveBeenCalledWith(
+    expect.objectContaining({ type: APPEND_VERSION_SESSION_LOG }),
+  );
+});
+
+test('clears the session log when the explore page hydrates', () => {
+  const store = buildStore();
+  run(store, { type: 'HYDRATE_EXPLORE', data: {} });
+  expect(store.dispatch).toHaveBeenCalledWith({
+    type: CLEAR_VERSION_SESSION_LOG,
+  });
+});
+
+test('a chart rename is logged as an unsaved change', () => {
+  // Renames travel through UPDATE_CHART_TITLE, not SET_FIELD_VALUE; without
+  // this the restore gate's dirty signal missed them and a restore silently
+  // discarded the rename.
+  const store = buildStore({
+    user: { firstName: 'Ada', lastName: 'Lovelace' },
+  });
+  run(store, { type: 'UPDATE_CHART_TITLE', sliceName: 'New name' });
+  expect(store.dispatch).toHaveBeenCalledWith(
+    expect.objectContaining({
+      type: APPEND_VERSION_SESSION_LOG,
+      entry: expect.objectContaining({
+        label: 'Renamed chart',
+        controlName: 'slice_name',
+        user: 'Ada Lovelace',
+      }),
+    }),
+  );
+});
+
+test('a dataset reconciliation is logged as an unsaved change', async () => {
+  // Editing a dataset in place reconciles the chart's form data against the
+  // new columns but dispatches nothing the control branches record — only a
+  // *swap* emits its own control change. Without this the restore gate saw
+  // an empty log while the form had changed, and a restore discarded the
+  // reconciled values with no warning. Built with the REAL action creator so
+  // the shape is pinned across modules.
+  const { updateFormDataByDatasource } =
+    await import('src/explore/actions/exploreActions');
+  const store = buildStore({
+    user: { firstName: 'Ada', lastName: 'Lovelace' },
+    explore: { controls: { datasource: { label: 'Dataset' } } },
+  });
+  const datasource = { id: 1, type: 'table' } as never;
+  run(store, updateFormDataByDatasource(datasource, datasource));
+  expect(store.dispatch).toHaveBeenCalledWith(
+    expect.objectContaining({
+      type: APPEND_VERSION_SESSION_LOG,
+      entry: expect.objectContaining({
+        label: "Changed 'Dataset'",
+        controlName: 'datasource',
+        user: 'Ada Lovelace',
+      }),
+    }),
+  );
+});
+
+test('a browser history step is logged as an unsaved change', async () => {
+  // Explore's own history entries carry chart state, so a back/forward
+  // between them is an undo/redo that rebuilds the whole control map without
+  // emitting a single control change. Unrecorded, a save (which clears the
+  // log) followed by a step back to the pre-save controls left the restore
+  // gate seeing a clean form that had in fact moved — and the restore
+  // discarded it silently. Built with the REAL action creator: the constant
+  // is named SET_EXPLORE_CONTROLS but its literal is
+  // 'UPDATE_EXPLORE_CONTROLS'.
+  const { setExploreControls } =
+    await import('src/explore/actions/exploreActions');
+  const store = buildStore({
+    user: { firstName: 'Ada', lastName: 'Lovelace' },
+  });
+  run(store, setExploreControls({ viz_type: 'table' } as never));
+  expect(store.dispatch).toHaveBeenCalledWith(
+    expect.objectContaining({
+      type: APPEND_VERSION_SESSION_LOG,
+      entry: expect.objectContaining({
+        label: 'Undid or redid a change',
+        controlName: '__history__',
+        user: 'Ada Lovelace',
+      }),
+    }),
+  );
+});
+
+test('a history step cannot collapse into an adjacent control entry', async () => {
+  // Collapsing is by controlName, so the sentinel must not collide with a
+  // real control — otherwise a history step would silently replace the
+  // preceding genuine edit rather than adding to it.
+  const { setExploreControls } =
+    await import('src/explore/actions/exploreActions');
+  const store = buildStore({ explore: { controls: {} } });
+  run(store, setExploreControls({} as never));
+  const { entry } = store.dispatch.mock.calls[0][0];
+  expect(entry.controlName).not.toMatch(/^[a-z]/);
+});
+
+test('does nothing when the feature flag is disabled', () => {
+  mockedIsFeatureEnabled.mockReturnValue(false);
+  const store = buildStore();
+  run(store, { type: 'SET_FIELD_VALUE', controlName: 'metrics', value: [] });
+  expect(store.dispatch).not.toHaveBeenCalled();
+});
+
+test('passes every action through to the next middleware', () => {
+  const store = buildStore();
+  const action = { type: 'UNRELATED' };
+  const next = run(store, action);
+  expect(next).toHaveBeenCalledWith(action);
+  expect(store.dispatch).not.toHaveBeenCalled();
+});
+
+test('inlined action-type literals match the real explore constants', async () => {
+  // The middleware inlines these to keep explore out of the global
+  // bundle; a rename in explore must fail here, not silently kill the
+  // session log. Test code can afford the real imports.
+  const middleware = await import('./sessionLogMiddleware');
+  const exploreActions = await import('src/explore/actions/exploreActions');
+  const hydrateExplore = await import('src/explore/actions/hydrateExplore');
+  expect(middleware.SET_FIELD_VALUE).toBe(exploreActions.SET_FIELD_VALUE);
+  expect(middleware.UPDATE_CHART_TITLE).toBe(exploreActions.UPDATE_CHART_TITLE);
+  expect(middleware.UPDATE_FORM_DATA_BY_DATASOURCE).toBe(
+    exploreActions.UPDATE_FORM_DATA_BY_DATASOURCE,
+  );
+  expect(middleware.SET_EXPLORE_CONTROLS).toBe(
+    exploreActions.SET_EXPLORE_CONTROLS,
+  );
+  expect(middleware.HYDRATE_EXPLORE).toBe(hydrateExplore.HYDRATE_EXPLORE);
+});
diff --git a/superset-frontend/src/features/versionHistory/sessionLogMiddleware.ts b/superset-frontend/src/features/versionHistory/sessionLogMiddleware.ts
new file mode 100644
index 00000000000..b139cf09b15
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/sessionLogMiddleware.ts
@@ -0,0 +1,155 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 { Middleware } from 'redux';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { appendVersionSessionLog, clearVersionSessionLog } from './reducer';
+
+// Action types are inlined (rather than imported from the explore
+// module) so this middleware does not pull explore code into every
+// page's bundle; the store applies it globally. Exported so the test
+// suite can assert they match the real explore constants — a rename
+// there would otherwise silently kill the session log.
+export const SET_FIELD_VALUE = 'SET_FIELD_VALUE';
+export const UPDATE_CHART_TITLE = 'UPDATE_CHART_TITLE';
+export const UPDATE_FORM_DATA_BY_DATASOURCE = 'UPDATE_FORM_DATA_BY_DATASOURCE';
+// Note the literal: explore names this constant SET_EXPLORE_CONTROLS but its
+// value is 'UPDATE_EXPLORE_CONTROLS'. The pinning test guards the mismatch.
+export const SET_EXPLORE_CONTROLS = 'UPDATE_EXPLORE_CONTROLS';
+export const HYDRATE_EXPLORE = 'HYDRATE_EXPLORE';
+
+// The control whose label names a datasource change in the log. Both the
+// swap and the Edit Dataset routes report under it, so the two entries
+// collapse into one when a swap emits both.
+const DATASOURCE_CONTROL_NAME = 'datasource';
+// Sentinel, not a real control: a history step rewrites the whole control
+// map, so it belongs to no single control. Namespaced so it can never
+// collapse into an adjacent genuine control entry.
+const HISTORY_CONTROL_NAME = '__history__';
+
+interface SessionLogState {
+  user?: { firstName?: string; lastName?: string };
+  explore?: {
+    controls?: Record<string, { label?: unknown } | undefined>;
+  };
+}
+
+function controlLabel(state: SessionLogState, controlName: string): string {
+  const label = state.explore?.controls?.[controlName]?.label;
+  return typeof label === 'string' && label
+    ? label
+    : controlName.replace(/_/g, ' ');
+}
+
+function userName(state: SessionLogState): string | null {
+  const name = [state.user?.firstName, state.user?.lastName]
+    .filter(Boolean)
+    .join(' ');
+  return name || null;
+}
+
+/**
+ * Records unsaved explore control changes in the version history
+ * session log ("Current version" section) and resets the log whenever
+ * the explore page (re)hydrates — initial load, save, or restore.
+ */
+export const versionSessionLogMiddleware: Middleware =
+  store => next => action => {
+    const result = next(action);
+    if (!isFeatureEnabled(FeatureFlag.VersionHistory)) {
+      return result;
+    }
+    if (action.type === HYDRATE_EXPLORE) {
+      store.dispatch(clearVersionSessionLog());
+    } else if (action.type === UPDATE_CHART_TITLE) {
+      // Renaming the chart is an unsaved change like any control edit, but it
+      // travels through its own action — without this branch the restore
+      // gate's dirty signal missed it and a restore silently discarded the
+      // rename.
+      const state = store.getState() as SessionLogState;
+      store.dispatch(
+        appendVersionSessionLog({
+          label: t('Renamed chart'),
+          controlName: 'slice_name',
+          ts: Date.now(),
+          user: userName(state),
+        }),
+      );
+    } else if (action.type === SET_EXPLORE_CONTROLS) {
+      // Explore pushes its chart state into history entries, so a browser
+      // back/forward between them is an undo/redo: it rebuilds the entire
+      // control map without emitting a single control change. Left
+      // unrecorded, a user who saved, stepped back to the pre-save controls
+      // and then restored a version had that step silently discarded — the
+      // restore gate saw an empty log while the form had moved.
+      //
+      // Recording it does mean stepping back to a state matching the saved
+      // chart still latches an entry, so restore is blocked where nothing
+      // actually differs. That is the append-only log's known failure
+      // direction, and it fails closed; the missing entry failed open.
+      // Baseline diffing replaces both.
+      const state = store.getState() as SessionLogState;
+      store.dispatch(
+        appendVersionSessionLog({
+          label: t('Undid or redid a change'),
+          controlName: HISTORY_CONTROL_NAME,
+          ts: Date.now(),
+          user: userName(state),
+        }),
+      );
+    } else if (action.type === UPDATE_FORM_DATA_BY_DATASOURCE) {
+      // Swapping the dataset and editing it in place both reconcile the
+      // chart's form data against the new columns, but only the swap emits
+      // a control change of its own (ChangeDatasourceModal's `onChange`).
+      // Editing a dataset dispatches nothing the branches below record, so
+      // without this the restore gate saw an empty log while the form had
+      // changed — a restore then discarded the reconciled values silently.
+      const state = store.getState() as SessionLogState;
+      store.dispatch(
+        appendVersionSessionLog({
+          label: t(
+            "Changed '%s'",
+            controlLabel(state, DATASOURCE_CONTROL_NAME),
+          ),
+          controlName: DATASOURCE_CONTROL_NAME,
+          ts: Date.now(),
+          user: userName(state),
+        }),
+      );
+    } else if (
+      action.type === SET_FIELD_VALUE &&
+      typeof action.controlName === 'string' &&
+      // Effects rewrite controls with no user gesture (transferred-control
+      // cleanup after load, derived values set alongside another control);
+      // logging those would tell the user they have unsaved edits they never
+      // made. Only user-initiated changes belong in the session log.
+      !action.programmatic
+    ) {
+      const state = store.getState() as SessionLogState;
+      store.dispatch(
+        appendVersionSessionLog({
+          label: t("Changed '%s'", controlLabel(state, action.controlName)),
+          controlName: action.controlName,
+          ts: Date.now(),
+          user: userName(state),
+        }),
+      );
+    }
+    return result;
+  };
diff --git a/superset-frontend/src/features/versionHistory/types.ts b/superset-frontend/src/features/versionHistory/types.ts
new file mode 100644
index 00000000000..0ef7b254d70
--- /dev/null
+++ b/superset-frontend/src/features/versionHistory/types.ts
@@ -0,0 +1,231 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 type VersionedEntityType = 'chart' | 'dashboard';
+
+export type ActivityInclude = 'self' | 'related' | 'all';
+
+export type ActivityEntityKind = 'chart' | 'dashboard' | 'dataset';
+
+export type ActivityOperation =
+  | 'add'
+  | 'remove'
+  | 'move'
+  | 'edit'
+  // The server's summary verb for a field group collapsed at the record cap.
+  | 'update'
+  | 'announce';
+
+export type ActivityActionKind = 'restore' | 'import' | 'clone' | null;
+
+export interface ActivityChangedBy {
+  id: number;
+  first_name: string | null;
+  last_name: string | null;
+}
+
+export interface ActivityRecord {
+  version_uuid: string | null;
+  entity_kind: ActivityEntityKind;
+  entity_uuid: string | null;
+  entity_name: string;
+  entity_deleted: boolean;
+  entity_deletion_state: string | null;
+  source: 'self' | 'related';
+  transaction_id: number;
+  action_kind: ActivityActionKind;
+  issued_at: string;
+  changed_by: ActivityChangedBy | null;
+  kind: string;
+  operation: ActivityOperation;
+  path: Array<string | number>;
+  from_value: unknown;
+  to_value: unknown;
+  summary: string;
+  impact: { charts: number } | null;
+  /**
+   * True only on records whose transaction is the entity's first tracked
+   * save (the first update after its retroactive baseline). Such saves can
+   * carry dozens of params-normalization records that aren't meaningful user
+   * edits, so the container is collapsed. Always false for hard-deleted
+   * entities. Optional for resilience against
+   * older backend responses that predate the field.
+   */
... 2899 lines suppressed ...