Skip to content

Commit 9da27d9

Browse files
committed
feat: add actions component
1 parent c0f4229 commit 9da27d9

File tree

12 files changed

+335
-10
lines changed

12 files changed

+335
-10
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script setup lang="ts">
2+
import { Action, Actions } from '@repo/elements/actions'
3+
import { Message, MessageContent } from '@repo/elements/message'
4+
import { Copy, Heart, RefreshCcw, Share, ThumbsDown, ThumbsUp } from 'lucide-vue-next'
5+
import { ref } from 'vue'
6+
7+
const liked = ref(false)
8+
const disliked = ref(false)
9+
const favorited = ref(false)
10+
11+
const responseContent = `This is a response from an assistant.
12+
13+
Try hovering over this message to see the actions appear!`
14+
15+
function handleRetry() {
16+
// eslint-disable-next-line no-console
17+
console.log('Retrying request...')
18+
}
19+
20+
function handleCopy(content?: string) {
21+
// eslint-disable-next-line no-console
22+
console.log('Copied:', content)
23+
}
24+
25+
function handleShare(content?: string) {
26+
// eslint-disable-next-line no-console
27+
console.log('Sharing:', content)
28+
}
29+
30+
const actions = [
31+
{ icon: RefreshCcw, label: 'Retry', onClick: handleRetry },
32+
{ icon: ThumbsUp, label: 'Like', onClick: () => (liked.value = !liked.value) },
33+
{ icon: ThumbsDown, label: 'Dislike', onClick: () => (disliked.value = !disliked.value) },
34+
{ icon: Copy, label: 'Copy', onClick: () => handleCopy(responseContent) },
35+
{ icon: Share, label: 'Share', onClick: () => handleShare(responseContent) },
36+
{ icon: Heart, label: 'Favorite', onClick: () => (favorited.value = !favorited.value) },
37+
]
38+
</script>
39+
40+
<template>
41+
<Message class="group flex flex-col items-start gap-2" from="assistant">
42+
<MessageContent>{{ responseContent }}</MessageContent>
43+
<Actions class="mt-2 opacity-0 group-hover:opacity-100">
44+
<Action v-for="action in actions" :key="action.label" :label="action.label" @click="action.onClick">
45+
<component :is="action.icon" class="size-3" />
46+
</Action>
47+
</Actions>
48+
</Message>
49+
</template>

apps/test/app/examples/actions.vue

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<script setup lang="ts">
2+
import { Action, Actions } from '@repo/elements/actions'
3+
import { Conversation, ConversationContent } from '@repo/elements/conversation'
4+
import { Message, MessageContent } from '@repo/elements/message'
5+
import { Copy, RefreshCcw, Share, ThumbsDown, ThumbsUp } from 'lucide-vue-next'
6+
import { nanoid } from 'nanoid'
7+
import { ref } from 'vue'
8+
9+
interface MsgItem {
10+
key: string
11+
from: 'user' | 'assistant'
12+
content: string
13+
avatar: string
14+
name: string
15+
}
16+
17+
const messages: MsgItem[] = [
18+
{
19+
key: nanoid(),
20+
from: 'user',
21+
content: 'Hello, how are you?',
22+
avatar: 'https://github.com/haydenbleasel.png',
23+
name: 'Hayden Bleasel',
24+
},
25+
{
26+
key: nanoid(),
27+
from: 'assistant',
28+
content: 'I am fine, thank you!',
29+
avatar: 'https://github.com/openai.png',
30+
name: 'OpenAI',
31+
},
32+
]
33+
34+
const liked = ref(false)
35+
const disliked = ref(false)
36+
37+
function handleRetry() {}
38+
function handleCopy() {}
39+
function handleShare() {}
40+
41+
const actions = [
42+
{ icon: RefreshCcw, label: 'Retry', onClick: handleRetry },
43+
{ icon: ThumbsUp, label: 'Like', onClick: () => (liked.value = !liked.value) },
44+
{ icon: ThumbsDown, label: 'Dislike', onClick: () => (disliked.value = !disliked.value) },
45+
{ icon: Copy, label: 'Copy', onClick: () => handleCopy() },
46+
{ icon: Share, label: 'Share', onClick: () => handleShare() },
47+
]
48+
</script>
49+
50+
<template>
51+
<Conversation class="relative w-full">
52+
<ConversationContent>
53+
<Message
54+
v-for="message in messages"
55+
:key="message.key"
56+
:from="message.from"
57+
class="flex flex-col gap-2"
58+
:class="message.from === 'assistant' ? 'items-start' : 'items-end'"
59+
>
60+
<MessageContent>{{ message.content }}</MessageContent>
61+
<Actions v-if="message.from === 'assistant'" class="mt-2">
62+
<Action v-for="action in actions" :key="action.label" :label="action.label" @click="action.onClick">
63+
<component :is="action.icon" class="size-4" />
64+
</Action>
65+
</Actions>
66+
</Message>
67+
</ConversationContent>
68+
</Conversation>
69+
</template>

apps/test/app/pages/index.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script setup lang="ts">
22
import { Card, CardContent, CardHeader, CardTitle } from '@repo/shadcn-vue/components/ui/card'
3+
import ActionsHover from '~/examples/actions-hover.vue'
4+
import Actions from '~/examples/actions.vue'
35
import Conversation from '~/examples/conversation.vue'
46
import MessageMarkdown from '~/examples/message-markdown.vue'
57
import Message from '~/examples/message.vue'
@@ -8,6 +10,8 @@ import Response from '~/examples/response.vue'
810
911
const components = [
1012
{ name: 'Message', Component: Message },
13+
{ name: 'Actions', Component: Actions },
14+
{ name: 'ActionsHover', Component: ActionsHover },
1115
{ name: 'PromptInput', Component: PromptInput },
1216
{ name: 'Conversation', Component: Conversation },
1317
{ name: 'Response', Component: Response },
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script setup lang="ts">
2+
import { Button } from '@repo/shadcn-vue/components/ui/button'
3+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@repo/shadcn-vue/components/ui/tooltip'
4+
import { cn } from '@repo/shadcn-vue/lib/utils'
5+
import { computed, useAttrs } from 'vue'
6+
7+
interface Props {
8+
class?: string
9+
tooltip?: string
10+
label?: string
11+
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
12+
size?: 'default' | 'sm' | 'lg' | 'icon'
13+
}
14+
15+
const props = withDefaults(defineProps<Props>(), {
16+
variant: 'ghost',
17+
size: 'sm',
18+
})
19+
20+
const attrs = useAttrs()
21+
22+
const classes = computed(() => cn('relative size-9 p-1.5 text-muted-foreground hover:text-foreground', props.class))
23+
</script>
24+
25+
<template>
26+
<TooltipProvider v-if="props.tooltip">
27+
<Tooltip>
28+
<TooltipTrigger as-child>
29+
<Button
30+
:class="classes"
31+
:size="props.size"
32+
:variant="props.variant"
33+
type="button"
34+
v-bind="attrs"
35+
>
36+
<slot />
37+
<span class="sr-only">{{ props.label || props.tooltip }}</span>
38+
</Button>
39+
</TooltipTrigger>
40+
<TooltipContent>
41+
<p>{{ props.tooltip }}</p>
42+
</TooltipContent>
43+
</Tooltip>
44+
</TooltipProvider>
45+
46+
<Button
47+
v-else
48+
:class="classes"
49+
:size="props.size"
50+
:variant="props.variant"
51+
type="button"
52+
v-bind="attrs"
53+
>
54+
<slot />
55+
<span class="sr-only">{{ props.label || props.tooltip }}</span>
56+
</Button>
57+
</template>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script setup lang="ts">
2+
import { cn } from '@repo/shadcn-vue/lib/utils'
3+
import { computed, useAttrs } from 'vue'
4+
5+
interface Props {
6+
class?: string
7+
}
8+
9+
const props = defineProps<Props>()
10+
const attrs = useAttrs()
11+
12+
const classes = computed(() => cn('flex items-center gap-1', props.class))
13+
</script>
14+
15+
<template>
16+
<div :class="classes" v-bind="attrs">
17+
<slot />
18+
</div>
19+
</template>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as Action } from './Action.vue'
2+
export { default as Actions } from './Actions.vue'

packages/elements/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './actions'
12
export * from './conversation'
23
export * from './message'
34
export * from './prompt-input'
Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
<script setup lang="ts">
2+
import { cn } from '@repo/shadcn-vue/lib/utils'
3+
import { computed } from 'vue'
4+
25
interface Props {
36
from: 'user' | 'assistant'
47
class?: string
58
}
69
710
const props = defineProps<Props>()
11+
12+
const classes = computed(() => cn(
13+
'group w-full py-4',
14+
props.from === 'user'
15+
? 'is-user flex items-end justify-end gap-2'
16+
: 'is-assistant flex flex-row-reverse justify-end gap-2',
17+
props.class,
18+
))
819
</script>
920

1021
<template>
11-
<div
12-
class="group flex w-full items-end justify-end gap-2 py-4 [&>div]:max-w-[80%]"
13-
:class="[
14-
props.from === 'user'
15-
? 'is-user'
16-
: 'is-assistant flex-row-reverse justify-end',
17-
props.class,
18-
]"
19-
>
22+
<div :class="classes">
2023
<slot />
2124
</div>
2225
</template>

packages/elements/src/message/MessageContent.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ interface Props {
88
const props = defineProps<Props>()
99
1010
const classes = computed(() => [
11-
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
11+
'max-w-[80%] flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
1212
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
1313
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
1414
'is-user:dark',
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script setup lang="ts">
2+
import { Action, Actions } from '@repo/elements/actions'
3+
import { Message, MessageContent } from '@repo/elements/message'
4+
import { Copy, Heart, RefreshCcw, Share, ThumbsDown, ThumbsUp } from 'lucide-vue-next'
5+
import { ref } from 'vue'
6+
7+
const liked = ref(false)
8+
const disliked = ref(false)
9+
const favorited = ref(false)
10+
11+
const responseContent = `This is a response from an assistant.
12+
13+
Try hovering over this message to see the actions appear!`
14+
15+
function handleRetry() {
16+
// eslint-disable-next-line no-console
17+
console.log('Retrying request...')
18+
}
19+
20+
function handleCopy(content?: string) {
21+
// eslint-disable-next-line no-console
22+
console.log('Copied:', content)
23+
}
24+
25+
function handleShare(content?: string) {
26+
// eslint-disable-next-line no-console
27+
console.log('Sharing:', content)
28+
}
29+
30+
const actions = [
31+
{ icon: RefreshCcw, label: 'Retry', onClick: handleRetry },
32+
{ icon: ThumbsUp, label: 'Like', onClick: () => (liked.value = !liked.value) },
33+
{ icon: ThumbsDown, label: 'Dislike', onClick: () => (disliked.value = !disliked.value) },
34+
{ icon: Copy, label: 'Copy', onClick: () => handleCopy(responseContent) },
35+
{ icon: Share, label: 'Share', onClick: () => handleShare(responseContent) },
36+
{ icon: Heart, label: 'Favorite', onClick: () => (favorited.value = !favorited.value) },
37+
]
38+
</script>
39+
40+
<template>
41+
<Message class="group flex flex-col items-start gap-2" from="assistant">
42+
<MessageContent>{{ responseContent }}</MessageContent>
43+
<Actions class="mt-2 opacity-0 group-hover:opacity-100">
44+
<Action v-for="action in actions" :key="action.label" :label="action.label" @click="action.onClick">
45+
<component :is="action.icon" class="size-3" />
46+
</Action>
47+
</Actions>
48+
</Message>
49+
</template>

0 commit comments

Comments
 (0)