Skip to content

Commit 9da39b4

Browse files
authored
Merge pull request #5461 from Agenta-AI/ui-shell-rail-and-lines
fix(frontend): tighten the app shell lines and the playground scrollbars
2 parents 9578607 + 3bd70de commit 9da39b4

15 files changed

Lines changed: 304 additions & 29 deletions

File tree

web/oss/src/components/Layout/assets/styles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const useStyles = createUseStyles((theme: JSSTheme) => ({
2727
justifyContent: "space-between",
2828
width: "100%",
2929
padding: "8px 1.5rem",
30-
borderBottom: `1px solid ${theme.colorBorderSecondary}`,
30+
borderBottom: "1px solid var(--ag-shell-line)",
3131
},
3232
topRightBar: {
3333
display: "flex",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"use client"
2+
3+
import {useCallback, useEffect, useRef, useState} from "react"
4+
5+
interface OverlayScrollbarProps {
6+
/** The scroll container this thumb drives. Must be inside a positioned ancestor. */
7+
target: HTMLElement | null
8+
}
9+
10+
interface Metrics {
11+
/** Thumb offset from the scroller's top edge, in pixels. */
12+
top: number
13+
height: number
14+
/** The scroller's own offset inside the positioned ancestor. */
15+
trackTop: number
16+
trackHeight: number
17+
}
18+
19+
const MIN_THUMB_HEIGHT = 28
20+
const SCROLL_FLASH_MS = 700
21+
22+
/**
23+
* A scrollbar drawn on top of the content instead of beside it.
24+
*
25+
* A native scrollbar takes layout width, which shortens every full-width row in the panel by the
26+
* scrollbar's size. This one floats, so rows still span the panel edge to edge. It shows while the
27+
* pointer is anywhere in the panel (CSS `group-hover`) or for a moment after a scroll, and it can
28+
* be dragged.
29+
*
30+
* Render it as a sibling of the scroller, inside a `relative group` ancestor.
31+
*/
32+
const OverlayScrollbar = ({target}: OverlayScrollbarProps) => {
33+
const [metrics, setMetrics] = useState<Metrics | null>(null)
34+
const [scrolling, setScrolling] = useState(false)
35+
const [dragging, setDragging] = useState(false)
36+
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
37+
38+
const measure = useCallback(() => {
39+
if (!target) {
40+
setMetrics(null)
41+
return
42+
}
43+
const {scrollHeight, clientHeight, scrollTop, offsetTop} = target
44+
const scrollable = scrollHeight - clientHeight
45+
if (scrollable <= 1) {
46+
setMetrics(null)
47+
return
48+
}
49+
const height = Math.max(MIN_THUMB_HEIGHT, (clientHeight / scrollHeight) * clientHeight)
50+
const top = (scrollTop / scrollable) * (clientHeight - height)
51+
setMetrics({top, height, trackTop: offsetTop, trackHeight: clientHeight})
52+
}, [target])
53+
54+
useEffect(() => {
55+
if (!target) return
56+
57+
const onScroll = () => {
58+
measure()
59+
setScrolling(true)
60+
if (flashTimerRef.current) clearTimeout(flashTimerRef.current)
61+
flashTimerRef.current = setTimeout(() => setScrolling(false), SCROLL_FLASH_MS)
62+
}
63+
64+
// The scroller keeps its own size while the content grows, and its children are swapped
65+
// as the panel loads, so a ResizeObserver on the child would go stale. Remeasure on any
66+
// subtree change instead, batched to one frame.
67+
let frame = 0
68+
const scheduleMeasure = () => {
69+
if (frame) return
70+
frame = requestAnimationFrame(() => {
71+
frame = 0
72+
measure()
73+
})
74+
}
75+
76+
measure()
77+
target.addEventListener("scroll", onScroll, {passive: true})
78+
79+
const resizeObserver = new ResizeObserver(scheduleMeasure)
80+
resizeObserver.observe(target)
81+
const mutationObserver = new MutationObserver(scheduleMeasure)
82+
mutationObserver.observe(target, {childList: true, subtree: true})
83+
84+
return () => {
85+
target.removeEventListener("scroll", onScroll)
86+
resizeObserver.disconnect()
87+
mutationObserver.disconnect()
88+
if (frame) cancelAnimationFrame(frame)
89+
if (flashTimerRef.current) clearTimeout(flashTimerRef.current)
90+
}
91+
}, [target, measure])
92+
93+
const handlePointerDown = useCallback(
94+
(event: React.PointerEvent<HTMLDivElement>) => {
95+
if (!target || !metrics) return
96+
event.preventDefault()
97+
const startY = event.clientY
98+
const startScroll = target.scrollTop
99+
const scrollable = target.scrollHeight - target.clientHeight
100+
const travel = metrics.trackHeight - metrics.height
101+
setDragging(true)
102+
103+
const onMove = (moveEvent: PointerEvent) => {
104+
if (travel <= 0) return
105+
const delta = ((moveEvent.clientY - startY) / travel) * scrollable
106+
target.scrollTop = startScroll + delta
107+
}
108+
// pointercancel too: a cancelled stream (touch interruption, app switch) never fires
109+
// pointerup, which would leave the drag state on and the listeners attached.
110+
const onUp = () => {
111+
setDragging(false)
112+
window.removeEventListener("pointermove", onMove)
113+
window.removeEventListener("pointerup", onUp)
114+
window.removeEventListener("pointercancel", onUp)
115+
}
116+
window.addEventListener("pointermove", onMove)
117+
window.addEventListener("pointerup", onUp)
118+
window.addEventListener("pointercancel", onUp)
119+
},
120+
[target, metrics],
121+
)
122+
123+
if (!metrics) return null
124+
125+
return (
126+
<div
127+
className="pointer-events-none absolute right-0 z-20 w-2"
128+
style={{top: metrics.trackTop, height: metrics.trackHeight}}
129+
>
130+
<div
131+
role="presentation"
132+
onPointerDown={handlePointerDown}
133+
className={[
134+
// touch-none so a touch drag moves the thumb instead of scrolling the page.
135+
"pointer-events-auto absolute right-0.5 w-1.5 cursor-default touch-none rounded-full",
136+
"opacity-0 transition-opacity duration-150 group-hover:opacity-100",
137+
scrolling || dragging ? "!opacity-100" : "",
138+
].join(" ")}
139+
style={{
140+
top: metrics.top,
141+
height: metrics.height,
142+
background: dragging
143+
? "var(--ag-scroll-thumb-hover)"
144+
: "var(--ag-scroll-thumb)",
145+
}}
146+
/>
147+
</div>
148+
)
149+
}
150+
151+
export default OverlayScrollbar

web/oss/src/components/Playground/Components/MainLayout/index.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import AgentChatSkeleton from "@/oss/components/AgentChatSlice/components/AgentC
2121
import {chatPanelMaximizedAtom} from "@/oss/components/AgentChatSlice/state/panelLayout"
2222
// Direct file import — the SessionInspector barrel would statically pull the (dynamic,
2323
// open-on-demand) inspector drawer back into this chunk.
24+
import OverlayScrollbar from "@/oss/components/OverlayScrollbar"
2425
import PanelSessionInspectorButton from "@/oss/components/SessionInspector/PanelSessionInspectorButton"
2526
import {routerAppIdAtom} from "@/oss/state/app/selectors/app"
2627
import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow"
@@ -268,7 +269,7 @@ const PlaygroundMainView = ({
268269
const animateSplit = justToggled || holdAnimate
269270

270271
const variantRefs = useRef<(HTMLDivElement | null)[]>([])
271-
const {setConfigPanelRef, setGenerationPanelRef} = usePlaygroundScrollSync({
272+
const {configPanelRef, setConfigPanelRef, setGenerationPanelRef} = usePlaygroundScrollSync({
272273
enabled: isComparisonView,
273274
})
274275

@@ -367,15 +368,17 @@ const PlaygroundMainView = ({
367368
size={configCollapsed ? 0 : undefined}
368369
min="20%"
369370
max={configMaxSize}
370-
className="!h-full"
371+
// antd panels default to overflow:auto; the section inside owns scrolling, and
372+
// a transient overflow leaves Chrome's thin panel scrollbar stuck full-height.
373+
className="!h-full !overflow-hidden"
371374
collapsible={splitCollapsible}
372375
key={`${splitterKey}-splitter-panel-config`}
373376
>
374377
{/* Column: [scrolling config sections][bottom-pinned agent-commit notice].
375378
The notice lives OUTSIDE the scroller, so it sits at the pane's bottom
376379
edge regardless of content height or scroll position. */}
377380
<div
378-
className={clsx("flex h-full min-h-0 w-full flex-col", {
381+
className={clsx("group relative flex h-full min-h-0 w-full flex-col", {
379382
// Config = the raised authoring surface (covers the notice too).
380383
"ag-panel-raised": isAgentConfig,
381384
})}
@@ -384,7 +387,8 @@ const PlaygroundMainView = ({
384387
ref={setConfigPanelRef}
385388
className={clsx([
386389
{
387-
"grow w-full min-h-0 overflow-y-auto": !isComparisonView,
390+
"ag-scroll-no-bar grow w-full min-h-0 overflow-y-auto":
391+
!isComparisonView,
388392
"grow w-full min-h-0 overflow-x-auto flex [&::-webkit-scrollbar]:w-0":
389393
isComparisonView,
390394
},
@@ -436,6 +440,9 @@ const PlaygroundMainView = ({
436440
)}
437441
</>
438442
</section>
443+
{!isComparisonView ? (
444+
<OverlayScrollbar target={configPanelRef} />
445+
) : null}
439446
{!isComparisonView && isAgentConfig && primaryConfigId ? (
440447
<AgentCommitNotice revisionId={primaryConfigId} />
441448
) : null}
@@ -445,6 +452,8 @@ const PlaygroundMainView = ({
445452
<SplitterPanel
446453
className={clsx("!h-full @container min-w-0", {
447454
"!overflow-y-hidden flex flex-col": isComparisonView,
455+
// Same stuck-scrollbar guard as the config panel (chat section scrolls itself).
456+
"!overflow-hidden": !isComparisonView,
448457
})}
449458
collapsible={splitCollapsible}
450459
defaultSize={runsDefaultSize}
@@ -456,7 +465,7 @@ const PlaygroundMainView = ({
456465
className={clsx([
457466
"playground-generation",
458467
{
459-
"grow w-full h-full overflow-y-auto overflow-x-hidden":
468+
"ag-scroll-quiet grow w-full h-full overflow-y-auto overflow-x-hidden":
460469
!isComparisonView,
461470
"grow w-full h-full overflow-auto [&::-webkit-scrollbar]:w-0":
462471
isComparisonView,

web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,7 @@ const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({className, ...divPro
633633
<>
634634
<div
635635
className={clsx(
636-
"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)]",
636+
"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)]",
637637
className,
638638
)}
639639
{...divProps}

web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ const BootShell = memo(function BootShell({shell}: {shell: "app" | "blank"}) {
3232
<div className="flex h-dvh w-full">
3333
<div
3434
className={clsx(
35-
"h-full shrink-0 border-0 border-r border-solid border-[var(--ag-surface-divider)] bg-[var(--ag-sidebar-bg)]",
36-
collapsed ? "w-[80px]" : "w-[236px]",
35+
"h-full shrink-0 border-0 border-r border-solid border-[var(--ag-shell-line)] bg-[var(--ag-sidebar-bg)]",
36+
collapsed ? "w-[48px]" : "w-[236px]",
3737
)}
3838
/>
3939
<div className="grow" />

web/oss/src/components/Sidebar/components/SidebarSkeletonLoader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const SidebarSkeletonLoader = () => {
1414
className={clsx(
1515
"flex flex-col justify-between h-screen border border-r border-solid border-gray-100",
1616
{
17-
"w-[80px] items-center": collapsed,
17+
"w-[48px] items-center": collapsed,
1818
"w-[236px]": !collapsed,
1919
},
2020
)}

web/oss/src/components/Sidebar/components/WorkflowPicker.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ const WorkflowPicker = memo(({collapsed}: WorkflowPickerProps) => {
4545
aria-label="Switch workflow"
4646
className={clsx(
4747
"flex items-center justify-between overflow-hidden transition-[width,height,padding,gap,border-color] duration-300 ease-in-out",
48-
collapsed
49-
? "!w-8 !h-8 !p-1 gap-0"
50-
: "w-full pl-2 pr-3 py-3 h-12 gap-2 border border-solid border-gray-200",
48+
// No border when expanded: the header row it sits in is already
49+
// framed by the rail's own line, so a box inside a box reads wrong.
50+
collapsed ? "!w-8 !h-8 !p-1 gap-0" : "w-full h-full pl-1.5 pr-2 gap-2",
5151
)}
5252
>
5353
<WorkflowIdentity

web/oss/src/components/Sidebar/engine/SidebarShell.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ const {Sider} = Layout
1212
const MENU_CLASS_NAME =
1313
"border-r-0 overflow-y-auto relative [&_.ant-menu-item-selected]:font-medium"
1414

15+
// The menu paints its own container background, which would break the rail into two
16+
// shades. Transparent lets the rail colour run edge to edge.
17+
const MENU_SURFACE_CLASS_NAME = "!bg-transparent"
18+
19+
// antd hardcodes a 80px width on collapsed inline menus; the rail is narrower than that,
20+
// so let the menu fill the rail and keep antd's own centering math working off that width.
21+
const COLLAPSED_MENU_CLASS_NAME = "[&.ant-menu-inline-collapsed]:!w-full"
22+
1523
class SidebarErrorBoundary extends React.Component<React.PropsWithChildren, {hasError: boolean}> {
1624
state = {hasError: false}
1725

@@ -247,7 +255,13 @@ const SidebarShell: React.FC<SidebarShellProps> = ({
247255
{renderSlot(section.before, collapsed)}
248256
<SidebarMenu
249257
menuProps={{
250-
className: isBottomSection ? "" : MENU_CLASS_NAME,
258+
className: [
259+
isBottomSection ? "" : MENU_CLASS_NAME,
260+
MENU_SURFACE_CLASS_NAME,
261+
COLLAPSED_MENU_CLASS_NAME,
262+
]
263+
.filter(Boolean)
264+
.join(" "),
251265
selectedKeys,
252266
...(isInlineSection
253267
? {
@@ -280,18 +294,18 @@ const SidebarShell: React.FC<SidebarShellProps> = ({
280294
const bottomSections = visibleSections.filter((section) => section.placement === "bottom")
281295

282296
return (
283-
<div className="border-0 border-r border-solid border-[var(--ag-surface-divider)]">
297+
<div className="border-0 border-r border-solid border-[var(--ag-shell-line)]">
284298
<Sider
285299
theme={theme}
286300
className="sticky top-0 bottom-0 h-screen bg-[var(--ag-sidebar-bg)]"
287301
collapsible
288-
width={collapsed ? 80 : 236}
302+
width={collapsed ? 48 : 236}
289303
trigger={null}
290304
>
291305
<div
292306
className={[
293307
"flex flex-col h-full transition-all duration-300",
294-
collapsed ? "w-[80px]" : "w-[236px]",
308+
collapsed ? "w-[48px]" : "w-[236px]",
295309
].join(" ")}
296310
>
297311
{renderSlot(scope.header, collapsed, scope.lastPath)}

web/oss/src/components/Sidebar/scopes/workflowScope.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import {useMemo} from "react"
22

3-
import {Divider} from "antd"
4-
53
import SidebarBackButton from "../components/SidebarBackButton"
64
import WorkflowPicker from "../components/WorkflowPicker"
75
import type {SidebarScope, SidebarSection, SidebarSlotContext} from "../engine/types"
@@ -14,21 +12,27 @@ interface WorkflowScopeOptions {
1412
lastPath?: string
1513
}
1614

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

28-
<div className={collapsed ? "flex w-full justify-center p-2" : "px-2 pt-1 pb-2"}>
28+
<div
29+
className={[
30+
"flex h-[45px] shrink-0 items-center border-0 border-b border-solid border-[var(--ag-shell-line)]",
31+
collapsed ? "w-full justify-center" : "px-2",
32+
].join(" ")}
33+
>
2934
<WorkflowPicker collapsed={collapsed} />
3035
</div>
31-
<Divider className="mb-1 mt-0" />
3236
</>
3337
)
3438

0 commit comments

Comments
 (0)