add Chat component

This commit is contained in:
bluemoehre
2026-07-01 01:56:21 +02:00
parent 68c4aaad11
commit 2f5736dbac
6 changed files with 462 additions and 93 deletions
+134
View File
@@ -0,0 +1,134 @@
import { useRef, useState, useEffect } from 'react'
import { type AgentState, type ReceivedMessage, useChat } from '@livekit/components-react'
import { Keyboard, Loader, SendHorizontal } from 'lucide-react'
import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'
import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation'
import { Button } from '@/components/ui/button'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Marker, MarkerContent } from '@/components/ui/marker'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
import chatMessages from '@/mocks/chat.json'
import { formatTime } from '@/utils/datetime'
import { Message, MessageContent, MessageFooter } from './ui/message'
interface ChatProps {
agentState: AgentState
messages: ReceivedMessage[]
}
export function Chat({ agentState, messages }: ChatProps) {
// const { send } = useChat({room:session.room})
const [isChatOpen, setChatOpen] = useState(false)
const [message, setMessage] = useState('')
const [isFormSubmitting, setFormSubmitting] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const isDisabled = isFormSubmitting || message.trim().length === 0
const handleSend = async () => {
if (isDisabled) return
try {
setFormSubmitting(true)
await send(message.trim())
setMessage('')
} catch (error) {
console.error(error)
} finally {
setFormSubmitting(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
useEffect(() => {
if (isChatOpen && inputRef.current) {
inputRef.current.focus()
}
}, [isChatOpen])
return (
<Drawer open={isChatOpen} onOpenChange={setChatOpen}>
<DrawerTrigger asChild>
<Button variant="outline" size="lg" className="fixed bottom-4 left-1/2 z-50 size-12 -translate-x-1/2">
<Keyboard className="size-6" />
{messages.length > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{messages.length}
</span>
)}
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex h-[50svh] w-full max-w-2xl flex-col">
<DrawerHeader>
<DrawerTitle>Chat</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-hidden">
{messages.length === 0 && chatMessages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No messages yet. Start a conversation!
</div>
) : (
<Conversation className="h-full">
<ConversationContent>
{chatMessages.length > 0 &&
(() => {
return (
<Marker variant="separator">
<MarkerContent>Conversation started</MarkerContent>
</Marker>
)
})()}
{chatMessages.map((msg) => (
<Message key={msg.id} align={msg.role === 'assistant' ? 'start' : 'end'}>
<MessageContent>
<Bubble variant={msg.role === 'assistant' ? 'muted' : 'default'}>
<BubbleContent>{msg.content}</BubbleContent>
</Bubble>
<MessageFooter>{formatTime(msg.timestamp)}</MessageFooter>
</MessageContent>
</Message>
))}
{messages.length > 0 && (
<AgentChatTranscript agentState={agentState} messages={messages} className="h-full" />
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
)}
</div>
<div className="border-t border-border p-4">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={message}
disabled={isFormSubmitting}
placeholder="Type a message..."
onKeyDown={handleKeyDown}
onChange={(e) => setMessage(e.target.value)}
className="field-sizing-content max-h-20 min-h-9 flex-1 resize-none [scrollbar-width:thin] rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:ring-2 focus:ring-ring focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
size="icon"
type="button"
disabled={isDisabled}
variant={isDisabled ? 'secondary' : 'default'}
title={isFormSubmitting ? 'Sending...' : 'Send'}
onClick={handleSend}
className="shrink-0"
>
{isFormSubmitting ? <Loader className="animate-spin" /> : <SendHorizontal />}
</Button>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
)
}
+128
View File
@@ -0,0 +1,128 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className
),
},
props
),
render,
state: {
slot: "bubble-content",
},
})
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
+71
View File
@@ -0,0 +1,71 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
}
)
function Marker({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(markerVariants({ variant, className })),
},
props
),
render,
state: {
slot: "marker",
variant,
},
})
}
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export { Marker, MarkerIcon, MarkerContent, markerVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
)
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
}
+32
View File
@@ -0,0 +1,32 @@
[
{
"id": 1,
"role": "user",
"content": "Hi Maia, can you help me find open positions in the engineering department?",
"timestamp": "2026-07-01T08:12:34Z"
},
{
"id": 2,
"role": "assistant",
"content": "Of course! I found 3 open positions in the engineering department: Senior Frontend Developer, Backend Engineer, and DevOps Specialist. Would you like more details on any of these?",
"timestamp": "2026-07-01T08:12:38Z"
},
{
"id": 3,
"role": "user",
"content": "Yes, tell me more about the Senior Frontend Developer role.",
"timestamp": "2026-07-01T08:13:05Z"
},
{
"id": 4,
"role": "assistant",
"content": "The Senior Frontend Developer position requires 5+ years of experience with React and TypeScript. It's a full-time role based in Berlin with hybrid work options. The application deadline is July 15, 2026. Would you like me to start the application process for you?",
"timestamp": "2026-07-01T08:13:09Z"
},
{
"id": 5,
"role": "user",
"content": "Not yet, but please add it to my saved jobs so I can review it later.",
"timestamp": "2026-07-01T08:14:22Z"
}
]
+5 -93
View File
@@ -1,12 +1,10 @@
import { useRef, useState, useEffect } from 'react'
import { useAgent, useChat, useSessionMessages, type UseSessionReturn } from '@livekit/components-react'
import { Keyboard, Loader, SendHorizontal } from 'lucide-react'
import { useAgent, useSessionMessages, type UseSessionReturn } from '@livekit/components-react'
import { AgentAudioVisualizerAura } from '@/components/agents-ui/agent-audio-visualizer-aura'
import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
import { AppBar } from '@/components/AppBar'
import { Chat } from '@/components/Chat'
import messages from '@/mocks/chat.json'
interface HomeViewProps {
session: UseSessionReturn
@@ -18,40 +16,6 @@ interface HomeViewProps {
export function HomeView({ session, onSettingsClick, onJobsClick, onNotificationsClick }: HomeViewProps) {
const { microphoneTrack, state: agentState } = useAgent(session)
const { messages } = useSessionMessages(session)
// const { send } = useChat({ room: session.room })
const [isChatOpen, setChatOpen] = useState(false)
const [message, setMessage] = useState('')
const [isFormSubmitting, setFormSubmitting] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const isDisabled = isFormSubmitting || message.trim().length === 0
const handleSend = async () => {
if (isDisabled) return
try {
setFormSubmitting(true)
await send(message.trim())
setMessage('')
} catch (error) {
console.error(error)
} finally {
setFormSubmitting(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
useEffect(() => {
if (isChatOpen && inputRef.current) {
inputRef.current.focus()
}
}, [isChatOpen])
return (
<div className="flex min-h-svh flex-col">
@@ -66,59 +30,7 @@ export function HomeView({ session, onSettingsClick, onJobsClick, onNotification
</div>
</div>
</div>
<Drawer open={isChatOpen} onOpenChange={setChatOpen}>
<DrawerTrigger asChild>
<Button variant="outline" size="lg" className="fixed bottom-4 left-1/2 z-50 size-12 -translate-x-1/2">
<Keyboard className="size-6" />
{messages.length > 0 && (
<span className="absolute -right-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{messages.length}
</span>
)}
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex h-[50svh] w-full max-w-2xl flex-col">
<DrawerHeader>
<DrawerTitle>Chat</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-hidden">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No messages yet. Start a conversation!
</div>
) : (
<AgentChatTranscript agentState={agentState} messages={messages} className="h-full" />
)}
</div>
<div className="border-t border-border p-4">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={message}
disabled={isFormSubmitting}
placeholder="Type a message..."
onKeyDown={handleKeyDown}
onChange={(e) => setMessage(e.target.value)}
className="field-sizing-content max-h-20 min-h-9 flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm [scrollbar-width:thin] placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
size="icon"
type="button"
disabled={isDisabled}
variant={isDisabled ? 'secondary' : 'default'}
title={isFormSubmitting ? 'Sending...' : 'Send'}
onClick={handleSend}
className="shrink-0"
>
{isFormSubmitting ? <Loader className="animate-spin" /> : <SendHorizontal />}
</Button>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
<Chat agentState={agentState} messages={messages} />
</div>
)
}