-
- {/* Left side content */}
-
-
- Ready to build AI faster and easier?
-
-
-
-
-
-
- {/* Right side content */}
-
- {/* See repo button positioned absolutely to align with the top text - desktop only */}
-
-
-
-
-
-
- {/* Links section - flex row on mobile, part of flex row in md */}
-
-
-
- Docs
-
-
- Contributors
-
-
-
-
- Terms and Conditions
-
-
- Privacy Policy
-
-
-
-
- {/* Social icons */}
-
-
+ return (
+
+
+
+
+
+
+
+
+
+
+
-
- )
- }
-
- return (
-
-
-
- {/* Left side content */}
-
-
+
+
- Ready to build AI faster and easier?
-
-
+
-
-
+ Contributors
+
-
- {/* Right side content */}
-
- {/* See repo button positioned absolutely to align with the top text - desktop only */}
-
+
-
-
-
-
-
- {/* Links section - flex row on mobile, part of flex row in md */}
-
-
-
- Docs
-
-
- Contributors
-
-
-
-
- Terms and Conditions
-
-
- Privacy Policy
-
-
-
-
- {/* Social icons */}
-
+
-
-
-
-
-
-
+ Terms of Service
+
+
+
+
+
+
+ © {new Date().getFullYear()} Sim. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/apps/sim/app/(landing)/components/sections/hero.tsx b/apps/sim/app/(landing)/components/sections/hero.tsx
index 02e556b4a4d..bd2b43c3753 100644
--- a/apps/sim/app/(landing)/components/sections/hero.tsx
+++ b/apps/sim/app/(landing)/components/sections/hero.tsx
@@ -1,152 +1,1312 @@
'use client'
-import { useEffect, useState } from 'react'
-import { Command, CornerDownLeft } from 'lucide-react'
-import { useRouter } from 'next/navigation'
+import React from 'react'
+import { gsap } from 'gsap'
+import {
+ ArrowUp,
+ BinaryIcon,
+ BookIcon,
+ BotIcon,
+ BoxesIcon,
+ CalendarIcon,
+ Check,
+ ChevronDownIcon,
+ ChevronUpIcon,
+ HammerIcon,
+ KeyIcon,
+ LayersIcon,
+ Mail,
+ MessageSquare,
+ Mic,
+ User,
+ VariableIcon,
+} from 'lucide-react'
+// import Image from 'next/image'
+import Link from 'next/link'
+import ReactFlow, {
+ BaseEdge,
+ type Edge,
+ type EdgeProps,
+ getSmoothStepPath,
+ Handle,
+ type Node,
+ Position,
+ ReactFlowProvider,
+ useReactFlow,
+} from 'reactflow'
+import { GmailIcon, SlackIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
-import { useSession } from '@/lib/auth-client'
-import { GridPattern } from '@/app/(landing)/components/grid-pattern'
-import HeroWorkflowProvider from '@/app/(landing)/components/hero-workflow'
+import { Textarea } from '@/components/ui/textarea'
+import { DotPattern } from '../dot-pattern'
+import 'reactflow/dist/style.css'
+
+// Visual constants for workflow nodes
+const CARD_WIDTH = 256
+const CARD_HEIGHT = 92
+// Public viewport control surface used by the landing timeline
+type LandingViewportApi = {
+ panTo: (x: number, y: number, options?: { duration?: number }) => void
+ getViewport: () => { x: number; y: number; zoom: number }
+}
+
+// Lazy-register plugins only on client
+if (typeof window !== 'undefined') {
+ // TextPlugin is bundled under gsap/all in ESM, but we can simulate typing without it.
+}
+
+const FireIcon = () => {
+ return
🔥
+}
+
+const AGENT_OPTIONS = [
+ { value: 'web-scrape', label: 'Web Scrape Agent', icon: FireIcon },
+ { value: 'gmail', label: 'Gmail Agent', icon: GmailIcon },
+ { value: 'leadgen', label: 'LeadGen Agent', icon: User },
+ { value: 'slack', label: 'Slack Agent', icon: SlackIcon },
+ { value: 'task', label: 'Task Agent', icon: Check },
+] as const
+
+const MODES = [
+ { value: 'agent', label: 'Agent', icon:
},
+ { value: 'ask', label: 'Ask', icon:
},
+] as const
function Hero() {
- const router = useRouter()
- const [isTransitioning, setIsTransitioning] = useState(true)
- const { data: session, isPending } = useSession()
- const isAuthenticated = !isPending && !!session?.user
+ // Build an immediate, non-blank preview for first paint (desktop positions fallback)
+ function buildInitialPreview(): { nodes: Node[]; edges: Edge[]; worldW: number } {
+ const pad = 16
+ const blocks: WorkflowManualBlock[] = [
+ {
+ id: 'start',
+ name: 'Start',
+ color: '#30B2FF',
+ icon:
,
+ positions: {
+ mobile: { x: 24, y: 120 },
+ tablet: { x: 60, y: 180 },
+ desktop: { x: 80, y: 241 },
+ },
+ tags: [
+ { icon:
, label: 'When: Call Received' },
+ { icon:
, label: '3 fields' },
+ ],
+ },
+ {
+ id: 'kb',
+ name: 'Knowledge Base',
+ color: '#01B0B0',
+ icon:
,
+ positions: {
+ mobile: { x: 120, y: 140 },
+ tablet: { x: 220, y: 200 },
+ desktop: { x: 420, y: 241 },
+ },
+ tags: [
+ { icon:
, label: 'Product Info' },
+ { icon:
, label: 'Limit: 10' },
+ ],
+ },
+ {
+ id: 'reason',
+ name: 'Agent',
+ color: '#802FFF',
+ icon:
,
+ positions: {
+ mobile: { x: 260, y: 80 },
+ tablet: { x: 400, y: 120 },
+ desktop: { x: 760, y: 60 },
+ },
+ tags: [
+ { icon:
, label: 'Reasoning' },
+ { icon:
, label: 'gpt-5' },
+ { icon:
, label: '2 tools' },
+ ],
+ },
+ {
+ id: 'reply',
+ name: 'Agent',
+ color: '#802FFF',
+ icon:
,
+ positions: {
+ mobile: { x: 400, y: 180 },
+ tablet: { x: 600, y: 220 },
+ desktop: { x: 760, y: 241 },
+ },
+ tags: [
+ { icon:
, label: 'Generate Reply' },
+ { icon:
, label: 'gpt-5' },
+ ],
+ },
+ {
+ id: 'tts',
+ name: 'Text-to-Speech',
+ color: '#FFB300',
+ icon:
,
+ positions: {
+ mobile: { x: 560, y: 120 },
+ tablet: { x: 800, y: 160 },
+ desktop: { x: 760, y: 400 },
+ },
+ },
+ ]
+ const edgesSpec: WorkflowEdgeData[] = [
+ { id: 'e1', from: 'start', to: 'kb' },
+ { id: 'e2', from: 'kb', to: 'reason' },
+ { id: 'e3', from: 'reason', to: 'reply' },
+ { id: 'e4', from: 'reply', to: 'tts' },
+ ]
+ const bp = 'desktop' as const
+ const nodesBase = blocks.map((b) => {
+ const pos = b.positions[bp]
+ const nx = Math.max(pad, pos.x)
+ const ny = Math.max(pad, pos.y)
+ return { id: b.id, x: nx, y: ny, name: b.name, color: b.color, icon: b.icon, tags: b.tags }
+ }) as WorkflowBlockNode[]
+ const maxRight = nodesBase.reduce((m, n) => Math.max(m, n.x), 0)
+ const worldW = maxRight + CARD_WIDTH + pad
+ const ordered = [...nodesBase].sort((a, b) => a.x - b.x || a.y - b.y)
+ const idToDelay = new Map
()
+ ordered.forEach((n, i) => idToDelay.set(n.id, i * 0.18))
+ const nodes: Node[] = nodesBase.map((b) => ({
+ id: b.id,
+ type: 'landing',
+ position: { x: b.x, y: b.y },
+ data: {
+ icon: b.icon,
+ color: b.color,
+ name: b.name,
+ tags: b.tags,
+ delay: idToDelay.get(b.id) ?? 0,
+ instant: true,
+ },
+ draggable: false,
+ selectable: false,
+ sourcePosition: Position.Right,
+ targetPosition: Position.Left,
+ }))
+ const edges: Edge[] = edgesSpec.map((e) => ({
+ id: e.id,
+ source: e.from,
+ target: e.to,
+ type: 'landingEdge',
+ animated: false,
+ data: { delay: 0, instant: true },
+ style: { strokeDasharray: '6 6', strokeWidth: 2, stroke: '#E1E1E1', opacity: 1 },
+ }))
+ return { nodes, edges, worldW }
+ }
+
+ const initialPreview = buildInitialPreview()
- const handleNavigate = () => {
+ const [prompt, setPrompt] = React.useState('')
+ const [mode, setMode] = React.useState<'agent' | 'ask'>('agent')
+ const [isRunning, setIsRunning] = React.useState(false)
+ // React Flow data
+ const [rfNodes, setRfNodes] = React.useState(() => initialPreview.nodes)
+ const [rfEdges, setRfEdges] = React.useState(() => initialPreview.edges)
+ const [groupBox, setGroupBox] = React.useState(null)
+ const [autoPlay, setAutoPlay] = React.useState(true)
+ const [isCondensed, setIsCondensed] = React.useState(false)
+ const [worldWidth, setWorldWidth] = React.useState(() => initialPreview.worldW)
+ const [isMobile, setIsMobile] = React.useState(false)
+
+ const textareaRef = React.useRef(null)
+ const textareaWrapRef = React.useRef(null)
+ const textareaSizerRef = React.useRef(null)
+ const sendBtnRef = React.useRef(null)
+ const modeTagRef = React.useRef(null)
+ const flowWrapRef = React.useRef(null)
+ const previewRef = React.useRef(null)
+ const tlRef = React.useRef(null)
+ const pulseRef = React.useRef(null)
+ type LandingViewportApi = {
+ panTo: (x: number, y: number, options?: { duration?: number }) => void
+ getViewport: () => { x: number; y: number; zoom: number }
+ }
+ const viewportApiRef = React.useRef(null)
+
+ // Animation timings (ms)
+ const COLLAPSE_MS = 2200
+ const EXPAND_MS = 600
+
+ // Fit the textarea height to its content and return the resulting height in px
+ const ensureTextareaAutosize = React.useCallback(() => {
+ const ta = textareaRef.current
+ if (!ta) return 0
+ ta.style.height = 'auto'
+ const style = window.getComputedStyle(ta)
+ const borderV =
+ Number.parseFloat(style.borderTopWidth || '0') +
+ Number.parseFloat(style.borderBottomWidth || '0')
+ const contentHeight = Math.ceil(ta.scrollHeight + borderV)
+ ta.style.height = `${contentHeight}px`
+ return contentHeight
+ }, [])
+
+ // Compute the textarea's ideal content height without mutating its current height
+ const computeTextareaContentHeight = React.useCallback(() => {
+ const ta = textareaRef.current
+ if (!ta) return 0
+ const prev = ta.style.height
+ ta.style.height = 'auto'
+ const style = window.getComputedStyle(ta)
+ const borderV =
+ Number.parseFloat(style.borderTopWidth || '0') +
+ Number.parseFloat(style.borderBottomWidth || '0')
+ const h = Math.ceil(ta.scrollHeight + borderV)
+ ta.style.height = prev
+ return h
+ }, [])
+
+ // Animate textarea height to a target with easing; cleans up inline transition styles
+ const animateTextareaHeightTo = React.useCallback(
+ (targetPx: number, durationMs: number, easing: string) => {
+ const ta = textareaRef.current
+ if (!ta) return
+ const startH = ta.getBoundingClientRect().height
+ ta.style.height = `${startH}px`
+ ta.style.overflow = 'hidden'
+ ta.style.willChange = 'height'
+ ta.style.transition = `height ${durationMs}ms ${easing}`
+ void ta.getBoundingClientRect().height
+ requestAnimationFrame(() => {
+ ta.style.height = `${targetPx}px`
+ })
+ const cleanup = () => {
+ ta.style.removeProperty('overflow')
+ ta.style.removeProperty('will-change')
+ ta.style.removeProperty('transition')
+ ta.removeEventListener('transitionend', cleanup)
+ }
+ ta.addEventListener('transitionend', cleanup, { once: true })
+ window.setTimeout(cleanup, durationMs + 120)
+ },
+ []
+ )
+
+ const handleSend = React.useCallback(() => {
+ const base = '/signup'
+ const url =
+ prompt && prompt.trim().length > 0
+ ? `${base}?prompt=${encodeURIComponent(prompt.trim())}`
+ : base
if (typeof window !== 'undefined') {
- // Check if user has an active session
- if (isAuthenticated) {
- router.push('/workspace')
- } else {
- // Check if user has logged in before
- const hasLoggedInBefore =
- localStorage.getItem('has_logged_in_before') === 'true' ||
- document.cookie.includes('has_logged_in_before=true')
-
- if (hasLoggedInBefore) {
- // User has logged in before but doesn't have an active session
- router.push('/login')
- } else {
- // User has never logged in before
- router.push('/signup')
+ window.open(url, '_blank')
+ }
+ }, [prompt])
+
+ const stopAuto = React.useCallback(() => {
+ setAutoPlay(false)
+ setIsRunning(false)
+ setIsCondensed(false)
+ // Kill animations
+ tlRef.current?.kill()
+ tlRef.current = null
+ pulseRef.current?.kill()
+ pulseRef.current = null
+ // Reveal controls
+ if (sendBtnRef.current)
+ gsap.set(sendBtnRef.current, { clearProps: 'all', autoAlpha: 1, scale: 1 })
+ if (modeTagRef.current)
+ gsap.set(modeTagRef.current, { clearProps: 'all', autoAlpha: 1, scale: 1 })
+ if (textareaWrapRef.current)
+ gsap.set(textareaWrapRef.current, { boxShadow: 'none', clearProps: 'height' })
+ }, [])
+
+ React.useEffect(() => {
+ if (!autoPlay) return
+ if (typeof window === 'undefined') return
+ const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
+ if (reduceMotion) return
+
+ // Dummy prompts and workflows (manual positions per breakpoint)
+ const PROMPTS: GridPrompt[] = [
+ {
+ text: 'Create a voice agent that answers support calls and searches the KB.',
+ blocks: [
+ {
+ id: 'start',
+ name: 'Start',
+ color: '#30B2FF',
+ icon: ,
+ positions: {
+ mobile: { x: 24, y: 120 },
+ tablet: { x: 60, y: 180 },
+ desktop: { x: 80, y: 241 },
+ },
+ tags: [
+ { icon: , label: 'When: Call Received' },
+ { icon: , label: '3 fields' },
+ ],
+ },
+ {
+ id: 'kb',
+ name: 'Knowledge Base',
+ color: '#01B0B0',
+ icon: ,
+ positions: {
+ mobile: { x: 120, y: 140 },
+ tablet: { x: 220, y: 200 },
+ desktop: { x: 420, y: 241 },
+ },
+ tags: [
+ { icon: , label: 'Product Info' },
+ { icon: , label: 'Limit: 10' },
+ ],
+ },
+ {
+ id: 'reason',
+ name: 'Agent',
+ color: '#802FFF',
+ icon: ,
+ positions: {
+ mobile: { x: 260, y: 80 },
+ tablet: { x: 400, y: 120 },
+ desktop: { x: 760, y: 60 },
+ },
+ tags: [
+ { icon: , label: 'Reasoning' },
+ { icon: , label: 'gpt-5' },
+ { icon: , label: '2 tools' },
+ ],
+ },
+ {
+ id: 'reply',
+ name: 'Agent',
+ color: '#802FFF',
+ icon: ,
+ positions: {
+ mobile: { x: 400, y: 180 },
+ tablet: { x: 600, y: 220 },
+ desktop: { x: 760, y: 241 },
+ },
+ tags: [
+ { icon: , label: 'Generate Reply' },
+ { icon: , label: 'gpt-5' },
+ ],
+ },
+ {
+ id: 'tts',
+ name: 'Text-to-Speech',
+ color: '#FFB300',
+ icon: ,
+ positions: {
+ mobile: { x: 560, y: 120 },
+ tablet: { x: 800, y: 160 },
+ desktop: { x: 760, y: 400 },
+ },
+ },
+ ],
+ edges: [
+ { id: 'e1', from: 'start', to: 'kb' },
+ { id: 'e2', from: 'kb', to: 'reason' },
+ { id: 'e3', from: 'reason', to: 'reply' },
+ { id: 'e4', from: 'reply', to: 'tts' },
+ ],
+ },
+ {
+ text: 'Summarize today’s emails and post updates to Slack channels.',
+ blocks: [
+ {
+ id: 'start',
+ name: 'Start',
+ color: '#6F3DFA',
+ icon: ,
+ positions: {
+ mobile: { x: 16, y: 40 },
+ tablet: { x: 40, y: 160 },
+ desktop: { x: 80, y: 160 },
+ },
+ },
+ {
+ id: 'stripe',
+ name: 'Stripe',
+ color: '#635BFF',
+ icon: ,
+ positions: {
+ mobile: { x: 16, y: 130 },
+ tablet: { x: 300, y: 160 },
+ desktop: { x: 360, y: 160 },
+ },
+ },
+ {
+ id: 'gmail1',
+ name: 'Gmail',
+ color: '#EA4335',
+ icon: ,
+ tags: [{ icon: , label: 'Search Messages' }],
+ positions: {
+ mobile: { x: 16, y: 220 },
+ tablet: { x: 560, y: 120 },
+ desktop: { x: 640, y: 120 },
+ },
+ },
+ {
+ id: 'gmail2',
+ name: 'Gmail',
+ color: '#EA4335',
+ icon: ,
+ tags: [{ icon: , label: 'Get Messages' }],
+ positions: {
+ mobile: { x: 16, y: 310 },
+ tablet: { x: 560, y: 220 },
+ desktop: { x: 640, y: 220 },
+ },
+ },
+ {
+ id: 'zendesk1',
+ name: 'Zendesk',
+ color: '#03363D',
+ icon: ,
+ positions: {
+ mobile: { x: 16, y: 400 },
+ tablet: { x: 820, y: 120 },
+ desktop: { x: 920, y: 120 },
+ },
+ },
+ {
+ id: 'zendesk2',
+ name: 'Zendesk',
+ color: '#03363D',
+ icon: ,
+ tags: [{ icon: , label: 'Draft Reply' }],
+ positions: {
+ mobile: { x: 16, y: 490 },
+ tablet: { x: 820, y: 220 },
+ desktop: { x: 920, y: 220 },
+ },
+ },
+ {
+ id: 'slack',
+ name: 'Slack',
+ color: '#36C5F0',
+ icon: ,
+ positions: {
+ mobile: { x: 16, y: 580 },
+ tablet: { x: 1080, y: 160 },
+ desktop: { x: 1200, y: 160 },
+ },
+ },
+ ],
+ edges: [
+ { id: 'a', from: 'start', to: 'stripe' },
+ { id: 'b', from: 'stripe', to: 'gmail1' },
+ { id: 'c', from: 'stripe', to: 'gmail2' },
+ { id: 'd', from: 'gmail1', to: 'zendesk1' },
+ { id: 'e', from: 'gmail2', to: 'zendesk2' },
+ { id: 'f', from: 'zendesk1', to: 'slack' },
+ { id: 'g', from: 'zendesk2', to: 'slack' },
+ ],
+ groupGrid: { colStart: 2, colEnd: 4, rowStart: 0, rowEnd: 1, labels: ['Loop', 'For Each'] },
+ },
+ ]
+
+ const typeText = (targetLength: number, text: string, secondsPerChar = 0.045) => {
+ const duration = Math.max(1.1, targetLength * secondsPerChar)
+ const proxy = { p: 0 }
+ return gsap.to(proxy, {
+ p: 1,
+ duration,
+ ease: 'none',
+ onUpdate: () => {
+ const len = Math.round(proxy.p * targetLength)
+ setPrompt(text.slice(0, len))
+ },
+ })
+ }
+
+ // Smooth exit animations: run at play time (not build time)
+ const runHideEdgesAndGroup = () => {
+ const root = flowWrapRef.current
+ const edgePaths = root
+ ? Array.from(root.querySelectorAll('.react-flow__edge-path'))
+ : []
+ if (edgePaths.length > 0) {
+ gsap.to(edgePaths, { opacity: 0, duration: 0.3, ease: 'power2.in' })
+ }
+ }
+
+ const runHideBlocks = () => {
+ const root = flowWrapRef.current
+ // Animate inner content so we don't fight React Flow transforms
+ const nodeInners = root
+ ? Array.from(root.querySelectorAll('.landing-node .landing-node-inner'))
+ : []
+ if (nodeInners.length > 0) {
+ const ordered = [...nodeInners].sort((a, b) => {
+ const ra = a.getBoundingClientRect()
+ const rb = b.getBoundingClientRect()
+ if (ra.left !== rb.left) return ra.left - rb.left
+ return ra.top - rb.top
+ })
+ gsap.to(ordered, {
+ y: 8,
+ scale: 0.98,
+ opacity: 0,
+ duration: 0.35,
+ ease: 'power2.in',
+ stagger: 0.06,
+ })
+ } else if (root) {
+ gsap.to(root, { opacity: 0, y: 8, scale: 0.98, duration: 0.35, ease: 'power2.in' })
+ }
+ }
+
+ // --- Layout helpers (manual responsive positions) ---------------------
+ function getCurrentBreakpoint(): 'mobile' | 'tablet' | 'desktop' {
+ if (typeof window === 'undefined') return 'desktop'
+ const w = window.innerWidth
+ if (w < 640) return 'mobile'
+ if (w < 1024) return 'tablet'
+ return 'desktop'
+ }
+
+ function computeLayout(base: WorkflowManualBlock[], _groupGrid?: GridPrompt['groupGrid']) {
+ const pad = 16
+ const bp = getCurrentBreakpoint()
+ const nodes: WorkflowBlockNode[] = base.map((b) => {
+ const pos = b.positions[bp]
+ const nx = Math.max(pad, pos.x)
+ const ny = Math.max(pad, pos.y)
+ return { id: b.id, x: nx, y: ny, name: b.name, color: b.color, icon: b.icon, tags: b.tags }
+ })
+ const maxRight = nodes.reduce((m, n) => Math.max(m, n.x), 0)
+ const worldW = maxRight + CARD_WIDTH + pad
+ const groupPx: WorkflowGroupData | null = null
+ return { nodes, worldW, groupPx }
+ }
+
+ // Camera panning helpers
+ // React Flow will handle panning via setViewport; we'll trigger it using a helper
+ function panRightIfOverflow() {
+ if (!previewRef.current) return
+ const overflow = Math.max(0, worldWidth - previewRef.current.clientWidth)
+ if (overflow > 12) {
+ // Pan will be handled inside LandingFlow with a delay
+ }
+ }
+
+ function panReset() {
+ const api = viewportApiRef.current
+ if (api) {
+ api.panTo(0, 0, { duration: 0 })
+ }
+ }
+
+ const panBackNow = () => {
+ const api = viewportApiRef.current
+ if (!api) return
+ const current = api.getViewport()
+ if (!current) return
+ const proxy = { x: current.x, y: current.y }
+ gsap.to(proxy, {
+ x: 0,
+ y: 0,
+ duration: 0.45,
+ ease: 'power2.inOut',
+ onUpdate: () => api.panTo(proxy.x, proxy.y, { duration: 0 }),
+ })
+ }
+
+ // Textarea height animation helpers (single-state: isCondensed)
+ // ---- Textarea expand/collapse: simple, robust, single-state ----
+ function getBaseMinHeightPx() {
+ return window.innerWidth >= 640 ? 112 : 96
+ }
+
+ // Use outer ensureTextareaAutosize directly
+
+ function animateWrapperHeight(targetPx: number) {
+ const wrap = textareaWrapRef.current
+ if (!wrap) return
+ const current = wrap.getBoundingClientRect().height
+ // If no change, skip
+ if (Math.abs(current - targetPx) < 1) return
+ // Lock current height and animate to target
+ wrap.style.height = `${current}px`
+ wrap.style.overflow = 'hidden'
+ wrap.style.willChange = 'height'
+ wrap.style.transition = `height ${EXPAND_MS}ms ease-in-out`
+ // Force a reflow to ensure the following height change animates
+ void wrap.getBoundingClientRect().height
+ requestAnimationFrame(() => {
+ wrap.style.height = `${targetPx}px`
+ })
+ const cleanup = () => {
+ wrap.style.removeProperty('height')
+ wrap.style.removeProperty('overflow')
+ wrap.style.removeProperty('will-change')
+ wrap.style.removeProperty('transition')
+ wrap.removeEventListener('transitionend', cleanup)
+ }
+ wrap.addEventListener('transitionend', cleanup, { once: true })
+ // Fallback cleanup (in case transitionend is missed)
+ window.setTimeout(cleanup, 900)
+ }
+
+ function collapseToContent() {
+ const wrap = textareaWrapRef.current
+ const ta = textareaRef.current
+ if (wrap && ta) {
+ const wrapStart = wrap.getBoundingClientRect().height
+ const targetH = computeTextareaContentHeight()
+ // Prepare wrapper animation
+ wrap.style.height = `${wrapStart}px`
+ wrap.style.overflow = 'hidden'
+ wrap.style.willChange = 'height'
+ wrap.style.transition = `height ${COLLAPSE_MS}ms ease-in-out`
+ // Toggle state before animating
+ setIsCondensed(true)
+ // Animate textarea and wrapper heights in sync
+ void wrap.getBoundingClientRect().height
+ requestAnimationFrame(() => {
+ animateTextareaHeightTo(targetH, COLLAPSE_MS, 'ease-in-out')
+ wrap.style.height = `${targetH}px`
+ })
+ const cleanup = () => {
+ wrap.style.removeProperty('height')
+ wrap.style.removeProperty('overflow')
+ wrap.style.removeProperty('will-change')
+ wrap.style.removeProperty('transition')
+ wrap.removeEventListener('transitionend', cleanup)
}
+ wrap.addEventListener('transitionend', cleanup, { once: true })
+ window.setTimeout(cleanup, COLLAPSE_MS + 120)
+ } else {
+ setIsCondensed(true)
+ ensureTextareaAutosize()
}
}
- }
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
- handleNavigate()
+ function expandToMin() {
+ const target = getBaseMinHeightPx()
+ const wrap = textareaWrapRef.current
+ if (wrap) {
+ wrap.style.height = `${wrap.getBoundingClientRect().height}px`
+ wrap.style.overflow = 'hidden'
+ wrap.style.willChange = 'height'
+ wrap.style.transition = `height ${EXPAND_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
+ setIsCondensed(false)
+ void wrap.getBoundingClientRect().height
+ requestAnimationFrame(() => {
+ wrap.style.height = `${target}px`
+ })
+ const cleanup = () => {
+ wrap.style.removeProperty('height')
+ wrap.style.removeProperty('overflow')
+ wrap.style.removeProperty('will-change')
+ wrap.style.removeProperty('transition')
+ wrap.removeEventListener('transitionend', cleanup)
+ }
+ wrap.addEventListener('transitionend', cleanup, { once: true })
+ window.setTimeout(cleanup, EXPAND_MS + 100)
+ } else {
+ setIsCondensed(false)
}
+ // Reset textarea inline height so min-height controls size again
+ const ta = textareaRef.current
+ if (ta) ta.style.removeProperty('height')
}
- window.addEventListener('keydown', handleKeyDown)
- return () => window.removeEventListener('keydown', handleKeyDown)
- }, [isAuthenticated])
+ // Control chip and send button visibility
+ function hideControls() {
+ if (sendBtnRef.current)
+ gsap.to(sendBtnRef.current, { autoAlpha: 0, scale: 0.9, duration: 0.2, ease: 'power2.out' })
+ if (modeTagRef.current)
+ gsap.to(modeTagRef.current, {
+ autoAlpha: 0,
+ scale: 0.95,
+ duration: 0.2,
+ ease: 'power2.out',
+ })
+ }
+ function showControls() {
+ if (sendBtnRef.current)
+ gsap.to(sendBtnRef.current, { autoAlpha: 1, scale: 1, duration: 0.25, ease: 'power2.out' })
+ if (modeTagRef.current)
+ gsap.to(modeTagRef.current, { autoAlpha: 1, scale: 1, duration: 0.25, ease: 'power2.out' })
+ }
- useEffect(() => {
- const timer = setTimeout(() => {
- setIsTransitioning(false)
- }, 300) // Reduced delay for faster button appearance
- return () => clearTimeout(timer)
- }, [])
+ const startPulse = () => {
+ if (!textareaWrapRef.current) return
+ pulseRef.current?.kill()
+ pulseRef.current = gsap.to(textareaWrapRef.current, {
+ boxShadow: '0 0 0 2px rgba(164,111,255,0.45), 0 0 0 6px rgba(164,111,255,0.15)',
+ duration: 0.6,
+ yoyo: true,
+ repeat: -1,
+ ease: 'sine.inOut',
+ })
+ }
- const renderActionUI = () => {
- if (isTransitioning || isPending) {
- return
+ const stopPulse = () => {
+ pulseRef.current?.kill()
+ pulseRef.current = null
+ if (textareaWrapRef.current) gsap.set(textareaWrapRef.current, { boxShadow: 'none' })
}
- return (
-