Compare commits

...
10 Commits
13 changed files with 680 additions and 123 deletions
+120
View File
@@ -0,0 +1,120 @@
import { type AgentState, type ReceivedMessage, useChat } from '@livekit/components-react'
import { Keyboard, Loader, Plus, SendHorizontal, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
import { Marker, MarkerContent } from '@/components/ui/marker'
import { Message, MessageContent, MessageFooter } from '@/components/ui/message'
import { formatTime } from '@/utils/datetime'
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" />
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="flex flex-row items-center justify-between border-b border-border">
<Button variant="ghost" size="icon" title="Delete conversation" className="text-muted-foreground">
<Trash2 className="size-5" />
</Button>
<DrawerTitle>Chat</DrawerTitle>
<Button variant="ghost" size="icon" title="New conversation" className="text-muted-foreground">
<Plus className="size-5" />
</Button>
</DrawerHeader>
<Conversation className="h-full overflow-y-auto">
<ConversationContent>
{messages.length > 0 &&
(() => {
return (
<Marker variant="separator">
<MarkerContent>Conversation started</MarkerContent>
</Marker>
)
})()}
{messages.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>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<DrawerFooter 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>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
+3 -2
View File
@@ -3,14 +3,15 @@ import { cn } from "@/lib/utils"
export interface JobListProps {
jobs: JobListItemProps[]
emptyLabel?: string
className?: string
}
export function JobList({ jobs, className }: JobListProps) {
export function JobList({ jobs, emptyLabel = "No running jobs.", className }: JobListProps) {
if (jobs.length === 0) {
return (
<div className={cn("flex flex-col gap-2", className)}>
<p className="text-sm text-muted-foreground">No running jobs.</p>
<p className="my-4 text-sm text-center text-muted-foreground">{emptyLabel ?? 'No jobs.'}</p>
</div>
)
}
+175 -12
View File
@@ -1,30 +1,193 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { XIcon } from "lucide-react"
import { Progress } from "@/components/ui/progress"
import { cn } from "@/lib/utils"
import { formatTime } from "@/utils/datetime"
import { formatElapsed, formatTime } from "@/utils/datetime"
const SWIPE_THRESHOLD = 100
const CANCEL_THRESHOLD = 150
export interface JobListItemProps {
title: string
runningTime: string
progress: number
startTime?: string
eta?: string
className?: string
onCancel?: () => void
}
export function JobListItem({ title, runningTime, progress, startTime, eta, className }: JobListItemProps) {
export function JobListItem({ title, progress, startTime, eta, className, onCancel }: JobListItemProps) {
const [displayTime, setDisplayTime] = useState(() =>
startTime ? formatElapsed(startTime) : 0,
)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Swipe state
const [offsetX, setOffsetX] = useState(0)
const [isSwiping, setIsSwiping] = useState(false)
const [isDismissing, setIsDismissing] = useState(false)
const startXRef = useRef(0)
const startYRef = useRef(0)
const currentOffsetRef = useRef(0)
const isTrackingRef = useRef(false)
const directionLockedRef = useRef<"horizontal" | "vertical" | null>(null)
useEffect(() => {
if (!startTime) return
intervalRef.current = setInterval(() => {
setDisplayTime(formatElapsed(startTime))
}, 1000)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [startTime])
const handleCancel = useCallback(() => {
if (onCancel) {
onCancel()
} else {
console.log(`Cancel job: ${title}`)
}
}, [onCancel, title])
const handlePointerDown = useCallback((e: React.PointerEvent) => {
// Only track primary pointer (left mouse / single touch)
if (e.button !== 0) return
startXRef.current = e.clientX
startYRef.current = e.clientY
isTrackingRef.current = true
directionLockedRef.current = null
currentOffsetRef.current = 0
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!isTrackingRef.current) return
const deltaX = e.clientX - startXRef.current
const deltaY = e.clientY - startYRef.current
// Determine direction lock on first significant movement
if (!directionLockedRef.current) {
const absDx = Math.abs(deltaX)
const absDy = Math.abs(deltaY)
if (absDx < 5 && absDy < 5) return // Dead zone
if (absDx > absDy) {
directionLockedRef.current = "horizontal"
setIsSwiping(true)
// Capture pointer to continue receiving events even outside element
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
} else {
directionLockedRef.current = "vertical"
isTrackingRef.current = false
return
}
}
if (directionLockedRef.current !== "horizontal") return
// Only allow swiping left (negative deltaX), with resistance for right swipe
const clampedOffset = deltaX > 0 ? deltaX * 0.2 : deltaX
currentOffsetRef.current = clampedOffset
setOffsetX(clampedOffset)
}, [])
const handlePointerUp = useCallback(() => {
if (!isTrackingRef.current && !isSwiping) return
isTrackingRef.current = false
const offset = currentOffsetRef.current
if (offset < -CANCEL_THRESHOLD) {
// Dismiss: animate off-screen then trigger cancel
setIsDismissing(true)
setOffsetX(-window.innerWidth)
setTimeout(() => {
handleCancel()
setIsDismissing(false)
setOffsetX(0)
setIsSwiping(false)
}, 300)
} else {
// Snap back
setOffsetX(0)
setTimeout(() => {
setIsSwiping(false)
}, 200)
}
}, [isSwiping, handleCancel])
const handlePointerCancel = useCallback(() => {
isTrackingRef.current = false
directionLockedRef.current = null
setOffsetX(0)
setIsSwiping(false)
}, [])
const swipeProgress = Math.min(Math.abs(offsetX) / SWIPE_THRESHOLD, 1)
const pastThreshold = Math.abs(offsetX) > SWIPE_THRESHOLD
return (
<div className={cn("flex flex-col gap-2 rounded-lg border p-3", className)}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{title}</span>
<span className="text-xs text-muted-foreground tabular-nums">{runningTime}</span>
<div
className={cn("relative overflow-hidden rounded-lg", className)}
style={{ touchAction: "pan-y" }}
>
{/* Background layer revealed on swipe */}
<div
className={cn(
"absolute inset-0 flex items-center justify-end rounded-lg px-4 transition-colors",
pastThreshold ? "bg-destructive" : "bg-destructive/60",
)}
style={{
opacity: swipeProgress,
}}
>
<XIcon
className={cn(
"size-5 text-white transition-transform",
pastThreshold && "scale-125",
)}
/>
</div>
<Progress value={progress} />
{(startTime || eta) && (
{/* Foreground card */}
<div
className={cn(
"relative flex flex-col gap-2 rounded-lg border bg-background p-3",
!isSwiping && !isDismissing && "transition-transform duration-200 ease-out",
isDismissing && "transition-transform duration-300 ease-in",
)}
style={{
transform: `translateX(${offsetX}px)`,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground tabular-nums">{startTime ? formatTime(startTime) : ""} started</span>
<span className="text-xs text-muted-foreground tabular-nums">{eta ? `ETA ${formatTime(eta)}` : ""}</span>
<span className="text-sm font-medium">{title}</span>
<button
type="button"
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive focus:ring-offset-2 transition-colors"
aria-label={`Cancel job: ${title}`}
onClick={handleCancel}
>
<XIcon className="size-4" />
</button>
</div>
)}
<Progress value={progress} />
{(startTime || eta) && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground tabular-nums">{displayTime}</span>
<span className="text-xs text-muted-foreground tabular-nums">{eta ? `ETA ${formatTime(eta)}` : ""}</span>
</div>
)}
</div>
</div>
)
}
+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,
}
+4 -4
View File
@@ -55,8 +55,8 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--primary: oklch(0.588 0.198 248.376);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
@@ -90,8 +90,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: oklch(0.588 0.198 248.376);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
+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
View File
@@ -1,5 +0,0 @@
[
{ "title": "Monitor Real-Time Stock Market Data", "runningTime": "2m 34s", "progress": 65, "startTime": "2026-06-30T20:48:00Z", "eta": "2026-06-30T20:52:00Z" },
{ "title": "Image Generation", "runningTime": "5m 12s", "progress": 30, "startTime": "2026-06-30T20:36:00Z", "eta": "2026-06-30T21:00:00Z" },
{ "title": "Process & Categorize Inbox Emails", "runningTime": "12m 47s", "progress": 88, "startTime": "2026-06-30T20:25:00Z", "eta": "2026-06-30T20:40:00Z" }
]
+22
View File
@@ -0,0 +1,22 @@
const now = new Date()
export default [
{
title: 'Monitor Real-Time Stock Market Data',
progress: 65,
startTime: new Date(now.getTime() - 100000).toISOString(),
eta: new Date(now.getTime() + 100000).toISOString(),
},
{
title: 'Image Generation',
progress: 30,
startTime: new Date(now.getTime() - 55000).toISOString(),
eta: new Date(now.getTime() + 300000).toISOString(),
},
{
title: 'Process & Categorize Inbox Emails',
progress: 88,
startTime: new Date(now.getTime() - 30000).toISOString(),
eta: new Date(now.getTime() + 30000).toISOString(),
},
]
+18 -2
View File
@@ -1,7 +1,23 @@
export function formatTime(iso: string, options?: Intl.DateTimeFormatOptions): string {
return new Date(iso).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour: '2-digit',
minute: '2-digit',
...options,
})
}
export function formatElapsed(startTime: string): string {
const elapsed = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000))
const days = Math.floor(elapsed / 86400)
const hours = Math.floor((elapsed % 86400) / 3600)
const minutes = Math.floor((elapsed % 3600) / 60)
const seconds = elapsed % 60
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
parts.push(`${seconds}s`)
return parts.join(' ')
}
+6 -94
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
@@ -17,41 +15,7 @@ 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])
// const { messages } = useSessionMessages(session)
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>
)
}
+9 -4
View File
@@ -1,10 +1,15 @@
import { JobList } from "@/components/JobList"
import mockJobs from "@/mocks/jobs.json"
import { JobList } from '@/components/JobList'
import { Marker, MarkerContent } from '@/components/ui/marker'
import mockJobs from '@/mocks/jobs'
export function JobsView() {
return (
<div className="flex flex-1 flex-col gap-1 mt-4 mb-4">
<JobList jobs={mockJobs} />
<div className="mt-4 mb-4 flex flex-1 flex-col gap-1">
<JobList jobs={mockJobs} emptyLabel="No running jobs." />
<Marker variant="separator" className="my-4">
<MarkerContent>Queue</MarkerContent>
</Marker>
<JobList jobs={[]} emptyLabel="No queued jobs." />
</div>
)
}