Skip to content

Commit f492faa

Browse files
feat(frontend): add scroll-to-bottom button, improve scroll disengagement, and tall content detection
1 parent c3802aa commit f492faa

10 files changed

Lines changed: 510 additions & 27 deletions

File tree

frontend/src/components/message/FloatingTTSButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function FloatingTTSButton({ messageId, content }: FloatingTTSButtonProps
9292
onPointerDown={handlePointerDown}
9393
onPointerUp={handlePointerUp}
9494
onPointerLeave={handlePointerLeave}
95-
className={`absolute -top-5 right-0 md:right-4 z-50 flex items-center transition-all duration-200 shadow-md backdrop-blur-md ring-1 ${buttonToneClasses} ${isLongPressVisual ? 'scale-95 opacity-80' : 'active:scale-95 hover:scale-105'} ${!hasContent && !autoPlay ? 'opacity-50 cursor-not-allowed' : ''}`}
95+
className={`flex items-center transition-all duration-200 shadow-md backdrop-blur-md ring-1 ${buttonToneClasses} ${isLongPressVisual ? 'scale-95 opacity-80' : 'active:scale-95 hover:scale-105'} ${!hasContent && !autoPlay ? 'opacity-50 cursor-not-allowed' : ''}`}
9696
title={pillTitle}
9797
aria-label={pillAriaLabel}
9898
disabled={!hasContent && !autoPlay}

frontend/src/components/message/PromptInput.stt.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,5 +380,35 @@ describe('PromptInput STT Gesture Tests', () => {
380380
expect(mockStartRecording).toHaveBeenCalledTimes(1)
381381
})
382382
})
383+
384+
it('renders mobile mic even when showScrollButton is true and no voice feedback is active', async () => {
385+
mocks.useMobile.mockReturnValue(true)
386+
387+
render(
388+
<QueryClientProvider client={createTestQueryClient()}>
389+
<PromptInput {...defaultProps} showScrollButton={true} />
390+
</QueryClientProvider>
391+
)
392+
393+
const allButtons = screen.getAllByRole('button')
394+
const voiceButtons = allButtons.filter((btn) => {
395+
const title = (btn.getAttribute('title') || '').toLowerCase()
396+
return title.includes('tap to speak') || title.includes('tap to transcribe') || title.includes('hold to speak')
397+
})
398+
399+
expect(voiceButtons.length).toBeGreaterThan(0)
400+
})
401+
402+
it('does NOT render mobile in-row ArrowDown button', async () => {
403+
mocks.useMobile.mockReturnValue(true)
404+
405+
render(
406+
<QueryClientProvider client={createTestQueryClient()}>
407+
<PromptInput {...defaultProps} showScrollButton={true} />
408+
</QueryClientProvider>
409+
)
410+
411+
expect(screen.queryByTitle('Scroll to bottom')).not.toBeInTheDocument()
412+
})
383413
})
384414
})

frontend/src/components/message/PromptInput.tsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,23 +1244,15 @@ return (
12441244

12451245
</div>
12461246
<div className="flex items-center gap-1.5 md:gap-2 flex-shrink-0">
1247-
{isMobile && showScrollButton && !showVoiceFeedback ? (
1247+
{!isMobile && (
12481248
<button
12491249
onClick={onScrollToBottom}
1250-
className="px-4 py-2 rounded-lg bg-black hover:bg-zinc-900 text-white transition-all duration-200 active:scale-95 shadow-md flex items-center justify-center min-w-[52px] border border-zinc-700"
1251-
title="Scroll to bottom"
1252-
>
1253-
<ArrowDown className="w-5 h-5" />
1254-
</button>
1255-
) : !isMobile ? (
1256-
<button
1257-
onClick={onScrollToBottom}
1258-
className={`p-2 rounded-lg bg-black hover:bg-zinc-900 text-white transition-all duration-200 active:scale-95 hover:scale-105 shadow-md border border-zinc-700 ${showScrollButton ? 'visible' : 'invisible'}`}
1250+
className={`p-2 rounded-lg bg-zinc-950/80 hover:bg-zinc-900/90 text-blue-300 hover:text-blue-200 transition-all duration-200 active:scale-95 hover:scale-105 shadow-md border border-blue-400/20 backdrop-blur-md ring-1 ring-blue-400/15 ${showScrollButton ? 'visible' : 'invisible'}`}
12591251
title="Scroll to bottom"
12601252
>
12611253
<ArrowDown className="w-6 h-6" />
12621254
</button>
1263-
) : null}
1255+
)}
12641256
{showStopButton && (
12651257
<button
12661258
onClick={handleStop}
@@ -1289,7 +1281,7 @@ return (
12891281
{sttEnabled && sttSupported && (
12901282
renderVoiceButton('desktop')
12911283
)}
1292-
{isMobile && sttEnabled && sttSupported && !hasPendingPermissionForSession && (!showScrollButton || showVoiceFeedback) && (
1284+
{isMobile && sttEnabled && sttSupported && !hasPendingPermissionForSession && (
12931285
renderVoiceButton('mobile')
12941286
)}
12951287
<button
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { useTallScrollContent } from '../useTallScrollContent'
4+
5+
let mockObserve: ReturnType<typeof vi.fn>
6+
let mockDisconnect: ReturnType<typeof vi.fn>
7+
let resizeCallback: ((entries: ResizeObserverEntry[]) => void) | null = null
8+
9+
vi.stubGlobal('ResizeObserver', vi.fn((cb: (entries: ResizeObserverEntry[]) => void) => {
10+
resizeCallback = cb
11+
return {
12+
observe: mockObserve,
13+
disconnect: mockDisconnect,
14+
unobserve: vi.fn(),
15+
}
16+
}))
17+
18+
describe('useTallScrollContent', () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks()
21+
mockObserve = vi.fn()
22+
mockDisconnect = vi.fn()
23+
resizeCallback = null
24+
})
25+
26+
afterEach(() => {
27+
vi.restoreAllMocks()
28+
})
29+
30+
function createContainerRef(scrollHeight: number, clientHeight: number) {
31+
const el = document.createElement('div')
32+
Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true })
33+
Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true })
34+
return { current: el }
35+
}
36+
37+
it('returns false when scrollHeight <= clientHeight * ratio', () => {
38+
const ref = createContainerRef(500, 400)
39+
40+
const { result } = renderHook(() => useTallScrollContent(ref as React.RefObject<HTMLElement>, 1.5))
41+
expect(result.current).toBe(false)
42+
})
43+
44+
it('returns true when scrollHeight > clientHeight * ratio', () => {
45+
const ref = createContainerRef(2000, 800)
46+
47+
const { result } = renderHook(() => useTallScrollContent(ref as React.RefObject<HTMLElement>, 1.5))
48+
expect(result.current).toBe(true)
49+
})
50+
51+
it('updates from false to true when ResizeObserver fires with taller content', () => {
52+
const ref = createContainerRef(500, 400)
53+
54+
const { result } = renderHook(() => useTallScrollContent(ref as React.RefObject<HTMLElement>, 1.5))
55+
expect(result.current).toBe(false)
56+
57+
Object.defineProperty(ref.current, 'scrollHeight', { value: 2000 })
58+
act(() => {
59+
resizeCallback?.([] as ResizeObserverEntry[])
60+
})
61+
62+
expect(result.current).toBe(true)
63+
})
64+
65+
it('updates from true to false when ResizeObserver fires with smaller container', () => {
66+
const ref = createContainerRef(2000, 800)
67+
68+
const { result } = renderHook(() => useTallScrollContent(ref as React.RefObject<HTMLElement>, 1.5))
69+
expect(result.current).toBe(true)
70+
71+
Object.defineProperty(ref.current, 'clientHeight', { value: 2000 })
72+
act(() => {
73+
resizeCallback?.([] as ResizeObserverEntry[])
74+
})
75+
76+
expect(result.current).toBe(false)
77+
})
78+
})

frontend/src/hooks/useAutoScroll.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function useAutoScroll({
2828
const userScrolledAtRef = useRef(0)
2929
const userDisengagedRef = useRef(false)
3030
const pointerStartYRef = useRef<number | null>(null)
31+
const pointerActiveRef = useRef(false)
3132
const onScrollStateChangeRef = useRef(onScrollStateChange)
3233

3334
onScrollStateChangeRef.current = onScrollStateChange
@@ -61,17 +62,19 @@ export function useAutoScroll({
6162

6263
const handlePointerDown = (e: PointerEvent) => {
6364
pointerStartYRef.current = e.clientY
65+
pointerActiveRef.current = true
6466
}
6567

6668
const handlePointerMove = (e: PointerEvent) => {
6769
if (pointerStartYRef.current === null) return
68-
if (e.clientY > pointerStartYRef.current) {
70+
if (e.clientY > pointerStartYRef.current + 4) {
6971
markDisengaged()
7072
}
7173
}
7274

7375
const handlePointerUp = () => {
7476
pointerStartYRef.current = null
77+
pointerActiveRef.current = false
7578
}
7679

7780
const handleWheel = (e: WheelEvent) => {
@@ -87,6 +90,7 @@ export function useAutoScroll({
8790
}
8891

8992
const handleScroll = () => {
93+
if (!hasInitialScrolledRef.current) return
9094
const { scrollTop, scrollHeight, clientHeight } = container
9195
const isAtBottom = scrollHeight - scrollTop - clientHeight < BOTTOM_THRESHOLD_PX
9296

@@ -96,7 +100,7 @@ export function useAutoScroll({
96100
userDisengagedRef.current = false
97101
onScrollStateChangeRef.current?.(false)
98102
}
99-
} else if (!userDisengagedRef.current) {
103+
} else if (pointerActiveRef.current && !userDisengagedRef.current) {
100104
userScrolledAtRef.current = Date.now()
101105
userDisengagedRef.current = true
102106
onScrollStateChangeRef.current?.(true)
@@ -146,7 +150,7 @@ export function useAutoScroll({
146150
const timeSinceUserScroll = Date.now() - userScrolledAtRef.current
147151
const recentlyScrolled = timeSinceUserScroll < SCROLL_LOCK_MS
148152

149-
if (recentlyScrolled || userDisengagedRef.current) {
153+
if (recentlyScrolled || userDisengagedRef.current || pointerActiveRef.current) {
150154
return
151155
}
152156

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useState, useEffect, useRef } from 'react'
2+
3+
export function useTallScrollContent(
4+
containerRef: React.RefObject<HTMLElement | null>,
5+
ratio = 1.5
6+
): boolean {
7+
const [isTall, setIsTall] = useState(false)
8+
const observingRef = useRef<HTMLElement | null>(null)
9+
10+
useEffect(() => {
11+
const recompute = () => {
12+
const el = containerRef.current
13+
if (!el) return
14+
setIsTall(el.scrollHeight > el.clientHeight * ratio)
15+
}
16+
17+
const el = containerRef.current
18+
19+
if (!el || el === observingRef.current) return
20+
21+
observingRef.current = el
22+
recompute()
23+
24+
const observer = new ResizeObserver(recompute)
25+
observer.observe(el)
26+
el.addEventListener('scroll', recompute)
27+
28+
return () => {
29+
observer.disconnect()
30+
el.removeEventListener('scroll', recompute)
31+
observingRef.current = null
32+
}
33+
})
34+
35+
return isTall
36+
}

frontend/src/index.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
--color-primary-foreground: #fafafa;
1212
--color-muted-foreground: #d4d4d8;
1313
--color-accent: #27272a;
14-
--color-input: #27272a;
14+
--color-input: #323232;
1515
--color-muted: #18181b;
1616
--color-popover: #18181b;
1717
--color-destructive: #dc2626;

frontend/src/pages/SessionDetail.tsx

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getRepo, initializeAssistantMode } from "@/api/repos";
55
import { MessageThread } from "@/components/message/MessageThread";
66
import { PromptInput, type PromptInputHandle } from "@/components/message/PromptInput";
77
import { FloatingTTSButton } from '@/components/message/FloatingTTSButton'
8-
import { X, CornerUpLeft } from "lucide-react";
8+
import { X, CornerUpLeft, ArrowDown } from "lucide-react";
99
import { ModelSelectDialog } from "@/components/model/ModelSelectDialog";
1010
import { Header } from "@/components/ui/header";
1111
import { SessionList } from "@/components/session/SessionList";
@@ -27,6 +27,7 @@ import { useSettingsDialog } from "@/hooks/useSettingsDialog";
2727
import { useAutoScroll } from "@/hooks/useAutoScroll";
2828
import { useMobile } from "@/hooks/useMobile";
2929
import { useVisualViewport } from "@/hooks/useVisualViewport";
30+
import { useTallScrollContent } from "@/hooks/useTallScrollContent";
3031
import { useTTS } from "@/hooks/useTTS";
3132
import { getAssistantText, getLatestPlayableAssistantMessage, useAutoPlayLastResponse } from "@/hooks/useAutoPlayLastResponse";
3233
import { useEffect, useRef, useCallback, useMemo } from "react";
@@ -86,12 +87,14 @@ export function SessionDetail() {
8687
const [minimizedQuestion, setMinimizedQuestion] = useState<QuestionRequest | null>(null);
8788
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
8889
const lastHeaderScrollTopRef = useRef(0);
90+
const lastHeaderScrollHeightRef = useRef(0);
8991

9092
const isMobile = useMobile();
9193
const { keyboardHeight } = useVisualViewport();
9294
const inputBottomOffset = isMobile ? keyboardHeight : 0;
9395
const promptOverlayRef = useRef<HTMLDivElement>(null);
9496
const [promptOverlayHeight, setPromptOverlayHeight] = useState(112);
97+
const isContentTall = useTallScrollContent(messageContainerRef, 1.5);
9598

9699
useEffect(() => {
97100
const el = promptOverlayRef.current;
@@ -216,27 +219,41 @@ export function SessionDetail() {
216219
if (!container) return
217220

218221
lastHeaderScrollTopRef.current = container.scrollTop
222+
lastHeaderScrollHeightRef.current = container.scrollHeight
219223
setIsHeaderVisible(true)
220224

225+
const SCROLL_DELTA_THRESHOLD = 8
226+
221227
const handleScroll = () => {
222228
if (!isMobile) {
223229
setIsHeaderVisible(true)
224230
return
225231
}
226232
const currentScrollTop = container.scrollTop
227233
const previousScrollTop = lastHeaderScrollTopRef.current
228-
const maxScrollTop = container.scrollHeight - container.clientHeight
229-
const isAtBottom = maxScrollTop - currentScrollTop < 24
230-
const isScrollingDown = currentScrollTop > previousScrollTop + 4
231-
const isScrollingUp = currentScrollTop < previousScrollTop - 50
234+
const currentScrollHeight = container.scrollHeight
235+
const previousScrollHeight = lastHeaderScrollHeightRef.current
236+
237+
lastHeaderScrollTopRef.current = currentScrollTop
238+
lastHeaderScrollHeightRef.current = currentScrollHeight
239+
240+
if (currentScrollHeight !== previousScrollHeight) return
241+
242+
const delta = currentScrollTop - previousScrollTop
243+
if (Math.abs(delta) < SCROLL_DELTA_THRESHOLD) return
232244

233-
if (isAtBottom || isScrollingDown) {
245+
const isAtTop = currentScrollTop < 24
246+
247+
if (isAtTop) {
234248
setIsHeaderVisible(true)
235-
} else if (isScrollingUp) {
236-
setIsHeaderVisible(false)
249+
return
237250
}
238251

239-
lastHeaderScrollTopRef.current = currentScrollTop
252+
if (delta > 0) {
253+
setIsHeaderVisible(true)
254+
} else {
255+
setIsHeaderVisible(false)
256+
}
240257
}
241258

242259
container.addEventListener('scroll', handleScroll, { passive: true })
@@ -538,7 +555,7 @@ export function SessionDetail() {
538555
style={{ bottom: inputBottomOffset }}
539556
>
540557
<div className="relative w-[94%] md:max-w-4xl">
541-
<div className="absolute -top-5 right-0 md:right-4 z-50 flex flex-col items-end gap-2">
558+
<div className="absolute -top-9 right-0 z-50 flex flex-col items-end gap-2">
542559
{ttsEnabled && !hasPromptContent && !isSessionActive && latestPlayableAssistant && (
543560
<FloatingTTSButton
544561
messageId={latestPlayableAssistant.message.info.id}
@@ -561,6 +578,18 @@ export function SessionDetail() {
561578
</button>
562579
)}
563580
</div>
581+
{isMobile && showScrollButton && isContentTall && (
582+
<div className="absolute -top-9 left-0 z-50 flex flex-col items-start gap-2">
583+
<button
584+
onClick={scrollToBottom}
585+
className="flex items-center justify-center px-3 py-1.5 rounded-lg bg-zinc-950/80 hover:bg-zinc-900/90 text-blue-300 hover:text-blue-200 border border-blue-400/20 shadow-md backdrop-blur-md transition-all duration-200 active:scale-95 hover:scale-105 ring-1 ring-blue-400/15"
586+
aria-label="Scroll to bottom"
587+
title="Scroll to bottom"
588+
>
589+
<ArrowDown className="w-5 h-5" />
590+
</button>
591+
</div>
592+
)}
564593
{leaderActive && (
565594
<div className="absolute -top-12 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-xl bg-primary/90 text-primary-foreground border border-primary shadow-lg backdrop-blur-md animate-pulse">
566595
<span className="text-sm font-medium">Waiting for shortcut key...</span>

0 commit comments

Comments
 (0)