diff --git a/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx b/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx index d699a509f7a5..54804cbaa88e 100644 --- a/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx +++ b/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx @@ -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 diff --git a/webviews/src/agent-session/acpmux/threadMinimap/height.experiment.ts b/webviews/src/agent-session/acpmux/threadMinimap/height.experiment.ts index 25d58474a153..f64c52bb9232 100644 --- a/webviews/src/agent-session/acpmux/threadMinimap/height.experiment.ts +++ b/webviews/src/agent-session/acpmux/threadMinimap/height.experiment.ts @@ -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.", @@ -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]; diff --git a/webviews/src/agent-session/acpmux/threadMinimap/model.ts b/webviews/src/agent-session/acpmux/threadMinimap/model.ts index 62d39f236502..a3107f87c329 100644 --- a/webviews/src/agent-session/acpmux/threadMinimap/model.ts +++ b/webviews/src/agent-session/acpmux/threadMinimap/model.ts @@ -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; 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. @@ -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"). @@ -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 }); } } @@ -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, @@ -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, diff --git a/webviews/src/agent-session/acpmux/threadMinimap/threadMinimapHeight.gallery.ts b/webviews/src/agent-session/acpmux/threadMinimap/threadMinimapHeight.gallery.ts index 45a963e47874..7a78088520ee 100644 --- a/webviews/src/agent-session/acpmux/threadMinimap/threadMinimapHeight.gallery.ts +++ b/webviews/src/agent-session/acpmux/threadMinimap/threadMinimapHeight.gallery.ts @@ -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; @@ -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; @@ -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) => { diff --git a/webviews/test/thread-minimap-rail.test.tsx b/webviews/test/thread-minimap-rail.test.tsx index cab3b160e0cd..551fcbd8d619 100644 --- a/webviews/test/thread-minimap-rail.test.tsx +++ b/webviews/test/thread-minimap-rail.test.tsx @@ -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 { @@ -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(); diff --git a/webviews/test/thread-minimap-wave-model.test.ts b/webviews/test/thread-minimap-wave-model.test.ts index d65621683162..afc27785477b 100644 --- a/webviews/test/thread-minimap-wave-model.test.ts +++ b/webviews/test/thread-minimap-wave-model.test.ts @@ -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(); + 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(); zipRetarget(motions, 10, 30, 0); @@ -114,7 +133,8 @@ describe("hover wave model", () => { const motions = new Map(); 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 }); diff --git a/webviews/test/thread-rail-full-height.test.tsx b/webviews/test/thread-rail-full-height.test.tsx index 7d3b151cba8c..4786e15fe426 100644 --- a/webviews/test/thread-rail-full-height.test.tsx +++ b/webviews/test/thread-rail-full-height.test.tsx @@ -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(".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. @@ -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()); @@ -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()); } diff --git a/webviews/test/thread-rail-layout-box.test.tsx b/webviews/test/thread-rail-layout-box.test.tsx index a2e68ff2fca3..f1793f4c3f99 100644 --- a/webviews/test/thread-rail-layout-box.test.tsx +++ b/webviews/test/thread-rail-layout-box.test.tsx @@ -115,7 +115,7 @@ const stackResized = (height: number) => for (const { callback, self } of observers) callback([], self); }); -test("a composer that grows over the view, or a tray that opens in the stack, keeps the rail's top and only lifts its foot above the stack", async () => { +test("a composer that grows over the view, or a tray that opens in the stack, never moves the rail or changes its height", async () => { stack = 120; const root = createRoot(document.getElementById("root")!); const dock = document.getElementById("dock")!; @@ -134,24 +134,16 @@ test("a composer that grows over the view, or a tray that opens in the stack, ke const scroller = document.querySelector(".acpmux-scroll")!; expect(scroller.style.getPropertyValue("--acpmux-dock-space")).toBe("120px"); const rest = railBox(); - expect(Number.parseFloat(rest.height)).toBeGreaterThan(0); - // Three more composer lines, then a background-task bar on top of them. The rail keeps its top - // and inset; only its foot rises, and only as far as the grown stack reaches past it, so no tick - // runs under the composer (Leo, 2026-11-03: "this left scroll rail prob goes a little bit too far - // down now"): the stack's top less the bottom fade's clearance (model.ts railRoom, 18 + 4 px). - const footAbove = (stackHeight: number) => { - const box = railBox(); - expect({ left: box.left, top: box.top }).toEqual({ left: rest.left, top: rest.top }); - const bottom = Number.parseFloat(box.top) + Number.parseFloat(box.height); - expect(bottom).toBeLessThanOrEqual(VIEWPORT - stackHeight - 22); - expect(bottom).toBeGreaterThan(VIEWPORT - stackHeight - 22 - 1); - }; + expect(Number.parseFloat(rest.height)).toBe(240); + const restBottom = Number.parseFloat(rest.top) + Number.parseFloat(rest.height); + // Leo, 2026-11-04: "the height of the left scroll rail should never be affected by anything", and + // 2026-11-03: "i just don't like it when the thing shifts". Three more composer lines, then a stack + // that reaches past the rail's foot: the box stays where it rests, and the stack lays over its foot. await stackResized(188); expect(scroller.style.getPropertyValue("--acpmux-dock-space")).toBe("188px"); - footAbove(188); - await stackResized(236); - footAbove(236); - // Back to rest: the same box. + expect(railBox()).toEqual(rest); + await stackResized(VIEWPORT - restBottom - 22 + 40); + expect(railBox()).toEqual(rest); await stackResized(120); expect(railBox()).toEqual(rest); } finally {