Compare commits
10
Commits
3fe5af4a31
...
a98d83a6a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a98d83a6a8 | ||
|
|
bd10c134bc | ||
|
|
e123347cad | ||
|
|
86f91b0242 | ||
|
|
8f1f724c53 | ||
|
|
f658f77b57 | ||
|
|
fab61d2bae | ||
|
|
fbbfb738f4 | ||
|
|
2f5736dbac | ||
|
|
68c4aaad11 |
@@ -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,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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user