Skip to content

Commit d8d15c1

Browse files
BlueHotDogclaude
andcommitted
fix: break ScrollButton/ResizeObserver feedback loop causing scroll snap
Move ScrollButton outside contentRef (the ResizeObserver-watched div) into ScrollContainer itself. Previously the button's 32px show/hide cycle based on isAtBottom caused the ResizeObserver to snap scrollTop, which toggled isAtBottom, which toggled the button — infinite ±32px oscillation that made it impossible to scroll up in the tools UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 33ebd39 commit d8d15c1

File tree

4 files changed

+66
-57
lines changed

4 files changed

+66
-57
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@frontman/client": patch
3+
---
4+
5+
fix: move ScrollButton outside contentRef to break ResizeObserver feedback loop
6+
7+
The scroll-to-bottom button was rendered inside the ResizeObserver-watched div.
8+
Its 32px show/hide cycle (driven by `isAtBottom`) caused the ResizeObserver to
9+
snap scroll position, which toggled `isAtBottom`, which toggled the button —
10+
creating an infinite oscillation that made it impossible to scroll up.

apps/marketing/src/components/ui/NavigationBar.astro

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,14 @@ function isActivePath(currentPath: string): boolean {
150150

151151
/* -- Nav links (centered) -- */
152152
.header__menu {
153-
@apply invisible fixed h-dvh w-full justify-center overflow-hidden overflow-y-auto overscroll-contain opacity-0 transition-[opacity,visibility] duration-300;
153+
@apply invisible fixed left-0 top-0 h-dvh w-full justify-center overflow-hidden overflow-y-auto overscroll-contain opacity-0 transition-[opacity,visibility] duration-300;
154154
z-index: 998;
155155
background: rgba(10, 10, 10, 0.97);
156156
list-style: none;
157-
margin: 0;
157+
margin: 12px 16px 0;
158158
padding: 5rem 2rem 2rem;
159-
left: 0;
160-
top: 0;
159+
width: calc(100% - 32px);
160+
height: calc(100dvh - 12px);
161161
border-radius: 16px;
162162
}
163163
@media (min-width: 1024px) {

libs/client/src/Client__Chatbox.res

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -121,22 +121,29 @@ let make = (
121121
let usageInfo = Client__State.useSelector(Client__State.Selectors.usageInfo)
122122
let configOptions = Client__State.useSelector(Client__State.Selectors.configOptions)
123123
let selectedModelValue = Client__State.useSelector(Client__State.Selectors.selectedModelValue)
124-
let hasProviderConfigured = Client__State.useSelector(Client__State.Selectors.hasAnyProviderConfigured)
125-
let webPreviewIsSelecting = Client__State.useSelector(Client__State.Selectors.webPreviewIsSelecting)
124+
let hasProviderConfigured = Client__State.useSelector(
125+
Client__State.Selectors.hasAnyProviderConfigured,
126+
)
127+
let webPreviewIsSelecting = Client__State.useSelector(
128+
Client__State.Selectors.webPreviewIsSelecting,
129+
)
126130
let annotations = Client__State.useSelector(Client__State.Selectors.annotations)
127-
let hasEnrichingAnnotations = Client__State.useSelector(Client__State.Selectors.hasEnrichingAnnotations)
131+
let hasEnrichingAnnotations = Client__State.useSelector(
132+
Client__State.Selectors.hasEnrichingAnnotations,
133+
)
128134
let runtimeConfig = RuntimeConfig.read()
129-
let hasEnvKey = RuntimeConfig.hasOpenrouterKey(runtimeConfig) || RuntimeConfig.hasAnthropicKey(runtimeConfig)
135+
let hasEnvKey =
136+
RuntimeConfig.hasOpenrouterKey(runtimeConfig) || RuntimeConfig.hasAnthropicKey(runtimeConfig)
130137
let hasAnyKey = hasProviderConfigured || hasEnvKey
131138

132-
let modelConfigOption = configOptions->Option.flatMap(opts =>
133-
FrontmanAiFrontmanProtocol.FrontmanProtocol__ACP.findConfigOptionByCategory(opts, Model)
134-
)
139+
let modelConfigOption =
140+
configOptions->Option.flatMap(opts =>
141+
FrontmanAiFrontmanProtocol.FrontmanProtocol__ACP.findConfigOptionByCategory(opts, Model)
142+
)
135143
let isModelsConfigLoading = configOptions->Option.isNone
136144

137145
let isUsageExhausted = switch (usageInfo, hasAnyKey) {
138-
| (Some({remaining: Some(remaining), hasServerKey: Some(true)}), false)
139-
if remaining <= 0 => true
146+
| (Some({remaining: Some(remaining), hasServerKey: Some(true)}), false) if remaining <= 0 => true
140147
| _ => false
141148
}
142149

@@ -154,15 +161,20 @@ let make = (
154161

155162
let handleSubmit = (~text: string, ~inputItems: array<Client__PromptInput.inputItem>) => {
156163
// Snapshot live annotations into serializable MessageAnnotation records
157-
let messageAnnotations = annotations->Array.map(Client__Message.MessageAnnotation.fromAnnotation)
164+
let messageAnnotations =
165+
annotations->Array.map(Client__Message.MessageAnnotation.fromAnnotation)
158166

159-
let sendWithContent = (content) => {
167+
let sendWithContent = content => {
160168
// Allow send if there's content OR annotations (annotations are first-class message content)
161169
switch Array.length(content) > 0 || Array.length(messageAnnotations) > 0 {
162170
| false => ()
163171
| true =>
164172
let sendMessage = (sessionId: string) => {
165-
Client__State.Actions.addUserMessage(~sessionId, ~content, ~annotations=messageAnnotations)
173+
Client__State.Actions.addUserMessage(
174+
~sessionId,
175+
~content,
176+
~annotations=messageAnnotations,
177+
)
166178
}
167179
switch session {
168180
| Some(sess) => sendMessage(sess.sessionId)
@@ -196,8 +208,10 @@ let make = (
196208
let _ =
197209
fileData
198210
->Array.map(((id, name, mediaType, dataUrl)) => {
199-
Client__ImageLimits.constrainDataUrl(dataUrl, Client__ImageLimits.conservative)
200-
->Promise.then(constrained => {
211+
Client__ImageLimits.constrainDataUrl(
212+
dataUrl,
213+
Client__ImageLimits.conservative,
214+
)->Promise.then(constrained => {
201215
let actualMediaType = switch constrained->String.startsWith("data:image/jpeg") {
202216
| true => "image/jpeg"
203217
| false => mediaType
@@ -242,9 +256,11 @@ let make = (
242256
let stableGroup: ToolGroupTypes.toolGroup = switch prevCache->Dict.get(group.id) {
243257
| Some(prev)
244258
if Array.length(prev.toolCalls) == Array.length(group.toolCalls) &&
245-
prev.toolCalls->Array.everyWithIndex((prevTc, i) => {
246-
prevTc === group.toolCalls->Array.getUnsafe(i)
247-
}) => prev
259+
prev.toolCalls->Array.everyWithIndex(
260+
(prevTc, i) => {
261+
prevTc === group.toolCalls->Array.getUnsafe(i)
262+
},
263+
) => prev
248264
| _ => group
249265
}
250266
newCache->Dict.set(stableGroup.id, stableGroup)
@@ -360,11 +376,7 @@ let make = (
360376
}
361377

362378
<div key={messageId} className="frontman-content-auto">
363-
<TodoListBlock
364-
todos
365-
isLoading
366-
messageId
367-
/>
379+
<TodoListBlock todos isLoading messageId />
368380
</div>
369381

370382
| ErrorMsg(Message.Error(err), _) =>
@@ -411,7 +423,6 @@ let make = (
411423
messageId={thinkingMessageId}
412424
/>
413425
</ScrollContainer.ContentWrapper>
414-
<ScrollContainer.ScrollButton />
415426
</ScrollContainer>
416427
<Client__PlanDisplay entries=planEntries />
417428
<Client__SelectedElementDisplay />
@@ -433,8 +444,7 @@ let make = (
433444
modelConfigOption
434445
isModelsConfigLoading
435446
selectedModelValue
436-
onModelChange={value =>
437-
Client__State.Actions.setSelectedModelValue(~value)}
447+
onModelChange={value => Client__State.Actions.setSelectedModelValue(~value)}
438448
isAgentRunning
439449
hasActiveACPSession
440450
disabled={isUsageExhausted}

libs/client/src/components/frontman/Client__ScrollContainer.res

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1+
// --- Scroll context ---
2+
13
/**
24
* Client__ScrollContainer - Scrollable container with stick-to-bottom behavior
35
*
46
* Uses CSS overflow-anchor for zero-JS stick-to-bottom during streaming,
57
* and IntersectionObserver for passive isAtBottom tracking.
68
* Replaces use-stick-to-bottom to eliminate layout thrashing (see #177).
79
*/
8-
9-
// --- Scroll context ---
10-
1110
type scrollContext = {
1211
isAtBottom: bool,
1312
scrollToBottom: unit => unit,
@@ -49,11 +48,7 @@ module ScrollButton = {
4948
if isAtBottom {
5049
React.null
5150
} else {
52-
<button
53-
type_="button"
54-
onClick={_ => scrollToBottom()}
55-
className={buttonClassName}
56-
>
51+
<button type_="button" onClick={_ => scrollToBottom()} className={buttonClassName}>
5752
<svg
5853
xmlns="http://www.w3.org/2000/svg"
5954
viewBox="0 0 24 24"
@@ -94,14 +89,13 @@ let make = (~className: option<string>=?, ~children: React.element) => {
9489
React.useEffect0(() => {
9590
switch (sentinelRef.current->Nullable.toOption, containerRef.current->Nullable.toOption) {
9691
| (Some(sentinel), Some(container)) =>
97-
let observer = Bindings__IntersectionObserver.make(
98-
entries => {
99-
entries->Array.forEach(entry => {
92+
let observer = Bindings__IntersectionObserver.make(entries => {
93+
entries->Array.forEach(
94+
entry => {
10095
setIsAtBottom(_ => entry.isIntersecting)
101-
})
102-
},
103-
{root: container, rootMargin: "10px", threshold: [0.0]},
104-
)
96+
},
97+
)
98+
}, {root: container, rootMargin: "10px", threshold: [0.0]})
10599
observer->Bindings__IntersectionObserver.observe(sentinel)
106100
Some(() => Bindings__IntersectionObserver.disconnect(observer))
107101
| _ => None
@@ -111,8 +105,7 @@ let make = (~className: option<string>=?, ~children: React.element) => {
111105
let scrollToBottom = React.useCallback0(() => {
112106
switch sentinelRef.current->Nullable.toOption {
113107
| None => ()
114-
| Some(sentinel) =>
115-
sentinel->Bindings__DomScrollIntoView.scrollIntoView({behavior: "smooth"})
108+
| Some(sentinel) => sentinel->Bindings__DomScrollIntoView.scrollIntoView({behavior: "smooth"})
116109
}
117110
})
118111

@@ -129,8 +122,7 @@ let make = (~className: option<string>=?, ~children: React.element) => {
129122

130123
let contentObserver = FrontmanBindings.ResizeObserver.make(_ => {
131124
let nearBottom =
132-
el.scrollTop +. el.clientHeight->Int.toFloat >=
133-
el.scrollHeight->Int.toFloat -. 150.0
125+
el.scrollTop +. el.clientHeight->Int.toFloat >= el.scrollHeight->Int.toFloat -. 150.0
134126
if nearBottom {
135127
el.scrollTop = el.scrollHeight->Int.toFloat
136128
}
@@ -168,15 +160,14 @@ let make = (~className: option<string>=?, ~children: React.element) => {
168160

169161
<Provider value={contextValue}>
170162
<div ref={ReactDOM.Ref.domRef(containerRef)} className={containerClassName} role="log">
171-
<div ref={ReactDOM.Ref.domRef(contentRef)}>
172-
{children}
173-
</div>
163+
<div ref={ReactDOM.Ref.domRef(contentRef)}> {children} </div>
164+
// ScrollButton lives here — outside contentRef — so its show/hide does not
165+
// affect the ResizeObserver-watched div and cannot trigger the snap feedback loop.
166+
<ScrollButton />
174167
// Sentinel: overflow-anchor keeps it in view while the user is at the bottom.
175168
// All message wrappers have overflow-anchor: none (via .frontman-content-auto).
176169
<div
177-
ref={ReactDOM.Ref.domRef(sentinelRef)}
178-
className="frontman-scroll-anchor"
179-
ariaHidden=true
170+
ref={ReactDOM.Ref.domRef(sentinelRef)} className="frontman-scroll-anchor" ariaHidden=true
180171
/>
181172
</div>
182173
</Provider>
@@ -194,8 +185,6 @@ module ContentWrapper = {
194185
}
195186
}, [className])
196187

197-
<div className={contentClassName}>
198-
{children}
199-
</div>
188+
<div className={contentClassName}> {children} </div>
200189
}
201190
}

0 commit comments

Comments
 (0)