chore: remove confirmed unused files (#6863)

This commit is contained in:
Justin Blumencranz
2026-08-19 18:16:44 -07:00
committed by GitHub
parent a9cf760c0f
commit 8aab6d573e
87 changed files with 1 additions and 4723 deletions
@@ -1,4 +1,4 @@
import { BuildCallout } from '@/app/(landing)/components/features/components/build-callout/build-callout'
import { BuildCallout } from '@/app/(landing)/components/features/components/build-callout'
import { FeatureCard } from '@/app/(landing)/components/features/components/feature-card'
import { IntegrationsCallout } from '@/app/(landing)/components/features/components/integrations-callout/integrations-callout'
import { KnowledgeCallout } from '@/app/(landing)/components/features/components/knowledge-callout/knowledge-callout'
@@ -1,99 +0,0 @@
'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: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>
)
}
@@ -1,44 +0,0 @@
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='size-[14px] 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>
)
}
@@ -1,127 +0,0 @@
'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 [transition-timing-function: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 [transition-timing-function: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>
)
}
@@ -1,135 +0,0 @@
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'
import { ZipIcon } from '@/app/(landing)/components/shared/zip-icon'
/** Generic audio 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>
)
}
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}
/>
)
}
@@ -1,457 +0,0 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { ArrowRight, Blimp, Checkbox, ChevronDown, cn, Shuffle } from '@sim/emcn'
import { Mail, Table, Task, TypeBoolean, TypeNumber, TypeText } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
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: Task },
{ 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='size-[14px] 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='size-[14px] 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 size-[14px] 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>
)
}
@@ -1,120 +0,0 @@
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}
/>
)
}
@@ -1,295 +0,0 @@
'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='border-[var(--border)] border-b'>
<tr>
{COL_HEADERS.map(({ key, label }) => (
<th key={key} className='h-10 px-6 py-1.5 text-left align-middle 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>
)
}
@@ -1,195 +0,0 @@
'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='border-[var(--border)] border-b'>
<tr>
{columns.map((col) => (
<th key={col.id} className='h-10 px-6 py-1.5 text-left align-middle 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>
)
}
@@ -1,13 +0,0 @@
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,
}
}
@@ -1,181 +0,0 @@
'use client'
import {
ChevronDown,
chipActiveSurfaceClass,
chipHoverSurfaceClass,
cn,
Home,
Library,
} from '@sim/emcn'
import { 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'
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: '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)]'>{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',
isActive ? chipActiveSurfaceClass : chipHoverSurfaceClass
)}
>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[13px] text-[var(--text-body)]'>{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'>
{/* 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='size-[14px] 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',
isHomeActive ? chipActiveSurfaceClass : chipHoverSurfaceClass
)}
>
<Home className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[13px] text-[var(--text-body)]'>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',
isActive ? chipActiveSurfaceClass : chipHoverSurfaceClass
)}
>
<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)]'>
{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>
)
}
@@ -1,37 +0,0 @@
import { PanelRight, Workflow, X } from '@sim/emcn/icons'
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 emcn `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>
)
}
@@ -1,569 +0,0 @@
'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 size-[14px] 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 text-[var(--text-primary)] text-small'>
{col.label}
</span>
<ChevronDown className='ml-auto size-[14px] 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>
)
}
@@ -1,154 +0,0 @@
'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>
)
}
@@ -1,329 +0,0 @@
'use client'
import { memo } from 'react'
import { Blimp } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { domAnimation, LazyMotion, m } from 'framer-motion'
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>
)
}
@@ -1,430 +0,0 @@
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 can’t 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: 'What’s in our knowledge base?',
assistant: 'Here are your knowledge bases. Your agents read from these to ground every answer.',
},
}
/** 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
}
@@ -1,29 +0,0 @@
'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]
)
}
@@ -1,348 +0,0 @@
'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 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',
}
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'`, `'tables'`).
*/
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>
)}
</AnimatePresence>
) : activeView === 'tables' ? (
<LandingPreviewTables />
) : activeView === 'files' ? (
<LandingPreviewFiles />
) : activeView === 'knowledge' ? (
<LandingPreviewKnowledge />
) : activeView === 'logs' ? (
<LandingPreviewLogs />
) : (
<LandingPreviewWorkflow workflow={activeWorkflow} />
)}
</div>
</div>
</>
)}
</div>
</div>
</m.div>
</LazyMotion>
)
}
@@ -1 +0,0 @@
export { BuildIcon, DeployIcon, MonitorIcon } from './lifecycle-icons'
@@ -1,55 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { GooMarkSvg } from '@/app/(landing)/components/mothership/components/goo-marks/goo-mark-svg'
import {
useGooHover,
useGooIds,
} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
/**
* Sim circle goo-mark: FAN RINGS.
* 5 same-size circles (radius 30), centers stepped horizontally by 9,
* symmetric about center - fuses into a vesica weave through the middle.
*/
export interface FanRingsProps {
size?: number
className?: string
animate?: boolean
forceHover?: boolean
}
export function FanRings({
size = 110,
className,
animate = true,
forceHover = false,
}: FanRingsProps) {
const { amt, bind } = useGooHover({ animate, forceHover })
const { gradId, gooId } = useGooIds()
const cx = 50
const cy = 50
const r = 30
const n = 5
const sp = 9 * (1 + amt * 0.25)
const start = -((n - 1) / 2) * sp
const circles: ReactNode[] = []
for (let i = 0; i < n; i++) {
circles.push(<circle key={i} cx={(cx + start + i * sp).toFixed(2)} cy={cy} r={r} />)
}
return (
<GooMarkSvg
size={size}
gradId={gradId}
gooId={gooId}
ariaLabel='Fan rings'
bind={bind}
className={className}
>
{circles}
</GooMarkSvg>
)
}
@@ -1,63 +0,0 @@
'use client'
import { GooMarkSvg } from '@/app/(landing)/components/mothership/components/goo-marks/goo-mark-svg'
import {
REACH,
TAU,
useGooHover,
useGooIds,
} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
/**
* Sim circle goo-mark: FLOWER OF LIFE.
* 7 circles - 1 center + 6 on a hexagon at radius 20 - each radius 20,
* scaled so the outer circle edges land on REACH.
*/
export interface FlowerOfLifeProps {
size?: number
className?: string
animate?: boolean
forceHover?: boolean
}
export function FlowerOfLife({
size = 110,
className,
animate = true,
forceHover = false,
}: FlowerOfLifeProps) {
const { amt, bind } = useGooHover({ animate, forceHover })
const { gradId, gooId } = useGooIds()
const cx = 50
const cy = 50
const hexR = 20
const cr = 20
const k = REACH / (hexR + cr)
const crS = cr * k
const breathe = 1 + amt * 0.06
const centers: [number, number][] = [[cx, cy]]
for (let i = 0; i < 6; i++) {
const a = i * (TAU / 6)
centers.push([cx + hexR * Math.cos(a), cy + hexR * Math.sin(a)])
}
return (
<GooMarkSvg
size={size}
gradId={gradId}
gooId={gooId}
ariaLabel='Flower of life'
bind={bind}
className={className}
>
{centers.map(([x, y], i) => {
const nx = cx + (x - cx) * k
const ny = cy + (y - cy) * k
return (
<circle key={i} cx={nx.toFixed(2)} cy={ny.toFixed(2)} r={(crS * breathe).toFixed(2)} />
)
})}
</GooMarkSvg>
)
}
@@ -1,25 +0,0 @@
/**
* Shared SVG `<defs>` for the circle goo-mark family: the brand radial gradient
* (`#2C2C2C` center → `#5F5F5F` edge) and the metaball goo filter (blur → alpha
* threshold). Values are locked to the brand recipe.
*/
interface GooDefsProps {
gradId: string
gooId: string
gooFusion?: number
}
export function GooDefs({ gradId, gooId, gooFusion = 1.5 }: GooDefsProps) {
return (
<defs>
<radialGradient id={gradId} gradientUnits='userSpaceOnUse' cx='50' cy='50' r='44'>
<stop stopColor='#2C2C2C' />
<stop offset='1' stopColor='#5F5F5F' />
</radialGradient>
<filter id={gooId} x='-25%' y='-25%' width='150%' height='150%'>
<feGaussianBlur in='SourceGraphic' stdDeviation={gooFusion} result='b' />
<feColorMatrix in='b' type='matrix' values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9' />
</filter>
</defs>
)
}
@@ -1,64 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
import { GooDefs } from '@/app/(landing)/components/mothership/components/goo-marks/goo-defs'
import {
GOO_FUSION,
STROKE,
} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
/**
* Shared `<svg>` shell for the circle goo marks. Children are the stroke
* `<path>`/`<circle>` elements (plus optional plain dots). Applies the gradient
* stroke + goo filter.
*/
interface GooMarkSvgProps {
size: number
gradId: string
gooId: string
/**
* Retained for caller ergonomics, but no longer rendered: these marks are
* decorative glyphs paired with a visible heading, so the `<svg>` is
* `aria-hidden` rather than labeled.
*/
ariaLabel?: string
bind?: Record<string, () => void>
children: ReactNode
dots?: ReactNode
gooFusion?: number
className?: string
}
export function GooMarkSvg({
size,
gradId,
gooId,
bind,
children,
dots,
gooFusion = GOO_FUSION,
className,
}: GooMarkSvgProps) {
return (
<svg
viewBox='0 0 100 100'
width={size}
height={size}
aria-hidden='true'
className={cn('block outline-none', className)}
{...bind}
>
<GooDefs gradId={gradId} gooId={gooId} gooFusion={gooFusion} />
<g
filter={`url(#${gooId})`}
stroke={`url(#${gradId})`}
strokeWidth={STROKE}
strokeLinecap='round'
strokeLinejoin='round'
fill='none'
>
{children}
</g>
{dots ? <g>{dots}</g> : null}
</svg>
)
}
@@ -1,76 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { GooMarkSvg } from '@/app/(landing)/components/mothership/components/goo-marks/goo-mark-svg'
import {
useGooHover,
useGooIds,
} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
/**
* Sim circle goo-mark: GRID-9.
* 3x3 lattice of overlapping circles, centers spaced one radius apart
* (circle radius 16), with intersection dots (r2.2, gradient fill) on the
* 9 lattice nodes.
*/
export interface Grid9Props {
size?: number
className?: string
/** Show the 9 intersection dots (default true). */
dots?: boolean
animate?: boolean
forceHover?: boolean
}
export function Grid9({
size = 110,
className,
dots = true,
animate = true,
forceHover = false,
}: Grid9Props) {
const { amt, bind } = useGooHover({ animate, forceHover })
const { gradId, gooId } = useGooIds()
const cx = 50
const cy = 50
const r = 16
const offs = [-r, 0, r]
const dotR = 2.2 * (1 + amt * 0.5)
const circles: ReactNode[] = []
const nodeDots: ReactNode[] = []
let i = 0
for (const oy of offs) {
for (const ox of offs) {
const x = (cx + ox).toFixed(2)
const y = (cy + oy).toFixed(2)
circles.push(<circle key={`c${i}`} cx={x} cy={y} r={r} />)
nodeDots.push(
<circle
key={`d${i}`}
cx={x}
cy={y}
r={dotR.toFixed(2)}
fill={`url(#${gradId})`}
stroke='none'
/>
)
i++
}
}
return (
<GooMarkSvg
size={size}
gradId={gradId}
gooId={gooId}
ariaLabel='Grid-9'
bind={bind}
className={className}
dots={dots ? nodeDots : undefined}
>
{circles}
</GooMarkSvg>
)
}
@@ -1,24 +0,0 @@
export {
FanRings,
type FanRingsProps,
} from '@/app/(landing)/components/mothership/components/goo-marks/fan-rings'
export {
FlowerOfLife,
type FlowerOfLifeProps,
} from '@/app/(landing)/components/mothership/components/goo-marks/flower-of-life'
export {
Grid9,
type Grid9Props,
} from '@/app/(landing)/components/mothership/components/goo-marks/grid-9'
export {
Lissajous32,
type Lissajous32Props,
} from '@/app/(landing)/components/mothership/components/goo-marks/lissajous-32'
export {
LissajousMorph,
type LissajousMorphProps,
} from '@/app/(landing)/components/mothership/components/goo-marks/lissajous-morph'
export {
WovenTorus,
type WovenTorusProps,
} from '@/app/(landing)/components/mothership/components/goo-marks/woven-torus'
@@ -1,49 +0,0 @@
'use client'
import { GooMarkSvg } from '@/app/(landing)/components/mothership/components/goo-marks/goo-mark-svg'
import {
normalizeReach,
type Pt,
sampleClosed,
toPath,
useGooHover,
useGooIds,
} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
/**
* Sim circle goo-mark: LISSAJOUS 3:2.
* x = 50 + 40·sin(3t + π/2), y = 50 + 40·sin(2t)
*/
export interface Lissajous32Props {
size?: number
className?: string
animate?: boolean
forceHover?: boolean
}
export function Lissajous32({
size = 110,
className,
animate = true,
forceHover = false,
}: Lissajous32Props) {
const { amt, bind } = useGooHover({ animate, forceHover })
const { gradId, gooId } = useGooIds()
const phase = Math.PI / 2 + amt * 0.4
const fn = (t: number): Pt => [50 + 40 * Math.sin(3 * t + phase), 50 + 40 * Math.sin(2 * t)]
const d = toPath(normalizeReach(sampleClosed(fn)))
return (
<GooMarkSvg
size={size}
gradId={gradId}
gooId={gooId}
ariaLabel='Lissajous 3:2'
bind={bind}
className={className}
>
<path d={d} />
</GooMarkSvg>
)
}
@@ -1,87 +0,0 @@
'use client'
import { cn } from '@sim/emcn'
import {
normalizeReach,
type Pt,
sampleClosed,
toPath,
useGooHover,
useGooIds,
} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
import { gradientForTone } from '@/app/(landing)/components/mothership/components/iso-marks/use-goo-mark'
/**
* Sim circle goo-mark: LISSAJOUS MORPH (Context).
* A Lissajous figure that morphs its frequency ratio on hover - 3:2 at rest
* eases to 5:4, with a phase shift - so the curve reweaves itself. Self-contained
* (its own gradient + goo defs) so it can carry its tuned stroke and constants
* independently of the static Build Lissajous.
*/
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const REST = { a: 3, b: 2, phase: 1.5708 }
const HOVER = { a: 5, b: 4, phase: 3.16 }
const AMP = 40
const STROKE = 2.5
const GOO_FUSION = 1.5
export interface LissajousMorphProps {
size?: number
className?: string
animate?: boolean
forceHover?: boolean
}
export function LissajousMorph({
size = 110,
className,
animate = true,
forceHover = false,
}: LissajousMorphProps) {
const { amt, bind } = useGooHover({ animate, forceHover })
const { gradId, gooId } = useGooIds()
const a = lerp(REST.a, HOVER.a, amt)
const b = lerp(REST.b, HOVER.b, amt)
const phase = lerp(REST.phase, HOVER.phase, amt)
const fn = (t: number): Pt => [50 + AMP * Math.sin(a * t + phase), 50 + AMP * Math.sin(b * t)]
const d = toPath(normalizeReach(sampleClosed(fn)))
const { from, to } = gradientForTone(amt)
return (
<svg
viewBox='0 0 100 100'
width={size}
height={size}
aria-hidden='true'
className={cn('block outline-none', className)}
{...bind}
>
<defs>
<radialGradient id={gradId} gradientUnits='userSpaceOnUse' cx='50' cy='50' r='44'>
<stop stopColor={from} />
<stop offset='1' stopColor={to} />
</radialGradient>
<filter id={gooId} x='-25%' y='-25%' width='150%' height='150%'>
<feGaussianBlur in='SourceGraphic' stdDeviation={GOO_FUSION} result='b' />
<feColorMatrix
in='b'
type='matrix'
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9'
/>
</filter>
</defs>
<g
filter={`url(#${gooId})`}
stroke={`url(#${gradId})`}
strokeWidth={STROKE}
strokeLinecap='round'
strokeLinejoin='round'
fill='none'
>
<path d={d} />
</g>
</svg>
)
}
@@ -1,119 +0,0 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react'
/**
* Sim abstract generative icon system - the circle / harmonograph "goo" family.
* Built from parametric curves and overlapping circles, normalized to a fixed
* REACH, fused by the goo filter.
*
* Locked recipe:
* viewBox 0 0 100 100, center (50,50)
* REACH = 40 (~80x80 footprint)
* stroke-width 3, round caps/joins, fill none
* gradient: radial userSpaceOnUse cx50 cy50 r44, #2C2C2C -> #5F5F5F
* goo filter: feGaussianBlur stdDeviation 1.5 + feColorMatrix alpha "20 -9"
*
* Hover (optional, subtle): a gentle breathe/rotate. These marks read well
* static; hover is a light accent, enabled when `animate` (default).
*/
export const REACH = 40
export const STROKE = 3
export const GOO_FUSION = 1.5
export const TAU = Math.PI * 2
export type Pt = [number, number]
/** Sample a closed parametric curve t in [0, 2π]. */
export function sampleClosed(fn: (t: number) => Pt, steps = 720): Pt[] {
const pts: Pt[] = []
for (let i = 0; i <= steps; i++) pts.push(fn((i / steps) * TAU))
return pts
}
/** Scale points so the farthest point from center reaches REACH. */
export function normalizeReach(pts: Pt[], cx = 50, cy = 50, reach = REACH): Pt[] {
let max = 0
for (const [x, y] of pts) {
const r = Math.hypot(x - cx, y - cy)
if (r > max) max = r
}
if (!max) return pts
const k = reach / max
return pts.map(([x, y]) => [cx + (x - cx) * k, cy + (y - cy) * k] as Pt)
}
export function toPath(pts: Pt[], close = true): string {
let d = `M${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)}`
for (let i = 1; i < pts.length; i++) d += ` L${pts[i][0].toFixed(2)} ${pts[i][1].toFixed(2)}`
if (close) d += ' Z'
return d
}
export interface UseGooHoverOptions {
/** Enable the subtle hover breathe/rotate. */
animate?: boolean
ease?: number
forceHover?: boolean
}
export interface GooHoverResult {
/** 0 at rest, eases toward 1 on hover. */
amt: number
bind: {
onMouseEnter: () => void
onMouseLeave: () => void
onFocus: () => void
onBlur: () => void
}
}
export function useGooHover({
animate = true,
ease = 0.1,
forceHover = false,
}: UseGooHoverOptions = {}): GooHoverResult {
const [hovered, setHovered] = useState(false)
const amtRef = useRef(0)
const [, setTick] = useState(0)
const activeRef = useRef(forceHover)
const easeRef = useRef(ease)
activeRef.current = (forceHover || hovered) && animate
easeRef.current = ease
useEffect(() => {
if (!animate) return
const reduced =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
let raf = 0
const loop = () => {
const target = activeRef.current ? 1 : 0
const next = reduced ? target : amtRef.current + (target - amtRef.current) * easeRef.current
if (Math.abs(next - amtRef.current) > 0.0005) {
amtRef.current = next
setTick((t) => (t + 1) % 1000000)
} else if (next !== amtRef.current) {
amtRef.current = next
}
raf = requestAnimationFrame(loop)
}
raf = requestAnimationFrame(loop)
return () => cancelAnimationFrame(raf)
}, [animate])
return {
amt: amtRef.current,
bind: {
onMouseEnter: useCallback(() => setHovered(true), []),
onMouseLeave: useCallback(() => setHovered(false), []),
onFocus: useCallback(() => setHovered(true), []),
onBlur: useCallback(() => setHovered(false), []),
},
}
}
/** Stable, SSR-safe gradient + filter ids per mark instance. */
export function useGooIds() {
const id = useId().replace(/:/g, '')
return { gradId: `goo-${id}-grad`, gooId: `goo-${id}-goo` }
}
@@ -1,62 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { GooMarkSvg } from '@/app/(landing)/components/mothership/components/goo-marks/goo-mark-svg'
import {
REACH,
TAU,
useGooHover,
useGooIds,
} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
/**
* Sim circle goo-mark: WOVEN TORUS.
* 3 offset rings (radius 22) whose centers sit on a small circle 120° apart -
* reads as orbit / motion.
*/
export interface WovenTorusProps {
size?: number
className?: string
animate?: boolean
forceHover?: boolean
}
export function WovenTorus({
size = 110,
className,
animate = true,
forceHover = false,
}: WovenTorusProps) {
const { amt, bind } = useGooHover({ animate, forceHover })
const { gradId, gooId } = useGooIds()
const cr = 22
const rot = amt * (TAU / 12)
const circles: ReactNode[] = []
for (let i = 0; i < 3; i++) {
const off = (i * (REACH - cr)) / 2
const ang = i * (TAU / 3) + rot
circles.push(
<circle
key={i}
cx={(50 + off * Math.cos(ang)).toFixed(2)}
cy={(50 + off * Math.sin(ang)).toFixed(2)}
r={cr}
/>
)
}
return (
<GooMarkSvg
size={size}
gradId={gradId}
gooId={gooId}
ariaLabel='Woven torus'
bind={bind}
className={className}
>
{circles}
</GooMarkSvg>
)
}
@@ -1,14 +0,0 @@
import { Martian_Mono } from 'next/font/google'
/**
* Martian Mono font configuration
* Monospaced variable font used for code snippets, technical content, and accent text
* on the landing page. Supports weights 100-800.
*/
export const martianMono = Martian_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-martian-mono',
weight: 'variable',
fallback: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
})
-22
View File
@@ -1,22 +0,0 @@
import type { V2Credential } from '@/lib/api/contracts/v2/credentials'
import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries'
/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */
export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential {
if (row.type !== 'oauth' && row.type !== 'service_account') {
throw new Error(`Secret credential type ${row.type} reached the credentials API`)
}
return {
id: row.id,
type: row.type,
displayName: row.displayName,
description: row.description,
providerId: row.providerId,
accountId: row.accountId,
hasServiceAccountKey: row.hasServiceAccountKey,
role: row.role,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}
@@ -1,2 +0,0 @@
export { BrowserSession } from './browser-session/browser-session'
export { GenericResourceContent } from './generic-resource-content'
@@ -1 +0,0 @@
export { MothershipView } from './mothership-view'
@@ -1 +0,0 @@
export { Admin } from './admin'
@@ -1 +0,0 @@
export { ApiKeys } from './api-keys'
@@ -1 +0,0 @@
export { Browser } from './browser'
@@ -1 +0,0 @@
export { BYOK } from './byok'
@@ -1 +0,0 @@
export { CustomTools } from './custom-tools'
@@ -1 +0,0 @@
export { Desktop } from './desktop'
@@ -1 +0,0 @@
export { General } from './general'
@@ -1 +0,0 @@
export { Inbox } from './inbox'
@@ -1 +0,0 @@
export { MCP } from './mcp'
@@ -1 +0,0 @@
export { Mothership } from './mothership'
@@ -1 +0,0 @@
export { RecentlyDeleted } from './recently-deleted'
@@ -1 +0,0 @@
export { Sandboxes } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes'
@@ -1 +0,0 @@
export { Secrets } from './secrets'
@@ -1 +0,0 @@
export { TeamManagement } from './team-management'
@@ -1 +0,0 @@
export { Teammates } from './teammates'
@@ -1 +0,0 @@
export { Terminal } from './terminal'
@@ -1 +0,0 @@
export { WorkflowMcpServers } from './workflow-mcp-servers'
@@ -1 +0,0 @@
export { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
@@ -1,63 +0,0 @@
export interface SubscriptionPermissions {
canUpgradeToPro: boolean
canUpgradeToTeam: boolean
canViewEnterprise: boolean
canManageTeam: boolean
canEditUsageLimit: boolean
canCancelSubscription: boolean
showTeamMemberView: boolean
showUpgradePlans: boolean
isEnterpriseMember: boolean
canViewUsageInfo: boolean
}
export interface SubscriptionState {
isFree: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isPaid: boolean
/**
* True when the subscription's `referenceId` is an organization. Source
* of truth for scope-based decisions — `pro_*` plans that have been
* transferred to an org are org-scoped even though `isTeam` is false.
*/
isOrgScoped: boolean
plan: string
status: string
}
export interface UserRole {
isTeamAdmin: boolean
userRole: string
}
export function getSubscriptionPermissions(
subscription: SubscriptionState,
userRole: UserRole
): SubscriptionPermissions {
const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription
const { isTeamAdmin } = userRole
// Non-admin org members see the "team member" view: no edit / no cancel
// / no upgrade, pooled usage display.
const orgMemberOnly = isOrgScoped && !isTeamAdmin
const orgAdminOrSolo = !isOrgScoped || isTeamAdmin
const isEnterpriseMember = isEnterprise && !isTeamAdmin
const canViewUsageInfo = !isEnterpriseMember
return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isOrgScoped),
canViewEnterprise: !isEnterprise && !orgMemberOnly,
canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise,
canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo,
canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo,
showTeamMemberView: orgMemberOnly,
showUpgradePlans:
(isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise,
isEnterpriseMember,
canViewUsageInfo,
}
}
@@ -1 +0,0 @@
export { ActionBar } from './action-bar'
@@ -1 +0,0 @@
export { Chat } from './chat'
@@ -1 +0,0 @@
export { NoteBlock } from './note-block'
@@ -1 +0,0 @@
export { KeyboardNavigationHandler } from './keyboard-navigation-handler'
@@ -1,12 +0,0 @@
export {
checkTagTrigger,
getTagSearchTerm,
TagDropdown,
useNestedNavigation,
} from './tag-dropdown'
export type {
BlockTagGroup,
NestedBlockTagGroup,
NestedTag,
NestedTagChild,
} from './types'
@@ -1 +0,0 @@
export { type CustomTool, CustomToolModal } from './custom-tool-modal'
@@ -1,8 +0,0 @@
export { CodeEditor } from './code-editor'
export { type CustomTool, CustomToolModal } from './custom-tool-modal'
export {
ParameterWithLabel,
type ParameterWithLabelProps,
ToolCredentialSelector,
ToolSubBlockRenderer,
} from './tools'
@@ -1,3 +0,0 @@
export { ToolCredentialSelector } from './credential-selector'
export { ParameterWithLabel, type ParameterWithLabelProps } from './parameter'
export { ToolSubBlockRenderer } from './sub-block-renderer'
@@ -1 +0,0 @@
export { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace'
@@ -1,3 +0,0 @@
export { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop'
export { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel'
export { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
@@ -1 +0,0 @@
export { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
@@ -1 +0,0 @@
export { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
@@ -1 +0,0 @@
export { Terminal } from './terminal'
@@ -1 +0,0 @@
export { Variables } from './variables'
@@ -1,11 +0,0 @@
export { CommandFadedList, CommandSearch } from './command-chrome'
export {
MemoizedCommandItem,
MemoizedFileItem,
MemoizedIconItem,
MemoizedPageItem,
MemoizedTaskItem,
MemoizedWorkflowItem,
MemoizedWorkspaceItem,
} from './command-items'
export { BlocksGroup, SearchEntryGroup, ToolsGroup } from './search-groups'
@@ -1 +0,0 @@
export { ContextMenu } from './context-menu'
@@ -1 +0,0 @@
export { DeleteModal } from './delete-modal'
@@ -1 +0,0 @@
export { FolderItem } from './folder-item'
@@ -1,5 +0,0 @@
export { ContextMenu } from './context-menu'
export { DeleteModal } from './delete-modal'
export { EmptyAreaContextMenu } from './empty-area-context-menu'
export { FolderItem } from './folder-item'
export { WorkflowItem } from './workflow-item'
@@ -1 +0,0 @@
export { WorkflowItem } from './workflow-item'
@@ -1 +0,0 @@
export { CredentialGroupInvitationEmail } from './credential-group-invitation-email'
-5
View File
@@ -1,5 +0,0 @@
import type { SerializedParallel } from '@/serializer/types'
export interface ParallelConfigWithNodes extends SerializedParallel {
nodes: string[]
}
-34
View File
@@ -1,34 +0,0 @@
'use client'
import { useMemo } from 'react'
import { derivePlanView, type PlanView } from '@/lib/billing/client'
import { useSubscriptionData } from '@/hooks/queries/subscription'
/**
* Result of {@link usePlanView}. `creditBalance` and `hasData` are surfaced so
* credit-display surfaces don't need a second `useSubscriptionData` call.
*/
export interface UsePlanViewResult {
planView: PlanView
creditBalance: number
isLoading: boolean
hasData: boolean
}
/**
* React Query wrapper over {@link useSubscriptionData} that exposes the
* canonical {@link PlanView}. The single hook every billing surface (upgrade
* page, home credits chip, sidebar, settings billing page) consumes for
* plan-derived UI decisions.
*/
export function usePlanView(options?: { includeOrg?: boolean }): UsePlanViewResult {
const { data, isLoading } = useSubscriptionData(options)
const plan = data?.data?.plan
const planView = useMemo(() => derivePlanView(plan), [plan])
return {
planView,
creditBalance: data?.data?.creditBalance ?? 0,
isLoading,
hasData: Boolean(data?.data),
}
}
-34
View File
@@ -1,34 +0,0 @@
import { z } from 'zod'
const mcpOauthMetadataQuerySchema = z.record(z.string(), z.string())
export type McpOauthMetadataQuery = z.input<typeof mcpOauthMetadataQuerySchema>
const xSimAuthSchema = z.object({
type: z.literal('api_key'),
header: z.string(),
})
const mcpAuthorizationServerMetadataSchema = z.object({
issuer: z.string(),
authorization_endpoint: z.string(),
token_endpoint: z.string(),
registration_endpoint: z.string(),
jwks_uri: z.string(),
token_endpoint_auth_methods_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
response_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()),
resource: z.string(),
x_sim_auth: xSimAuthSchema,
})
export type McpAuthorizationServerMetadata = z.output<typeof mcpAuthorizationServerMetadataSchema>
const mcpProtectedResourceMetadataSchema = z.object({
resource: z.string(),
authorization_servers: z.array(z.string()),
bearer_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()),
x_sim_auth: xSimAuthSchema,
})
export type McpProtectedResourceMetadata = z.output<typeof mcpProtectedResourceMetadataSchema>
@@ -1,41 +0,0 @@
/**
* Hand-authored, integration-specific landing content, keyed by integration
* slug. This is a pure-data generation input: `scripts/generate-docs.ts` reads
* it and bakes the matching entry into `integrations.json`, so the landing page
* consumes a single source (`integration.landingContent`) with no render-time
* augmentation. Has no app imports so the build script can import it safely.
*/
import type { IntegrationLandingContent } from '@/app/(landing)/integrations/data/types'
export const INTEGRATION_LANDING_CONTENT: Record<string, IntegrationLandingContent> = {
slack: {
install: {
heading: 'Add Sim to your Slack workspace',
intro:
'Sim connects to Slack through Slack’s official OAuth flow. The “Add to Slack” button lives inside your Sim account (after sign-in) — connect from there and the Sim bot is installed in your Slack workspace. The steps below show exactly how to reach it.',
steps: [
{
title: 'Create your free Sim account',
body: 'Sign up at sim.ai — no credit card required.',
},
{
title: 'Add a Slack block',
body: 'Open a workflow, drag in a Slack block, and open its credential dropdown.',
},
{
title: 'Connect Slack',
body: 'Click Connect Slack, choose your workspace, and approve the requested permissions. This installs the Sim bot in your Slack workspace.',
},
{
title: 'Invite the bot and build',
body: 'Invite the Sim bot to the channels it should act in, pick a Slack action, wire it into your agent, and run.',
},
],
},
privacy: {
body: 'Sim requests only the Slack permissions its actions and triggers need, and never shows private channel names or messages to people who are not members of those channels in Slack.',
href: '/privacy',
},
},
}
-166
View File
@@ -1,166 +0,0 @@
import type { ToolResponse } from '@/tools/types'
/** Params shared by every Zoho tool: the OAuth token and the account's region. */
export interface ZohoBaseParams {
accessToken: string
dataCenter?: string
}
/** Params shared by every Zoho Desk tool — Desk requires an `orgId` header. */
export interface ZohoDeskBaseParams extends ZohoBaseParams {
orgId: string
}
/**
* Pagination block returned by CRM list/search endpoints.
* @see https://www.zoho.com/crm/developer/docs/api/v8/get-records.html
*/
export interface ZohoCrmPageInfo {
page: number | null
perPage: number | null
count: number | null
moreRecords: boolean
}
/** A CRM write result entry, as returned in the `data` array of insert/update/upsert. */
export interface ZohoCrmWriteResult {
id: string | null
code: string | null
status: string | null
message: string | null
}
export interface ZohoCrmGetRecordsParams extends ZohoBaseParams {
module: string
recordId?: string
fields?: string
page?: string | number
perPage?: string | number
sortBy?: string
sortOrder?: string
}
export interface ZohoCrmGetRecordsResponse extends ToolResponse {
output: {
records: Record<string, unknown>[]
record?: Record<string, unknown> | null
info: ZohoCrmPageInfo
}
}
export interface ZohoCrmCreateRecordsParams extends ZohoBaseParams {
module: string
records: unknown
trigger?: string
}
export interface ZohoCrmWriteResponse extends ToolResponse {
output: {
results: ZohoCrmWriteResult[]
}
}
export interface ZohoCrmUpdateRecordParams extends ZohoBaseParams {
module: string
recordId: string
record: unknown
trigger?: string
}
export interface ZohoCrmUpsertRecordsParams extends ZohoBaseParams {
module: string
records: unknown
duplicateCheckFields?: string
trigger?: string
}
export interface ZohoCrmDeleteRecordParams extends ZohoBaseParams {
module: string
recordId: string
wfTrigger?: string
}
export interface ZohoCrmSearchRecordsParams extends ZohoBaseParams {
module: string
criteria?: string
email?: string
phone?: string
word?: string
fields?: string
page?: string | number
perPage?: string | number
}
export interface ZohoCrmCoqlQueryParams extends ZohoBaseParams {
selectQuery: string
}
export interface ZohoCrmCoqlQueryResponse extends ToolResponse {
output: {
records: Record<string, unknown>[]
info: ZohoCrmPageInfo
}
}
export interface ZohoCrmGetModulesParams extends ZohoBaseParams {}
export interface ZohoCrmGetModulesResponse extends ToolResponse {
output: {
modules: Array<{
apiName: string | null
moduleName: string | null
id: string | null
pluralLabel: string | null
singularLabel: string | null
creatable: boolean | null
editable: boolean | null
deletable: boolean | null
}>
}
}
export interface ZohoCrmGetFieldsParams extends ZohoBaseParams {
module: string
}
export interface ZohoCrmGetFieldsResponse extends ToolResponse {
output: {
fields: Array<{
apiName: string | null
displayLabel: string | null
dataType: string | null
id: string | null
required: boolean | null
readOnly: boolean | null
length: number | null
}>
}
}
export interface ZohoCrmGetUsersParams extends ZohoBaseParams {
type?: string
page?: string | number
perPage?: string | number
}
export interface ZohoCrmGetUsersResponse extends ToolResponse {
output: {
users: Record<string, unknown>[]
info: ZohoCrmPageInfo
}
}
export interface ZohoCrmAddNoteParams extends ZohoBaseParams {
parentId: string
seModule: string
noteTitle?: string
noteContent: string
}
export type ZohoCrmResponse =
| ZohoCrmGetRecordsResponse
| ZohoCrmWriteResponse
| ZohoCrmCoqlQueryResponse
| ZohoCrmGetModulesResponse
| ZohoCrmGetFieldsResponse
| ZohoCrmGetUsersResponse
-66
View File
@@ -1,66 +0,0 @@
/**
* Zoho data centers Sim supports.
*
* Zoho is multi-DC: a user's account lives in exactly one region, and every API
* host is region-scoped. Zoho's guidance is to never hardcode a single region —
* the token response carries an `api_domain` naming the caller's CRM host.
* Sim cannot persist that value (the shared Better Auth `account` table has no
* column for provider-specific token fields), so the region is selected on the
* block instead and resolved to hosts here.
*
* Only regions whose CRM *and* Desk hosts are confirmed in Zoho's own
* documentation are listed. CA and SA are deliberately absent: Zoho's accounts
* docs give `accounts.zohocloud.ca` while the Desk SDK uses `accounts.zoho.ca`,
* and neither source states their CRM API host.
*
* @see https://www.zoho.com/crm/developer/docs/api/v8/multi-dc.html
*/
export const ZOHO_DATA_CENTERS = {
us: { crm: 'https://www.zohoapis.com', desk: 'https://desk.zoho.com' },
eu: { crm: 'https://www.zohoapis.eu', desk: 'https://desk.zoho.eu' },
in: { crm: 'https://www.zohoapis.in', desk: 'https://desk.zoho.in' },
au: { crm: 'https://www.zohoapis.com.au', desk: 'https://desk.zoho.com.au' },
jp: { crm: 'https://www.zohoapis.jp', desk: 'https://desk.zoho.jp' },
} as const
export type ZohoDataCenter = keyof typeof ZOHO_DATA_CENTERS
/** Zoho CRM API version pinned across every CRM tool. */
export const ZOHO_CRM_API_VERSION = 'v8'
/**
* Parses a JSON object supplied either as an object (from the LLM) or as a JSON
* string (from a block input), and rejects anything that is not a plain object.
*/
export function parseJsonObject(
value: unknown,
label: string
): Record<string, unknown> | undefined {
if (value === undefined || value === null || value === '') return undefined
let parsed: unknown = value
if (typeof value === 'string') {
try {
parsed = JSON.parse(value)
} catch {
throw new Error(`${label} must be valid JSON.`)
}
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`${label} must be a JSON object.`)
}
return parsed as Record<string, unknown>
}
/**
* Appends only the defined entries of `params` to a URL's query string, so unset
* optional params never surface as empty values Zoho would reject.
*/
export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === '') continue
search.set(key, String(value))
}
const query = search.toString()
return query ? `?${query}` : ''
}