Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions apps/agentic-chat/src/components/AuroraBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ function isWebGLAvailable(): boolean {
}

function CSSFallback() {
// Approximates the WebGL Aurora's purple-to-green palette (colorA #7B2FBE,
// colorB #00CD98, colorC #A855F7) with layered radial gradients.
return (
<div
className="absolute inset-0 w-full h-full"
style={{
background:
'radial-gradient(ellipse 80% 60% at 50% 0%, oklch(0.45 0.2 290 / 0.35) 0%, transparent 70%), radial-gradient(ellipse 60% 40% at 30% 20%, oklch(0.55 0.18 160 / 0.2) 0%, transparent 60%)',
background: [
'radial-gradient(ellipse 110% 55% at 50% 0%, rgba(123, 47, 190, 0.55) 0%, rgba(123, 47, 190, 0.18) 38%, transparent 70%)',
'radial-gradient(ellipse 80% 60% at 50% 95%, rgba(0, 205, 152, 0.45) 0%, transparent 72%)',
'radial-gradient(ellipse 65% 50% at 22% 28%, rgba(168, 85, 247, 0.32) 0%, transparent 68%)',
'radial-gradient(ellipse 55% 45% at 82% 62%, rgba(0, 205, 152, 0.22) 0%, transparent 70%)',
].join(', '),
}}
/>
)
Expand Down Expand Up @@ -77,7 +83,26 @@ function AuroraCanvas({ onError }: { onError: () => void }) {
shader.destroy()
return
}
cleanup = () => shader.destroy()

// createShader pins the canvas to a fixed pixel size and watches the
// canvas itself for resizes — so CSS-driven layout changes (e.g. the
// sidebar opening/closing) never reach it. Observe the parent instead
// and resize explicitly.
const parent = canvas.parentElement
let resizeObserver: ResizeObserver | null = null
if (parent) {
resizeObserver = new ResizeObserver(([entry]) => {
if (!entry) return
const { width, height } = entry.contentRect
if (width > 0 && height > 0) shader.resize(width, height)
})
resizeObserver.observe(parent)
}

cleanup = () => {
resizeObserver?.disconnect()
shader.destroy()
}
})
.catch(err => {
console.error('[AuroraBackground] shader init failed, falling back to CSS:', err)
Expand Down
26 changes: 6 additions & 20 deletions apps/agentic-chat/src/components/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { AssistantMessage } from './AssistantMessage'
import { AuroraBackground } from './AuroraBackground'
import { Composer } from './Composer'
import { LoadingIndicator } from './LoadingIndicator'
import { Button } from './ui/Button'
import { PopularActionsCarousel } from './PopularActionsCarousel'
import { UserMessage } from './UserMessage'

const WELCOME_SUGGESTIONS = [
const POPULAR_ACTIONS = [
'Show my recent transaction activity',
'Create a stop loss for my ETH position',
'Swap half my USDC on Ethereum to FOX',
'What is my USDC balance on Arbitrum?',
'Swap half my USDC on arb to FOX',
'Give me some info about FOX on Arb',
]

Expand Down Expand Up @@ -117,23 +119,7 @@ export function Chat() {
</div>

{/* Suggestions above composer - only shown when empty */}
{isEmpty && (
<div className="bg-background/80 backdrop-blur-md border-t border-border">
<div className="mx-auto flex max-w-2xl gap-2 px-4 py-3">
{WELCOME_SUGGESTIONS.map((suggestion, index) => (
<Button
key={index}
onClick={() => handleSuggestionClick(suggestion)}
title={suggestion}
variant="outline"
className="flex-1 min-w-0 h-[52px] line-clamp-2 whitespace-normal"
>
{suggestion}
</Button>
))}
</div>
</div>
)}
{isEmpty && <PopularActionsCarousel actions={POPULAR_ACTIONS} onActionClick={handleSuggestionClick} />}

{/* Composer */}
<div className={isEmpty ? 'bg-background/80 backdrop-blur-md' : 'bg-background'}>
Expand Down
134 changes: 134 additions & 0 deletions apps/agentic-chat/src/components/PopularActionsCarousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from 'react'

import { Button } from './ui/Button'

type PopularActionsCarouselProps = {
actions: string[]
onActionClick: (action: string) => void
}

const AUTO_ADVANCE_MS = 5000

export function PopularActionsCarousel({ actions, onActionClick }: PopularActionsCarouselProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [isPaused, setIsPaused] = useState(false)
const pauseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rafRef = useRef<number | null>(null)

const stopResumeTimer = useCallback(() => {
if (!pauseTimeoutRef.current) return
clearTimeout(pauseTimeoutRef.current)
pauseTimeoutRef.current = null
}, [])

const resumeAfterInteraction = useCallback(() => {
stopResumeTimer()
pauseTimeoutRef.current = setTimeout(() => {
setIsPaused(false)
pauseTimeoutRef.current = null
}, 1200)
}, [stopResumeTimer])

const goToSlide = useCallback((index: number) => {
const container = containerRef.current
if (!container) return
const item = container.querySelector<HTMLButtonElement>(`[data-action-index="${index}"]`)
if (!item) return
item.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' })
setActiveIndex(index)
}, [])

const handleScroll = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)

rafRef.current = requestAnimationFrame(() => {
const container = containerRef.current
if (!container) return

const children = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-action-index]'))
if (children.length === 0) return

let closestIndex = 0
let closestDistance = Number.POSITIVE_INFINITY

children.forEach((child, index) => {
const distance = Math.abs(child.offsetLeft - container.scrollLeft)
if (distance < closestDistance) {
closestDistance = distance
closestIndex = index
}
})

setActiveIndex(closestIndex)
})
}, [])

useEffect(() => {
if (actions.length <= 1 || isPaused) return

const timer = setInterval(() => {
const nextIndex = (activeIndex + 1) % actions.length
goToSlide(nextIndex)
}, AUTO_ADVANCE_MS)

return () => clearInterval(timer)
}, [actions.length, activeIndex, goToSlide, isPaused])

useEffect(() => {
return () => {
stopResumeTimer()
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [stopResumeTimer])

return (
<div
className="bg-background/80 backdrop-blur-md border-t border-border"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onFocusCapture={() => setIsPaused(true)}
onBlurCapture={() => setIsPaused(false)}
>
<div className="mx-auto max-w-2xl px-4 py-3">
<div
ref={containerRef}
className="flex snap-x snap-mandatory gap-2 overflow-x-auto scroll-smooth pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onScroll={handleScroll}
onTouchStart={() => {
stopResumeTimer()
setIsPaused(true)
}}
onTouchEnd={resumeAfterInteraction}
onTouchCancel={resumeAfterInteraction}
role="region"
aria-label="Popular actions"
>
{actions.map((action, index) => (
<Button
key={action}
data-action-index={index}
onClick={() => onActionClick(action)}
title={action}
variant="outline"
className="h-[52px] w-[85%] shrink-0 snap-start whitespace-normal text-left leading-tight sm:w-[calc(50%-0.25rem)]"
>
{action}
</Button>
))}
</div>
<div className="mt-2 flex justify-center gap-1.5">
{actions.map((action, index) => (
<button
key={`dot-${action}`}
type="button"
onClick={() => goToSlide(index)}
aria-label={`Show action ${index + 1}`}
className={`h-1.5 w-1.5 rounded-full transition-colors ${index === activeIndex ? 'bg-foreground' : 'bg-muted-foreground/30'}`}
/>
))}
</div>
</div>
</div>
)
}
Loading