diff --git a/.gitignore b/.gitignore index 4684df71..1f21bf12 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ test-output *.db* .mastra +# NX cache +.nx/ + # Vite cache node_modules/.vite **/.vite @@ -62,3 +65,7 @@ Thumbs.db # Playwright MCP .playwright-mcp + +# Nx — local machine cache (agents/CI should never commit these) +.nx/cache +.nx/workspace-data diff --git a/apps/agentic-chat/package.json b/apps/agentic-chat/package.json index 90dd628b..13537118 100644 --- a/apps/agentic-chat/package.json +++ b/apps/agentic-chat/package.json @@ -55,8 +55,10 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "shaders": "^2.5.109", "sonner": "^2.0.7", "tailwind-merge": "^3.2.0", + "three": "^0.184.0", "viem": "*", "wagmi": "^2.15.6", "zod": "*", diff --git a/apps/agentic-chat/src/app/dashboard/page.tsx b/apps/agentic-chat/src/app/dashboard/page.tsx index db3e1a0e..d2886e3e 100644 --- a/apps/agentic-chat/src/app/dashboard/page.tsx +++ b/apps/agentic-chat/src/app/dashboard/page.tsx @@ -13,7 +13,7 @@ export const Dashboard = () => { {isSidebarLeftEnabled && } -
+
{isSidebarLeftEnabled && }
diff --git a/apps/agentic-chat/src/components/AuroraBackground.tsx b/apps/agentic-chat/src/components/AuroraBackground.tsx new file mode 100644 index 00000000..f3da6d00 --- /dev/null +++ b/apps/agentic-chat/src/components/AuroraBackground.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef, useState } from 'react' + +import { useIsMobile } from '@/hooks/use-mobile' + +let cachedWebGLAvailable: boolean | null = null +function isWebGLAvailable(): boolean { + if (cachedWebGLAvailable !== null) return cachedWebGLAvailable + try { + const canvas = document.createElement('canvas') + const ctx = window.WebGLRenderingContext && (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) + if (ctx) { + // Release the test context immediately so we don't exhaust the browser's limit + const ext = (ctx as WebGLRenderingContext).getExtension('WEBGL_lose_context') + ext?.loseContext() + } + cachedWebGLAvailable = !!ctx + } catch { + cachedWebGLAvailable = false + } + return cachedWebGLAvailable +} + +function CSSFallback() { + // Approximates the WebGL Aurora's purple-to-green palette (colorA #7B2FBE, + // colorB #00CD98, colorC #A855F7) with layered radial gradients. + return ( +
+ ) +} + +function AuroraCanvas({ onError }: { onError: () => void }) { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + let cleanup: (() => void) | null = null + let cancelled = false + + import('shaders/js') + .then(({ createShader }) => { + if (cancelled) return Promise.resolve(null) + return createShader( + canvas, + { + components: [ + { + id: 'aurora', + type: 'Aurora', + props: { + colorA: '#7B2FBE', + colorB: '#00CD98', + colorC: '#A855F7', + speed: 3.5, + waviness: 70, + intensity: 90, + curtainCount: 4, + rayDensity: 25, + height: 150, + balance: 40, + colorSpace: 'linear', + }, + }, + ], + }, + { disableTelemetry: true } + ) + }) + .then(shader => { + if (!shader) return + if (cancelled) { + shader.destroy() + return + } + + // 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) + cleanup?.() + cleanup = null + if (!cancelled) onError() + }) + + return () => { + cancelled = true + cleanup?.() + } + }, [onError]) + + return +} + +export function AuroraBackground() { + const isMobile = useIsMobile() + const [shaderFailed, setShaderFailed] = useState(false) + + if (isMobile || !isWebGLAvailable() || shaderFailed) { + return + } + + return setShaderFailed(true)} /> +} diff --git a/apps/agentic-chat/src/components/Chat.tsx b/apps/agentic-chat/src/components/Chat.tsx index 5c1e1e75..c03e5d00 100644 --- a/apps/agentic-chat/src/components/Chat.tsx +++ b/apps/agentic-chat/src/components/Chat.tsx @@ -6,14 +6,17 @@ import { useStreamPauseDetector } from '../hooks/useStreamPauseDetector' import { useChatContext } from '../providers/ChatProvider' 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', ] @@ -93,8 +96,9 @@ export function Chat() { {/* Messages viewport */}
{isEmpty ? ( -
-
How can I help you today?
+
+ +
How can I help you today?
) : ( {/* Suggestions above composer - only shown when empty */} - {isEmpty && ( -
-
- {WELCOME_SUGGESTIONS.map((suggestion, index) => ( - - ))} -
-
- )} + {isEmpty && } {/* Composer */} -
+
diff --git a/apps/agentic-chat/src/components/Composer.tsx b/apps/agentic-chat/src/components/Composer.tsx index 1b9988b6..5301e787 100644 --- a/apps/agentic-chat/src/components/Composer.tsx +++ b/apps/agentic-chat/src/components/Composer.tsx @@ -55,7 +55,7 @@ export function Composer() { onKeyDown={onKeyDown} placeholder="Write a message..." rows={1} - className="flex-1 resize-none rounded-lg border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + className="flex-1 resize-none rounded-2xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring" style={ { minHeight: '48px', diff --git a/apps/agentic-chat/src/components/PopularActionsCarousel.tsx b/apps/agentic-chat/src/components/PopularActionsCarousel.tsx new file mode 100644 index 00000000..4ffe82c5 --- /dev/null +++ b/apps/agentic-chat/src/components/PopularActionsCarousel.tsx @@ -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(null) + const [activeIndex, setActiveIndex] = useState(0) + const [isPaused, setIsPaused] = useState(false) + const pauseTimeoutRef = useRef | null>(null) + const rafRef = useRef(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(`[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('[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 ( +
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + onFocusCapture={() => setIsPaused(true)} + onBlurCapture={() => setIsPaused(false)} + > +
+
{ + stopResumeTimer() + setIsPaused(true) + }} + onTouchEnd={resumeAfterInteraction} + onTouchCancel={resumeAfterInteraction} + role="region" + aria-label="Popular actions" + > + {actions.map((action, index) => ( + + ))} +
+
+ {actions.map((action, index) => ( +
+
+
+ ) +} diff --git a/apps/agentic-chat/src/styles.css b/apps/agentic-chat/src/styles.css index 56e15b26..341611f4 100644 --- a/apps/agentic-chat/src/styles.css +++ b/apps/agentic-chat/src/styles.css @@ -72,7 +72,7 @@ } :root { - --radius: 0.5rem; + --radius: 0.75rem; --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); diff --git a/apps/agentic-chat/vite.config.mjs b/apps/agentic-chat/vite.config.mjs index 7476d3ff..ba44552e 100644 --- a/apps/agentic-chat/vite.config.mjs +++ b/apps/agentic-chat/vite.config.mjs @@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; export default defineConfig(() => ({ plugins: [react(), tailwindcss()], + optimizeDeps: { + include: ['three'], + }, envDir: resolve(__dirname, '../..'), cacheDir: resolve(__dirname, '../../node_modules/.vite/agentic-chat'), define: { diff --git a/apps/agentic-chat/vite.config.mts b/apps/agentic-chat/vite.config.mts index 38e2d2f6..3a6650ba 100644 --- a/apps/agentic-chat/vite.config.mts +++ b/apps/agentic-chat/vite.config.mts @@ -6,6 +6,9 @@ import { defineConfig } from 'vite' export default defineConfig(() => ({ plugins: [react(), tailwindcss()], + optimizeDeps: { + include: ['three'], + }, envDir: resolve(__dirname, '../..'), cacheDir: resolve(__dirname, '../../node_modules/.vite/agentic-chat'), define: { diff --git a/bun.lock b/bun.lock index aab5c6fb..f278590d 100644 --- a/bun.lock +++ b/bun.lock @@ -85,8 +85,10 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "shaders": "^2.5.109", "sonner": "^2.0.7", "tailwind-merge": "^3.2.0", + "three": "^0.184.0", "viem": "*", "wagmi": "^2.15.6", "zod": "*", @@ -2158,6 +2160,8 @@ "sha256-uint8array": ["sha256-uint8array@0.10.7", "", {}, "sha512-1Q6JQU4tX9NqsDGodej6pkrUVQVNapLZnvkwIhddH/JqzBZF1fSaxSWNY6sziXBE8aEa2twtGkXUrwzGeZCMpQ=="], + "shaders": ["shaders@2.5.109", "", { "dependencies": { "three": "^0.184.0" }, "peerDependencies": { "pixi.js": "^8.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.8.0", "svelte": "^5", "vue": "^3.5.0" }, "optionalPeers": ["pixi.js", "react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-7lrVAAAxVDzR0P+XgWgs9PlAAZQtLGrxvV0jaV4edkUIM//6/oEOQQP9fxYYJa6ERFobsRnZ43ejcFOn1Etxsw=="], + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -2264,6 +2268,8 @@ "thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="], + "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="], + "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],