Skip to content
Merged
2 changes: 1 addition & 1 deletion web/oss/src/components/Layout/assets/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const useStyles = createUseStyles((theme: JSSTheme) => ({
justifyContent: "space-between",
width: "100%",
padding: "8px 1.5rem",
borderBottom: `1px solid ${theme.colorBorderSecondary}`,
borderBottom: "1px solid var(--ag-shell-line)",
},
topRightBar: {
display: "flex",
Expand Down
151 changes: 151 additions & 0 deletions web/oss/src/components/OverlayScrollbar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"use client"

import {useCallback, useEffect, useRef, useState} from "react"

interface OverlayScrollbarProps {
/** The scroll container this thumb drives. Must be inside a positioned ancestor. */
target: HTMLElement | null
}

interface Metrics {
/** Thumb offset from the scroller's top edge, in pixels. */
top: number
height: number
/** The scroller's own offset inside the positioned ancestor. */
trackTop: number
trackHeight: number
}

const MIN_THUMB_HEIGHT = 28
const SCROLL_FLASH_MS = 700

/**
* A scrollbar drawn on top of the content instead of beside it.
*
* A native scrollbar takes layout width, which shortens every full-width row in the panel by the
* scrollbar's size. This one floats, so rows still span the panel edge to edge. It shows while the
* pointer is anywhere in the panel (CSS `group-hover`) or for a moment after a scroll, and it can
* be dragged.
*
* Render it as a sibling of the scroller, inside a `relative group` ancestor.
*/
const OverlayScrollbar = ({target}: OverlayScrollbarProps) => {
const [metrics, setMetrics] = useState<Metrics | null>(null)
const [scrolling, setScrolling] = useState(false)
const [dragging, setDragging] = useState(false)
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

const measure = useCallback(() => {
if (!target) {
setMetrics(null)
return
}
const {scrollHeight, clientHeight, scrollTop, offsetTop} = target
const scrollable = scrollHeight - clientHeight
if (scrollable <= 1) {
setMetrics(null)
return
}
const height = Math.max(MIN_THUMB_HEIGHT, (clientHeight / scrollHeight) * clientHeight)
const top = (scrollTop / scrollable) * (clientHeight - height)
setMetrics({top, height, trackTop: offsetTop, trackHeight: clientHeight})
}, [target])

useEffect(() => {
if (!target) return

const onScroll = () => {
measure()
setScrolling(true)
if (flashTimerRef.current) clearTimeout(flashTimerRef.current)
flashTimerRef.current = setTimeout(() => setScrolling(false), SCROLL_FLASH_MS)
}

// The scroller keeps its own size while the content grows, and its children are swapped
// as the panel loads, so a ResizeObserver on the child would go stale. Remeasure on any
// subtree change instead, batched to one frame.
let frame = 0
const scheduleMeasure = () => {
if (frame) return
frame = requestAnimationFrame(() => {
frame = 0
measure()
})
}

measure()
target.addEventListener("scroll", onScroll, {passive: true})

const resizeObserver = new ResizeObserver(scheduleMeasure)
resizeObserver.observe(target)
const mutationObserver = new MutationObserver(scheduleMeasure)
mutationObserver.observe(target, {childList: true, subtree: true})

return () => {
target.removeEventListener("scroll", onScroll)
resizeObserver.disconnect()
mutationObserver.disconnect()
if (frame) cancelAnimationFrame(frame)
if (flashTimerRef.current) clearTimeout(flashTimerRef.current)
}
}, [target, measure])

const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!target || !metrics) return
event.preventDefault()
const startY = event.clientY
const startScroll = target.scrollTop
const scrollable = target.scrollHeight - target.clientHeight
const travel = metrics.trackHeight - metrics.height
setDragging(true)

const onMove = (moveEvent: PointerEvent) => {
if (travel <= 0) return
const delta = ((moveEvent.clientY - startY) / travel) * scrollable
target.scrollTop = startScroll + delta
}
// pointercancel too: a cancelled stream (touch interruption, app switch) never fires
// pointerup, which would leave the drag state on and the listeners attached.
const onUp = () => {
setDragging(false)
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
window.removeEventListener("pointercancel", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
window.addEventListener("pointercancel", onUp)
},
[target, metrics],
)
Comment thread
mmabrouk marked this conversation as resolved.

if (!metrics) return null

return (
<div
className="pointer-events-none absolute right-0 z-20 w-2"
style={{top: metrics.trackTop, height: metrics.trackHeight}}
>
<div
role="presentation"
onPointerDown={handlePointerDown}
className={[
// touch-none so a touch drag moves the thumb instead of scrolling the page.
"pointer-events-auto absolute right-0.5 w-1.5 cursor-default touch-none rounded-full",
"opacity-0 transition-opacity duration-150 group-hover:opacity-100",
scrolling || dragging ? "!opacity-100" : "",
].join(" ")}
style={{
top: metrics.top,
height: metrics.height,
background: dragging
? "var(--ag-scroll-thumb-hover)"
: "var(--ag-scroll-thumb)",
}}
/>
</div>
)
}

export default OverlayScrollbar
19 changes: 14 additions & 5 deletions web/oss/src/components/Playground/Components/MainLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import AgentChatSkeleton from "@/oss/components/AgentChatSlice/components/AgentC
import {chatPanelMaximizedAtom} from "@/oss/components/AgentChatSlice/state/panelLayout"
// Direct file import — the SessionInspector barrel would statically pull the (dynamic,
// open-on-demand) inspector drawer back into this chunk.
import OverlayScrollbar from "@/oss/components/OverlayScrollbar"
import PanelSessionInspectorButton from "@/oss/components/SessionInspector/PanelSessionInspectorButton"
import {routerAppIdAtom} from "@/oss/state/app/selectors/app"
import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow"
Expand Down Expand Up @@ -268,7 +269,7 @@ const PlaygroundMainView = ({
const animateSplit = justToggled || holdAnimate

const variantRefs = useRef<(HTMLDivElement | null)[]>([])
const {setConfigPanelRef, setGenerationPanelRef} = usePlaygroundScrollSync({
const {configPanelRef, setConfigPanelRef, setGenerationPanelRef} = usePlaygroundScrollSync({
enabled: isComparisonView,
})

Expand Down Expand Up @@ -367,15 +368,17 @@ const PlaygroundMainView = ({
size={configCollapsed ? 0 : undefined}
min="20%"
max={configMaxSize}
className="!h-full"
// antd panels default to overflow:auto; the section inside owns scrolling, and
// a transient overflow leaves Chrome's thin panel scrollbar stuck full-height.
className="!h-full !overflow-hidden"
collapsible={splitCollapsible}
key={`${splitterKey}-splitter-panel-config`}
>
{/* Column: [scrolling config sections][bottom-pinned agent-commit notice].
The notice lives OUTSIDE the scroller, so it sits at the pane's bottom
edge regardless of content height or scroll position. */}
<div
className={clsx("flex h-full min-h-0 w-full flex-col", {
className={clsx("group relative flex h-full min-h-0 w-full flex-col", {
// Config = the raised authoring surface (covers the notice too).
"ag-panel-raised": isAgentConfig,
})}
Expand All @@ -384,7 +387,8 @@ const PlaygroundMainView = ({
ref={setConfigPanelRef}
className={clsx([
{
"grow w-full min-h-0 overflow-y-auto": !isComparisonView,
"ag-scroll-no-bar grow w-full min-h-0 overflow-y-auto":
!isComparisonView,
"grow w-full min-h-0 overflow-x-auto flex [&::-webkit-scrollbar]:w-0":
isComparisonView,
},
Expand Down Expand Up @@ -436,6 +440,9 @@ const PlaygroundMainView = ({
)}
</>
</section>
{!isComparisonView ? (
<OverlayScrollbar target={configPanelRef} />
) : null}
{!isComparisonView && isAgentConfig && primaryConfigId ? (
<AgentCommitNotice revisionId={primaryConfigId} />
) : null}
Expand All @@ -445,6 +452,8 @@ const PlaygroundMainView = ({
<SplitterPanel
className={clsx("!h-full @container min-w-0", {
"!overflow-y-hidden flex flex-col": isComparisonView,
// Same stuck-scrollbar guard as the config panel (chat section scrolls itself).
"!overflow-hidden": !isComparisonView,
})}
collapsible={splitCollapsible}
defaultSize={runsDefaultSize}
Expand All @@ -456,7 +465,7 @@ const PlaygroundMainView = ({
className={clsx([
"playground-generation",
{
"grow w-full h-full overflow-y-auto overflow-x-hidden":
"ag-scroll-quiet grow w-full h-full overflow-y-auto overflow-x-hidden":
!isComparisonView,
"grow w-full h-full overflow-auto [&::-webkit-scrollbar]:w-0":
isComparisonView,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({className, ...divPro
<>
<div
className={clsx(
"flex items-center justify-between gap-4 px-2.5 py-2 bg-[var(--ag-surface-raised)] border-0 border-b border-solid border-[var(--ag-surface-divider)]",
"flex items-center justify-between gap-4 px-2.5 py-2 bg-[var(--ag-surface-raised)] border-0 border-b border-solid border-[var(--ag-shell-line)]",
className,
)}
{...divProps}
Expand Down
4 changes: 2 additions & 2 deletions web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ const BootShell = memo(function BootShell({shell}: {shell: "app" | "blank"}) {
<div className="flex h-dvh w-full">
<div
className={clsx(
"h-full shrink-0 border-0 border-r border-solid border-[var(--ag-surface-divider)] bg-[var(--ag-sidebar-bg)]",
collapsed ? "w-[80px]" : "w-[236px]",
"h-full shrink-0 border-0 border-r border-solid border-[var(--ag-shell-line)] bg-[var(--ag-sidebar-bg)]",
collapsed ? "w-[48px]" : "w-[236px]",
)}
/>
<div className="grow" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const SidebarSkeletonLoader = () => {
className={clsx(
"flex flex-col justify-between h-screen border border-r border-solid border-gray-100",
{
"w-[80px] items-center": collapsed,
"w-[48px] items-center": collapsed,
"w-[236px]": !collapsed,
},
)}
Expand Down
6 changes: 3 additions & 3 deletions web/oss/src/components/Sidebar/components/WorkflowPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ const WorkflowPicker = memo(({collapsed}: WorkflowPickerProps) => {
aria-label="Switch workflow"
className={clsx(
"flex items-center justify-between overflow-hidden transition-[width,height,padding,gap,border-color] duration-300 ease-in-out",
collapsed
? "!w-8 !h-8 !p-1 gap-0"
: "w-full pl-2 pr-3 py-3 h-12 gap-2 border border-solid border-gray-200",
// No border when expanded: the header row it sits in is already
// framed by the rail's own line, so a box inside a box reads wrong.
collapsed ? "!w-8 !h-8 !p-1 gap-0" : "w-full h-full pl-1.5 pr-2 gap-2",
)}
>
<WorkflowIdentity
Expand Down
22 changes: 18 additions & 4 deletions web/oss/src/components/Sidebar/engine/SidebarShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ const {Sider} = Layout
const MENU_CLASS_NAME =
"border-r-0 overflow-y-auto relative [&_.ant-menu-item-selected]:font-medium"

// The menu paints its own container background, which would break the rail into two
// shades. Transparent lets the rail colour run edge to edge.
const MENU_SURFACE_CLASS_NAME = "!bg-transparent"

// antd hardcodes a 80px width on collapsed inline menus; the rail is narrower than that,
// so let the menu fill the rail and keep antd's own centering math working off that width.
const COLLAPSED_MENU_CLASS_NAME = "[&.ant-menu-inline-collapsed]:!w-full"

class SidebarErrorBoundary extends React.Component<React.PropsWithChildren, {hasError: boolean}> {
state = {hasError: false}

Expand Down Expand Up @@ -247,7 +255,13 @@ const SidebarShell: React.FC<SidebarShellProps> = ({
{renderSlot(section.before, collapsed)}
<SidebarMenu
menuProps={{
className: isBottomSection ? "" : MENU_CLASS_NAME,
className: [
isBottomSection ? "" : MENU_CLASS_NAME,
MENU_SURFACE_CLASS_NAME,
COLLAPSED_MENU_CLASS_NAME,
]
.filter(Boolean)
.join(" "),
selectedKeys,
...(isInlineSection
? {
Expand Down Expand Up @@ -280,18 +294,18 @@ const SidebarShell: React.FC<SidebarShellProps> = ({
const bottomSections = visibleSections.filter((section) => section.placement === "bottom")

return (
<div className="border-0 border-r border-solid border-[var(--ag-surface-divider)]">
<div className="border-0 border-r border-solid border-[var(--ag-shell-line)]">
<Sider
theme={theme}
className="sticky top-0 bottom-0 h-screen bg-[var(--ag-sidebar-bg)]"
collapsible
width={collapsed ? 80 : 236}
width={collapsed ? 48 : 236}
trigger={null}
>
<div
className={[
"flex flex-col h-full transition-all duration-300",
collapsed ? "w-[80px]" : "w-[236px]",
collapsed ? "w-[48px]" : "w-[236px]",
].join(" ")}
>
{renderSlot(scope.header, collapsed, scope.lastPath)}
Expand Down
16 changes: 10 additions & 6 deletions web/oss/src/components/Sidebar/scopes/workflowScope.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {useMemo} from "react"

import {Divider} from "antd"

import SidebarBackButton from "../components/SidebarBackButton"
import WorkflowPicker from "../components/WorkflowPicker"
import type {SidebarScope, SidebarSection, SidebarSlotContext} from "../engine/types"
Expand All @@ -14,21 +12,27 @@ interface WorkflowScopeOptions {
lastPath?: string
}

// The two header rows are 45px tall so the rail's lines land on the same y as the
// breadcrumb bar's and the playground header's, and read as one line across the app.
const WorkflowSidebarHeader = ({collapsed, lastPath}: SidebarSlotContext) => (
<>
<div
className={[
"w-full h-[48px] flex items-center",
collapsed ? "justify-center" : "mx-1.5",
"w-full h-[45px] shrink-0 flex items-center border-0 border-b border-solid border-[var(--ag-shell-line)]",
collapsed ? "justify-center" : "px-1.5",
].join(" ")}
>
<SidebarBackButton collapsed={collapsed} lastPath={lastPath} />
</div>

<div className={collapsed ? "flex w-full justify-center p-2" : "px-2 pt-1 pb-2"}>
<div
className={[
"flex h-[45px] shrink-0 items-center border-0 border-b border-solid border-[var(--ag-shell-line)]",
collapsed ? "w-full justify-center" : "px-2",
].join(" ")}
>
<WorkflowPicker collapsed={collapsed} />
</div>
<Divider className="mb-1 mt-0" />
</>
)

Expand Down
Loading
Loading