diff --git a/packages/app/src/runtime/extension/side-region.tsx b/packages/app/src/runtime/extension/side-region.tsx index 431e1ed0d06d..fe89a040d561 100644 --- a/packages/app/src/runtime/extension/side-region.tsx +++ b/packages/app/src/runtime/extension/side-region.tsx @@ -125,7 +125,7 @@ export function SideRegion(props: {
}) { @@ -40,7 +40,11 @@ export function createSessionScreenLayout(session: SessionModel, input: { wide: const panelWidth = createMemo(() => { if (!tabsOpen()) return "100%" - return `${resizedWidth()}px` + return sessionPanelWidthCss({ + width: view().session.width(), + split: splitSide(), + resized: view().session.resized(), + }) }) const panelMax = createMemo(() => { diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 1d005c38083a..959f8de6bd20 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -47,7 +47,13 @@ import { usePlatform } from "@/runtime/platform/platform" import { useServer } from "@/runtime/server/current" import { reviewGlyphFill, SessionReviewToggle } from "./header/session-header-actions" import { createTimelineCache } from "./timeline/cache" -import { createPanelMotion, jamesPanelShift, replayPanelToggle } from "@/shell/panel-motion" +import { + createPanelMotion, + holdLandingWidth, + jamesPanelShift, + panelOpacity, + replayPanelToggle, +} from "@/shell/panel-motion" export function SessionScreenView(props: { session: SessionModel }) { // The timeline cache captures its owner when created, so link handling must be provided above it. @@ -159,8 +165,11 @@ function SessionScreenContent(props: { let sideContent: HTMLDivElement | undefined let sideGlyph: SVGPathElement | undefined let sideClosed = 0 + // While the left sidebar moves the side panel's edge, its contents keep the width they land at. + holdLandingWidth(() => sideContent) // The toggle's pane fills from the panel's edge as far as the panel is open. const paintSideGlyph = () => sideGlyph?.setAttribute("transform", reviewGlyphFill(1 - sideClosed)) + const sideMotion = createPanelMotion({ open: sideVisible, identity: session.layout.tabKey, @@ -170,7 +179,7 @@ function SessionScreenContent(props: { paintSideGlyph() // Dims the contents as they leave and fades them in as they arrive. - if (sideContent) sideContent.style.opacity = closed === 0 ? "" : `${1 - closed}` + if (sideContent) sideContent.style.opacity = panelOpacity(closed) if (!chat) return const open = chatOpenWidth() diff --git a/packages/app/src/session/session-panel-width.ts b/packages/app/src/session/session-panel-width.ts index e66fa9d1aea2..9afe75e9994f 100644 --- a/packages/app/src/session/session-panel-width.ts +++ b/packages/app/src/session/session-panel-width.ts @@ -22,6 +22,15 @@ export function sessionPanelWidthMax(input: { available: number; split: boolean; return Math.max(SESSION_PANEL_WIDTH_FIT, input.available - pane) } +// The same limit in CSS, against the pane row's width less its 8px gap, so the chat panel follows the row on the frame +// it resizes, e.g. while the left sidebar moves, rather than after a ResizeObserver callback. +export function sessionPanelWidthCss(input: { width: number; split: boolean; resized: boolean }) { + const minimum = input.resized ? SESSION_PANEL_WIDTH_MIN : SESSION_PANEL_WIDTH_FIT + const pane = input.resized ? SIDE_PANE_WIDTH_MIN : input.split ? REVIEW_PANE_WIDTH_FIT_SPLIT : REVIEW_PANE_WIDTH_FIT + + return `min(${input.width}px, max(${minimum}px, 100% - ${pane + 8}px))` +} + // `available` is undefined until the layout row is first measured; render the // stored width untouched until then to avoid a first-frame snap. export function clampSessionPanelWidth(input: { diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index f9e040e9b27f..3eb0a56bc5c6 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -29,6 +29,7 @@ import type { createTimelineProjection } from "./projection" import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { filterVirtualIndexes } from "./virtual-items" import { prefersReducedMotion } from "@opencode/ui/motion" +import { holdLandingWidth } from "@/shell/panel-motion" const fallbackItemSize = 60 @@ -198,6 +199,22 @@ export function createTimelineVirtualizer(input: Input) { let pointerHeld = false let maxScroll = 0 let virtualContent: HTMLDivElement | undefined + let rowLayer: HTMLDivElement | undefined + // While a sidebar's spring moves the timeline's edge, rows keep the width they land at, centred on the timeline, so + // their text wraps once instead of on every frame. The first column in view (turn gaps span the row) keeps its start + // edge where it was. + holdLandingWidth( + () => rowLayer, + () => { + const top = listRoot()?.getBoundingClientRect().top ?? 0 + + return [ + ...(rowLayer?.querySelectorAll( + '[data-timeline-key] [data-timeline-row]:not([data-timeline-row="TurnGap"])', + ) ?? []), + ].find((row) => row.getBoundingClientRect().bottom > top) + }, + ) let scrollTop = 0 let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined let reportRect: ((rect: { width: number; height: number }) => void) | undefined @@ -850,8 +867,8 @@ export function createTimelineVirtualizer(input: Input) { style={{ position: "absolute", top: `${item().start - topOffset() - rendering.scrollAdjustment}px`, - left: "0", - width: "100%", + left: "calc((100% - var(--panel-hold-width, 100%)) / 2)", + width: "var(--panel-hold-width, 100%)", height: `${item().size}px`, overflow: "clip", "overflow-clip-margin": Predicate.isTagged(row(), "TurnGap") ? undefined : "0.5px", @@ -964,10 +981,14 @@ export function createTimelineVirtualizer(input: Input) { height: `${virtualizer.getTotalSize() - rendering.scrollAdjustment}px`, position: "relative", width: "100%", + // Rows held wider than the timeline mid-flight don't scroll it sideways. + "overflow-x": "clip", visibility: coldBottomMount ? "hidden" : undefined, }} > - {(rowKey) => } +
+ {(rowKey) => } +
flights() > 0 // The "James" option, chosen in the preset strip beside Snappy, Smooth and Calm, swaps the section's knobs for its own: layout // changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. The neighbours beside it // glide from their old box to the new one. All run as Web Animations, so the compositor plays them through main-thread -// work. -// The shipped sidebar motion; the spring presets and Instant stay one choice away. -const james = motionTuning.mode("sidebars.james", { label: "James", group, value: true }) +// work. The shared spring ships; James stays one choice away. +const james = motionTuning.mode("sidebars.james", { label: "James", group }) const springs = () => !james() @@ -39,6 +38,20 @@ const style = motionTuning.choice("sidebars.style", { labels: { spring: "Spring", instant: "Instant" }, }) +// How far a panel's contents dim at the closed end of a spring flight; they never black out. +const fadeFloor = motionTuning.number("sidebars.fadeFloor", { + label: "Fade floor", + group, + when: springs, + value: 0.5, + min: 0, + max: 1, + step: 0.05, +}) + +/** The opacity of a panel's contents `closed` of the way through a spring flight, from 1 down to the fade floor. */ +export const panelOpacity = (closed: number) => (closed === 0 ? "" : `${1 - closed * (1 - fadeFloor())}`) + const jamesShift = motionTuning.spring("sidebars.james.shift", { label: "Movement", group, @@ -115,6 +128,90 @@ const faded = 0.15 /** Where a neighbour's content sits: centred content follows its box's centre, start-pinned content its start edge. */ export type Anchor = "center" | "start" +type Hold = { box: () => HTMLElement | null | undefined; anchor?: () => HTMLElement | null | undefined } + +type Landing = { + /** Paints where the flight lands, then back where it is. */ + land: () => void + resume: () => void + /** Starts counting the flight's remaining travel from here, 1 now and 0 where it lands. */ + begin: () => void + remaining: () => number +} + +const holds = new Set() + +// The spring flights in progress, and the one whose travel carries the held boxes' shift. +const landings = new Set() + +let driver: Landing | undefined + +let held: { box: HTMLElement; shift: number }[] = [] + +/** + * Keeps a content box beside the panels at the width it will have once every spring flight lands, so its text wraps + * once as a flight starts rather than on every frame while its container's width moves. For the flight, the box gets + * `--panel-hold-width`, which its layout uses in place of its container's width. A box that lands at no width, like the + * contents of a closing panel, is left to its own layout. + * + * `anchor` is an element inside the box whose start edge must not jump when the box takes its landing width: the box + * shifts by the difference, with a transform, and the shift runs out over the flight. + */ +export function holdLandingWidth(box: Hold["box"], anchor?: Hold["anchor"]) { + const hold = { box, anchor } + holds.add(hold) + onCleanup(() => holds.delete(hold)) +} + +// Measures every held box with each moving panel painted where it lands. A flight that starts, turns around or lands +// forces a few layouts here; no frame of a flight reads layout. +function rehold(next: Landing | undefined) { + const flights = [...landings] + + const boxes = flights.length + ? [...holds].flatMap((hold) => { + const box = hold.box() + + if (!box?.isConnected) return [] + const anchor = hold.anchor?.() + + return [{ box, anchor, before: anchor ? anchorX(anchor, "start") : undefined }] + }) + : [] + + held.forEach((item) => { + item.box.style.removeProperty("--panel-hold-width") + item.box.style.translate = "" + }) + held = [] + driver = next + driver?.begin() + + if (boxes.length === 0) return + flights.forEach((flight) => flight.land()) + const widths = boxes.map((item) => item.box.getBoundingClientRect().width) + flights.forEach((flight) => flight.resume()) + held = boxes + .filter((item, index) => { + if (widths[index] === 0) return false + item.box.style.setProperty("--panel-hold-width", `${widths[index]}px`) + + return true + }) + .map((item) => ({ + box: item.box, + shift: item.anchor && item.before !== undefined ? item.before - anchorX(item.anchor, "start") : 0, + })) + shiftHeld() +} + +function shiftHeld() { + const remaining = driver?.remaining() ?? 0 + held.forEach((item) => { + if (item.shift !== 0) item.box.style.translate = `${item.shift * remaining}px` + }) +} + /** Adds a button that toggles a panel to the Motion workspace's Sidebars section while the panel is mounted. */ export function replayPanelToggle(input: { id: string; label: string; toggle: () => void }) { onCleanup(motionTuning.action({ id: `sidebars.${input.id}`, label: input.label, group, run: input.toggle })) @@ -136,8 +233,8 @@ export function createPanelMotion(input: { /** Reads layout in the frame loop's read phase, before each frame of a flight paints. */ measure?: () => void /** - * James: the panel layer that shifts and fades, and how far it shifts, in pixels toward its edge (negative for - * the inline-start edge; mirrored in RTL). + * The panel layer, which fades in under reduced motion. James shifts and fades it, `offset` pixels toward its edge + * (negative for the inline-start edge; mirrored in RTL). */ element?: Accessor offset?: () => number @@ -165,10 +262,30 @@ export function createPanelMotion(input: { const paint = () => input.paint(Math.min(1, Math.max(0, motion.value / 100))) + // Where the flight's current leg started, for how much of it remains. + let from = 0 + + const landing: Landing = { + land: () => input.paint(motion.target / 100), + resume: paint, + begin: () => { + from = motion.value + }, + remaining: () => + from === motion.target ? 0 : Math.min(1, Math.max(0, (motion.value - motion.target) / (from - motion.target))), + } + + const unhold = () => { + if (!landings.delete(landing)) return + // Another flight still moving carries the shift from here. + rehold(driver === landing ? landings.values().next().value : driver) + } + const settle = () => { stop?.() stop = undefined fly(false) + unhold() const queued = landed landed = [] @@ -207,13 +324,21 @@ export function createPanelMotion(input: { fly(false) motion.snap(target) paint() + // After the snap, so a flight still moving measures this layout's landing. + unhold() if (!open) setPresent(false) return } - if (now || prefersReducedMotion() || (!james() && style() === "instant")) return snap(open) + if (prefersReducedMotion() && !now) { + snap(open) + + return open ? reveal() : undefined + } + + if (now || (!james() && style() === "instant")) return snap(open) // James closes at once; only its neighbours glide into place. if (james()) return open ? fade() : glide(false, () => snap(false)) @@ -224,22 +349,35 @@ export function createPanelMotion(input: { // Retargeting keeps the current position and velocity, so a second toggle mid-flight turns around smoothly. motion.set(target) fly(true) + landings.add(landing) + rehold(landing) + // Writes only; anything reading layout this frame does so in the loop's read phase, before this runs. const step = (dt: number) => { motion.step(dt) paint() + if (driver === landing) shiftHeld() + if (!motion.resting) return true stop = undefined settle() return false } + stop ??= input.measure ? frameLoop.addMeasured(input.measure, step) : frameLoop.add(step) }, ), ) + /** Reduced motion: the panel, opened at once, fades in without travel. */ + const reveal = () => + // Deferred so a panel that mounts with this open exists; microtasks run before the next paint. + queueMicrotask(() => { + if (input.open()) input.element?.()?.animate?.({ opacity: [0, 1] }, { duration: 150, easing: ease.out }) + }) + /** The layout opens at once, gliding its neighbours into place; the panel then shifts in and fades up. */ const fade = () => { stop?.() @@ -316,6 +454,7 @@ export function createPanelMotion(input: { stop?.() fades.forEach((animation) => animation.cancel()) fly(false) + unhold() }) return { diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index ddcbaeb719fe..b76858f4f514 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -18,7 +18,7 @@ import { useGlobal } from "@/runtime/server/runtime" import { useServers } from "@/runtime/server/registry" import { useTabs } from "@/shell/tabs/tabs" import { CollapsedSidebarToggle, measureSidebarToggles, paintCollapsedSlots } from "@/shell/sidebar-toggle" -import { createPanelMotion, guardClosingFocus, replayPanelToggle } from "@/shell/panel-motion" +import { createPanelMotion, guardClosingFocus, panelOpacity, replayPanelToggle } from "@/shell/panel-motion" import { windowDragEnabled } from "@/session/header/drag-area" export const sidebarToggleCommand = { id: "sidebar.toggle", keybind: "mod+shift+b" } @@ -59,8 +59,8 @@ export default function Layout(props: ParentProps) { if (aside) aside.style.width = `${state.tabsWidth * (1 - closed)}px` - // Dims the contents, background included, as they leave and fade them in as they arrive. - if (state.tabsMount) state.tabsMount.style.opacity = closed === 0 ? "" : `${1 - closed}` + // Dims the contents, background included, as they leave and brightens them as they arrive. + if (state.tabsMount) state.tabsMount.style.opacity = panelOpacity(closed) paintCollapsedSlots(closed) }, measure: () => measureSidebarToggles(state.tabsWidth), diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index dfb3422215fd..5a67b5072ee4 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it } from "bun:test" import { batch, createRoot, createSignal } from "solid-js" import { motionTuning } from "@opencode/ui/motion-tuning" -import { createPanelMotion, guardClosingFocus, panelsMoving } from "../src/shell/panel-motion" +import { createPanelMotion, guardClosingFocus, holdLandingWidth, panelsMoving } from "../src/shell/panel-motion" let frames: Map @@ -215,6 +215,70 @@ it("reports a flight from its first frame until it lands, for native views to hi expect(panelsMoving()).toBe(false) }) +it("holds content beside the panel at its landing width for the flight, its anchor's edge travelling smoothly", () => { + const box = document.createElement("div") + const anchor = document.createElement("div") + box.append(anchor) + document.body.append(box) + cleanups.push(() => box.remove()) + const [open, setOpen] = createSignal(false) + const painted = { width: 0 } + // The box beside the panel takes what the panel leaves of 1000px; its centred column starts 60px further in once + // the box is held at its landing width. happy-dom has no layout. + box.getBoundingClientRect = () => new DOMRect(0, 0, 1000 - painted.width, 10) + anchor.getBoundingClientRect = () => + new DOMRect(box.style.getPropertyValue("--panel-hold-width") ? 160 : 100, 0, 10, 10) + createRoot((dispose) => { + cleanups.push(dispose) + holdLandingWidth( + () => box, + () => anchor, + ) + createPanelMotion({ open, paint: (shut) => (painted.width = 300 * (1 - shut)) }) + }) + + setOpen(true) + expect(painted.width).toBe(0) + expect(box.style.getPropertyValue("--panel-hold-width")).toBe("700px") + // The column's start edge stays where it was, and the shift runs out with the flight. + expect(box.style.translate).toBe("-60px") + const shifts: number[] = [] + + for (let i = 0; i < 6; i++) { + frame() + shifts.push(parseFloat(box.style.translate)) + } + + expect(box.style.getPropertyValue("--panel-hold-width")).toBe("700px") + expect(shifts.every((shift, index) => shift > (shifts[index - 1] ?? -60) && shift < 0)).toBe(true) + + setOpen(false) + expect(box.style.getPropertyValue("--panel-hold-width")).toBe("1000px") + settle() + expect(box.style.getPropertyValue("--panel-hold-width")).toBe("") + expect(box.style.translate).toBe("") +}) + +it("fades an opening panel in without travel under reduced motion", async () => { + document.documentElement.dataset.motion = "reduce" + cleanups.push(() => delete document.documentElement.dataset.motion) + const played: PropertyIndexedKeyframes[] = [] + const element = document.createElement("div") + Object.defineProperty(element, "animate", { value: (keyframes: PropertyIndexedKeyframes) => played.push(keyframes) }) + const [open, setOpen] = createSignal(false) + const painted = { width: 0 } + createRoot((dispose) => { + cleanups.push(dispose) + createPanelMotion({ open, paint: (shut) => (painted.width = 200 * (1 - shut)), element: () => element }) + }) + + setOpen(true) + expect(painted.width).toBe(200) + expect(frames.size).toBe(0) + await Promise.resolve() + expect(played).toEqual([{ opacity: [0, 1] }]) +}) + it("moves focus out of a closing panel when the close starts and when it re-enters mid-flight", () => { const root = document.createElement("div") const inner = document.createElement("button") @@ -363,18 +427,18 @@ it("swaps the spring knobs for James's while it's on", () => { .filter((knob) => knob.group === "Sidebars" && knob.kind !== "mode" && (knob.when?.() ?? true)) .map((knob) => knob.label) - expect(shown()).toEqual(["Spring", "Motion"]) + expect(shown()).toEqual(["Spring", "Motion", "Fade floor"]) setJames(true) expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Panel shift", "Layout", "Layout open", "Layout close"]) setJames(false) - expect(shown()).toEqual(["Spring", "Motion"]) + expect(shown()).toEqual(["Spring", "Motion", "Fade floor"]) }) -it("ships James as the default sidebar motion", () => { +it("ships the spring as the default sidebar motion, with James one choice away", () => { const knob = motionTuning.list().find((item) => item.id === "sidebars.james") expect(knob?.kind).toBe("mode") - expect(knob?.initial).toBe(true) + expect(knob?.initial).toBe(false) // The side panel shifts 30px by default; the left sidebar stays at 10px. expect(motionTuning.list().find((item) => item.id === "sidebars.james.panelShift")?.initial).toBe(30) })