Skip to content

Commit 79e26d8

Browse files
committed
feat(frontend): overlay scrollbar, model names, narrower playground divider
- The config panel gets a scrollbar drawn on top of the content instead of beside it, so the sticky headers keep the full panel width and the thumb still appears on hover, after a scroll, and while dragging. - Model & harness names the model the way the picker does ("Sonnet"), from the harness catalog, instead of the stored id ("sonnet"). - The divider channel between config and chat drops from 12px to 9px.
1 parent 6bc0a1c commit 79e26d8

4 files changed

Lines changed: 170 additions & 3 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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+
const onUp = () => {
109+
setDragging(false)
110+
window.removeEventListener("pointermove", onMove)
111+
window.removeEventListener("pointerup", onUp)
112+
}
113+
window.addEventListener("pointermove", onMove)
114+
window.addEventListener("pointerup", onUp)
115+
},
116+
[target, metrics],
117+
)
118+
119+
if (!metrics) return null
120+
121+
return (
122+
<div
123+
className="pointer-events-none absolute right-0 z-20 w-2"
124+
style={{top: metrics.trackTop, height: metrics.trackHeight}}
125+
>
126+
<div
127+
role="presentation"
128+
onPointerDown={handlePointerDown}
129+
className={[
130+
"pointer-events-auto absolute right-0.5 w-1.5 cursor-default rounded-full",
131+
"opacity-0 transition-opacity duration-150 group-hover:opacity-100",
132+
scrolling || dragging ? "!opacity-100" : "",
133+
].join(" ")}
134+
style={{
135+
top: metrics.top,
136+
height: metrics.height,
137+
background: dragging
138+
? "var(--ag-scroll-thumb-hover)"
139+
: "var(--ag-scroll-thumb)",
140+
}}
141+
/>
142+
</div>
143+
)
144+
}
145+
146+
export default OverlayScrollbar

web/oss/src/styles/globals.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ body {
214214
handle. */
215215
.playground-splitter-agent > .ant-splitter-bar {
216216
background: var(--ag-surface-gutter) !important;
217-
flex-basis: 12px !important;
218-
width: 12px !important;
217+
flex-basis: 9px !important;
218+
width: 9px !important;
219219
}
220220

221221
/* ── Agent Playground surface classes ──────────────────────────────────────────────────────────

web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
harnessAllowsModel,
3333
harnessSupportsUserMcp,
3434
modelIdFromConfig,
35+
modelLabel,
3536
providerForModel,
3637
vaultModelGroups,
3738
vaultPickedProviderFamily,
@@ -354,8 +355,13 @@ export function useModelHarness({
354355
[harness, setSection],
355356
)
356357

358+
// Prefer the harness catalog's label ("Sonnet") over the stored id ("sonnet"), so the summary
359+
// names the model the way the picker did.
357360
const modelSummary =
358-
[enumLabel(harnessProps.kind, harness.kind), enumLabel(props.llm, modelId)]
361+
[
362+
enumLabel(harnessProps.kind, harness.kind),
363+
modelLabel(capabilities, harnessValue, modelId) ?? enumLabel(props.llm, modelId),
364+
]
359365
.filter(Boolean)
360366
.join(" · ") || undefined
361367

web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,21 @@ export function buildModelOptionGroups(
323323
}))
324324
}
325325

326+
/**
327+
* The display label for a picked model id, from the harness's curated catalog. Same precedence as
328+
* the picker's options, so a summary line reads the way the picker did. Returns null when the id
329+
* is not in the catalog, so callers can fall back to whatever they showed before.
330+
*/
331+
export function modelLabel(
332+
capabilities: HarnessCapabilitiesMap | null | undefined,
333+
harness: string | null | undefined,
334+
modelId: string | null | undefined,
335+
): string | null {
336+
if (!modelId) return null
337+
const hit = capsFor(capabilities, harness)?.model_catalog?.find((e) => e.id === modelId)
338+
return hit?.label ?? hit?.name ?? null
339+
}
340+
326341
/**
327342
* The provider family that owns a picked model id, derived from the harness's published models
328343
* (the group the id sits in). Returns null when the id is not in any group (e.g. a stale id under

0 commit comments

Comments
 (0)