Skip to content
Closed
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
141 changes: 92 additions & 49 deletions packages/app/e2e/regression/panel-edges.spec.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,119 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { seed, sessionHref } from "../utils/app"
import { fixture, installStressSessionTabs, mockStressTimeline } from "../utils/session-fixture"

test.use({ viewport: { width: 1440, height: 900 } })

// Samples every frame of a click toggle until the edge has rested for 300 ms: the moving edge, and the gap from the
// element before it to the one after.
async function trace(page: Page, toggle: Locator, before: string, after: string) {
const sidebar = '[data-slot="vertical-tabs-sidebar"]'

const sidebarPanel = `${sidebar} > div`

const sidePanel = '[data-slot="session-side-panel-presence"]'

// Samples every frame for 1.5 s after `toggle`: the content's edge beside the panel, and the panel's opacity and shift.
async function trace(page: Page, toggle: () => Promise<void>, edge: string, panel: string) {
const frames = page.evaluate(
([before, after]) =>
new Promise<{ edge: number; gap?: number }[]>((resolve) => {
([edge, panel]) =>
new Promise<{ edge: number; opacity: number; shift: number }[]>((resolve) => {
const start = performance.now()
const out: { edge: number; gap?: number }[] = []
let changed: number | undefined

const tick = (now: number) => {
const a = document.querySelector(before)?.getBoundingClientRect()
const b = document.querySelector(after)?.getBoundingClientRect()
const edge = (a ?? b)!.right
const out: { edge: number; opacity: number; shift: number }[] = []

if (out.length && edge !== out.at(-1)!.edge) changed = now
// A settled-closed panel leaves the row, so the gap only holds while it has width.
out.push({ edge, gap: a && b && b.width > 0 ? b.left - a.right : undefined })
// The rAF timestamp follows the slowed animation timeline, so time the window with performance.now().
const tick = () => {
const style = getComputedStyle(document.querySelector(panel)!)
out.push({
edge: document.querySelector(edge)!.getBoundingClientRect().right,
opacity: Number(style.opacity),
shift: style.transform === "none" ? 0 : new DOMMatrix(style.transform).m41,
})

if (changed === undefined ? now - start < 10_000 : now - changed < 300) return requestAnimationFrame(tick)
if (performance.now() - start < 1500) return requestAnimationFrame(tick)
resolve(out)
}

requestAnimationFrame(tick)
}),
[before, after] as const,
[edge, panel] as const,
)

await toggle.click({ force: true })
await toggle()

return frames
}

test("the content beside a panel follows the panel's edge on every frame of a toggle", async ({ page }) => {
for (const direction of ["ltr", "rtl"] as const) {
test(`a click opens a panel with a fade and closes it at once, the content moving in one frame (${direction})`, async ({
page,
}) => {
await mockStressTimeline(page)
await installStressSessionTabs(page)
await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } } })
await page.goto(sessionHref(fixture.targetID))
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle })).toBeVisible()
// A fifth of the speed, so even a slow runner samples several frames of each fade.
const devtools = await page.context().newCDPSession(page)
await devtools.send("Animation.enable")
await devtools.send("Animation.setPlaybackRate", { playbackRate: 0.2 })

const sidebarToggle = page
.locator(
`${sidebar} button[aria-controls="vertical-tabs-sidebar"], main button[aria-controls="vertical-tabs-sidebar"]`,
)
.locator("visible=true")
.first()

const reviewToggle = page.locator('[data-slot="session-review-toggle"] button')

// Each panel shifts toward its own edge: the sidebar toward the start, the side panel toward the end.
const start = direction === "ltr" ? -1 : 1

const chat = '[data-slot="session-chat-panel"]'

// The sidebar starts open and the side panel closed.
const runs = [
{ toggle: sidebarToggle, edge: sidebar, panel: sidebarPanel, shift: 10 * start, opens: false },
{ toggle: sidebarToggle, edge: sidebar, panel: sidebarPanel, shift: 10 * start, opens: true },
{ toggle: reviewToggle, edge: chat, panel: sidePanel, shift: -12 * start, opens: true },
{ toggle: reviewToggle, edge: chat, panel: sidePanel, shift: -12 * start, opens: false },
]

for (const run of runs) {
const frames = await trace(page, () => run.toggle.click({ force: true }), run.edge, run.panel)
// The layout jumps straight from where it was to where it lands.
expect(new Set(frames.map((frame) => Math.round(frame.edge))).size).toBeLessThanOrEqual(2)

if (!run.opens) {
expect(frames.every((frame) => frame.opacity === 1 && frame.shift === 0)).toBe(true)
continue
}

// An opening panel fades over several frames from 0.15 and shifts in from at most its offset toward its edge.
const opacities = frames.map((frame) => frame.opacity).filter((opacity) => opacity < 1)
expect(new Set(opacities).size).toBeGreaterThan(2)
expect(Math.min(...opacities)).toBeGreaterThan(0.14)
frames.forEach((frame) => {
expect(Math.abs(frame.shift)).toBeLessThanOrEqual(Math.abs(run.shift) + 0.5)
expect(frame.shift * run.shift).toBeGreaterThanOrEqual(0)
})
}
})
}

test("keyboard toggles switch the panels without motion", async ({ page }) => {
await mockStressTimeline(page)
await installStressSessionTabs(page)
// Half speed, so even a slow runner draws several frames of each flight.
await seed(page, {
settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } },
storage: { "opencode.motion.v2": { speed: 0.5 } },
})
await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } } })
await page.goto(sessionHref(fixture.targetID))
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle })).toBeVisible()

const sidebar = page
.locator(
'[data-slot="vertical-tabs-sidebar"] button[aria-controls="vertical-tabs-sidebar"], main button[aria-controls="vertical-tabs-sidebar"]',
)
.locator("visible=true")
.first()

const review = page.locator('[data-slot="session-review-toggle"] button')

const runs = [
{ toggle: sidebar, before: '[data-slot="vertical-tabs-sidebar"] > div > div', after: "main", gap: 0 },
{ toggle: sidebar, before: '[data-slot="vertical-tabs-sidebar"] > div > div', after: "main", gap: 0 },
{ toggle: review, before: '[data-slot="session-chat-panel"]', after: "#review-panel", gap: 8 },
{ toggle: review, before: '[data-slot="session-chat-panel"]', after: "#review-panel", gap: 8 },
]

for (const run of runs) {
const frames = await trace(page, run.toggle, run.before, run.after)
const edges = frames.map((frame) => frame.edge)
// The toggle animates over several frames rather than jumping to its end, and never doubles back.
expect(new Set(edges.map(Math.round)).size).toBeGreaterThan(3)
const steps = edges.slice(1).map((edge, index) => edge - edges[index])
expect(steps.every((step) => step >= -0.5) || steps.every((step) => step <= 0.5)).toBe(true)
frames.flatMap((frame) => frame.gap ?? []).forEach((gap) => expect(gap).toBeCloseTo(run.gap, 0))
for (const [shortcut, panel] of [
["ControlOrMeta+Shift+B", sidebarPanel],
["ControlOrMeta+Shift+B", sidebarPanel],
["ControlOrMeta+Shift+R", sidePanel],
["ControlOrMeta+Shift+R", sidePanel],
] as const) {
const frames = await trace(page, () => page.keyboard.press(shortcut), sidebar, panel)
expect(frames.every((frame) => frame.opacity === 1 && frame.shift === 0)).toBe(true)
}
})
2 changes: 0 additions & 2 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,6 @@
);
}

/* The review panel's visible width, which the header fade below tracks while the panel animates. Its fixed-width
content holds nothing else that reads it. */
#review-panel,
#bottom-panel {
container-type: inline-size;
Expand Down
19 changes: 7 additions & 12 deletions packages/app/src/runtime/extension/side-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
type SessionPanelMenuItem,
} from "@opencode/gui-extensions/sdk"
import { useCommand } from "@/shell/commands/command"
import { guardClosingFocus } from "@/shell/panel-motion"
import { releaseClosedFocus } from "@/shell/panel-motion"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useSessionLayout } from "@/session/session-layout"
Expand All @@ -51,7 +51,6 @@ export function SideRegion(props: {
screen: SessionScreen
region: Region
sidebar: PanelSidebar
present?: boolean
size: Sizing
drag: PaneDrag
}) {
Expand All @@ -61,10 +60,9 @@ export function SideRegion(props: {

const isDesktop = createMediaQuery("(min-width: 768px)")
const tabsOpen = createMemo(() => isDesktop() && view().side.opened())
const tabsVisible = createMemo(() => tabsOpen() || !!props.present)
// Once shown, the contents stay mounted while the region is hidden, so reopening doesn't rebuild them mid-flight.
const contentMounted = createMemo<boolean>((mounted) => mounted || tabsVisible(), false)
const panelWidth = createMemo(() => (tabsVisible() ? "auto" : "0px"))
const contentMounted = createMemo<boolean>((mounted) => mounted || tabsOpen(), false)
const panelWidth = createMemo(() => (tabsOpen() ? "auto" : "0px"))

const menu = createSessionPanelMenu()
const primary = createMemo(() => menu().find((item) => item.primary))
Expand Down Expand Up @@ -99,9 +97,9 @@ export function SideRegion(props: {

const lowerHeight = createMemo(() => Math.round(column.height * (1 - view().split.ratio())))

guardClosingFocus({
releaseClosedFocus({
root,
closing: () => !tabsOpen() && tabsVisible(),
open: tabsOpen,
target: () => document.querySelector<HTMLElement>('[data-slot="session-review-toggle"] button') ?? undefined,
})

Expand All @@ -114,12 +112,9 @@ export function SideRegion(props: {
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!tabsOpen()}
class="relative h-full shrink-0 min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)] outline-none"
classList={{ "flex-1": tabsVisible() }}
classList={{ "flex-1": tabsOpen() }}
style={{ width: panelWidth() }}
>
{/* Takes the pointer while the panel closes. Inherited pointer-events or inert would restyle every element in
the panel when they flip; once closed, the screen skips the region with content-visibility instead. */}
<div class="absolute inset-0 z-50" classList={{ hidden: tabsOpen() || !tabsVisible() }} />
<Show when={contentMounted()}>
<div
data-slot="session-review-content"
Expand Down Expand Up @@ -165,7 +160,7 @@ export function SideRegion(props: {
screen={props.screen}
sidebar={props.sidebar}
shown={tabsOpen}
present={tabsVisible}
present={tabsOpen}
animate={() => !props.size.active()}
placeholder={index() === 0}
actions={index() === 0}
Expand Down
96 changes: 36 additions & 60 deletions packages/app/src/session/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { SessionIdentityHeader, SessionLocation } from "./session-identity-heade
import { useServer } from "@/runtime/server/current"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createTimelineCache } from "./timeline/cache"
import { createPanelMotion, replayPanelToggle } from "@/shell/panel-motion"
import { createPanelMotion } 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 @@ -86,12 +86,9 @@ function SessionScreenContent(props: {
closed: attachment.closed,
open: (key) => attachment.openTab(key, props.view()),
side: () => session.layout.view().side,
// Closing the last tab closes the side region, which opens with a new tab next time. The user's own hand closed
// it, so it goes at once.
// Closing the last tab closes the side region, which opens with a new tab next time.
onEmptied: () => {
if (!session.layout.view().side.opened()) return
session.layout.view().side.toggle()
sideMotion.sync(false)
if (session.layout.view().side.opened()) session.layout.view().side.toggle()
},
settled: (run) => sideMotion.whenSettled(run),
})
Expand Down Expand Up @@ -146,34 +143,15 @@ function SessionScreenContent(props: {
}),
}

// The chat keeps its open width as the far end of a close's travel.
const chatOpenWidth = createMemo<string>((previous) => (sideVisible() ? screen.panel.width() : previous), "100%")
let chat: HTMLDivElement | undefined
let sidePresence: HTMLDivElement | undefined

// Once opened, the side region stays mounted and is skipped by content-visibility when a close settles, so neither
// direction mounts its contents mid-flight, and a reopen reuses their style and layout.
// Once opened, the side region stays mounted and is skipped by content-visibility when a close settles, so a reopen
// reuses its style and layout.
const sideMotion = createPanelMotion({
open: sideVisible,
element: () => sidePresence,
offset: 12,
identity: session.layout.tabKey,
// The chat's real width moves the side panel's edge, whose contents keep their open width pinned to it.
paint: (closed) => {
if (!chat) return
const open = chatOpenWidth()

if (closed === 1) {
chat.style.width = "100%"

return
}

chat.style.width = closed === 0 ? open : `calc(${open} + (100% - ${open}) * ${closed})`
},
})

replayPanelToggle({
id: "side",
label: "Toggle the side panel",
toggle: () => session.layout.view().side.toggle(),
})

const hideTimelineScrollbar = () => setStore("timelineScrollbarHidden", true)
Expand Down Expand Up @@ -415,10 +393,10 @@ function SessionScreenContent(props: {
</div>
</Show>
<div
ref={chat}
class="relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none"
data-slot="session-chat-panel"
data-scrollbar-hidden={store.timelineScrollbarHidden}
style={{ width: screen.panel.width() }}
onPointerMove={revealTimelineScrollbar}
onPointerDown={revealTimelineScrollbar}
on:wheel={{ passive: true, handleEvent: revealTimelineScrollbar }}
Expand Down Expand Up @@ -447,39 +425,37 @@ function SessionScreenContent(props: {
</Show>
</div>

<Show when={sideMotion.present() || store.sideMounted}>
<div
ref={sidePresence}
data-slot="session-side-panel-presence"
class="min-w-0 h-full overflow-visible"
classList={{
"relative z-0 flex-1": sideVisible(),
// Out of the row, so a closed panel takes no space or gap.
"absolute inset-y-0 end-0 w-0 [content-visibility:hidden]": !sideVisible(),
}}
>
<div
data-slot="session-side-panel-presence"
class="z-0 min-w-0 h-full overflow-visible"
classList={{
"relative flex-1": sideMotion.present(),
// Out of the row, so a closed panel takes no space or gap.
"absolute inset-y-0 end-0 w-0 [content-visibility:hidden]": !sideMotion.present(),
}}
data-slot="session-side-panel-content"
class="absolute inset-y-0 start-0 size-full [contain:layout_style]"
style={{ "--session-side-content-width": screen.side.contentWidth() }}
>
<div
data-slot="session-side-panel-content"
class="absolute inset-y-0 start-0 size-full [contain:layout_style]"
style={{ "--session-side-content-width": screen.side.contentWidth() }}
>
<div data-slot="session-side-region" class="absolute inset-0 min-h-0 overflow-visible">
<Show when={store.sideMounted}>
<div data-slot="session-side-region-presence" class="absolute inset-0">
<SideRegion
screen={props.screen}
view={props.view()}
region={region}
sidebar={sidebar}
present={sideMotion.present()}
size={screen.size}
drag={drag}
/>
</div>
</Show>
</div>
<div data-slot="session-side-region" class="absolute inset-0 min-h-0 overflow-visible">
<Show when={store.sideMounted}>
<div data-slot="session-side-region-presence" class="absolute inset-0">
<SideRegion
screen={props.screen}
view={props.view()}
region={region}
sidebar={sidebar}
size={screen.size}
drag={drag}
/>
</div>
</Show>
</div>
</div>
</Show>
</div>
</div>

{/* Hidden rather than unmounted while the user hides it, so its terminals keep their screens. */}
Expand Down
Loading
Loading