diff --git a/packages/studio/src/hooks/timelineTrackVisibility.test.ts b/packages/studio/src/hooks/timelineTrackVisibility.test.ts
index 8ccb088fe..24d114b98 100644
--- a/packages/studio/src/hooks/timelineTrackVisibility.test.ts
+++ b/packages/studio/src/hooks/timelineTrackVisibility.test.ts
@@ -95,7 +95,9 @@ describe("toggleTimelineTrackHidden", () => {
expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"]));
expect(timestampRef.current).toBeGreaterThan(0);
expect(recordEdit).toHaveBeenCalledTimes(1);
- expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 0");
+ // Display row, not the raw key: track 0 is the first row, so it reads "1",
+ // the same number the track header announces for that row.
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 1");
expect(Object.keys(recordEdit.mock.calls[0]?.[0]?.files ?? {}).sort()).toEqual([
"index.html",
"scene.html",
@@ -134,6 +136,64 @@ describe("toggleTimelineTrackHidden", () => {
expect(writes.get("index.html")).not.toContain("data-hidden");
});
+
+ // An expanded sub-comp child gets a synthesized FRACTIONAL track key
+ // (`display.track + n / (siblings + 2)`), so a label built from the raw key
+ // reads "Hide track 0.16666666666666666" in the undo history. Track 0 would
+ // format cleanly and prove nothing, hence 1 / 6.
+ it("labels the undo entry with the display row, not the fractional track key", async () => {
+ const files = new Map([
+ ["index.html", `
`],
+ ]);
+ stubProjectFiles(files);
+
+ const recordEdit = vi.fn();
+
+ await toggleTimelineTrackHidden({
+ projectId: "project-1",
+ activeCompPath: "index.html",
+ timelineElements: [
+ element({ id: "host", domId: "host", track: 0 }),
+ element({ id: "child", domId: "child", track: 1 / 6 }),
+ ],
+ track: 1 / 6,
+ hidden: true,
+ previewIframe: null,
+ writeProjectFile: async () => {},
+ recordEdit,
+ domEditSaveTimestampRef: { current: 0 },
+ pendingTimelineEditPathRef: { current: new Set() },
+ });
+
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 2");
+ });
+
+ it("labels a show back with the display row too", async () => {
+ const files = new Map([
+ ["index.html", ``],
+ ]);
+ stubProjectFiles(files);
+
+ const recordEdit = vi.fn();
+
+ await toggleTimelineTrackHidden({
+ projectId: "project-1",
+ activeCompPath: "index.html",
+ timelineElements: [
+ element({ id: "host", domId: "host", track: 0 }),
+ element({ id: "child", domId: "child", track: 1 / 6, hidden: true }),
+ ],
+ track: 1 / 6,
+ hidden: false,
+ previewIframe: null,
+ writeProjectFile: async () => {},
+ recordEdit,
+ domEditSaveTimestampRef: { current: 0 },
+ pendingTimelineEditPathRef: { current: new Set() },
+ });
+
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Show track 2");
+ });
});
describe("toggleTimelineElementHidden", () => {
diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts
index f305226c9..634ea6ee6 100644
--- a/packages/studio/src/hooks/timelineTrackVisibility.ts
+++ b/packages/studio/src/hooks/timelineTrackVisibility.ts
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { usePlayerStore, type TimelineElement } from "../player";
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
+import { timelineTrackOrder, trackDisplayNumber } from "../player/components/timelineTrackDisplay";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
import {
@@ -208,12 +209,15 @@ export async function toggleTimelineTrackHidden({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: ToggleTimelineTrackHiddenInput): Promise {
+ // `track` is the fractional sort key the callback needs; the history entry is
+ // read by a human, so it gets the display row instead.
+ const displayNumber = trackDisplayNumber(timelineTrackOrder(timelineElements), track);
return setElementsHidden({
projectId,
activeCompPath,
elements: timelineElements.filter((element) => element.track === track),
hidden,
- label: hidden ? `Hide track ${track}` : `Show track ${track}`,
+ label: hidden ? `Hide track ${displayNumber}` : `Show track ${displayNumber}`,
previewIframe,
writeProjectFile,
recordEdit,
diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx
index c69402557..32e805631 100644
--- a/packages/studio/src/player/components/TimelineLanes.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.tsx
@@ -4,6 +4,7 @@ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
+import { trackDisplayNumber } from "./timelineTrackDisplay";
import { clipTimingStart } from "../../hooks/gsapShared";
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
@@ -105,6 +106,7 @@ export function TimelineLanes({
// in a virtualizer if editorial workflows ever push very high clip counts.
// fallow-ignore-next-line complexity
displayTrackOrder.map((trackNum, row) => {
+ const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
const rowHeight = getTimelineRowHeight(row, rowHeights);
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
@@ -150,8 +152,10 @@ export function TimelineLanes({
trackNumber={trackNum}
// What gets announced. `trackNum` is a fractional z-order sort
// key, so it stays out of every label and in every callback.
- trackDisplayNumber={row + 1}
- trackLabel={els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${row + 1}`}
+ trackDisplayNumber={displayNumber}
+ trackLabel={
+ els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${displayNumber}`
+ }
lanesId={lanesId}
contentOrigin={contentOrigin}
keyframeClip={keyframeClip}
diff --git a/packages/studio/src/player/components/timelineTrackDisplay.ts b/packages/studio/src/player/components/timelineTrackDisplay.ts
new file mode 100644
index 000000000..4bf059e93
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTrackDisplay.ts
@@ -0,0 +1,24 @@
+/**
+ * The one owner of "what track number does the user see".
+ *
+ * `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
+ * sub-composition child gets a synthesized fractional key (`host.track + n /
+ * (siblings + 2)`, see `useExpandedTimelineElements`), so putting it in a string
+ * announces "Hide track 0.16666666666666666". Every user-visible track number,
+ * whether it is rendered by a component or baked into an undo-history label,
+ * routes through here; the raw key stays in callbacks and lookups only.
+ */
+
+/** Ascending distinct track keys, the row order the timeline renders in. */
+export function timelineTrackOrder(elements: readonly { track: number }[]): number[] {
+ return [...new Set(elements.map((element) => element.track))].sort((a, b) => a - b);
+}
+
+/**
+ * A track key's 1-based display row. A key not in `trackOrder` (a drag preview
+ * onto a brand-new track, say) reads as the row it would land on at the end.
+ */
+export function trackDisplayNumber(trackOrder: readonly number[], track: number): number {
+ const row = trackOrder.indexOf(track);
+ return row < 0 ? trackOrder.length + 1 : row + 1;
+}