chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,99 @@
'use client'
import { useRef } from 'react'
import { ArrowUp, cn, Mic, Paperclip, Slash } from '@sim/emcn'
interface LandingPreviewChatInputProps {
value: string
onChange?: (value: string) => void
onSubmit: () => void
placeholder: string
/** Locks the field (used while the demo auto-types). */
readOnly?: boolean
/** Hides the caret (auto-type has no real cursor). */
caretHidden?: boolean
/** Lifts the field with the home-view shadow (only the initial empty state). */
shadow?: boolean
}
const ICON_BUTTON =
'flex size-[28px] flex-shrink-0 items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-1)]'
/**
* The canonical Mothership chat input - a faithful copy of the workspace
* `UserInput`: a white, `rounded-[17px]` field with the text area on top and a
* control row beneath (attach + skills on the left, mic + send on the right).
* The send button carries the real `SEND_BUTTON` fills (`#383838` active,
* `#808080` disabled). Shared by the home empty state and the docked chat pane
* so both read identically.
*/
export function LandingPreviewChatInput({
value,
onChange,
onSubmit,
placeholder,
readOnly = false,
caretHidden = false,
shadow = false,
}: LandingPreviewChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isEmpty = value.trim().length === 0
return (
<div
onClick={() => textareaRef.current?.focus()}
className={cn(
'cursor-text rounded-[17px] border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2',
shadow && 'shadow-[0_1px_2px_0_rgba(18,18,18,0.05)]'
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
if (!readOnly) onChange?.(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
}}
placeholder={placeholder}
aria-label={placeholder}
rows={1}
readOnly={readOnly}
className='m-0 block max-h-[200px] min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-1 py-1 font-body text-[15px] text-[var(--text-primary)] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[var(--text-muted)] focus-visible:ring-0'
style={{ caretColor: caretHidden ? 'transparent' : 'var(--text-primary)' }}
/>
<div className='mt-1 flex items-center justify-between'>
<div className='flex items-center gap-1'>
<span className={ICON_BUTTON}>
<Paperclip className='size-[16px] text-[var(--text-muted)]' />
</span>
<span className={ICON_BUTTON}>
<Slash className='size-[16px] text-[var(--text-muted)]' />
</span>
</div>
<div className='flex items-center gap-1.5'>
<span className={ICON_BUTTON}>
<Mic className='size-[16px] text-[var(--text-muted)]' />
</span>
<button
type='button'
onClick={onSubmit}
disabled={isEmpty}
aria-label='Send message'
className={cn(
'flex size-[28px] flex-shrink-0 items-center justify-center rounded-full border-0 p-0 transition-colors',
isEmpty ? 'cursor-not-allowed bg-[#808080]' : 'bg-[#383838] hover-hover:bg-[#575757]'
)}
>
<ArrowUp className='size-[16px] text-white' />
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,44 @@
import { BubbleChatDelay, ChevronDown, PanelLeft, X } from '@sim/emcn'
interface LandingPreviewChatTitleBarProps {
/** Chat name shown in the switcher chip. */
chatName: string
/** Renders the close (×) control at the right edge (shown when a resource is staged). */
showClose?: boolean
}
/**
* The chat pane's title bar - a faithful copy of the workspace `ChatTitleBar`:
* the sidebar toggle, the chat-switcher split pill (chat icon + name | chevron),
* and an optional close control. Shared by the docked chat pane and the home
* empty state so the two read identically.
*/
export function LandingPreviewChatTitleBar({
chatName,
showClose = false,
}: LandingPreviewChatTitleBarProps) {
return (
<div className='flex h-[44px] flex-shrink-0 items-center gap-1 border-[var(--border)] border-b px-4'>
<span className='-ml-[9px] flex size-[30px] flex-shrink-0 items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
<PanelLeft className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
</span>
{/* Chat-switcher split pill: icon+name segment and a chevron segment
sliced by a 1px panel-colored divider. */}
<span className='flex h-[30px] min-w-0 flex-shrink items-stretch'>
<span className='flex min-w-0 items-center gap-1.5 rounded-l-lg pr-1 pl-2 transition-colors hover-hover:bg-[var(--surface-active)]'>
<BubbleChatDelay className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>{chatName}</span>
</span>
<span aria-hidden='true' className='w-px self-stretch bg-[var(--surface-2)]' />
<span className='flex items-center rounded-r-lg px-1 transition-colors hover-hover:bg-[var(--surface-active)]'>
<ChevronDown className='h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
</span>
</span>
{showClose && (
<span className='-mr-[9px] ml-auto flex size-[30px] flex-shrink-0 items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
<X className='size-[14px] text-[var(--text-icon)]' />
</span>
)}
</div>
)
}
@@ -0,0 +1,127 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { ThinkingLoader } from '@/components/ui'
import { LandingPreviewChatInput } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-input'
import { LandingPreviewChatTitleBar } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-title-bar'
import type { PreviewChat } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/hooks/use-landing-submit'
interface LandingPreviewChatProps {
/** The scripted exchange to play, or `null` to show only the input. */
chat: PreviewChat | null
/** Name shown in the chat-switcher breadcrumb chip. */
chatName: string
/** Re-runs the reveal timeline whenever it changes (one per staged resource). */
animationKey: number
}
/** Reveal beats for the scripted conversation, in ms from the step start. */
const THINKING_AT = 480
const ASSISTANT_AT = 1280
/** Ordered stages of the scripted reveal (user request -> think -> reply). */
type RevealPhase = 'hidden' | 'user' | 'thinking' | 'assistant'
/**
* The Mothership chat pane - the persistent left column of the "chat everywhere"
* layout. Its title bar carries the chat-switcher breadcrumb (a chat-bubble
* chip + the active chat's name + chevron) exactly like the real workspace, so
* the chat reads as the constant that every staged resource hangs off of.
*
* On each `animationKey` change it replays a short scripted exchange: the user's
* request slides in, Sim "thinks" (the cycle loader), then the reply slides in -
* mirroring the workflow building itself on the staged panel to the right. The
* reveal is plain state + CSS transition (no layout animation) so it composes
* under the preview's root `LazyMotion` without pulling in `domMax`.
*
* The input is live: submitting stores the prompt and routes to `/signup`, so a
* visitor's first message survives the auth hop.
*/
export function LandingPreviewChat({ chat, chatName, animationKey }: LandingPreviewChatProps) {
const submit = useLandingSubmit()
const [value, setValue] = useState('')
const [phase, setPhase] = useState<RevealPhase>('hidden')
const revealRef = useRef<{ key: number; chat: PreviewChat | null }>({ key: animationKey, chat })
/**
* Restart the reveal synchronously when the timeline or staged chat changes, so the
* pane never flashes the previous reply before the effect reruns. The previous
* key/chat live in a ref: updates come from the parent's timer-driven state (never a
* transition/Suspense boundary), so the render can't be discarded between the ref
* write and commit.
*/
if (revealRef.current.key !== animationKey || revealRef.current.chat !== chat) {
revealRef.current = { key: animationKey, chat }
setPhase('hidden')
}
useEffect(() => {
if (!chat) return
const raf = requestAnimationFrame(() => setPhase('user'))
const t1 = setTimeout(() => setPhase('thinking'), THINKING_AT)
const t2 = setTimeout(() => setPhase('assistant'), ASSISTANT_AT)
return () => {
cancelAnimationFrame(raf)
clearTimeout(t1)
clearTimeout(t2)
}
}, [animationKey, chat])
const showUser = phase !== 'hidden'
const showThinking = phase === 'thinking'
const showAssistant = phase === 'assistant'
const isEmpty = value.trim().length === 0
const handleSubmit = () => {
if (isEmpty) return
submit(value)
}
return (
<div className='flex h-full w-[400px] flex-shrink-0 flex-col bg-[var(--surface-2)]'>
<LandingPreviewChatTitleBar chatName={chatName} showClose />
{/* Conversation - bottom-anchored so it rests just above the input. */}
<div className='flex min-h-0 flex-1 flex-col justify-end gap-3 overflow-hidden px-3.5 pt-4 pb-1'>
{chat && (
<>
<div
className={cn(
'max-w-[85%] self-end rounded-2xl bg-[var(--surface-1)] px-3.5 py-2 text-[13.5px] text-[var(--text-primary)] leading-[1.45] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0,0,1)]',
showUser ? 'translate-y-0 opacity-100' : 'translate-y-1.5 opacity-0'
)}
>
{chat.user}
</div>
<div className='min-h-[20px] self-start pr-2'>
{showThinking && <ThinkingLoader size={20} startVariant='corners' />}
{showAssistant && (
<p
className={cn(
'max-w-[92%] text-[13.5px] text-[var(--text-primary)] leading-[1.5] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0,0,1)]',
showAssistant ? 'translate-y-0 opacity-100' : 'translate-y-1.5 opacity-0'
)}
>
{chat.assistant}
</p>
)}
</div>
</>
)}
</div>
{/* Input - live: routes a typed prompt to /signup. */}
<div className='flex-shrink-0 px-3 pt-2 pb-3'>
<LandingPreviewChatInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder='Ask Sim anything…'
/>
</div>
</div>
)
}
@@ -0,0 +1,155 @@
import { File } from '@sim/emcn/icons'
import { DocxIcon, PdfIcon } from '@/components/icons/document-icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { ownerCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/utils'
/** Generic audio/zip icon using basic SVG since no dedicated component exists */
function AudioIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M9 18V5l12-2v13' />
<circle cx='6' cy='18' r='3' />
<circle cx='18' cy='16' r='3' />
</svg>
)
}
function JsonlIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M10 9H8' />
<path d='M16 13H8' />
<path d='M16 17H8' />
</svg>
)
}
function ZipIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M10 6h1' />
<path d='M10 10h1' />
<path d='M10 14h1' />
<path d='M9 18h2v2h-2z' />
</svg>
)
}
const COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'size', header: 'Size' },
{ id: 'type', header: 'Type' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: <PdfIcon className='size-[14px]' />, label: 'Q1 Performance Report.pdf' },
size: { label: '2.4 MB' },
type: { icon: <PdfIcon className='size-[14px]' />, label: 'PDF' },
created: { label: '3 hours ago' },
owner: ownerCell('T', 'Theo L.'),
},
},
{
id: '2',
cells: {
name: { icon: <ZipIcon className='size-[14px]' />, label: 'product-screenshots.zip' },
size: { label: '18.7 MB' },
type: { icon: <ZipIcon className='size-[14px]' />, label: 'ZIP' },
created: { label: '1 day ago' },
owner: ownerCell('A', 'Alex M.'),
},
},
{
id: '3',
cells: {
name: { icon: <JsonlIcon className='size-[14px]' />, label: 'training-dataset.jsonl' },
size: { label: '892 KB' },
type: { icon: <JsonlIcon className='size-[14px]' />, label: 'JSONL' },
created: { label: '3 days ago' },
owner: ownerCell('J', 'Jordan P.'),
},
},
{
id: '4',
cells: {
name: { icon: <PdfIcon className='size-[14px]' />, label: 'brand-guidelines.pdf' },
size: { label: '5.1 MB' },
type: { icon: <PdfIcon className='size-[14px]' />, label: 'PDF' },
created: { label: '1 week ago' },
owner: ownerCell('S', 'Sarah K.'),
},
},
{
id: '5',
cells: {
name: { icon: <AudioIcon className='size-[14px]' />, label: 'customer-interviews.mp3' },
size: { label: '45.2 MB' },
type: { icon: <AudioIcon className='size-[14px]' />, label: 'Audio' },
created: { label: 'March 20th, 2026' },
owner: ownerCell('V', 'Vik M.'),
},
},
{
id: '6',
cells: {
name: { icon: <DocxIcon className='size-[14px]' />, label: 'onboarding-playbook.docx' },
size: { label: '1.1 MB' },
type: { icon: <DocxIcon className='size-[14px]' />, label: 'DOCX' },
created: { label: 'March 14th, 2026' },
owner: ownerCell('S', 'Sarah K.'),
},
},
]
/**
* Static landing preview of the Files workspace page.
*/
export function LandingPreviewFiles() {
return (
<LandingPreviewResource
icon={File}
title='Files'
createLabel='Upload file'
searchPlaceholder='Search files...'
columns={COLUMNS}
rows={ROWS}
/>
)
}
@@ -0,0 +1,458 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { ArrowRight, Blimp, Checkbox, ChevronDown, cn, Shuffle } from '@sim/emcn'
import { TypeBoolean, TypeNumber, TypeText } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import { Mail, MessageSquare, Table } from 'lucide-react'
import { captureClientEvent } from '@/lib/posthog/client'
import { LandingPreviewChatInput } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-input'
import { LandingPreviewChatTitleBar } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-title-bar'
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/hooks/use-landing-submit'
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
/** Static, greyscale suggestion rows mirroring the workspace SuggestedActions. */
const SUGGESTED_ACTIONS = [
{ id: 'crm', label: 'Create a CRM with sample data', icon: Table },
{ id: 'slack', label: 'Summarize my unread Slack messages', icon: MessageSquare },
{ id: 'gmail', label: 'Draft replies to my support emails', icon: Mail },
{ id: 'tracker', label: 'Build a project tracker table', icon: Table },
] as const
const AUTO_PROMPT = 'Analyze our customer leads and identify the top prospects'
const MOCK_RESPONSE =
'I analyzed your **Customer Leads** table and found **3 top prospects** with the highest lead scores:\n\n1. **Carol Davis** (StartupCo), Score: 94\n2. **Frank Lee** (Ventures), Score: 88\n3. **Alice Johnson** (Acme Corp), Score: 87\n\nAll three are qualified leads. Want me to draft outreach emails?'
const HOME_TYPE_MS = 40
const HOME_TYPE_START_MS = 600
const TOOL_CALL_DELAY_MS = 500
const RESPONSE_DELAY_MS = 800
const RESOURCE_PANEL_DELAY_MS = 600
const MINI_TABLE_COLUMNS = [
{ id: 'name', label: 'Name', type: 'text' as const, width: '32%' },
{ id: 'company', label: 'Company', type: 'text' as const, width: '30%' },
{ id: 'score', label: 'Score', type: 'number' as const, width: '18%' },
{ id: 'qualified', label: 'Qualified', type: 'boolean' as const, width: '20%' },
]
const MINI_TABLE_ROWS = [
{ name: 'Alice Johnson', company: 'Acme Corp', score: '87', qualified: 'true' },
{ name: 'Bob Williams', company: 'TechCo', score: '62', qualified: 'false' },
{ name: 'Carol Davis', company: 'StartupCo', score: '94', qualified: 'true' },
{ name: 'Dan Miller', company: 'BigCorp', score: '71', qualified: 'true' },
{ name: 'Eva Chen', company: 'Design IO', score: '45', qualified: 'false' },
{ name: 'Frank Lee', company: 'Ventures', score: '88', qualified: 'true' },
]
const COLUMN_TYPE_ICONS = {
text: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
} as const
interface LandingPreviewHomeProps {
autoType?: boolean
}
type ChatPhase = 'input' | 'sent' | 'tool-call' | 'responding' | 'done'
/**
* Landing preview replica of the workspace Home view.
*
* When `autoType` is true, automatically types a prompt, sends it,
* shows a mothership agent group with tool calls, types a response,
* and opens a resource panel - matching the real workspace chat UI.
*/
export const LandingPreviewHome = memo(function LandingPreviewHome({
autoType = false,
}: LandingPreviewHomeProps) {
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const animatedPlaceholder = useAnimatedPlaceholder()
const [chatPhase, setChatPhase] = useState<ChatPhase>('input')
const [responseTypedLength, setResponseTypedLength] = useState(0)
const [showResourcePanel, setShowResourcePanel] = useState(false)
const [toolsExpanded, setToolsExpanded] = useState(true)
const typeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const responseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
const clearAllTimers = useCallback(() => {
for (const t of timersRef.current) clearTimeout(t)
timersRef.current = []
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
if (responseIntervalRef.current) clearInterval(responseIntervalRef.current)
typeIntervalRef.current = null
responseIntervalRef.current = null
}, [])
useEffect(() => {
if (!autoType) return
setChatPhase('input')
setResponseTypedLength(0)
setShowResourcePanel(false)
setToolsExpanded(true)
setInputValue('')
const t1 = setTimeout(() => {
let idx = 0
typeIntervalRef.current = setInterval(() => {
idx++
setInputValue(AUTO_PROMPT.slice(0, idx))
if (idx >= AUTO_PROMPT.length) {
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
typeIntervalRef.current = null
const t2 = setTimeout(() => {
setChatPhase('sent')
const t3 = setTimeout(() => {
setChatPhase('tool-call')
const t4 = setTimeout(() => {
setShowResourcePanel(true)
}, RESOURCE_PANEL_DELAY_MS)
timersRef.current.push(t4)
const t5 = setTimeout(() => {
setToolsExpanded(false)
setChatPhase('responding')
let rIdx = 0
responseIntervalRef.current = setInterval(() => {
rIdx++
setResponseTypedLength(rIdx)
if (rIdx >= MOCK_RESPONSE.length) {
if (responseIntervalRef.current) clearInterval(responseIntervalRef.current)
responseIntervalRef.current = null
setChatPhase('done')
}
}, 8)
}, TOOL_CALL_DELAY_MS + RESPONSE_DELAY_MS)
timersRef.current.push(t5)
}, TOOL_CALL_DELAY_MS)
timersRef.current.push(t3)
}, 400)
timersRef.current.push(t2)
}
}, HOME_TYPE_MS)
}, HOME_TYPE_START_MS)
timersRef.current.push(t1)
return clearAllTimers
}, [autoType, clearAllTimers])
const isEmpty = inputValue.trim().length === 0
const handleSubmit = useCallback(() => {
if (isEmpty) return
captureClientEvent('landing_prompt_submitted', {})
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
if (chatPhase !== 'input') {
const isResponding = chatPhase === 'responding' || chatPhase === 'done'
const showToolCall = chatPhase === 'tool-call' || isResponding
return (
<LazyMotion features={domAnimation}>
<div className='flex h-full flex-col'>
<LandingPreviewChatTitleBar chatName='New chat' />
<div className='flex min-h-0 flex-1 overflow-hidden'>
{/* Chat area - matches mothership-view layout */}
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8'>
<div className='mx-auto max-w-[42rem] space-y-6'>
{/* User message - rounded bubble, right-aligned */}
<m.div
className='flex flex-col items-end gap-[6px] pt-3'
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }}
>
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-active)] px-3.5 py-2'>
<p className='font-body text-[14px] text-[var(--text-primary)] leading-[1.5]'>
{AUTO_PROMPT}
</p>
</div>
</m.div>
{/* Assistant - no bubble, full-width prose */}
<AnimatePresence>
{showToolCall && (
<m.div
className='space-y-2.5'
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }}
>
{/* Agent group header - icon + label + chevron */}
<button
type='button'
onClick={() => setToolsExpanded((p) => !p)}
className='flex cursor-pointer items-center gap-2'
>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<Blimp className='size-[16px] text-[var(--text-icon)]' />
</div>
<span className='text-[var(--text-body)] text-sm'>Sim</span>
<ChevronDown
className='h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150'
style={{
transform: toolsExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
}}
/>
</button>
{/* Tool call items - collapsible */}
<div
className='grid transition-[grid-template-rows] duration-200 ease-out'
style={{
gridTemplateRows: toolsExpanded ? '1fr' : '0fr',
}}
>
<div className='overflow-hidden'>
<div className='flex flex-col gap-1.5 pt-0.5'>
<ToolCallRow
icon={<Table className='size-[15px] text-[var(--text-muted)]' />}
title='Read Customer Leads'
/>
</div>
</div>
</div>
{/* Response prose - full width, no card */}
{isResponding && (
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
>
<ChatMarkdown
content={MOCK_RESPONSE}
visibleLength={responseTypedLength}
isTyping={chatPhase === 'responding'}
/>
</m.div>
)}
</m.div>
)}
</AnimatePresence>
</div>
</div>
{/* Resource panel - slides in from right */}
<AnimatePresence>
{showResourcePanel && (
<m.div
className='hidden h-full flex-shrink-0 overflow-hidden border-[var(--border-1)] border-l lg:flex'
initial={{ width: 0, opacity: 0 }}
animate={{ width: '55%', opacity: 1 }}
transition={{ duration: 0.35, ease: EASE_OUT }}
>
<MiniTablePanel />
</m.div>
)}
</AnimatePresence>
</div>
</div>
</LazyMotion>
)
}
return (
<LazyMotion features={domAnimation}>
<div className='flex h-full flex-col'>
<LandingPreviewChatTitleBar chatName='New chat' />
<div className='flex min-h-0 flex-1 flex-col items-center justify-center px-6 pb-[8vh]'>
<m.h1
className='mb-7 max-w-[36rem] text-balance text-[30px] text-[var(--text-primary)]'
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT }}
>
What should we get done?
</m.h1>
<m.div
className='w-full max-w-[36rem]'
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1, ease: EASE_OUT }}
>
<LandingPreviewChatInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
placeholder={animatedPlaceholder}
readOnly={autoType}
caretHidden={autoType}
shadow
/>
{/* Suggested actions - mirrors the workspace home, greyscale icons. */}
<div className='mt-7'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-[var(--text-muted)] text-small'>Suggested actions</span>
<ChevronDown className='h-[7px] w-[9px] text-[var(--text-muted)]' />
</div>
<div className='-mr-2 flex items-center gap-1.5 rounded-lg px-2 py-1'>
<span className='-mt-px text-[var(--text-muted)] text-small'>Shuffle</span>
<Shuffle className='size-[16px] flex-shrink-0 text-[var(--text-muted)]' />
</div>
</div>
<div className='mt-2 flex flex-col'>
{SUGGESTED_ACTIONS.map((action, i) => {
const Icon = action.icon
return (
<div
key={action.id}
className={cn(
'flex items-center gap-2 border-[var(--border-1)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-1)]',
i > 0 && 'border-t'
)}
>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-muted)]' />
<span className='flex-1 truncate text-[var(--text-primary)] text-sm'>
{action.label}
</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-muted)]' />
</div>
)
})}
</div>
</div>
</m.div>
</div>
</div>
</LazyMotion>
)
})
/**
* Single tool call row matching the real `ToolCallItem` layout:
* indented icon + display title.
*/
function ToolCallRow({ icon, title }: { icon: React.ReactNode; title: string }) {
return (
<div className='flex items-center gap-[8px] pl-[24px]'>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>{icon}</div>
<span className='text-[13px] text-[var(--text-muted)]'>{title}</span>
</div>
)
}
/**
* Renders chat response as full-width prose with bold markdown
* and progressive reveal for the typing effect.
*/
function ChatMarkdown({
content,
visibleLength,
isTyping,
}: {
content: string
visibleLength: number
isTyping: boolean
}) {
const visible = content.slice(0, visibleLength)
const rendered = visible.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br />')
return (
<div className='font-body text-[14px] text-[var(--text-primary)] leading-[1.6]'>
<span dangerouslySetInnerHTML={{ __html: rendered }} />
{isTyping && (
<m.span
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[var(--text-primary)]'
animate={{ opacity: [1, 0] }}
transition={{
duration: 0.6,
repeat: Number.POSITIVE_INFINITY,
repeatType: 'reverse',
}}
/>
)}
</div>
)
}
/**
* Mini Customer Leads table panel matching the resource panel pattern.
*/
function MiniTablePanel() {
return (
<div className='flex h-full w-full flex-col bg-[var(--surface-2)]'>
<div className='flex items-center gap-2 border-[var(--border)] border-b px-3 py-2'>
<Table className='size-[14px] text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-primary)] text-sm'>Customer Leads</span>
</div>
<div className='min-h-0 flex-1 overflow-auto'>
<table className='w-full table-fixed border-separate border-spacing-0 text-[12px]'>
<colgroup>
{MINI_TABLE_COLUMNS.map((col) => (
<col key={col.id} style={{ width: col.width }} />
))}
</colgroup>
<thead className='sticky top-0 z-10'>
<tr>
{MINI_TABLE_COLUMNS.map((col) => {
const Icon = COLUMN_TYPE_ICONS[col.type]
return (
<th
key={col.id}
className='border-[var(--border-1)] border-r border-b bg-[var(--surface-1)] p-0 text-left'
>
<div className='flex items-center gap-1 px-2 py-1.5'>
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='font-medium text-[11px] text-[var(--text-primary)]'>
{col.label}
</span>
<ChevronDown className='ml-auto h-[6px] w-[8px] text-[var(--text-muted)]' />
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{MINI_TABLE_ROWS.map((row, i) => (
<m.tr
key={row.name}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: i * 0.04, ease: EASE_OUT }}
>
{MINI_TABLE_COLUMNS.map((col) => {
const val = row[col.id as keyof typeof row]
return (
<td
key={col.id}
className='border-[var(--border-1)] border-r border-b px-2 py-1.5 text-[var(--text-body)]'
>
{col.type === 'boolean' ? (
<div className='flex items-center justify-center'>
<Checkbox
size='sm'
checked={val === 'true'}
className='pointer-events-none'
/>
</div>
) : (
<span className='block truncate'>{val}</span>
)}
</td>
)
})}
</m.tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,120 @@
import { Database } from '@sim/emcn/icons'
import {
AirtableIcon,
AsanaIcon,
ConfluenceIcon,
GoogleDocsIcon,
GoogleDriveIcon,
JiraIcon,
SalesforceIcon,
SlackIcon,
ZendeskIcon,
} from '@/components/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const DB_ICON = <Database className='size-[14px]' />
/** Connector icons keyed by a stable slug so list keys never depend on array index
* or a component name that could be mangled/emptied under minification. */
const CONNECTOR_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
airtable: AirtableIcon,
asana: AsanaIcon,
confluence: ConfluenceIcon,
'google-docs': GoogleDocsIcon,
'google-drive': GoogleDriveIcon,
jira: JiraIcon,
salesforce: SalesforceIcon,
slack: SlackIcon,
zendesk: ZendeskIcon,
}
function connectorIcons(slugs: string[]) {
return {
content: (
<div className='flex items-center gap-1'>
{slugs.map((slug) => {
const Icon = CONNECTOR_ICONS[slug]
return <Icon key={slug} className='size-3.5 flex-shrink-0' />
})}
</div>
),
}
}
const COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents' },
{ id: 'tokens', header: 'Tokens' },
{ id: 'connectors', header: 'Connectors' },
{ id: 'created', header: 'Created' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: DB_ICON, label: 'Product Documentation' },
documents: { label: '847' },
tokens: { label: '1,284,392' },
connectors: connectorIcons(['asana', 'google-docs']),
created: { label: '2 days ago' },
},
},
{
id: '2',
cells: {
name: { icon: DB_ICON, label: 'Customer Support KB' },
documents: { label: '234' },
tokens: { label: '892,104' },
connectors: connectorIcons(['zendesk', 'slack']),
created: { label: '1 week ago' },
},
},
{
id: '3',
cells: {
name: { icon: DB_ICON, label: 'Engineering Wiki' },
documents: { label: '1,203' },
tokens: { label: '2,847,293' },
connectors: connectorIcons(['confluence', 'jira']),
created: { label: 'March 12th, 2026' },
},
},
{
id: '4',
cells: {
name: { icon: DB_ICON, label: 'Marketing Assets' },
documents: { label: '189' },
tokens: { label: '634,821' },
connectors: connectorIcons(['google-drive', 'airtable']),
created: { label: 'March 5th, 2026' },
},
},
{
id: '5',
cells: {
name: { icon: DB_ICON, label: 'Sales Playbook' },
documents: { label: '92' },
tokens: { label: '418,570' },
connectors: connectorIcons(['salesforce']),
created: { label: 'February 28th, 2026' },
},
},
]
export function LandingPreviewKnowledge() {
return (
<LandingPreviewResource
icon={Database}
title='Knowledge Base'
createLabel='New base'
searchPlaceholder='Search knowledge bases...'
columns={COLUMNS}
rows={ROWS}
/>
)
}
@@ -0,0 +1,298 @@
'use client'
import { useMemo, useState } from 'react'
import type { BadgeProps } from '@sim/emcn'
import { ArrowUpDown, Badge, cn, Library, ListFilter, Search } from '@sim/emcn'
import { Download, Workflow } from '@sim/emcn/icons'
interface LogRow {
id: string
workflowName: string
date: string
status: 'completed' | 'error' | 'running'
cost: string
trigger: 'webhook' | 'api' | 'schedule' | 'manual' | 'mcp' | 'chat'
triggerLabel: string
duration: string
}
type BadgeVariant = BadgeProps['variant']
const STATUS_VARIANT: Record<LogRow['status'], BadgeVariant> = {
completed: 'gray-secondary',
error: 'gray',
running: 'gray',
}
const STATUS_LABELS: Record<LogRow['status'], string> = {
completed: 'Completed',
error: 'Error',
running: 'Running',
}
const MOCK_LOGS: LogRow[] = [
{
id: '1',
workflowName: 'Customer Onboarding',
date: 'Apr 1 10:42 AM',
status: 'running',
cost: '-',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '-',
},
{
id: '2',
workflowName: 'Lead Enrichment',
date: 'Apr 1 09:15 AM',
status: 'error',
cost: '1 credit',
trigger: 'api',
triggerLabel: 'API',
duration: '2.7s',
},
{
id: '3',
workflowName: 'Email Campaign',
date: 'Apr 1 08:30 AM',
status: 'completed',
cost: '2 credits',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '0.8s',
},
{
id: '4',
workflowName: 'Data Pipeline',
date: 'Mar 31 10:14 PM',
status: 'completed',
cost: '7 credits',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '4.1s',
},
{
id: '5',
workflowName: 'Invoice Processing',
date: 'Mar 31 08:45 PM',
status: 'completed',
cost: '2 credits',
trigger: 'manual',
triggerLabel: 'Manual',
duration: '0.9s',
},
{
id: '6',
workflowName: 'Support Triage',
date: 'Mar 31 07:22 PM',
status: 'completed',
cost: '3 credits',
trigger: 'api',
triggerLabel: 'API',
duration: '1.6s',
},
{
id: '7',
workflowName: 'Content Moderator',
date: 'Mar 31 06:11 PM',
status: 'error',
cost: '1 credit',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '3.2s',
},
]
type SortKey = 'workflowName' | 'date' | 'status' | 'cost' | 'trigger' | 'duration'
const COL_HEADERS: { key: SortKey; label: string }[] = [
{ key: 'workflowName', label: 'Workflow' },
{ key: 'date', label: 'Date' },
{ key: 'status', label: 'Status' },
{ key: 'cost', label: 'Cost' },
{ key: 'trigger', label: 'Trigger' },
{ key: 'duration', label: 'Duration' },
]
export function LandingPreviewLogs() {
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [activeTab, setActiveTab] = useState<'logs' | 'dashboard'>('logs')
function handleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir('asc')
}
}
const sorted = useMemo(() => {
const q = search.toLowerCase()
const filtered = q
? MOCK_LOGS.filter(
(log) =>
log.workflowName.toLowerCase().includes(q) ||
log.triggerLabel.toLowerCase().includes(q) ||
STATUS_LABELS[log.status].toLowerCase().includes(q)
)
: MOCK_LOGS
if (!sortKey) return filtered
return [...filtered].sort((a, b) => {
const av = sortKey === 'cost' ? a.cost.replace(/\D/g, '') : a[sortKey]
const bv = sortKey === 'cost' ? b.cost.replace(/\D/g, '') : b[sortKey]
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [search, sortKey, sortDir])
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Header - fixed 44px to align with the chat title bar across the split. */}
<div className='flex h-[44px] flex-shrink-0 items-center border-[var(--border)] border-b px-6'>
<div className='flex w-full items-center justify-between'>
<div className='flex items-center gap-3'>
<Library className='size-[14px] text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-body)] text-sm'>Logs</span>
</div>
<div className='flex items-center gap-1'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<Download className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Export
</div>
<button
type='button'
onClick={() => setActiveTab('logs')}
className={cn(
'rounded-md px-2 py-1 text-caption transition-colors',
activeTab === 'logs'
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
: 'text-[var(--text-secondary)]'
)}
>
Logs
</button>
<button
type='button'
onClick={() => setActiveTab('dashboard')}
className={cn(
'rounded-md px-2 py-1 text-caption transition-colors',
activeTab === 'dashboard'
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
: 'text-[var(--text-secondary)]'
)}
>
Dashboard
</button>
</div>
</div>
</div>
{/* Options bar */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-2.5'>
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder='Search logs...'
aria-label='Search logs'
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
<div className='flex items-center gap-1.5'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Filter
</div>
<button
type='button'
onClick={() => handleSort(sortKey ?? 'workflowName')}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
<ArrowUpDown className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Sort
</button>
</div>
</div>
</div>
{/* Table - uses <table> for pixel-perfect column alignment with headers */}
<div className='min-h-0 flex-1 overflow-hidden'>
<table className='w-full table-fixed text-sm'>
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '18%' }} />
</colgroup>
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{COL_HEADERS.map(({ key, label }) => (
<th
key={key}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<button
type='button'
onClick={() => handleSort(key)}
className={cn(
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
sortKey === key ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'
)}
>
{label}
{sortKey === key && <ArrowUpDown className='size-[10px] opacity-60' />}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((log) => (
<tr
key={log.id}
className='h-[44px] cursor-default transition-colors hover-hover:bg-[var(--surface-3)]'
>
<td className='px-6 align-middle'>
<div className='flex items-center gap-2'>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-caption'>
{log.workflowName}
</span>
</div>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.date}
</td>
<td className='px-6 align-middle'>
<Badge variant={STATUS_VARIANT[log.status]} size='sm' dot>
{STATUS_LABELS[log.status]}
</Badge>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.cost}
</td>
<td className='px-6 align-middle'>
<Badge variant='gray-secondary' size='sm'>
{log.triggerLabel}
</Badge>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.duration}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,198 @@
'use client'
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { ArrowUpDown, cn, ListFilter, Plus, Search } from '@sim/emcn'
export interface PreviewColumn {
id: string
header: string
width?: number
}
export interface PreviewCell {
icon?: ReactNode
label?: string
content?: ReactNode
}
export interface PreviewRow {
id: string
cells: Record<string, PreviewCell>
}
interface LandingPreviewResourceProps {
icon: React.ComponentType<{ className?: string }>
title: string
createLabel: string
searchPlaceholder: string
columns: PreviewColumn[]
rows: PreviewRow[]
onRowClick?: (id: string) => void
}
export function LandingPreviewResource({
icon: Icon,
title,
createLabel,
searchPlaceholder,
columns,
rows,
onRowClick,
}: LandingPreviewResourceProps) {
const [search, setSearch] = useState('')
const [sortColId, setSortColId] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
function handleSortClick(colId: string) {
if (sortColId === colId) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortColId(colId)
setSortDir('asc')
}
}
const sorted = useMemo(() => {
const q = search.toLowerCase()
const filtered = q
? rows.filter((row) =>
Object.values(row.cells).some((cell) => cell.label?.toLowerCase().includes(q))
)
: rows
if (!sortColId) return filtered
return [...filtered].sort((a, b) => {
const av = a.cells[sortColId]?.label ?? ''
const bv = b.cells[sortColId]?.label ?? ''
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [rows, search, sortColId, sortDir])
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Header - fixed 44px to align with the chat title bar across the split. */}
<div className='flex h-[44px] flex-shrink-0 items-center border-[var(--border)] border-b px-6'>
<div className='flex w-full items-center justify-between'>
<div className='flex items-center gap-3'>
<Icon className='size-[14px] text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-body)] text-sm'>{title}</span>
</div>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<Plus className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
{createLabel}
</div>
</div>
</div>
{/* Options bar */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-2.5'>
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
<div className='flex items-center gap-1.5'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Filter
</div>
<button
type='button'
onClick={() => handleSortClick(sortColId ?? columns[0]?.id)}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
<ArrowUpDown className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Sort
</button>
</div>
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-hidden'>
<table className='w-full table-fixed text-sm'>
<colgroup>
{columns.map((col, i) => (
<col
key={col.id}
style={i === 0 ? { minWidth: col.width ?? 200 } : { width: col.width ?? 160 }}
/>
))}
</colgroup>
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{columns.map((col) => (
<th
key={col.id}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<button
type='button'
onClick={() => handleSortClick(col.id)}
className={cn(
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
sortColId === col.id
? 'text-[var(--text-secondary)]'
: 'text-[var(--text-muted)]'
)}
>
{col.header}
{sortColId === col.id && <ArrowUpDown className='size-[10px] opacity-60' />}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr
key={row.id}
onClick={() => onRowClick?.(row.id)}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer'
)}
>
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
{cell?.content ? (
cell.content
) : (
<span
className={cn(
'flex min-w-0 items-center gap-3 font-medium text-sm',
colIdx === 0
? 'text-[var(--text-body)]'
: 'text-[var(--text-secondary)]'
)}
>
{cell?.icon && (
<span className='flex-shrink-0 text-[var(--text-icon)]'>
{cell.icon}
</span>
)}
<span className='truncate'>{cell?.label ?? ''}</span>
</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,13 @@
import type { PreviewCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
/** Builds a preview owner cell: a small circular initial badge next to a name. */
export function ownerCell(initial: string, name: string): PreviewCell {
return {
icon: (
<span className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{initial}
</span>
),
label: name,
}
}
@@ -0,0 +1,88 @@
import { Calendar } from '@sim/emcn/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const CAL_ICON = <Calendar className='size-[14px]' />
const COLUMNS: PreviewColumn[] = [
{ id: 'task', header: 'Task' },
{ id: 'schedule', header: 'Schedule', width: 240 },
{ id: 'nextRun', header: 'Next Run' },
{ id: 'lastRun', header: 'Last Run' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
task: { icon: CAL_ICON, label: 'Sync CRM contacts' },
schedule: { label: 'Recurring, every day at 9:00 AM' },
nextRun: { label: 'Tomorrow' },
lastRun: { label: '2 hours ago' },
},
},
{
id: '2',
cells: {
task: { icon: CAL_ICON, label: 'Generate weekly report' },
schedule: { label: 'Recurring, every Monday at 8:00 AM' },
nextRun: { label: 'In 5 days' },
lastRun: { label: '6 days ago' },
},
},
{
id: '3',
cells: {
task: { icon: CAL_ICON, label: 'Clean up stale files' },
schedule: { label: 'Recurring, every Sunday at midnight' },
nextRun: { label: 'In 2 days' },
lastRun: { label: '6 days ago' },
},
},
{
id: '4',
cells: {
task: { icon: CAL_ICON, label: 'Send performance digest' },
schedule: { label: 'Recurring, every Friday at 5:00 PM' },
nextRun: { label: 'In 3 days' },
lastRun: { label: '3 days ago' },
},
},
{
id: '5',
cells: {
task: { icon: CAL_ICON, label: 'Backup production data' },
schedule: { label: 'Recurring, every 4 hours' },
nextRun: { label: 'In 2 hours' },
lastRun: { label: '2 hours ago' },
},
},
{
id: '6',
cells: {
task: { icon: CAL_ICON, label: 'Scrape competitor pricing' },
schedule: { label: 'Recurring, every Tuesday at 6:00 AM' },
nextRun: { label: 'In 6 days' },
lastRun: { label: '1 week ago' },
},
},
]
/**
* Static landing preview of the Scheduled Tasks workspace page.
*/
export function LandingPreviewScheduledTasks() {
return (
<LandingPreviewResource
icon={Calendar}
title='Scheduled Tasks'
createLabel='New scheduled task'
searchPlaceholder='Search scheduled tasks...'
columns={COLUMNS}
rows={ROWS}
/>
)
}
@@ -0,0 +1,206 @@
'use client'
import { ChevronDown, cn, Home, Library } from '@sim/emcn'
import {
Calendar,
Database,
File,
HelpCircle,
Search,
Settings,
Table,
Workflow,
} from '@sim/emcn/icons'
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
export type SidebarView =
| 'home'
| 'workflow'
| 'tables'
| 'files'
| 'knowledge'
| 'logs'
| 'scheduled-tasks'
interface LandingPreviewSidebarProps {
workflows: PreviewWorkflow[]
activeWorkflowId: string
activeView: SidebarView
onSelectWorkflow: (id: string) => void
onSelectHome: () => void
onSelectNav: (id: SidebarView) => void
}
const WORKSPACE_NAV = [
{ id: 'tables', label: 'Tables', icon: Table },
{ id: 'files', label: 'Files', icon: File },
{ id: 'knowledge', label: 'Knowledge Base', icon: Database },
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
{ id: 'logs', label: 'Logs', icon: Library },
] as const
const FOOTER_NAV = [
{ id: 'help', label: 'Help', icon: HelpCircle },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const
function NavItem({
icon: Icon,
label,
isActive,
onClick,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
label: string
isActive?: boolean
onClick?: () => void
}) {
if (!onClick) {
return (
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[13px] text-[var(--text-body)]' style={{ fontWeight: 450 }}>
{label}
</span>
</div>
)
}
return (
<button
type='button'
onClick={onClick}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isActive && 'bg-[var(--c-active)]'
)}
>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[13px] text-[var(--text-body)]' style={{ fontWeight: 450 }}>
{label}
</span>
</button>
)
}
/**
* Lightweight sidebar replicating the real workspace sidebar layout and sizing.
* Starts from the workspace header (no logo/collapse row).
* Only workflow items are interactive - everything else is pointer-events-none.
*/
export function LandingPreviewSidebar({
workflows,
activeWorkflowId,
activeView,
onSelectWorkflow,
onSelectHome,
onSelectNav,
}: LandingPreviewSidebarProps) {
const isHomeActive = activeView === 'home'
return (
<div
className='flex h-full w-[248px] flex-shrink-0 flex-col bg-[var(--surface-1)] pt-3'
style={{ '--c-active': 'var(--surface-active)' } as React.CSSProperties}
>
{/* Workspace Header */}
<div className='flex-shrink-0 px-2.5'>
<div className='pointer-events-none flex h-[32px] w-full items-center gap-2 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] pr-2 pl-[5px]'>
<div className='flex size-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
<path
d='M1 9C1 4.58 4.58 1 9 1'
stroke='var(--surface-1)'
strokeWidth='1.8'
strokeLinecap='round'
/>
</svg>
</div>
<span className='min-w-0 flex-1 truncate text-left font-medium text-[13px] text-[var(--text-primary)]'>
Superark
</span>
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
</div>
</div>
{/* Top Navigation: Home (interactive), Search (static) */}
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
<button
type='button'
onClick={onSelectHome}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isHomeActive && 'bg-[var(--c-active)]'
)}
>
<Home className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span
className='truncate text-[13px] text-[var(--text-body)]'
style={{ fontWeight: 450 }}
>
Home
</span>
</button>
<NavItem icon={Search} label='Search' />
</div>
{/* Workspace */}
<div className='mt-3.5 flex flex-shrink-0 flex-col'>
<div className='px-4 pb-1.5'>
<div className='text-[12px] text-[var(--text-icon)]'>Workspace</div>
</div>
<div className='flex flex-col gap-0.5 px-2'>
{WORKSPACE_NAV.map((item) => (
<NavItem
key={item.id}
icon={item.icon}
label={item.label}
isActive={activeView === item.id}
onClick={() => onSelectNav(item.id)}
/>
))}
</div>
</div>
{/* Scrollable Tasks + Workflows */}
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-3.5'>
{/* Workflows */}
<div className='flex flex-col'>
<div className='px-4'>
<div className='text-[12px] text-[var(--text-icon)]'>Workflows</div>
</div>
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
{workflows.map((workflow) => {
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
return (
<button
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className={cn(
'mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--surface-active)]',
isActive && 'bg-[var(--surface-active)]'
)}
>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<div
className='min-w-0 flex-1 truncate text-left text-[13px] text-[var(--text-body)]'
style={{ fontWeight: 450 }}
>
{workflow.name}
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Footer */}
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pt-[9px] pb-2'>
{FOOTER_NAV.map((item) => (
<NavItem key={item.id} icon={item.icon} label={item.label} />
))}
</div>
</div>
)
}
@@ -0,0 +1,38 @@
import { X } from '@sim/emcn'
import { PanelRight, Workflow } from 'lucide-react'
interface LandingPreviewStageHeaderProps {
/** The staged resource's display name. */
name: string
}
/**
* The staged-resource header - a faithful copy of the workspace `PanelHeader`
* (44px, `px-4`, `gap-1.5`) that sits above the workflow canvas in the "chat
* everywhere" layout. There is no tab strip and no Deploy/Run: a workflow's
* panel actions are `null` in the real header, so it carries only the staged
* resource's identity - the lucide `Workflow` mark (`size-[14px]`, `--text-icon`)
* and its name in chip geometry - plus the panel's close + collapse controls on
* the right. Aligns to the chat pane's title bar so the two read as one header
* row across the split.
*/
export function LandingPreviewStageHeader({ name }: LandingPreviewStageHeaderProps) {
return (
<div className='flex h-[44px] flex-shrink-0 items-center gap-1.5 border-[var(--border)] border-b px-4'>
<div className='flex min-w-0 flex-1 items-center overflow-hidden'>
<span className='inline-flex h-[30px] min-w-0 items-center justify-start gap-1.5 rounded-lg px-2 text-left'>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-muted)]' />
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>{name}</span>
</span>
</div>
<div className='ml-auto flex flex-shrink-0 items-center gap-1.5'>
<span className='flex size-[30px] items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
<X className='size-[14px] text-[var(--text-muted)]' />
</span>
<span className='-mr-[9px] flex size-[30px] items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
<PanelRight className='size-[16px] text-[var(--text-muted)]' />
</span>
</div>
</div>
)
}
@@ -0,0 +1,569 @@
'use client'
import { useState } from 'react'
import { Checkbox, cn } from '@sim/emcn'
import {
ChevronDown,
Columns3,
Rows3,
Table,
TypeBoolean,
TypeNumber,
TypeText,
} from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { ownerCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/utils'
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
const CELL_CHECKBOX =
'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none'
const CELL_HEADER =
'border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'
const CELL_HEADER_CHECKBOX =
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
const CELL_CONTENT =
'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small'
const SELECTION_OVERLAY =
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px z-[5] border-[2px] border-[var(--selection)]'
const LIST_COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
{ id: 'rows', header: 'Rows' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
]
const TABLE_METAS: Record<string, string> = {
'1': 'Customer Leads',
'2': 'Product Catalog',
'3': 'Campaign Analytics',
'4': 'User Profiles',
'5': 'Invoice Records',
}
const TABLE_ICON = <Table className='size-[14px]' />
const COLUMNS_ICON = <Columns3 className='size-[14px]' />
const ROWS_ICON = <Rows3 className='size-[14px]' />
const LIST_ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: TABLE_ICON, label: 'Customer Leads' },
columns: { icon: COLUMNS_ICON, label: '8' },
rows: { icon: ROWS_ICON, label: '2,847' },
created: { label: '2 days ago' },
owner: ownerCell('S', 'Sarah K.'),
},
},
{
id: '2',
cells: {
name: { icon: TABLE_ICON, label: 'Product Catalog' },
columns: { icon: COLUMNS_ICON, label: '12' },
rows: { icon: ROWS_ICON, label: '1,203' },
created: { label: '5 days ago' },
owner: ownerCell('A', 'Alex M.'),
},
},
{
id: '3',
cells: {
name: { icon: TABLE_ICON, label: 'Campaign Analytics' },
columns: { icon: COLUMNS_ICON, label: '6' },
rows: { icon: ROWS_ICON, label: '534' },
created: { label: '1 week ago' },
owner: ownerCell('W', 'Emaan K.'),
},
},
{
id: '4',
cells: {
name: { icon: TABLE_ICON, label: 'User Profiles' },
columns: { icon: COLUMNS_ICON, label: '15' },
rows: { icon: ROWS_ICON, label: '18,492' },
created: { label: '2 weeks ago' },
owner: ownerCell('J', 'Jordan P.'),
},
},
{
id: '5',
cells: {
name: { icon: TABLE_ICON, label: 'Invoice Records' },
columns: { icon: COLUMNS_ICON, label: '9' },
rows: { icon: ROWS_ICON, label: '742' },
created: { label: 'March 15th, 2026' },
owner: ownerCell('S', 'Sarah K.'),
},
},
]
interface SpreadsheetColumn {
id: string
label: string
type: 'text' | 'number' | 'boolean'
width: number
}
interface SpreadsheetRow {
id: string
cells: Record<string, string>
}
const COLUMN_TYPE_ICONS = {
text: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
} as const
const SPREADSHEET_DATA: Record<string, { columns: SpreadsheetColumn[]; rows: SpreadsheetRow[] }> = {
'1': {
columns: [
{ id: 'name', label: 'Name', type: 'text', width: 160 },
{ id: 'email', label: 'Email', type: 'text', width: 200 },
{ id: 'company', label: 'Company', type: 'text', width: 160 },
{ id: 'score', label: 'Score', type: 'number', width: 100 },
{ id: 'qualified', label: 'Qualified', type: 'boolean', width: 120 },
],
rows: [
{
id: '1',
cells: {
name: 'Alice Johnson',
email: 'alice@acme.com',
company: 'Acme Corp',
score: '87',
qualified: 'true',
},
},
{
id: '2',
cells: {
name: 'Bob Williams',
email: 'bob@techco.io',
company: 'TechCo',
score: '62',
qualified: 'false',
},
},
{
id: '3',
cells: {
name: 'Carol Davis',
email: 'carol@startup.co',
company: 'StartupCo',
score: '94',
qualified: 'true',
},
},
{
id: '4',
cells: {
name: 'Dan Miller',
email: 'dan@bigcorp.com',
company: 'BigCorp',
score: '71',
qualified: 'true',
},
},
{
id: '5',
cells: {
name: 'Eva Chen',
email: 'eva@design.io',
company: 'Design IO',
score: '45',
qualified: 'false',
},
},
{
id: '6',
cells: {
name: 'Frank Lee',
email: 'frank@ventures.co',
company: 'Ventures',
score: '88',
qualified: 'true',
},
},
],
},
'2': {
columns: [
{ id: 'sku', label: 'SKU', type: 'text', width: 120 },
{ id: 'name', label: 'Product Name', type: 'text', width: 200 },
{ id: 'price', label: 'Price', type: 'number', width: 100 },
{ id: 'stock', label: 'In Stock', type: 'number', width: 120 },
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
],
rows: [
{
id: '1',
cells: {
sku: 'PRD-001',
name: 'Wireless Headphones',
price: '79.99',
stock: '234',
active: 'true',
},
},
{
id: '2',
cells: { sku: 'PRD-002', name: 'USB-C Hub', price: '49.99', stock: '89', active: 'true' },
},
{
id: '3',
cells: {
sku: 'PRD-003',
name: 'Laptop Stand',
price: '39.99',
stock: '0',
active: 'false',
},
},
{
id: '4',
cells: {
sku: 'PRD-004',
name: 'Mechanical Keyboard',
price: '129.99',
stock: '52',
active: 'true',
},
},
{
id: '5',
cells: { sku: 'PRD-005', name: 'Webcam HD', price: '89.99', stock: '17', active: 'true' },
},
{
id: '6',
cells: {
sku: 'PRD-006',
name: 'Mouse Pad XL',
price: '24.99',
stock: '0',
active: 'false',
},
},
],
},
'3': {
columns: [
{ id: 'campaign', label: 'Campaign', type: 'text', width: 180 },
{ id: 'clicks', label: 'Clicks', type: 'number', width: 100 },
{ id: 'conversions', label: 'Conversions', type: 'number', width: 140 },
{ id: 'spend', label: 'Spend ($)', type: 'number', width: 130 },
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
],
rows: [
{
id: '1',
cells: {
campaign: 'Spring Sale 2026',
clicks: '12,847',
conversions: '384',
spend: '2,400',
active: 'true',
},
},
{
id: '2',
cells: {
campaign: 'Email Reactivation',
clicks: '3,201',
conversions: '97',
spend: '450',
active: 'false',
},
},
{
id: '3',
cells: {
campaign: 'Referral Program',
clicks: '8,923',
conversions: '210',
spend: '1,100',
active: 'true',
},
},
{
id: '4',
cells: {
campaign: 'Product Launch',
clicks: '24,503',
conversions: '891',
spend: '5,800',
active: 'true',
},
},
{
id: '5',
cells: {
campaign: 'Retargeting Q1',
clicks: '6,712',
conversions: '143',
spend: '980',
active: 'false',
},
},
],
},
'4': {
columns: [
{ id: 'username', label: 'Username', type: 'text', width: 140 },
{ id: 'email', label: 'Email', type: 'text', width: 200 },
{ id: 'plan', label: 'Plan', type: 'text', width: 120 },
{ id: 'seats', label: 'Seats', type: 'number', width: 100 },
{ id: 'active', label: 'Active', type: 'boolean', width: 100 },
],
rows: [
{
id: '1',
cells: {
username: 'alice_j',
email: 'alice@acme.com',
plan: 'Pro',
seats: '5',
active: 'true',
},
},
{
id: '2',
cells: {
username: 'bobw',
email: 'bob@techco.io',
plan: 'Starter',
seats: '1',
active: 'true',
},
},
{
id: '3',
cells: {
username: 'carol_d',
email: 'carol@startup.co',
plan: 'Enterprise',
seats: '25',
active: 'true',
},
},
{
id: '4',
cells: {
username: 'dan.m',
email: 'dan@bigcorp.com',
plan: 'Pro',
seats: '10',
active: 'false',
},
},
{
id: '5',
cells: {
username: 'eva_chen',
email: 'eva@design.io',
plan: 'Starter',
seats: '1',
active: 'true',
},
},
{
id: '6',
cells: {
username: 'frank_lee',
email: 'frank@ventures.co',
plan: 'Enterprise',
seats: '50',
active: 'true',
},
},
],
},
'5': {
columns: [
{ id: 'invoice', label: 'Invoice #', type: 'text', width: 140 },
{ id: 'client', label: 'Client', type: 'text', width: 160 },
{ id: 'amount', label: 'Amount ($)', type: 'number', width: 130 },
{ id: 'paid', label: 'Paid', type: 'boolean', width: 80 },
],
rows: [
{
id: '1',
cells: { invoice: 'INV-2026-001', client: 'Acme Corp', amount: '4,800.00', paid: 'true' },
},
{
id: '2',
cells: { invoice: 'INV-2026-002', client: 'TechCo', amount: '1,200.00', paid: 'true' },
},
{
id: '3',
cells: { invoice: 'INV-2026-003', client: 'StartupCo', amount: '750.00', paid: 'false' },
},
{
id: '4',
cells: { invoice: 'INV-2026-004', client: 'BigCorp', amount: '12,500.00', paid: 'true' },
},
{
id: '5',
cells: { invoice: 'INV-2026-005', client: 'Design IO', amount: '3,300.00', paid: 'false' },
},
],
},
}
interface SpreadsheetViewProps {
tableId: string
tableName: string
onBack: () => void
}
function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
const data = SPREADSHEET_DATA[tableId] ?? SPREADSHEET_DATA['1']
const [selectedCell, setSelectedCell] = useState<{ row: string; col: string } | null>(null)
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Breadcrumb header - matches real Resource.Header breadcrumb layout */}
<div className='border-[var(--border)] border-b px-4 py-[8.5px]'>
<div className='flex items-center gap-3'>
<button
type='button'
onClick={onBack}
className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-secondary)] text-sm transition-colors hover-hover:text-[var(--text-body)]'
>
<Table className='mr-3 size-[14px] text-[var(--text-icon)]' />
Tables
</button>
<span className='select-none text-[var(--text-icon)] text-sm'>/</span>
<span className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-body)] text-sm'>
{tableName}
<ChevronDown className='ml-2 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</span>
</div>
</div>
{/* Spreadsheet - matches exact real table editor structure */}
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
<table className='table-fixed border-separate border-spacing-0 text-small'>
<colgroup>
<col style={{ width: 40 }} />
{data.columns.map((col) => (
<col key={col.id} style={{ width: col.width }} />
))}
</colgroup>
<thead className='sticky top-0 z-10'>
<tr>
<th className={CELL_HEADER_CHECKBOX} aria-label='Row number' />
{data.columns.map((col) => {
const Icon = COLUMN_TYPE_ICONS[col.type] ?? TypeText
return (
<th key={col.id} className={CELL_HEADER}>
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{col.label}
</span>
<ChevronDown className='ml-auto h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{data.rows.map((row, rowIdx) => (
<tr key={row.id}>
<td className={cn(CELL_CHECKBOX, 'text-center')}>
<span className='text-[var(--text-tertiary)] text-xs tabular-nums'>
{rowIdx + 1}
</span>
</td>
{data.columns.map((col) => {
const isSelected = selectedCell?.row === row.id && selectedCell?.col === col.id
const cellValue = row.cells[col.id] ?? ''
return (
<td
key={col.id}
onClick={() => setSelectedCell({ row: row.id, col: col.id })}
className={cn(
CELL,
'relative cursor-default text-[var(--text-body)]',
isSelected && 'bg-[rgba(37,99,235,0.06)]'
)}
>
{isSelected && <div className={SELECTION_OVERLAY} />}
<div className={CELL_CONTENT}>
{col.type === 'boolean' ? (
<div className='flex min-h-[20px] items-center justify-center'>
<Checkbox
size='sm'
checked={cellValue === 'true'}
aria-label={col.label}
className='pointer-events-none'
/>
</div>
) : (
cellValue
)}
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
const tableViewTransition = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
transition: { duration: 0.25, ease: EASE_OUT },
} as const
export function LandingPreviewTables() {
const [openTableId, setOpenTableId] = useState<string | null>(null)
return (
<LazyMotion features={domAnimation}>
<AnimatePresence mode='wait'>
{openTableId !== null ? (
<m.div
key={`spreadsheet-${openTableId}`}
className='flex h-full flex-1 flex-col'
{...tableViewTransition}
>
<SpreadsheetView
tableId={openTableId}
tableName={TABLE_METAS[openTableId] ?? 'Table'}
onBack={() => setOpenTableId(null)}
/>
</m.div>
) : (
<m.div key='table-list' className='flex h-full flex-1 flex-col' {...tableViewTransition}>
<LandingPreviewResource
icon={Table}
title='Tables'
createLabel='New table'
searchPlaceholder='Search tables...'
columns={LIST_COLUMNS}
rows={LIST_ROWS}
onRowClick={(id) => setOpenTableId(id)}
/>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
)
}
@@ -0,0 +1,154 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import ReactFlow, {
applyEdgeChanges,
applyNodeChanges,
type Edge,
type EdgeProps,
type EdgeTypes,
getSmoothStepPath,
type Node,
type NodeTypes,
type OnEdgesChange,
type OnNodesChange,
ReactFlowProvider,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { PreviewBlockNode } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
import {
EASE_OUT,
type PreviewWorkflow,
toReactFlowElements,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
interface LandingPreviewWorkflowProps {
workflow: PreviewWorkflow
animate?: boolean
}
/**
* Custom edge that draws left-to-right on initial load via stroke animation.
* Falls back to a static path when `data.animate` is false.
*/
function PreviewEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
data,
}: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
})
if (data?.animate) {
return (
<m.path
id={id}
className='react-flow__edge-path'
d={edgePath}
style={{ ...style, fill: 'none' }}
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
opacity: { duration: 0.15, delay: data.delay ?? 0 },
}}
/>
)
}
return (
<path
id={id}
className='react-flow__edge-path'
d={edgePath}
style={{ ...style, fill: 'none' }}
/>
)
}
const NODE_TYPES: NodeTypes = { previewBlock: PreviewBlockNode }
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
const PRO_OPTIONS = { hideAttribution: true }
const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.5, maxZoom: 1 } as const
/**
* Inner flow component. Keyed on workflow ID by the parent so it remounts
* cleanly on workflow switch - fitView fires on mount with zero delay.
*/
function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => toReactFlowElements(workflow, animate),
[workflow, animate]
)
const [nodes, setNodes] = useState<Node[]>(initialNodes)
const [edges, setEdges] = useState<Edge[]>(initialEdges)
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
)
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[]
)
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{ type: 'previewEdge' }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
zoomOnScroll={false}
zoomOnDoubleClick={false}
panOnScroll={false}
zoomOnPinch={false}
panOnDrag={false}
preventScrolling={false}
autoPanOnNodeDrag={false}
proOptions={PRO_OPTIONS}
minZoom={0.5}
fitView
fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}
className='h-full w-full bg-[var(--surface-2)]'
/>
)
}
/**
* Lightweight ReactFlow canvas displaying a static workflow preview -
* decorative, so panning, dragging, zooming, and selecting are all disabled.
* The key on workflow.id forces a clean remount on switch - instant fitView,
* no timers, no flicker.
*/
export function LandingPreviewWorkflow({ workflow, animate = false }: LandingPreviewWorkflowProps) {
return (
<LazyMotion features={domAnimation}>
<div className='h-full w-full'>
<ReactFlowProvider key={workflow.id}>
<PreviewFlow workflow={workflow} animate={animate} />
</ReactFlowProvider>
</div>
</LazyMotion>
)
}
@@ -0,0 +1,329 @@
'use client'
import { memo } from 'react'
import { Blimp } from '@sim/emcn'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { Database } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import {
AgentIcon,
AnthropicIcon,
FirecrawlIcon,
GeminiIcon,
GithubIcon,
GmailIcon,
GoogleCalendarIcon,
GoogleSheetsIcon,
HubspotIcon,
JiraIcon,
LinearIcon,
LinkedInIcon,
MistralIcon,
NotionIcon,
OpenAIIcon,
RedditIcon,
ReductoIcon,
SalesforceIcon,
ScheduleIcon,
SlackIcon,
StartIcon,
SupabaseIcon,
TelegramIcon,
TextractIcon,
WebhookIcon,
xAIIcon,
xIcon,
YouTubeIcon,
} from '@/components/icons'
import {
BLOCK_STAGGER,
EASE_OUT,
type PreviewTool,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import { getTileIconColorClass } from '@/blocks/icon-color'
/** Map block type strings to their icon components. */
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
starter: StartIcon,
start_trigger: StartIcon,
agent: AgentIcon,
slack: SlackIcon,
jira: JiraIcon,
x: xIcon,
youtube: YouTubeIcon,
schedule: ScheduleIcon,
telegram: TelegramIcon,
knowledge_base: Database,
webhook: WebhookIcon,
github: GithubIcon,
supabase: SupabaseIcon,
google_calendar: GoogleCalendarIcon,
gmail: GmailIcon,
google_sheets: GoogleSheetsIcon,
hubspot: HubspotIcon,
linear: LinearIcon,
firecrawl: FirecrawlIcon,
reddit: RedditIcon,
notion: NotionIcon,
reducto: ReductoIcon,
salesforce: SalesforceIcon,
textract: TextractIcon,
linkedin: LinkedInIcon,
mothership: Blimp,
}
/** Model prefix → provider icon for the "Model" row in agent blocks. */
const MODEL_PROVIDER_ICONS: Array<{
prefix: string
icon: React.ComponentType<{ className?: string }>
size?: string
}> = [
{ prefix: 'gpt-', icon: OpenAIIcon },
{ prefix: 'o3', icon: OpenAIIcon },
{ prefix: 'o4', icon: OpenAIIcon },
{ prefix: 'claude-', icon: AnthropicIcon },
{ prefix: 'gemini-', icon: GeminiIcon },
{ prefix: 'grok-', icon: xAIIcon, size: 'size-[17px]' },
{ prefix: 'mistral-', icon: MistralIcon },
]
function getModelIconEntry(modelValue: string) {
const lower = modelValue.toLowerCase()
return MODEL_PROVIDER_ICONS.find((m) => lower.startsWith(m.prefix)) ?? null
}
/**
* Data shape for preview block nodes
*/
interface PreviewBlockData {
name: string
blockType: string
bgColor: string
rows: Array<{ title: string; value: string }>
tools?: PreviewTool[]
markdown?: string
hideTargetHandle?: boolean
hideSourceHandle?: boolean
index?: number
animate?: boolean
}
/**
* Handle styling matching the real WorkflowBlock handles.
* --workflow-edge in dark mode: #c9c9c9
*/
const HANDLE_BASE = '!z-[10] !top-5 !-translate-y-1/2 !border-none !bg-[var(--surface-7)]'
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
/**
* Static preview block node matching the real WorkflowBlock styling.
* Renders a block header with icon + name, sub-block rows, and tool chips.
*
* Colors sourced from dark theme CSS variables:
* --surface-2: #ffffff, --border-1: #e6e6e6
* --text-primary: #121212, --text-tertiary: #5f5f5f
*/
export const PreviewBlockNode = memo(function PreviewBlockNode({
data,
}: NodeProps<PreviewBlockData>) {
const {
name,
blockType,
bgColor,
rows,
tools,
markdown,
hideTargetHandle,
hideSourceHandle,
index = 0,
animate = false,
} = data
const Icon = BLOCK_ICONS[blockType]
const delay = animate ? index * BLOCK_STAGGER : 0
if (blockType === 'note' && markdown) {
return (
<LazyMotion features={domAnimation}>
<m.div
className='relative'
initial={animate ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
>
<div className='w-[280px] select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
<div className='border-[var(--border)] border-b p-2'>
<span className='font-medium text-[16px] text-[var(--text-primary)]'>Note</span>
</div>
<div className='p-2.5'>
<NoteMarkdown content={markdown} />
</div>
</div>
</m.div>
</LazyMotion>
)
}
const hasContent = rows.length > 0 || (tools && tools.length > 0)
return (
<LazyMotion features={domAnimation}>
<m.div
className='relative'
initial={animate ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
>
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
{/* Target handle (left side) */}
{!hideTargetHandle && (
<Handle
type='target'
position={Position.Left}
id='target'
className={HANDLE_LEFT}
isConnectableStart={false}
isConnectableEnd={false}
/>
)}
{/* Header */}
<div
className={`flex items-center justify-between p-2 ${hasContent ? 'border-[var(--border)] border-b' : ''}`}
>
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
<div
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ background: bgColor }}
>
{Icon && <Icon className={`size-[16px] ${getTileIconColorClass(bgColor)}`} />}
</div>
<span className='truncate font-medium text-[16px] text-[var(--text-primary)]'>
{name}
</span>
</div>
</div>
{/* Sub-block rows + tools */}
{hasContent && (
<div className='flex flex-col gap-2 p-2'>
{rows.map((row) => {
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
const ModelIcon = modelEntry?.icon
return (
<div key={row.title} className='flex items-center gap-2'>
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--text-muted)] capitalize'>
{row.title}
</span>
{row.value && (
<span className='flex min-w-0 flex-1 items-center justify-end gap-2 font-normal text-[14px] text-[var(--text-primary)]'>
{ModelIcon && (
<ModelIcon
className={`inline-block flex-shrink-0 text-[var(--text-primary)] ${modelEntry.size ?? 'size-[14px]'}`}
/>
)}
<span className='truncate'>{row.value}</span>
</span>
)}
</div>
)
})}
{/* Tool chips - inline with label */}
{tools && tools.length > 0 && (
<div className='flex items-center gap-2'>
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--text-muted)]'>
Tools
</span>
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
{tools.map((tool) => {
const ToolIcon = BLOCK_ICONS[tool.type]
return (
<div
key={tool.type}
className='flex items-center gap-[5px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-1)] px-[6px] py-[3px]'
>
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: tool.bgColor }}
>
{ToolIcon && (
<ToolIcon
className={`size-[10px] ${getTileIconColorClass(tool.bgColor)}`}
/>
)}
</div>
<span className='font-normal text-[12px] text-[var(--text-primary)]'>
{tool.name}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Source handle (right side) */}
{!hideSourceHandle && (
<Handle
type='source'
position={Position.Right}
id='source'
className={HANDLE_RIGHT}
isConnectableStart={false}
isConnectableEnd={false}
/>
)}
</div>
</m.div>
</LazyMotion>
)
})
/**
* Renders lightweight markdown-like content for note blocks.
* Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing.
*/
function NoteMarkdown({ content }: { content: string }) {
const lines = content.split('\n')
return (
<div className='flex flex-col gap-1'>
{lines.map((line, i) => {
const trimmed = line.trim()
if (!trimmed) return <div key={`${line}-${i}`} className='h-[4px]' />
if (trimmed === '---') {
return <hr key={`${line}-${i}`} className='my-1 border-[var(--border)] border-t' />
}
if (trimmed.startsWith('### ')) {
return (
<p
key={`${line}-${i}`}
className='font-semibold text-[16px] text-[var(--text-primary)] leading-[1.3]'
>
{trimmed.slice(4)}
</p>
)
}
return (
<p
key={`${line}-${i}`}
className='font-medium text-[13px] text-[var(--text-primary)] leading-[1.5]'
dangerouslySetInnerHTML={{
__html: trimmed
.replace(/\*\*_(.+?)_\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/_"(.+?)"_/g, '<em>&ldquo;$1&rdquo;</em>')
.replace(/_(.+?)_/g, '<em>$1</em>'),
}}
/>
)
})}
</div>
)
}
@@ -0,0 +1,434 @@
import type { Edge, Node } from 'reactflow'
import { Position } from 'reactflow'
/**
* Tool entry displayed as a chip on agent blocks
*/
export interface PreviewTool {
name: string
type: string
bgColor: string
}
/**
* Static block definition for preview workflow nodes
*/
export interface PreviewBlock {
id: string
name: string
type: string
bgColor: string
rows: Array<{ title: string; value: string }>
tools?: PreviewTool[]
markdown?: string
position: { x: number; y: number }
hideTargetHandle?: boolean
hideSourceHandle?: boolean
}
/**
* A scripted Mothership conversation shown in the chat pane beside a staged
* resource - the request a builder typed and Sim's reply. Anchors the
* "chat everywhere" narrative: the chat drives whatever is staged on the right.
*/
export interface PreviewChat {
user: string
assistant: string
}
/**
* Workflow definition containing nodes, edges, and metadata
*/
export interface PreviewWorkflow {
id: string
name: string
color: string
blocks: PreviewBlock[]
edges: Array<{ id: string; source: string; target: string }>
/** The Mothership exchange that produced this workflow, shown in the chat pane. */
chat?: PreviewChat
/** Public JSON export used to seed the landing-page import flow */
seedPath?: string
}
/**
* IT Service Management workflow - Slack Trigger -> Agent (KB tool) -> Jira
*/
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
id: 'wf-it-service',
name: 'IT Service Management',
color: '#2C2C2C',
blocks: [
{
id: 'slack-1',
name: 'Slack',
type: 'slack',
bgColor: '#2C2C2C',
rows: [
{ title: 'Channel', value: '#it-support' },
{ title: 'Event', value: 'New Message' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-1',
name: 'Agent',
type: 'agent',
bgColor: '#2C2C2C',
rows: [
{ title: 'Model', value: 'claude-sonnet-4.6' },
{
title: 'System Prompt',
value:
'Triage incoming IT support requests from Slack, categorize by severity, and create Jira tickets for the appropriate team.',
},
],
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#2C2C2C' }],
position: { x: 420, y: 40 },
},
{
id: 'jira-1',
name: 'Jira',
type: 'jira',
bgColor: '#2C2C2C',
rows: [
{ title: 'Operation', value: 'Get Issues' },
{ title: 'Project', value: 'IT-Support' },
],
position: { x: 420, y: 260 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'slack-1', target: 'agent-1' },
{ id: 'e-2', source: 'slack-1', target: 'jira-1' },
],
chat: {
user: 'Set up an IT support agent that answers Slack questions from our docs and files Jira tickets.',
assistant:
'Built IT Service Management. It watches #it-support, answers from your knowledge base, and opens Jira issues for anything it cant resolve.',
},
}
/**
* Self-healing CRM workflow - Schedule -> Agent
*/
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
id: 'wf-self-healing-crm',
name: 'Self-healing CRM',
color: '#2C2C2C',
blocks: [
{
id: 'schedule-1',
name: 'Schedule',
type: 'schedule',
bgColor: '#2C2C2C',
rows: [
{ title: 'Run Frequency', value: 'Daily' },
{ title: 'Time', value: '09:00 AM' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-crm',
name: 'CRM Agent',
type: 'agent',
bgColor: '#2C2C2C',
rows: [
{ title: 'Model', value: 'gpt-5.4' },
{
title: 'System Prompt',
value:
'Audit CRM records, identify data inconsistencies, and fix duplicate contacts, missing fields, and stale pipeline entries across HubSpot and Salesforce.',
},
],
tools: [
{ name: 'HubSpot', type: 'hubspot', bgColor: '#2C2C2C' },
{ name: 'Salesforce', type: 'salesforce', bgColor: '#2C2C2C' },
],
position: { x: 420, y: 140 },
hideSourceHandle: true,
},
],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-crm' }],
chat: {
user: 'Build an agent that audits our CRM every morning and fixes bad records.',
assistant:
'Done. Self-healing CRM runs daily at 9 AM, audits HubSpot and Salesforce, and fixes duplicates, missing fields, and stale entries.',
},
}
/**
* Customer Support Agent workflow - Gmail Trigger -> Agent (KB + Notion tools) -> Slack
*/
const CUSTOMER_SUPPORT_WORKFLOW: PreviewWorkflow = {
id: 'wf-customer-support',
name: 'Customer Support Agent',
color: '#2C2C2C',
blocks: [
{
id: 'gmail-1',
name: 'Gmail',
type: 'gmail',
bgColor: '#2C2C2C',
rows: [
{ title: 'Event', value: 'New Email' },
{ title: 'Label', value: 'Support' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-3',
name: 'Support Agent',
type: 'agent',
bgColor: '#2C2C2C',
rows: [
{ title: 'Model', value: 'gpt-5.4' },
{
title: 'System Prompt',
value:
'Resolve customer support issues using the knowledge base, draft a response, and notify the team in Slack.',
},
],
tools: [
{ name: 'Knowledge', type: 'knowledge_base', bgColor: '#2C2C2C' },
{ name: 'Notion', type: 'notion', bgColor: '#2C2C2C' },
],
position: { x: 420, y: 40 },
},
{
id: 'slack-3',
name: 'Slack',
type: 'slack',
bgColor: '#2C2C2C',
rows: [
{ title: 'Channel', value: '#support' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 420, y: 260 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-cs-1', source: 'gmail-1', target: 'agent-3' },
{ id: 'e-cs-2', source: 'gmail-1', target: 'slack-3' },
],
chat: {
user: 'Make a support agent that replies to customer emails using our help docs.',
assistant:
'Here it is. Customer Support Agent triages new support email, drafts replies from your Knowledge Base and Notion, and posts to #support.',
},
}
/**
* Empty "New Agent" workflow - a single note prompting the user to start building
*/
const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
id: 'wf-new-agent',
name: 'New Agent',
color: '#2C2C2C',
blocks: [
{
id: 'note-1',
name: '',
type: 'note',
bgColor: 'transparent',
rows: [],
markdown: '### What will you build?\n\n_"Find Linear todos and send in Slack"_',
position: { x: 0, y: 0 },
hideTargetHandle: true,
hideSourceHandle: true,
},
],
edges: [],
}
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
SELF_HEALING_CRM_WORKFLOW,
IT_SERVICE_WORKFLOW,
CUSTOMER_SUPPORT_WORKFLOW,
NEW_AGENT_WORKFLOW,
]
/** Stagger delay between each block appearing (seconds). */
export const BLOCK_STAGGER = 0.12
/** Shared cubic-bezier easing - fast deceleration, gentle settle. */
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
/** Shared edge style applied to all preview workflow connections */
const EDGE_STYLE = { stroke: '#c9c9c9', strokeWidth: 1.5 } as const
/**
* Converts a PreviewWorkflow to React Flow nodes and edges.
*
* @param workflow - The workflow definition
* @param animate - When true, node/edge data includes animation metadata
*/
export function toReactFlowElements(
workflow: PreviewWorkflow,
animate = false
): {
nodes: Node[]
edges: Edge[]
} {
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
const nodes: Node[] = workflow.blocks.map((block, index) => ({
id: block.id,
type: 'previewBlock',
position: block.position,
data: {
name: block.name,
blockType: block.type,
bgColor: block.bgColor,
rows: block.rows,
tools: block.tools,
markdown: block.markdown,
hideTargetHandle: block.hideTargetHandle,
hideSourceHandle: block.hideSourceHandle,
index,
animate,
},
draggable: true,
selectable: false,
connectable: false,
sourcePosition: Position.Right,
targetPosition: Position.Left,
}))
const edges: Edge[] = workflow.edges.map((e) => {
const sourceIndex = blockIndexMap.get(e.source) ?? 0
return {
id: e.id,
source: e.source,
target: e.target,
type: 'previewEdge',
animated: false,
style: EDGE_STYLE,
sourceHandle: 'source',
targetHandle: 'target',
data: {
animate,
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
},
}
})
return { nodes, edges }
}
/** Block types that carry an editable prompt suitable for the Editor tab. */
const AGENT_BLOCK_TYPES = new Set(['agent', 'mothership'])
export interface EditorPromptData {
blockId: string
blockName: string
blockType: string
bgColor: string
prompt: string
model: string | null
tools: PreviewTool[]
}
/**
* Extracts the editor-facing prompt from the first agent/mothership block.
*
* @returns Block metadata + prompt + model + tools, or `null` when the workflow has no agent.
*/
export function getEditorPrompt(workflow: PreviewWorkflow): EditorPromptData | null {
for (const block of workflow.blocks) {
if (!AGENT_BLOCK_TYPES.has(block.type)) continue
/** Single ordered pass: first row matching either prompt title, and the first Model row. */
let promptRow: (typeof block.rows)[number] | undefined
let modelRow: (typeof block.rows)[number] | undefined
for (const row of block.rows) {
if (!promptRow && (row.title === 'Prompt' || row.title === 'System Prompt')) promptRow = row
if (!modelRow && row.title === 'Model') modelRow = row
}
if (promptRow) {
return {
blockId: block.id,
blockName: block.name,
blockType: block.type,
bgColor: block.bgColor,
prompt: promptRow.value,
model: modelRow?.value ?? null,
tools: block.tools ?? [],
}
}
}
return null
}
/**
* Computes the delay (ms) before the Editor tab should activate.
* Accounts for all block staggers + edge draw durations + a small buffer.
*/
export function getWorkflowAnimationTiming(workflow: PreviewWorkflow): { editorDelay: number } {
const maxBlockIndex = Math.max(0, workflow.blocks.length - 1)
const hasEdges = workflow.edges.length > 0
const edgeDuration = hasEdges ? 0.4 : 0
const buffer = 0.15
const total = maxBlockIndex * BLOCK_STAGGER + BLOCK_STAGGER + edgeDuration + buffer
return { editorDelay: Math.round(total * 1000) }
}
/**
* Scripted chat for the non-workflow resources the demo stages (logs, tables),
* keyed by sidebar view. Keeps the chat pane present and on-topic so the
* "chat everywhere" story holds while a non-workflow resource is staged.
*/
export const RESOURCE_CHATS: Record<string, PreviewChat> = {
logs: {
user: 'How are my agents doing today?',
assistant:
'7 runs today across your agents, 2 errors: Lead Enrichment and Content Moderator. Want me to dig into those?',
},
tables: {
user: 'Show me my data tables.',
assistant:
'Here are your tables: Customer Leads, Product Catalog, and three more. Ask me to query or update any of them.',
},
files: {
user: 'What files do we have in the workspace?',
assistant: 'Pulling up your files. I can summarize, extract, or feed any of them to an agent.',
},
knowledge: {
user: 'Whats in our knowledge base?',
assistant: 'Here are your knowledge bases. Your agents read from these to ground every answer.',
},
'scheduled-tasks': {
user: 'Whats scheduled to run?',
assistant: 'These are your scheduled tasks. I can pause, edit, or add a new one for you.',
},
}
/** The chat shown for a staged resource: the workflow's own exchange, else the view's. */
export function getViewChat(view: string, workflow?: PreviewWorkflow): PreviewChat | null {
if (view === 'workflow') return workflow?.chat ?? null
return RESOURCE_CHATS[view] ?? null
}
/** Milliseconds between each character typed in the Editor prompt animation. */
export const TYPE_INTERVAL_MS = 30
/** Extra pause (ms) after switching to the Editor tab before typing begins. */
export const TYPE_START_BUFFER_MS = 150
/** How long to dwell on a completed step before advancing (ms). */
export const STEP_DWELL_MS = 2500
/**
* Computes the total time (ms) a workflow step occupies, including
* canvas animation, editor typing, and a dwell period.
*/
export function getWorkflowStepDuration(workflow: PreviewWorkflow): number {
const { editorDelay } = getWorkflowAnimationTiming(workflow)
const prompt = getEditorPrompt(workflow)
const typingTime = prompt ? prompt.prompt.length * TYPE_INTERVAL_MS : 0
return editorDelay + TYPE_START_BUFFER_MS + typingTime + STEP_DWELL_MS
}
@@ -0,0 +1,29 @@
'use client'
import { useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import { trackLandingCta } from '@/app/(landing)/track-landing-cta'
/**
* Stores the typed prompt in browser storage and routes to `/signup`, so a
* visitor's first message survives the auth hop. Shared by the landing
* preview's chat pane and the home empty-state input.
*/
export function useLandingSubmit() {
const router = useRouter()
return useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed) return
LandingPromptStorage.store(trimmed)
trackLandingCta({
label: 'Prompt submit',
section: 'landing_preview',
destination: '/signup',
})
router.push('/signup')
},
[router]
)
}
@@ -0,0 +1,55 @@
'use client'
import dynamic from 'next/dynamic'
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'
/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */
const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]'
/**
* Client mount for the {@link LandingPreview} - the heavy, animated workspace
* island (framer-motion + reactflow). Isolated here so the sections that show it
* stay Server Components: only this leaf is `'use client'`.
*
* Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in
* the server-rendered HTML, and gated on viewport proximity via
* {@link useLazyMount} so the below-the-fold previews don't pull the heavy
* bundle into the initial homepage load. A dimension-stable placeholder (the
* preview's exact `aspect-[1116/615]` footprint, filled with the canvas
* surface) holds the space before and during load, so there is zero layout
* shift or flash.
*/
const LandingPreview = dynamic(
() =>
import('@/app/(landing)/components/landing-preview/landing-preview').then(
(mod) => mod.LandingPreview
),
{
ssr: false,
loading: () => <div className={PLACEHOLDER_CLASS} />,
}
)
interface LandingPreviewMountProps {
/** Forwarded to {@link LandingPreview}; `false` renders a static snapshot. */
autoplay?: boolean
/** Forwarded to {@link LandingPreview}; the static snapshot's staged view. */
view?: SidebarView
/** Forwarded to {@link LandingPreview}; the static snapshot's workflow. */
workflowId?: string
}
export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) {
const { ref, inView } = useLazyMount('400px')
return (
<div ref={ref}>
{inView ? (
<LandingPreview autoplay={autoplay} initialView={view} initialWorkflowId={workflowId} />
) : (
<div className={PLACEHOLDER_CLASS} />
)}
</div>
)
}
@@ -0,0 +1,361 @@
'use client'
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { AnimatePresence, domAnimation, LazyMotion, m, type Variants } from 'framer-motion'
import { LandingPreviewChat } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/landing-preview-chat'
import { LandingPreviewFiles } from '@/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files'
import { LandingPreviewHome } from '@/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home'
import { LandingPreviewKnowledge } from '@/app/(landing)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
import { LandingPreviewLogs } from '@/app/(landing)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
import { LandingPreviewScheduledTasks } from '@/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewSidebar } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewStageHeader } from '@/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header'
import { LandingPreviewTables } from '@/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables'
import { LandingPreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
import {
EASE_OUT,
getViewChat,
getWorkflowStepDuration,
PREVIEW_WORKFLOWS,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/** Chat-switcher breadcrumb title per non-workflow staged view. */
const CHAT_TITLES: Partial<Record<SidebarView, string>> = {
logs: 'Agent activity',
tables: 'Workspace data',
files: 'Files',
knowledge: 'Knowledge base',
'scheduled-tasks': 'Scheduled tasks',
}
const containerVariants: Variants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.15 },
},
}
const sidebarVariants: Variants = {
hidden: { opacity: 0, x: -12 },
visible: {
opacity: 1,
x: 0,
transition: {
x: { duration: 0.25, ease: EASE_OUT },
opacity: { duration: 0.25, ease: EASE_OUT },
},
},
}
const viewTransition = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.2, ease: EASE_OUT },
} as const
interface DemoStep {
type: 'workflow' | 'home' | 'logs'
workflowId?: string
duration: number
}
const WORKFLOW_MAP = new Map(PREVIEW_WORKFLOWS.map((w) => [w.id, w]))
const HOME_STEP_MS = 12000
const LOGS_STEP_MS = 5000
const DESKTOP_QUERY = '(min-width: 1024px)'
/** SSR-safe desktop media-query subscription for {@link useSyncExternalStore}. */
function subscribeDesktop(onChange: () => void) {
const mql = window.matchMedia(DESKTOP_QUERY)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}
const getDesktopSnapshot = () => window.matchMedia(DESKTOP_QUERY).matches
const getDesktopServerSnapshot = () => true
/** Full desktop sequence: CRM -> home -> logs -> ITSM -> support -> repeat */
const DESKTOP_STEPS: DemoStep[] = [
{
type: 'workflow',
workflowId: 'wf-self-healing-crm',
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-self-healing-crm')!),
},
{ type: 'home', duration: HOME_STEP_MS },
{ type: 'logs', duration: LOGS_STEP_MS },
{
type: 'workflow',
workflowId: 'wf-it-service',
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-it-service')!),
},
{
type: 'workflow',
workflowId: 'wf-customer-support',
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-customer-support')!),
},
]
interface LandingPreviewProps {
/**
* When false, render a static snapshot: no auto-cycle, no entrance/data-flow
* animation. Used when the preview is a faded backdrop behind an elevated
* callout rather than the live demo.
*/
autoplay?: boolean
/**
* Initial staged view for the static snapshot (`autoplay={false}`). Defaults
* to `'workflow'`. Lets each feature stage show the platform surface that
* matches its callout (e.g. `'logs'`, `'scheduled-tasks'`).
*/
initialView?: SidebarView
/** Initial workflow for the static snapshot. Defaults to the first preview workflow. */
initialWorkflowId?: string
}
/**
* Interactive workspace preview for the hero section.
*
* Desktop: auto-cycles CRM -> home -> logs -> ITSM -> support -> repeat.
* Mobile: static workflow canvas (no animation, no cycling).
* User interaction permanently stops the auto-cycle.
*
* Pass `autoplay={false}` to render a fully static snapshot (no cycling, no
* animation) - for use as a background behind a feature callout.
*/
export function LandingPreview({
autoplay = true,
initialView = 'workflow',
initialWorkflowId = PREVIEW_WORKFLOWS[0].id,
}: LandingPreviewProps) {
const [activeView, setActiveView] = useState<SidebarView>(initialView)
const [activeWorkflowId, setActiveWorkflowId] = useState(initialWorkflowId)
const [animationKey, setAnimationKey] = useState(0)
const [autoTypeHome, setAutoTypeHome] = useState(false)
const isDesktop = useSyncExternalStore(
subscribeDesktop,
getDesktopSnapshot,
getDesktopServerSnapshot
)
const demoIndexRef = useRef(0)
const demoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const autoCycleActiveRef = useRef(true)
const clearDemoTimer = useCallback(() => {
if (demoTimerRef.current) {
clearTimeout(demoTimerRef.current)
demoTimerRef.current = null
}
}, [])
const applyDemoStep = useCallback((step: DemoStep) => {
setAutoTypeHome(false)
if (step.type === 'workflow' && step.workflowId) {
setActiveWorkflowId(step.workflowId)
setActiveView('workflow')
setAnimationKey((k) => k + 1)
} else if (step.type === 'home') {
setActiveView('home')
setAutoTypeHome(true)
} else if (step.type === 'logs') {
setActiveView('logs')
}
}, [])
const scheduleNextStep = useCallback(() => {
if (!autoCycleActiveRef.current) return
const steps = DESKTOP_STEPS
const currentStep = steps[demoIndexRef.current]
demoTimerRef.current = setTimeout(() => {
if (!autoCycleActiveRef.current) return
demoIndexRef.current = (demoIndexRef.current + 1) % steps.length
applyDemoStep(steps[demoIndexRef.current])
scheduleNextStep()
}, currentStep.duration)
}, [applyDemoStep])
useEffect(() => {
/** `isDesktop` is reactive, so gate on the cycle still being active - a resize after interaction must not restart. */
if (!isDesktop || !autoplay || !autoCycleActiveRef.current) return
/** Reset the index so a restart replays from step 0 (index, applied step, and delay stay consistent). */
demoIndexRef.current = 0
applyDemoStep(DESKTOP_STEPS[0])
scheduleNextStep()
return clearDemoTimer
}, [isDesktop, autoplay, applyDemoStep, scheduleNextStep, clearDemoTimer])
const stopAutoCycle = useCallback(() => {
autoCycleActiveRef.current = false
clearDemoTimer()
}, [clearDemoTimer])
const handleSelectWorkflow = useCallback(
(id: string) => {
stopAutoCycle()
setAutoTypeHome(false)
setActiveWorkflowId(id)
setActiveView('workflow')
setAnimationKey((k) => k + 1)
},
[stopAutoCycle]
)
const handleSelectHome = useCallback(() => {
stopAutoCycle()
setAutoTypeHome(false)
setActiveView('home')
}, [stopAutoCycle])
const handleSelectNav = useCallback(
(id: SidebarView) => {
stopAutoCycle()
setAutoTypeHome(false)
setActiveView(id)
},
[stopAutoCycle]
)
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
const isWorkflowView = activeView === 'workflow'
const isHomeView = activeView === 'home'
const chatName = isWorkflowView ? activeWorkflow.name : (CHAT_TITLES[activeView] ?? 'New chat')
/** Desktop demo motion only runs when autoplaying; otherwise a static snapshot. */
const animated = isDesktop && autoplay
return (
<LazyMotion features={domAnimation}>
<m.div
className='flex aspect-[1116/615] w-full overflow-hidden rounded bg-[var(--surface-1)] antialiased'
initial={animated ? 'hidden' : false}
animate='visible'
variants={containerVariants}
>
<m.div className='hidden lg:flex' variants={sidebarVariants}>
<LandingPreviewSidebar
workflows={PREVIEW_WORKFLOWS}
activeWorkflowId={activeWorkflowId}
activeView={activeView}
onSelectWorkflow={handleSelectWorkflow}
onSelectHome={handleSelectHome}
onSelectNav={handleSelectNav}
/>
</m.div>
<div className='flex min-w-0 flex-1 flex-col py-2 pr-2 pl-2 lg:pl-0'>
<div className='flex flex-1 overflow-hidden rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
{isHomeView ? (
/* Home: the chat IS the whole view - its empty "What should we get
done?" state, with no resource staged. */
<div className='relative flex min-w-0 flex-1 flex-col overflow-hidden'>
{animated ? (
<AnimatePresence mode='wait'>
<m.div
key={`home-${animationKey}`}
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewHome autoType={autoTypeHome} />
</m.div>
</AnimatePresence>
) : (
<LandingPreviewHome autoType={autoTypeHome} />
)}
</div>
) : (
/* Chat everywhere: the persistent Mothership chat pane on the left,
a single staged resource on the right. The chat pane stays
mounted as the staged resource crossfades - the chat is the
constant the work hangs off of. */
<>
<m.div className='hidden lg:flex' variants={sidebarVariants}>
<LandingPreviewChat
chat={isDesktop ? getViewChat(activeView, activeWorkflow) : null}
chatName={chatName}
animationKey={animationKey}
/>
</m.div>
<div className='relative flex min-w-0 flex-1 flex-col overflow-hidden border-[var(--border-1)] border-l'>
{isWorkflowView && <LandingPreviewStageHeader name={activeWorkflow.name} />}
<div className='relative min-h-0 flex-1 overflow-hidden'>
{animated ? (
<AnimatePresence mode='wait'>
{activeView === 'workflow' && (
<m.div
key={`wf-${activeWorkflow.id}-${animationKey}`}
className='h-full w-full'
{...viewTransition}
>
<LandingPreviewWorkflow workflow={activeWorkflow} animate />
</m.div>
)}
{activeView === 'tables' && (
<m.div
key={`tables-${animationKey}`}
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewTables />
</m.div>
)}
{activeView === 'files' && (
<m.div
key='files'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewFiles />
</m.div>
)}
{activeView === 'knowledge' && (
<m.div
key='knowledge'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewKnowledge />
</m.div>
)}
{activeView === 'logs' && (
<m.div key='logs' className='flex h-full w-full flex-col' initial={false}>
<LandingPreviewLogs />
</m.div>
)}
{activeView === 'scheduled-tasks' && (
<m.div
key='scheduled-tasks'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewScheduledTasks />
</m.div>
)}
</AnimatePresence>
) : activeView === 'tables' ? (
<LandingPreviewTables />
) : activeView === 'files' ? (
<LandingPreviewFiles />
) : activeView === 'knowledge' ? (
<LandingPreviewKnowledge />
) : activeView === 'logs' ? (
<LandingPreviewLogs />
) : activeView === 'scheduled-tasks' ? (
<LandingPreviewScheduledTasks />
) : (
<LandingPreviewWorkflow workflow={activeWorkflow} />
)}
</div>
</div>
</>
)}
</div>
</div>
</m.div>
</LazyMotion>
)
}