}) {
@@ -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)
})