Skip to content
16 changes: 11 additions & 5 deletions webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,21 +402,27 @@ export function ThreadMinimap({
/// index (`earlier`) give every prompt its tick from the first frame, so the box is full height and
/// centered once, and history paging in neither grows nor re-centres it (Leo, 2026-11-03: "mostly i
/// just don't like it when the thing shifts and whatnot trying to center itself").
/// Never shorter than the prompts on screen, so the band always draws whole (model.ts railHeight).
/// One height for a fixed arm; the others are never shorter than the prompts on screen (railHeight).
const heightConfig = railHeightConfig(heightArm);
const boxRail = railHeight(
turns.length,
windowHeight,
boxHeight,
boxFoot,
onScreen.last - onScreen.first + 1,
railHeightConfig(heightArm),
heightConfig,
);
const boxTop = railTopAt(boxRail, boxHeight, boxFoot);
/// The bottom stack's top as it is now (VirtualTranscript `railBox.live`): the rail's foot and the
/// card's both stay above it, so the two never disagree on where the view ends.
const liveFoot = railBox?.live ?? boxFoot ?? viewportHeight;
/// The list's own height: the whole box, less what a stack grown since rest takes (railClearHeight).
const height = railClearHeight(boxRail, boxTop, boxHeight, boxFoot ?? boxHeight, liveFoot);
/// A fixed rail neither moves nor changes height when the stack grows (Leo, 2026-11-04: "the height
/// of the left scroll rail should never be affected by anything"; 2026-11-03: "i just don't like it
/// when the thing shifts"): a stack tall enough to reach it lays over its foot. The other arms keep
/// their top and shorten from the foot (railClearHeight).
const boxTop = railTopAt(boxRail, boxHeight, boxFoot);
const height = heightConfig.fixed
? boxRail
: railClearHeight(boxRail, boxTop, boxHeight, boxFoot ?? boxHeight, liveFoot);
/// Where the list's first row draws in the view.
const railTop = boxTop;
// The compact rail is pinned to the pane's left edge. It never follows the centered transcript or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,30 @@
import { defineExperiment } from "../../../experiments/experiment";
import { RAIL_HEIGHTS, type RailHeightArm } from "./model";

/// The compact rail's initial and maximum height. Leo asked for a rail that grows toward ChatGPT's
/// nearly full-pane rail instead of tracking the visible transcript slice. The gallery compares four
/// choices; `initial-320-max-92` is the selected balance between a generous first view and room for the
/// transcript's own edge mask. Dogfood switches from `cmux.experiments` with this experiment id.
/// The compact rail's initial and maximum height. Leo first asked for a rail that grows toward
/// ChatGPT's nearly full-pane rail instead of tracking the visible transcript slice
/// (`initial-320-max-92` shipped for that). Then, 2026-11-04: "the left side session rail should just
/// be having a nicer max height instead of going all the way up and down. make it look like codex's
/// size." and "the height of the left scroll rail should never be affected by anything". The `fixed-*`
/// arms are one height for every chat, whatever its prompt count or the pane's height; `fixed-240`
/// ships. Dogfood switches from `cmux.experiments` with this experiment id.
export const threadMinimapHeight = defineExperiment({
id: "thread-minimap-height",
title: "Thread minimap height",
description: "How tall the compact prompt rail starts and how close its maximum gets to the pane height.",
arms: {
"fixed-200": {
label: "Fixed 200px",
description: "One short height for every chat; longer chats scroll the rail's own list.",
},
"fixed-240": {
label: "Fixed 240px",
description: "The Codex rail's size: one height for every chat, unaffected by prompt count or pane height.",
},
"fixed-280": {
label: "Fixed 280px",
description: "One slightly taller height for every chat.",
},
"initial-240-max-80": {
label: "240px / 80%",
description: "A modest initial rail, capped at 80% of the pane.",
Expand All @@ -28,7 +43,7 @@ export const threadMinimapHeight = defineExperiment({
description: "The tallest initial rail, using all available cleared pane room.",
},
},
defaultArm: "initial-320-max-92",
defaultArm: "fixed-240",
});

export const railHeightConfig = (arm: RailHeightArm) => RAIL_HEIGHTS[arm];
39 changes: 29 additions & 10 deletions webviews/src/agent-session/acpmux/threadMinimap/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,26 @@ export function railPlacement(
}
/// Height choices for the compact rail. The initial height keeps a short thread from looking like a
/// transcript slice; the share and row cap let a long thread grow toward the pane's full height before
/// its own list scrolls. The gallery compares these arms; the selected default is the middle ground.
export type RailHeightConfig = { initial: number; maxRows: number; maxShare: number };
/// its own list scrolls. The `fixed-*` arms are one height for every chat, the Codex rail's size: the
/// prompt count, the window and the pane leave it alone, and past it the list scrolls (Leo,
/// 2026-11-04: "the left side session rail should just be having a nicer max height instead of going
/// all the way up and down. make it look like codex's size." and "the height of the left scroll rail
/// should never be affected by anything"). Only a view too short to hold it still clips it
/// (railRoom). `fixed-240` ships; the gallery compares the arms.
/// A `fixed` arm is one height whatever the chat, window or bottom stack (Leo, 2026-11-04: "the height
/// of the left scroll rail should never be affected by anything"): only a view too short for it clips it.
export type RailHeightConfig = { initial: number; maxRows: number; maxShare: number; fixed?: boolean };
export const RAIL_HEIGHTS = {
"fixed-200": { initial: 200, maxRows: 20, maxShare: 1, fixed: true },
"fixed-240": { initial: 240, maxRows: 24, maxShare: 1, fixed: true },
"fixed-280": { initial: 280, maxRows: 28, maxShare: 1, fixed: true },
"initial-240-max-80": { initial: 240, maxRows: 96, maxShare: 0.8 },
"initial-320-max-92": { initial: 320, maxRows: 128, maxShare: 0.92 },
"initial-400-max-96": { initial: 400, maxRows: 160, maxShare: 0.96 },
"initial-480-max-full": { initial: 480, maxRows: 200, maxShare: 1 },
} as const satisfies Record<string, RailHeightConfig>;
export type RailHeightArm = keyof typeof RAIL_HEIGHTS;
export const DEFAULT_RAIL_HEIGHT: RailHeightArm = "initial-320-max-92";
export const DEFAULT_RAIL_HEIGHT: RailHeightArm = "fixed-240";
/// The fisheye by row distance from the hovered (or scrubbed) row: 26, 20, 14, 10, then 6 px.
const PROGRESS = [1, 0.7, 0.4, 0.2] as const;
/// Space kept free above and below the card inside the transcript's viewport.
Expand Down Expand Up @@ -196,11 +206,14 @@ export function waveFollow(focus: number, target: number, elapsed: number): numb
/// ease to its new length when the hovered row changes, so a sweep zips down the rail tick by tick.
/// A tick heading somewhere new starts from where it is, never from rest.
export type ZipMotion = { from: number; to: number; start: number; ms: number };
/// A tick that is not the hovered one easing to its new length.
/// A tick shrinking to its new length as the hovered row moves away: the zipper's trailing tail.
export const ZIP_MS = MOTION_MS.standard;
/// The hovered tick growing as the pointer arrives from off the rail. On a move between rows the
/// hovered tick is already longer than its old neighbours would allow it to wait, so it takes its
/// full length at once (`zipStep`).
/// A tick growing: the hovered tick as the pointer arrives from off the rail, and every tick the bump
/// moves onto. Growing over the quick step keeps the bump's leading side up with the pointer: over
/// ZIP_MS a sweep left the hovered tick long and the rows ahead of it short, holes in the wave (Leo,
/// 2026-11-04: "hovering across the session rail gets a bit laggy and the missing chunks of the wave
/// bother me :( it needs to get faster"). On a move between rows the hovered tick is already longer
/// than its old neighbours would allow it to wait, so it takes its full length at once (`zipStep`).
export const ZIP_HOVER_MS = MOTION_MS.quick;
/// The pointer leaving: every tick shrinks back to rest over the scale's ceiling (Leo, 2026-11-03:
/// "chatgpt has an animation for when it shrinks").
Expand Down Expand Up @@ -242,7 +255,7 @@ export function zipRetarget(
motions.delete(index);
continue;
}
const ms = hovered === undefined ? ZIP_RELEASE_MS : index === hovered ? ZIP_HOVER_MS : ZIP_MS;
const ms = hovered === undefined ? ZIP_RELEASE_MS : index === hovered || to > value ? ZIP_HOVER_MS : ZIP_MS;
motions.set(index, { from: value, to, start: now, ms });
}
}
Expand Down Expand Up @@ -293,8 +306,9 @@ export function railRoom(viewportHeight: number, foot = viewportHeight): { top:
/// 70% of the window, after which the rows scroll. It fits the room the transcript gives it
/// (railRoom: its viewport less the mask's bands), so a short pane's rows all scroll into view and no
/// tick sits under the transcript's fades (Leo 2026-10-22: "it wouldn't show the highlighted bottom
/// ... it just didn't go to the bottom"). It is never shorter than the band, the `band` prompts on
/// screen: in a pane too short for both, the mask's clearance gives way first, up to the whole view.
/// ... it just didn't go to the bottom"). A `fixed` arm is its one height, clipped only by that room.
/// The others are never shorter than the band, the `band` prompts on screen: in a pane too short for
/// both, the mask's clearance gives way first, up to the whole view.
export function railHeight(
count: number,
windowHeight: number,
Expand All @@ -305,6 +319,11 @@ export function railHeight(
): number {
if (count <= 0) return 0;
const room = railRoom(viewportHeight, foot);
// A fixed rail does not grow for the band: its list scrolls to keep the band in view (railScrollTop).
if (config.fixed)
return viewportHeight > 0
? Math.max(0, Math.min(config.initial, Math.floor(room.bottom - room.top)))
: config.initial;
const height = Math.min(
Math.max(count * ROW_PITCH, config.initial),
config.maxRows * ROW_PITCH,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default agentPaneEntry({
},
variants: {
"eighteen-prompts": {
note: "Eighteen prompts with a tall initial rail. Compare how much quiet vertical rail each height arm gives the thread before overflow.",
note: "Eighteen prompts. The fixed arms (200, 240 shipped, 280) keep one height with quiet rail under the last tick; the older initial-* arms are here for comparison.",
snapshot: chat(medium, { title: "Thread minimap height: initial rail" }),
play: async (ctx) => {
if (!ctx.document.querySelector('[data-tick="8"]')) return;
Expand All @@ -44,7 +44,7 @@ export default agentPaneEntry({
},
},
"seventy-prompts": {
note: "Seventy prompts. Compare the maximum arms as they approach the cleared pane height and leave only the rail's own scroll for overflow.",
note: "Seventy prompts. A fixed arm stays at its one height and its list scrolls; the initial-* arms grow toward the pane before they scroll.",
snapshot: chat(long, { title: "Thread minimap height: long chat" }),
play: async (ctx) => {
if (!ctx.document.querySelector('[data-tick="40"]')) return;
Expand All @@ -58,7 +58,7 @@ export default agentPaneEntry({
},
},
"tall-pane-caps": {
note: "Two hundred sixty prompts in a tall pane. The four arms reach distinct row caps before the viewport clearance bound.",
note: "Two hundred sixty prompts in a 2100 px pane. A fixed arm keeps the same height as in a 720 px pane; the initial-* arms reach their row caps.",
height: 2100,
snapshot: chat(tall, { title: "Thread minimap height: distinct caps" }),
play: async (ctx) => {
Expand Down
21 changes: 14 additions & 7 deletions webviews/test/thread-minimap-rail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,20 +346,27 @@ test("focus in the card keeps it; Escape there or focus leaving rail and card cl

test("the rail never outgrows a short transcript", async () => {
const { railHeight, RAIL_HEIGHTS } = await import("../src/agent-session/acpmux/threadMinimap/model");
// 60 prompts in an 820 px window over a 400 px transcript: the rail fits the transcript, less the
// 60 prompts in an 820 px window over a 200 px transcript: the rail fits the transcript, less the
// mask's 18 px bands and 4 px past each.
expect(railHeight(60, 820, 400)).toBe(400 - 22 - 22);
expect(railHeight(60, 820, 820)).toBe(600);
expect(railHeight(60, 820, 200)).toBe(200 - 22 - 22);
// Not measured yet: the window caps it alone.
expect(railHeight(60, 820, 0)).toBe(railHeight(60, 820));
// The selected height arm starts a short thread tall and grows toward the cleared pane room.
expect(railHeight(4, 720, 720)).toBe(320);
expect(railHeight(70, 720, 720)).toBe(Math.floor(720 * 0.92));
// The visible transcript band no longer controls the rail's initial height.
expect(railHeight(18, 720, 720, 720, 1)).toBe(railHeight(18, 720, 720, 720, 4));
expect(railHeight(18, 720, 720, 720, 4, RAIL_HEIGHTS["initial-240-max-80"])).toBe(240);
});

test("the rail is one height, the Codex rail's, whatever the prompt count or the pane's height", async () => {
const { railHeight } = await import("../src/agent-session/acpmux/threadMinimap/model");
// Leo, 2026-11-04: "make it look like codex's size" and "the height of the left scroll rail should
// never be affected by anything". Past 24 prompts the rail's own list scrolls.
for (const count of [1, 4, 12, 24, 70, 1000])
for (const view of [400, 720, 1100, 2100]) expect(railHeight(count, view, view)).toBe(240);
// Nor the prompts on screen: a view showing 50 short prompts at once keeps 240, and its list
// scrolls to keep them in view (railScrollTop).
for (const band of [1, 24, 50, 1000]) expect(railHeight(1000, 1000, 1000, 1000, band)).toBe(240);
});

test("wheel browsing belongs to the rail and is not snapped back by transcript scrolling", async () => {
const view = await mount(70);
try {
Expand Down Expand Up @@ -452,7 +459,7 @@ test("a wheel over a rail that scrolls itself is not the reader's: the transcrip
input.mockClear();
await act(async () => {
list.dispatchEvent(new dom.window.WheelEvent("wheel", { deltaY: -120, bubbles: true, cancelable: true }));
tick(40).dispatchEvent(new dom.window.WheelEvent("wheel", { deltaY: -40, bubbles: true, cancelable: true }));
tick(10).dispatchEvent(new dom.window.WheelEvent("wheel", { deltaY: -40, bubbles: true, cancelable: true }));
});
expect(captured).toEqual([-120, -40]);
expect(input).not.toHaveBeenCalled();
Expand Down
22 changes: 21 additions & 1 deletion webviews/test/thread-minimap-wave-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,25 @@ describe("hover wave model", () => {
expect(zipSettled(motions, 1000 + ZIP_MS)).toBe(true);
});

test("a sweep keeps the bump whole: the rows ahead of the pointer grow as fast as it arrives", () => {
// Leo, 2026-11-04: "the missing chunks of the wave bother me". A sweep down a row every 16 ms:
// each frame, the hovered row's neighbours on both sides are already most of their way up, so no
// hole opens between the hovered tick and the rows it moves onto.
const motions = new Map<number, ZipMotion>();
const frame = 1000 / 60;
let now = 0;
for (let row = 10; row <= 30; row += 1, now += frame) {
zipRetarget(motions, row, 60, now);
const values = zipStep(motions, row, now + frame);
// One frame in, the row ahead has at least half its length (0.7) and the next at least a third
// of its (0.4): over ZIP_MS they had 30% and 18%.
expect(values.get(row + 1)!).toBeGreaterThan(0.7 * 0.5);
expect(values.get(row + 2)!).toBeGreaterThan(0.4 * 0.33);
// Shrinking ticks behind the pointer keep the zipper's tail and never outgrow the hovered one.
for (const [index, value] of values) if (index !== row) expect(value).toBeLessThan(values.get(row)!);
}
});

test("a tick already heading to its target keeps its own motion", () => {
const motions = new Map<number, ZipMotion>();
zipRetarget(motions, 10, 30, 0);
Expand All @@ -114,7 +133,8 @@ describe("hover wave model", () => {
const motions = new Map<number, ZipMotion>();
zipRetarget(motions, 10, 30, 0);
expect(motions.get(10)!.ms).toBe(ZIP_HOVER_MS);
expect(motions.get(11)!.ms).toBe(ZIP_MS);
// Its neighbours grow over the same quick step; only shrinking ticks take ZIP_MS.
expect(motions.get(11)!.ms).toBe(ZIP_HOVER_MS);
zipStep(motions, 10, 1000);
zipRetarget(motions, undefined, 30, 1000);
for (const motion of motions.values()) expect(motion).toMatchObject({ to: 0, ms: ZIP_RELEASE_MS });
Expand Down
27 changes: 16 additions & 11 deletions webviews/test/thread-rail-full-height.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,18 @@ test("held ticks sit centered in the view, never stacked at the box's foot, whil
// The first frame: twelve prompts held, the summary says forty-five.
await render({ promptCount: 45 });
const open = rail();
// The box holds all forty-five ticks, the thirty-three not held yet as resting ticks, centered: no
// empty rows above the held ones.
// The box is the rail's one height (240 px, the Codex rail's size) over all forty-five ticks, the
// thirty-three not held yet as resting ticks, centered: no empty rows above the held ones.
expect(open.lead).toBe(0);
expect(open.height).toBe(450);
expect(document.querySelectorAll(".acpmux-minimap__entry").length).toBe(45);
expect(document.querySelectorAll(".acpmux-minimap__gap").length).toBe(0);
expect(open.top).toBeLessThanOrEqual(centered(450));
expect(open.height).toBe(240);
// Every prompt has its row, drawn or (past the list's view) a gap's.
const rows = [...document.querySelectorAll<HTMLElement>(".acpmux-minimap__entry, .acpmux-minimap__gap")].reduce(
(sum, node) =>
sum + (node.classList.contains("acpmux-minimap__gap") ? Number.parseFloat(node.style.height) / 10 : 1),
0,
);
expect(rows).toBe(45);
expect(open.top).toBeLessThanOrEqual(centered(240));
// The ticks' run is about the view's middle, nowhere near its foot.
expect(Math.abs(middle(open) - VIEWPORT / 2)).toBeLessThanOrEqual(100);
// The latest tick ends the box, clear of the bottom stack.
Expand All @@ -174,8 +179,8 @@ test("held ticks sit centered in the view, never stacked at the box's foot, whil
const full = rail();
expect(full).toEqual(open);
expect(full.lead).toBe(0);
expect(full.height).toBe(450);
expect(full.top).toBeLessThanOrEqual(centered(450));
expect(full.height).toBe(240);
expect(full.top).toBeLessThanOrEqual(centered(240));
expect(Math.abs(middle(full) - VIEWPORT / 2)).toBeLessThanOrEqual(100);
} finally {
await act(async () => root.unmount());
Expand All @@ -198,9 +203,9 @@ test("without a count the rail grows as prompts arrive, as before", async () =>
/>,
),
);
// Twelve prompts: the rail's 320 px floor, centered.
expect(rail().height).toBe(320);
expect(rail().top).toBeLessThanOrEqual(centered(320));
// Twelve prompts: the rail's one height, centered.
expect(rail().height).toBe(240);
expect(rail().top).toBeLessThanOrEqual(centered(240));
} finally {
await act(async () => root.unmount());
}
Expand Down
Loading
Loading