Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/runtime/extension/side-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function SideRegion(props: {
<div
data-slot="session-review-content"
class="h-full flex shrink-0 contain-strict"
style={{ width: "var(--session-side-content-width, 100%)" }}
style={{ width: "var(--panel-hold-width, var(--session-side-content-width, 100%))" }}
>
<div
ref={stack}
Expand Down
8 changes: 6 additions & 2 deletions packages/app/src/session/screen-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createSizing } from "./helpers"
import type { SessionModel } from "./model"
import { clampSessionPanelWidth, sessionPanelWidthMax } from "./session-panel-width"
import { clampSessionPanelWidth, sessionPanelWidthCss, sessionPanelWidthMax } from "./session-panel-width"

/** wide asks for the wider session minimum. */
export function createSessionScreenLayout(session: SessionModel, input: { wide: Accessor<boolean> }) {
Expand Down Expand Up @@ -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(() => {
Expand Down
13 changes: 11 additions & 2 deletions packages/app/src/session/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/session/session-panel-width.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
27 changes: 24 additions & 3 deletions packages/app/src/session/timeline/virtualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<HTMLElement>(
'[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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<div ref={rowLayer} class="absolute inset-0">
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
</div>
<div
data-timeline-row="bottom-spacer"
class="absolute top-0 left-0 w-full"
Expand Down
153 changes: 146 additions & 7 deletions packages/app/src/shell/panel-motion.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { frameLoop, prefersReducedMotion, Spring, type DurationOptions } from "@opencode/ui/motion"
import { ease, frameLoop, prefersReducedMotion, Spring, type DurationOptions } from "@opencode/ui/motion"
import { motionTuning } from "@opencode/ui/motion-tuning"

const group = "Sidebars"
Expand All @@ -12,9 +12,8 @@ export const panelsMoving = () => 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()

Expand All @@ -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,
Expand Down Expand Up @@ -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<Hold>()

// The spring flights in progress, and the one whose travel carries the held boxes' shift.
const landings = new Set<Landing>()

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 }))
Expand All @@ -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<HTMLElement | undefined>
offset?: () => number
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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))
Expand All @@ -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?.()
Expand Down Expand Up @@ -316,6 +454,7 @@ export function createPanelMotion(input: {
stop?.()
fades.forEach((animation) => animation.cancel())
fly(false)
unhold()
})

return {
Expand Down
Loading
Loading