Skip to content

Commit 44b959d

Browse files
committed
feat(conversation): implement smooth stick-to-bottom scrolling
1 parent 6e7cc91 commit 44b959d

File tree

8 files changed

+229
-92
lines changed

8 files changed

+229
-92
lines changed
Lines changed: 165 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,177 @@
11
<script setup lang="ts">
2-
import { Conversation, ConversationContent } from '@repo/elements/conversation'
2+
import { Conversation, ConversationContent, ConversationScrollButton } from '@repo/elements/conversation'
33
import { Message, MessageAvatar, MessageContent } from '@repo/elements/message'
4+
import { nanoid } from 'nanoid'
5+
import { onMounted, onUnmounted, ref } from 'vue'
46
5-
const messages = [
6-
{ id: 1, from: 'assistant', text: 'Welcome! Ask me anything.' },
7-
{ id: 2, from: 'user', text: 'Show me an example conversation.' },
8-
{ id: 3, from: 'assistant', text: 'Here is a scrollable chat area with auto-stick-to-bottom.' },
7+
interface MessageItem {
8+
key: string
9+
value: string
10+
name: string
11+
avatar: string
12+
}
13+
14+
const messages: MessageItem[] = [
15+
{
16+
key: nanoid(),
17+
value: 'Hello, how are you?',
18+
name: 'Alex Johnson',
19+
avatar: 'https://github.com/haydenbleasel.png',
20+
},
21+
{
22+
key: nanoid(),
23+
value: 'I\'m good, thank you! How can I assist you today?',
24+
name: 'AI Assistant',
25+
avatar: 'https://github.com/openai.png',
26+
},
27+
{
28+
key: nanoid(),
29+
value: 'I\'m looking for information about your services.',
30+
name: 'Alex Johnson',
31+
avatar: 'https://github.com/haydenbleasel.png',
32+
},
33+
{
34+
key: nanoid(),
35+
value: 'Sure! We offer a variety of AI solutions. What are you interested in?',
36+
name: 'AI Assistant',
37+
avatar: 'https://github.com/openai.png',
38+
},
39+
{
40+
key: nanoid(),
41+
value: 'I\'m interested in natural language processing tools.',
42+
name: 'Alex Johnson',
43+
avatar: 'https://github.com/haydenbleasel.png',
44+
},
45+
{
46+
key: nanoid(),
47+
value: 'Great choice! We have several NLP APIs. Would you like a demo?',
48+
name: 'AI Assistant',
49+
avatar: 'https://github.com/openai.png',
50+
},
51+
{
52+
key: nanoid(),
53+
value: 'Yes, a demo would be helpful.',
54+
name: 'Alex Johnson',
55+
avatar: 'https://github.com/haydenbleasel.png',
56+
},
57+
{
58+
key: nanoid(),
59+
value: 'Alright, I can show you a sentiment analysis example. Ready?',
60+
name: 'AI Assistant',
61+
avatar: 'https://github.com/openai.png',
62+
},
63+
{
64+
key: nanoid(),
65+
value: 'Yes, please proceed.',
66+
name: 'Alex Johnson',
67+
avatar: 'https://github.com/haydenbleasel.png',
68+
},
69+
{
70+
key: nanoid(),
71+
value: 'Here is a sample: \'I love this product!\' → Positive sentiment.',
72+
name: 'AI Assistant',
73+
avatar: 'https://github.com/openai.png',
74+
},
75+
{
76+
key: nanoid(),
77+
value: 'Impressive! Can it handle multiple languages?',
78+
name: 'Alex Johnson',
79+
avatar: 'https://github.com/haydenbleasel.png',
80+
},
81+
{
82+
key: nanoid(),
83+
value: 'Absolutely, our models support over 20 languages.',
84+
name: 'AI Assistant',
85+
avatar: 'https://github.com/openai.png',
86+
},
87+
{
88+
key: nanoid(),
89+
value: 'How do I get started with the API?',
90+
name: 'Alex Johnson',
91+
avatar: 'https://github.com/haydenbleasel.png',
92+
},
93+
{
94+
key: nanoid(),
95+
value: 'You can sign up on our website and get an API key instantly.',
96+
name: 'AI Assistant',
97+
avatar: 'https://github.com/openai.png',
98+
},
99+
{
100+
key: nanoid(),
101+
value: 'Is there a free trial available?',
102+
name: 'Alex Johnson',
103+
avatar: 'https://github.com/haydenbleasel.png',
104+
},
105+
{
106+
key: nanoid(),
107+
value: 'Yes, we offer a 14-day free trial with full access.',
108+
name: 'AI Assistant',
109+
avatar: 'https://github.com/openai.png',
110+
},
111+
{
112+
key: nanoid(),
113+
value: 'What kind of support do you provide?',
114+
name: 'Alex Johnson',
115+
avatar: 'https://github.com/haydenbleasel.png',
116+
},
117+
{
118+
key: nanoid(),
119+
value: 'We provide 24/7 chat and email support for all users.',
120+
name: 'AI Assistant',
121+
avatar: 'https://github.com/openai.png',
122+
},
123+
{
124+
key: nanoid(),
125+
value: 'Thank you for the information!',
126+
name: 'Alex Johnson',
127+
avatar: 'https://github.com/haydenbleasel.png',
128+
},
129+
{
130+
key: nanoid(),
131+
value: 'You\'re welcome! Let me know if you have any more questions.',
132+
name: 'AI Assistant',
133+
avatar: 'https://github.com/openai.png',
134+
},
9135
]
136+
137+
const visibleMessages = ref<MessageItem[]>([])
138+
139+
let timer: number | null = null
140+
141+
onMounted(() => {
142+
let index = 0
143+
timer = window.setInterval(() => {
144+
const next = messages[index]
145+
if (!next) {
146+
if (timer !== null) {
147+
clearInterval(timer)
148+
timer = null
149+
}
150+
return
151+
}
152+
visibleMessages.value = [...visibleMessages.value, next]
153+
index += 1
154+
}, 500)
155+
})
156+
157+
onUnmounted(() => {
158+
if (timer !== null) {
159+
clearInterval(timer)
160+
timer = null
161+
}
162+
})
10163
</script>
11164

12165
<template>
13-
<div class="h-[300px] rounded-xl border">
14-
<Conversation class="h-full">
15-
<ConversationContent class="p-2 space-y-2">
16-
<Message v-for="m in messages" :key="m.id" :from="m.from === 'user' ? 'user' : 'assistant'">
17-
<MessageAvatar src="https://github.com/openai.png" :name="m.from === 'user' ? 'Me' : 'AI'" />
18-
<MessageContent>{{ m.text }}</MessageContent>
166+
<div class="h-[498px] rounded-xl border">
167+
<Conversation class="relative h-full">
168+
<ConversationContent class="space-y-2">
169+
<Message v-for="(m, idx) in visibleMessages" :key="m.key" :from="idx % 2 === 0 ? 'user' : 'assistant'">
170+
<MessageContent>{{ m.value }}</MessageContent>
171+
<MessageAvatar :src="m.avatar" :name="m.name" />
19172
</Message>
20173
</ConversationContent>
174+
<ConversationScrollButton />
21175
</Conversation>
22176
</div>
23177
</template>

apps/test/nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export default defineNuxtConfig({
2424
'clsx',
2525
'tailwind-merge',
2626
'class-variance-authority',
27+
'nanoid',
2728
],
2829
},
2930
},

apps/test/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"class-variance-authority": "^0.7.1",
1919
"clsx": "^2.1.1",
2020
"lucide-vue-next": "^0.542.0",
21+
"nanoid": "^5.1.5",
2122
"nuxt": "^4.0.3",
2223
"shadcn-nuxt": "2.2.0",
2324
"tailwind-merge": "^3.3.1",

packages/elements/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"@repo/shadcn-vue": "workspace:*",
1111
"ai": "^5.0.28",
1212
"lucide-vue-next": "^0.542.0",
13+
"stick-to-bottom-vue": "^0.1.0",
1314
"streamdown-vue": "^1.0.12",
1415
"vue": "^3.5.20"
1516
},
Lines changed: 26 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,42 @@
11
<script setup lang="ts">
2-
import { onMounted, onUnmounted, provide, ref } from 'vue'
3-
import { conversationContextKey } from './context'
2+
import { StickToBottom } from 'stick-to-bottom-vue'
3+
import ConversationScrollButton from './ConversationScrollButton.vue'
44
55
interface Props {
66
ariaLabel?: string
77
class?: string
8+
initial?: boolean | 'instant' | { damping?: number, stiffness?: number, mass?: number }
9+
resize?: 'instant' | { damping?: number, stiffness?: number, mass?: number }
10+
damping?: number
11+
stiffness?: number
12+
mass?: number
13+
anchor?: 'auto' | 'none'
814
}
915
10-
const props = defineProps<Props>()
11-
12-
const ariaLabel = props.ariaLabel ?? 'Conversation'
13-
const root = ref<HTMLElement | null>(null)
14-
const isAtBottom = ref(true)
15-
16-
function scrollToBottom(behavior: ScrollBehavior = 'auto') {
17-
const el = root.value
18-
if (!el)
19-
return
20-
el.scrollTo({ top: el.scrollHeight, behavior })
21-
}
22-
23-
function handleScroll() {
24-
const el = root.value
25-
if (!el)
26-
return
27-
const threshold = 4
28-
const distanceFromBottom = el.scrollHeight - el.clientHeight - el.scrollTop
29-
isAtBottom.value = distanceFromBottom <= threshold
30-
}
31-
32-
provide(conversationContextKey, { isAtBottom, scrollToBottom })
33-
34-
let mutationObserver: MutationObserver | null = null
35-
36-
onMounted(() => {
37-
const el = root.value
38-
if (!el)
39-
return
40-
41-
// Track initial state and auto-scroll when at bottom
42-
handleScroll()
43-
44-
mutationObserver = new MutationObserver(() => {
45-
if (isAtBottom.value) {
46-
scrollToBottom('smooth')
47-
}
48-
})
49-
50-
mutationObserver.observe(el, { childList: true, subtree: true })
51-
})
52-
53-
onUnmounted(() => {
54-
mutationObserver?.disconnect()
55-
mutationObserver = null
16+
const props = withDefaults(defineProps<Props>(), {
17+
ariaLabel: 'Conversation',
18+
initial: true,
19+
damping: 0.7,
20+
stiffness: 0.05,
21+
mass: 1.25,
22+
anchor: 'none',
5623
})
5724
</script>
5825

5926
<template>
60-
<div
61-
ref="root"
62-
:aria-label="ariaLabel" class="relative flex-1 overflow-y-auto"
27+
<StickToBottom
28+
:aria-label="props.ariaLabel"
29+
class="relative flex-1"
6330
:class="[props.class]"
6431
role="log"
65-
@scroll="handleScroll"
32+
:initial="props.initial"
33+
:resize="props.resize"
34+
:damping="props.damping"
35+
:stiffness="props.stiffness"
36+
:mass="props.mass"
37+
:anchor="props.anchor"
6638
>
6739
<slot />
68-
</div>
40+
<ConversationScrollButton />
41+
</StickToBottom>
6942
</template>
Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,35 @@
11
<script setup lang="ts">
22
import { Button } from '@repo/shadcn-vue/components/ui/button'
3-
import { LucideChevronDown } from 'lucide-vue-next'
4-
import { inject, ref } from 'vue'
5-
import { conversationContextKey } from './context'
3+
import { ChevronDown } from 'lucide-vue-next'
4+
import { useStickToBottomContext } from 'stick-to-bottom-vue'
5+
import { computed } from 'vue'
66
77
interface Props {
88
class?: string
99
}
1010
1111
const props = defineProps<Props>()
12-
13-
const ctx = inject(conversationContextKey)
14-
15-
const isAtBottom = ctx?.isAtBottom ?? ref(true)
12+
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
13+
const showScrollButton = computed(() => !isAtBottom.value)
1614
1715
function handleClick() {
18-
ctx?.scrollToBottom('smooth')
16+
scrollToBottom()
1917
}
2018
</script>
2119

2220
<template>
23-
<Button
24-
v-if="!isAtBottom"
25-
class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full"
26-
:class="[props.class]"
27-
size="icon"
28-
type="button"
29-
variant="outline"
30-
@click="handleClick"
31-
>
32-
<LucideChevronDown class="size-4" />
33-
</Button>
21+
<div class="pointer-events-none absolute inset-0 z-20 flex items-end justify-center pb-4">
22+
<Button
23+
v-show="showScrollButton"
24+
class="pointer-events-auto rounded-full shadow-sm"
25+
:class="[props.class]"
26+
size="icon"
27+
type="button"
28+
variant="outline"
29+
v-bind="$attrs"
30+
@click="handleClick"
31+
>
32+
<ChevronDown class="size-4" />
33+
</Button>
34+
</div>
3435
</template>

packages/elements/src/conversation/context.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)