Skip to content

Commit 399f52d

Browse files
authored
feat: Update conversation components with improved structure and new empty state (#42)
1 parent 8196e26 commit 399f52d

File tree

7 files changed

+272
-198
lines changed

7 files changed

+272
-198
lines changed

apps/www/content/3.components/1.chatbot/conversation.md

Lines changed: 163 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Conversation
3-
description:
3+
description: Wraps messages and automatically scrolls to the bottom. Also includes a scroll button that appears when not at the bottom.
44
icon: lucide:message-square
55
---
66

@@ -30,146 +30,169 @@ The `Conversation` component wraps messages and automatically scrolls to the bot
3030
Copy and paste the following code in the same folder.
3131

3232
:::code-group
33-
```vue [Conversation.vue]
34-
<script setup lang="ts">
35-
import { StickToBottom } from 'vue-stick-to-bottom'
36-
import ConversationScrollButton from './ConversationScrollButton.vue'
37-
38-
interface Props {
39-
ariaLabel?: string
40-
class?: string
41-
initial?: boolean | 'instant' | { damping?: number, stiffness?: number, mass?: number }
42-
resize?: 'instant' | { damping?: number, stiffness?: number, mass?: number }
43-
damping?: number
44-
stiffness?: number
45-
mass?: number
46-
anchor?: 'auto' | 'none'
47-
}
33+
```vue [Conversation.vue] height=300 collapse
34+
<script setup lang="ts">
35+
import type { HTMLAttributes } from 'vue'
36+
import { cn } from '@repo/shadcn-vue/lib/utils'
37+
import { StickToBottom } from 'vue-stick-to-bottom'
38+
39+
interface Props {
40+
ariaLabel?: string
41+
class?: HTMLAttributes['class']
42+
initial?: boolean | 'instant' | { damping?: number, stiffness?: number, mass?: number }
43+
resize?: 'instant' | { damping?: number, stiffness?: number, mass?: number }
44+
damping?: number
45+
stiffness?: number
46+
mass?: number
47+
anchor?: 'auto' | 'none'
48+
}
4849
49-
const props = withDefaults(defineProps<Props>(), {
50-
ariaLabel: 'Conversation',
51-
initial: true,
52-
damping: 0.7,
53-
stiffness: 0.05,
54-
mass: 1.25,
55-
anchor: 'none',
56-
})
57-
</script>
58-
59-
<template>
60-
<StickToBottom
61-
:aria-label="props.ariaLabel"
62-
class="relative flex-1"
63-
:class="[props.class]"
64-
role="log"
65-
:initial="props.initial"
66-
:resize="props.resize"
67-
:damping="props.damping"
68-
:stiffness="props.stiffness"
69-
:mass="props.mass"
70-
:anchor="props.anchor"
71-
>
72-
<slot />
73-
<ConversationScrollButton />
74-
</StickToBottom>
75-
</template>
76-
```
50+
const props = withDefaults(defineProps<Props>(), {
51+
ariaLabel: 'Conversation',
52+
initial: true,
53+
damping: 0.7,
54+
stiffness: 0.05,
55+
mass: 1.25,
56+
anchor: 'none',
57+
})
58+
</script>
7759
78-
```vue [ConversationContent.vue]
79-
<script setup lang="ts">
80-
import { computed } from 'vue'
60+
<template>
61+
<StickToBottom
62+
:aria-label="props.ariaLabel"
63+
:class="cn('relative flex-1 overflow-y-hidden', props.class)"
64+
role="log"
65+
:initial="props.initial"
66+
:resize="props.resize"
67+
:damping="props.damping"
68+
:stiffness="props.stiffness"
69+
:mass="props.mass"
70+
:anchor="props.anchor"
71+
>
72+
<slot />
73+
</StickToBottom>
74+
</template>
75+
```
8176

82-
interface Props {
83-
class?: string
84-
}
77+
```vue [ConversationContent.vue] height=300 collapse
78+
<script setup lang="ts">
79+
import type { HTMLAttributes } from 'vue'
80+
import { computed } from 'vue'
8581
86-
const props = defineProps<Props>()
82+
interface Props {
83+
class?: HTMLAttributes['class']
84+
}
8785
88-
const classes = computed(() => [
89-
'p-4',
90-
props.class,
91-
])
92-
</script>
86+
const props = defineProps<Props>()
9387
94-
<template>
95-
<div :class="classes">
96-
<slot />
97-
</div>
98-
</template>
99-
```
88+
const classes = computed(() => [
89+
'flex flex-col gap-8 p-4',
90+
props.class,
91+
])
92+
</script>
10093
101-
```vue [ConversationScrollButton.vue]
102-
<script setup lang="ts">
103-
import { ChevronDown } from 'lucide-vue-next'
104-
import { computed } from 'vue'
105-
import { useStickToBottomContext } from 'vue-stick-to-bottom'
106-
import { Button } from '@/components/ui/button'
94+
<template>
95+
<div :class="classes">
96+
<slot />
97+
</div>
98+
</template>
99+
```
107100

108-
interface Props {
109-
class?: string
110-
}
101+
```vue [ConversationEmptyState.vue] height=300 collapse
102+
<script setup lang="ts">
103+
import type { HTMLAttributes } from 'vue'
104+
import { cn } from '@repo/shadcn-vue/lib/utils'
111105
112-
const props = defineProps<Props>()
113-
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
114-
const showScrollButton = computed(() => !isAtBottom.value)
106+
interface Props {
107+
class?: HTMLAttributes['class']
108+
title?: string
109+
description?: string
110+
}
115111
116-
function handleClick() {
117-
scrollToBottom()
118-
}
119-
</script>
120-
121-
<template>
122-
<div class="pointer-events-none absolute inset-0 z-20 flex items-end justify-center pb-4">
123-
<Button
124-
v-show="showScrollButton"
125-
class="pointer-events-auto rounded-full shadow-sm"
126-
:class="[props.class]"
127-
size="icon"
128-
type="button"
129-
variant="outline"
130-
v-bind="$attrs"
131-
@click="handleClick"
132-
>
133-
<ChevronDown class="size-4" />
134-
</Button>
135-
</div>
136-
</template>
137-
```
112+
const props = withDefaults(defineProps<Props>(), {
113+
title: 'No messages yet',
114+
description: 'Start a conversation to see messages here',
115+
})
116+
</script>
138117
139-
```ts [index.ts]
140-
export { default as Conversation } from './Conversation.vue'
141-
export { default as ConversationContent } from './ConversationContent.vue'
142-
export { default as ConversationScrollButton } from './ConversationScrollButton.vue'
143-
```
144-
:::
118+
<template>
119+
<div
120+
:class="cn(
121+
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
122+
props.class,
123+
)"
124+
v-bind="$attrs"
125+
>
126+
<slot>
127+
<div v-if="$slots.icon" class="text-muted-foreground">
128+
<slot name="icon" />
129+
</div>
130+
131+
<div class="space-y-1">
132+
<h3 class="font-medium text-sm">
133+
{{ props.title }}
134+
</h3>
135+
<p v-if="props.description" class="text-muted-foreground text-sm">
136+
{{ props.description }}
137+
</p>
138+
</div>
139+
</slot>
140+
</div>
141+
</template>
142+
```
145143

146-
## Usage
144+
```vue [ConversationScrollButton.vue] height=300 collapse
145+
<script setup lang="ts">
146+
import type { HTMLAttributes } from 'vue'
147+
import { Button } from '@repo/shadcn-vue/components/ui/button'
148+
import { cn } from '@repo/shadcn-vue/lib/utils'
149+
import { ArrowDownIcon } from 'lucide-vue-next'
150+
import { computed } from 'vue'
151+
import { useStickToBottomContext } from 'vue-stick-to-bottom'
152+
153+
interface Props {
154+
class?: HTMLAttributes['class']
155+
}
147156
148-
```ts
149-
import {
150-
Conversation,
151-
ConversationContent,
152-
ConversationScrollButton,
153-
} from '@/components/ai-elements/conversation'
157+
const props = defineProps<Props>()
158+
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
159+
const showScrollButton = computed(() => !isAtBottom.value)
160+
161+
function handleClick() {
162+
scrollToBottom()
163+
}
164+
</script>
165+
166+
<template>
167+
<Button
168+
v-if="showScrollButton"
169+
:class="cn('absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full', props.class)"
170+
size="icon"
171+
type="button"
172+
variant="outline"
173+
v-bind="$attrs"
174+
@click="handleClick"
175+
>
176+
<ArrowDownIcon class="size-4" />
177+
</Button>
178+
</template>
154179
```
155180

156-
```vue
157-
<Conversation class="relative w-full" style="height: 500px;">
158-
<ConversationContent>
159-
<Message from="user">
160-
<MessageContent>Hi there!</MessageContent>
161-
</Message>
162-
</ConversationContent>
163-
</Conversation>
181+
```ts [index.ts]
182+
export { default as Conversation } from './Conversation.vue'
183+
export { default as ConversationContent } from './ConversationContent.vue'
184+
export { default as ConversationEmptyState } from './ConversationEmptyState.vue'
185+
export { default as ConversationScrollButton } from './ConversationScrollButton.vue'
164186
```
187+
:::
165188

166189
## Usage with AI SDK
167190

168191
Build a simple conversational UI with `Conversation` and [`PromptInput`](/components/prompt-input):
169192

170193
Add the following component to your frontend:
171194

172-
```vue [pages/index.vue]
195+
```vue [pages/index.vue] height=300 collapse
173196
<script setup lang="ts">
174197
import { useChat } from '@ai-sdk/vue'
175198
import { ref } from 'vue'
@@ -234,22 +257,22 @@ function handleSubmit(e: Event) {
234257

235258
Add the following route to your backend:
236259

237-
```ts [api/chat/route.ts]
260+
```ts [server/api/chat/route.ts]
238261
import { convertToModelMessages, streamText, UIMessage } from 'ai'
239262

240263
// Allow streaming responses up to 30 seconds
241264
export const maxDuration = 30
242265

243-
export async function POST(req: Request) {
244-
const { messages }: { messages: UIMessage[] } = await req.json()
266+
export default defineEventHandler(async (event) => {
267+
const { messages }: { messages: UIMessage[] } = await readBody(event)
245268

246269
const result = streamText({
247270
model: 'openai/gpt-4o',
248-
messages: convertToModelMessages(messages),
271+
messages: convertToModelMessages(messages)
249272
})
250273

251-
return result.toUIMessageStreamResponse()
252-
}
274+
return result.toAIStreamResponse(event)
275+
})
253276
```
254277

255278
## Features
@@ -309,6 +332,22 @@ export async function POST(req: Request) {
309332
::
310333
:::
311334

335+
### `<ConversationEmptyState />`
336+
337+
:::field-group
338+
::field{name="title" type="string" defaultValue="'No messages yet'"}
339+
The title text to display.
340+
::
341+
342+
::field{name="description" type="string" defaultValue="'Start a conversation to see messages here'"}
343+
The description text to display.
344+
::
345+
346+
::field{name="class" type="string"}
347+
Additional classes applied to the empty state wrapper.
348+
::
349+
:::
350+
312351
### `<ConversationScrollButton />`
313352

314353
:::field-group

packages/elements/src/conversation/Conversation.vue

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { cn } from '@repo/shadcn-vue/lib/utils'
24
import { StickToBottom } from 'vue-stick-to-bottom'
3-
import ConversationScrollButton from './ConversationScrollButton.vue'
45
56
interface Props {
67
ariaLabel?: string
7-
class?: string
8+
class?: HTMLAttributes['class']
89
initial?: boolean | 'instant' | { damping?: number, stiffness?: number, mass?: number }
910
resize?: 'instant' | { damping?: number, stiffness?: number, mass?: number }
1011
damping?: number
@@ -26,8 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
2627
<template>
2728
<StickToBottom
2829
:aria-label="props.ariaLabel"
29-
class="relative flex-1"
30-
:class="[props.class]"
30+
:class="cn('relative flex-1 overflow-y-hidden', props.class)"
3131
role="log"
3232
:initial="props.initial"
3333
:resize="props.resize"
@@ -37,6 +37,5 @@ const props = withDefaults(defineProps<Props>(), {
3737
:anchor="props.anchor"
3838
>
3939
<slot />
40-
<ConversationScrollButton />
4140
</StickToBottom>
4241
</template>

packages/elements/src/conversation/ConversationContent.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
23
import { computed } from 'vue'
34
45
interface Props {
5-
class?: string
6+
class?: HTMLAttributes['class']
67
}
78
89
const props = defineProps<Props>()
910
1011
const classes = computed(() => [
11-
'p-4',
12+
'flex flex-col gap-8 p-4',
1213
props.class,
1314
])
1415
</script>

0 commit comments

Comments
 (0)