Skip to content

Commit 9f718e9

Browse files
authored
Feat/assistant scroll (#31042)
1 parent 4b437c0 commit 9f718e9

File tree

4 files changed

+122
-73
lines changed

4 files changed

+122
-73
lines changed

apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx

+48-71
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { PermissionAction } from '@supabase/shared-types/out/constants'
22
import { AnimatePresence, motion } from 'framer-motion'
33
import { last } from 'lodash'
4-
import { FileText, Info, X } from 'lucide-react'
5-
import { memo, useEffect, useMemo, useRef, useState } from 'react'
4+
import { FileText, Info, X, ArrowDown } from 'lucide-react'
5+
import { memo, useEffect, useMemo, useRef, useState, useCallback } from 'react'
66
import { toast } from 'sonner'
77

88
import type { Message as MessageType } from 'ai/react'
@@ -44,6 +44,7 @@ import DotGrid from '../DotGrid'
4444
import AIOnboarding from './AIOnboarding'
4545
import CollapsibleCodeBlock from './CollapsibleCodeBlock'
4646
import { Message } from './Message'
47+
import { useAutoScroll } from './hooks'
4748

4849
const MemoizedMessage = memo(
4950
({ message, isLoading }: { message: MessageType; isLoading: boolean }) => {
@@ -89,8 +90,7 @@ export const AIAssistant = ({
8990
const { open, initialInput, sqlSnippets, suggestions } = aiAssistantPanel
9091

9192
const inputRef = useRef<HTMLTextAreaElement>(null)
92-
const bottomRef = useRef<HTMLDivElement>(null)
93-
const scrollContainerRef = useRef<HTMLDivElement>(null)
93+
const { ref: scrollContainerRef, isSticky, scrollToEnd } = useAutoScroll()
9494

9595
const [value, setValue] = useState<string>(initialInput)
9696
const [assistantError, setAssistantError] = useState<string>()
@@ -229,37 +229,16 @@ export const AIAssistant = ({
229229
)
230230
}
231231

232-
const handleScroll = () => {
233-
const container = scrollContainerRef.current
234-
if (container) {
235-
const scrollPercentage =
236-
(container.scrollTop / (container.scrollHeight - container.clientHeight)) * 100
237-
const isScrollable = container.scrollHeight > container.clientHeight
238-
const isAtBottom = scrollPercentage >= 100
239-
240-
setShowFade(isScrollable && !isAtBottom)
241-
}
242-
}
243-
244-
// Add useEffect to set up scroll listener
232+
// Update scroll behavior for new messages
245233
useEffect(() => {
246-
// Use a small delay to ensure container is mounted and has content
247-
const timeoutId = setTimeout(() => {
248-
const container = scrollContainerRef.current
249-
if (container) {
250-
container.addEventListener('scroll', handleScroll)
251-
handleScroll()
252-
}
253-
}, 100)
234+
if (!isChatLoading) {
235+
if (inputRef.current) inputRef.current.focus()
236+
}
254237

255-
return () => {
256-
clearTimeout(timeoutId)
257-
const container = scrollContainerRef.current
258-
if (container) {
259-
container.removeEventListener('scroll', handleScroll)
260-
}
238+
if (isSticky) {
239+
setTimeout(scrollToEnd, 0)
261240
}
262-
}, [])
241+
}, [isChatLoading, isSticky, scrollToEnd, messages])
263242

264243
useEffect(() => {
265244
setValue(initialInput)
@@ -269,30 +248,6 @@ export const AIAssistant = ({
269248
}
270249
}, [initialInput])
271250

272-
useEffect(() => {
273-
if (!isChatLoading) {
274-
if (inputRef.current) inputRef.current.focus()
275-
}
276-
277-
setTimeout(
278-
() => {
279-
if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' })
280-
},
281-
isChatLoading ? 100 : 500
282-
)
283-
}, [isChatLoading])
284-
285-
useEffect(() => {
286-
if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' })
287-
handleScroll()
288-
// Load messages into state
289-
if (!isChatLoading) {
290-
setAiAssistantPanel({
291-
messages,
292-
})
293-
}
294-
}, [messages, isChatLoading, setAiAssistantPanel])
295-
296251
// Remove suggestions if sqlSnippets were removed
297252
useEffect(() => {
298253
if (!sqlSnippets || sqlSnippets.length === 0) {
@@ -310,11 +265,7 @@ export const AIAssistant = ({
310265
return (
311266
<>
312267
<div className={cn('flex flex-col h-full', className)}>
313-
<div
314-
ref={scrollContainerRef}
315-
className={cn('flex-grow overflow-auto flex flex-col')}
316-
onScroll={handleScroll}
317-
>
268+
<div ref={scrollContainerRef} className={cn('flex-grow overflow-auto flex flex-col')}>
318269
<div className="z-30 sticky top-0">
319270
<div className="border-b flex items-center bg gap-x-3 px-5 h-[46px]">
320271
<AiIconAnimation allowHoverEffect />
@@ -401,7 +352,7 @@ export const AIAssistant = ({
401352
</motion.div>
402353
</div>
403354
)}
404-
<div ref={bottomRef} className="h-1" />
355+
<div className="h-1" />
405356
</motion.div>
406357
) : suggestions ? (
407358
<div className="w-full h-full px-8 py-0 flex flex-col flex-1 justify-end">
@@ -490,16 +441,41 @@ export const AIAssistant = ({
490441
</div>
491442
)}
492443
</div>
444+
493445
<AnimatePresence>
494-
{showFade && (
495-
<motion.div
496-
initial={{ opacity: 0 }}
497-
animate={{ opacity: 1 }}
498-
exit={{ opacity: 0 }}
499-
className="pointer-events-none z-10 -mt-24"
500-
>
501-
<div className="h-24 w-full bg-gradient-to-t from-background muted to-transparent" />
502-
</motion.div>
446+
{!isSticky && (
447+
<>
448+
<motion.div
449+
initial={{ opacity: 0 }}
450+
animate={{ opacity: 1 }}
451+
exit={{ opacity: 0 }}
452+
className="pointer-events-none z-10 -mt-24"
453+
>
454+
<div className="h-24 w-full bg-gradient-to-t from-background to-transparent" />
455+
</motion.div>
456+
<motion.div
457+
className="absolute bottom-20 left-1/2 -translate-x-1/2"
458+
variants={{
459+
hidden: { y: 5, opacity: 0 },
460+
show: { y: 0, opacity: 1 },
461+
}}
462+
transition={{ duration: 0.1 }}
463+
initial="hidden"
464+
animate="show"
465+
exit="hidden"
466+
>
467+
<Button
468+
type="default"
469+
className="rounded-full w-8 h-8 p-1.5"
470+
onClick={() => {
471+
scrollToEnd()
472+
if (inputRef.current) inputRef.current.focus()
473+
}}
474+
>
475+
<ArrowDown size={16} />
476+
</Button>
477+
</motion.div>
478+
</>
503479
)}
504480
</AnimatePresence>
505481

@@ -567,6 +543,7 @@ export const AIAssistant = ({
567543
.join('\n') || ''
568544
const valueWithSnippets = [value, sqlSnippetsString].filter(Boolean).join('\n\n')
569545
sendMessageToAssistant(valueWithSnippets)
546+
scrollToEnd()
570547
} else {
571548
sendMessageToAssistant(value)
572549
}

apps/studio/components/ui/AIAssistantPanel/SqlSnippet.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ export const SqlCard = ({
183183
: 'This query involves running a function.'}{' '}
184184
Are you sure you want to execute it?
185185
</p>
186+
<p className="text-foreground-light">
187+
Make sure you are not accidentally removing something important.
188+
</p>
186189
<div className="flex justify-stretch mt-2 gap-2">
187190
<Button
188191
type="outline"
@@ -193,7 +196,7 @@ export const SqlCard = ({
193196
Cancel
194197
</Button>
195198
<Button
196-
type="outline"
199+
type="danger"
197200
size="tiny"
198201
className="w-full flex-1"
199202
onClick={() => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
3+
interface UseAutoScrollProps {
4+
enabled?: boolean
5+
}
6+
7+
export function useAutoScroll({ enabled = true }: UseAutoScrollProps = {}) {
8+
const [container, setContainer] = useState<HTMLDivElement | null>(null)
9+
const [isSticky, setIsSticky] = useState(true)
10+
const isStickyRef = useRef(true)
11+
const lastScrollHeightRef = useRef<number>()
12+
13+
const ref = useCallback((element: HTMLDivElement | null) => {
14+
if (element) {
15+
setContainer(element)
16+
}
17+
}, [])
18+
19+
const scrollToEnd = useCallback(() => {
20+
if (container) {
21+
isStickyRef.current = true
22+
setIsSticky(true)
23+
container.scrollTo({
24+
top: container.scrollHeight,
25+
behavior: 'smooth',
26+
})
27+
}
28+
}, [container])
29+
30+
useEffect(() => {
31+
if (!container || !enabled) return
32+
33+
const resizeObserver = new ResizeObserver(() => {
34+
// Prevent duplicate scroll events from phantom height changes
35+
if (
36+
lastScrollHeightRef.current !== undefined &&
37+
container.scrollHeight !== lastScrollHeightRef.current
38+
) {
39+
lastScrollHeightRef.current = container.scrollHeight
40+
if (isStickyRef.current) {
41+
scrollToEnd()
42+
}
43+
}
44+
})
45+
46+
const handleScroll = () => {
47+
const isAtBottom =
48+
Math.abs(container.scrollHeight - container.scrollTop - container.clientHeight) < 10
49+
50+
isStickyRef.current = isAtBottom
51+
setIsSticky(isAtBottom)
52+
}
53+
54+
// Observe all children of the container
55+
Array.from(container.children).forEach((child) => {
56+
resizeObserver.observe(child)
57+
})
58+
59+
container.addEventListener('scroll', handleScroll)
60+
61+
return () => {
62+
resizeObserver.disconnect()
63+
container.removeEventListener('scroll', handleScroll)
64+
}
65+
}, [container, enabled, scrollToEnd])
66+
67+
return { ref, isSticky, scrollToEnd }
68+
}

apps/studio/pages/api/ai/sql/generate-v3.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
104104
105105
# For all your abilities, follow these instructions:
106106
- First look at the list of provided schemas and if needed, get more information about a schema. You will almost always need to retrieve information about the public schema before answering a question.
107-
- If the question is about users or involves creating a users table, also retrieve the auth schema.
107+
- If the question is about users or involves creating a users table, also retrieve the auth schema.
108+
- If it a query is a destructive query e.g. table drop, ask for confirmation before writing the query. The user will still have to run the query once you create it
108109
109110
110111
Here are the existing database schema names you can retrieve: ${schemas}

0 commit comments

Comments
 (0)