Compare commits

...

82 Commits

Author SHA1 Message Date
Waleed
7f1efcc798 fix(blocks): resolve Ollama models incorrectly requiring API key in Docker (#3976)
* fix(blocks): resolve Ollama models incorrectly requiring API key in Docker

Server-side validation failed for Ollama models like mistral:latest because
the Zustand providers store is empty on the server and getProviderFromModel
misidentified them via regex pattern matching (e.g. mistral:latest matched
Mistral AI's /^mistral/ pattern).

Replace the hardcoded CLOUD_PROVIDER_PREFIXES list with existing data sources:
- Provider store (definitive on client, checks all provider buckets)
- getBaseModelProviders() from PROVIDER_DEFINITIONS (server-side static cloud model lookup)
- Slash convention for dynamic cloud providers (fireworks/, openrouter/, etc.)
- isOllamaConfigured feature flag using existing OLLAMA_URL env var

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove getProviderFromModel regex fallback from API key validation

The fallback was the last piece of regex-based matching in the function and
only ran for self-hosted without OLLAMA_URL on the server — a path where
Ollama models cannot appear in the dropdown anyway.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix: handle vLLM models in store provider check

vLLM is a local model server like Ollama and should not require an API key.
Add vllm to the store provider check as a safety net for models that may
not have the vllm/ prefix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 12:14:52 -07:00
Waleed
a680cec78f fix(core): consolidate ID generation to prevent HTTP self-hosted crashes (#3977)
* fix(core): consolidate ID generation to prevent HTTP self-hosted crashes

crypto.randomUUID() requires a secure context (HTTPS) in browsers,
causing white-screen crashes on self-hosted HTTP deployments. This
replaces all direct usage of crypto.randomUUID(), nanoid, and the uuid
package with a central utility that falls back to crypto.getRandomValues()
which works in all contexts.

- Add generateId(), generateShortId(), isValidUuid() in @/lib/core/utils/uuid
- Replace crypto.randomUUID() imports across ~220 server + client files
- Replace nanoid imports with generateShortId()
- Replace uuid package validate with isValidUuid()
- Remove nanoid dependency from apps/sim and packages/testing
- Remove browser polyfill script from layout.tsx
- Update test mocks to target @/lib/core/utils/uuid
- Update CLAUDE.md, AGENTS.md, cursor rules, claude rules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update bunlock

* fix(core): remove UUID_REGEX shim, use isValidUuid directly

* fix(core): remove deprecated uuid mock helpers that use vi.doMock

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 11:28:54 -07:00
Waleed
235f0748ca feat(files): expand file editor to support more formats, add docx/xlsx preview (#3971)
* feat(files): expand file editor to support more formats, add docx/xlsx preview

* lint

* fix(files): narrow fileData type for closure in docx/xlsx preview effects

* fix(files): address PR review — fix xlsx type, simplify error helper, tighten iframe sandbox

* add mothership read externsions

* fix(files): update upload test — js is now a supported extension

* fix(files): deduplicate code extensions, handle dotless filenames

* fix(files): lower xlsx preview row cap to 1k and type workbookRef properly

Reduces XLSX_MAX_ROWS from 10,000 to 1,000 to prevent browser sluggishness
on large spreadsheets. Types workbookRef with the proper xlsx.WorkBook
interface instead of unknown, removing the unsafe cast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(files): extract shared DataTable, isolate client-safe constants

- Move SUPPORTED_CODE_EXTENSIONS to validation-constants.ts so client
  components no longer transitively import Node's `path` module
- Extract shared DataTable component used by both CsvPreview and
  XlsxPreview, eliminating duplicated table markup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(validation): remove Node path import, use plain string extraction

Replace `import path from 'path'` with a simple `extractExtension` helper
that does `fileName.slice(fileName.lastIndexOf('.') + 1)`. This removes
the only Node module dependency from validation.ts, making it safe to
import from client components without pulling in a Node polyfill.

Deletes the unnecessary validation-constants.ts that was introduced as
a workaround — the constants now live back in validation.ts where they
belong.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:57:49 -07:00
Emir Karabeg
ebc19484f2 improvement(landing, blog): ui/ux (#3972)
* refactor: moved home into landing

* improvement(landing): blog dropdown and content updates

* improvement: ui/ux

* improvement: footer, enterprise, templates,etc.

* improvement: blog

* fix: reset feature flags to dynamic values

* fix: remove unused mobileLabel reference in features tabs

* improvement(auth): match oauth button styling with inputs and navbar login

* fix: resolve TypeScript errors in landing components

- Add explicit FeatureTab interface type for FEATURE_TABS with optional mobileLabel property
- Remove reference to undefined MOBILE_STEPS in landing-preview (mobile uses static display anyway)

Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>

* fix(demo-request): remove region from schema and route, delete unused PostGrid

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
2026-04-04 20:22:35 -07:00
Vikhyath Mondreti
33e6921954 improvement(execution): multiple response blocks (#3918)
* improvement(execution): multiple response blocks

* address comments
2026-04-04 20:05:22 -07:00
Waleed
adfcb67dc2 feat(cursor): add list artifacts and download artifact tools (#3970)
* feat(cursor): add list artifacts and download artifact tools

* fix(cursor): resolve build errors in cursor block and download artifact types

- Remove invalid wandConfig with unsupported generationType 'json-array' from promptImages subBlock
- Remove invalid 'optional' property from summary output definition
- Split DownloadArtifactResponse into v1 (content/metadata) and v2 (file) response types

* fix(cursor): address PR review feedback

- Remove redundant Array.isArray guards in list_artifacts.ts
- Pass through actual HTTP status on presigned URL download failure instead of hardcoded 400
2026-04-04 19:59:37 -07:00
Waleed
f9a7c4538e fix(settings): align skeleton loading states with actual page layouts (#3967)
* fix(settings): align skeleton loading states with actual page layouts

* lint

* fix(settings): address PR feedback — deduplicate skeleton, fix import order, remove inline comments
2026-04-04 19:18:46 -07:00
Waleed
d0baf5b1df feat(cloudformation): add AWS CloudFormation integration with 7 operations (#3964)
* feat(cloudformation): add AWS CloudFormation integration with 7 operations

* fix(cloudformation): add pagination to list-stack-resources, describe-stacks, and describe-stack-events routes
2026-04-04 18:39:02 -07:00
Theodore Li
855c892f55 feat(block): Add cloudwatch block (#3953)
* feat(block): Add cloudwatch block (#3911)

* feat(block): add cloudwatch integration

* Fix bun lock

* Add logger, use execution timeout

* Switch metric dimensions to map style input

* Fix attribute names for dimension map

* Fix import styling

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* Fix import ordering

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-04 19:54:12 -04:00
Waleed
8ae4b88d80 fix(integrations): show disabled role combobox for readonly members (#3962) 2026-04-04 16:50:11 -07:00
Waleed
a70ccddef5 fix(kb): fix Linear connector GraphQL type errors and tag slot reuse (#3961)
* fix(kb): fix Linear connector GraphQL type errors and tag slot reuse

* fix(kb): simplify tag slot reuse, revert Linear GraphQL types to String

Clean up newTagSlotMapping into direct assignment, remove unnecessary
comment, and revert ID! back to String! to match Linear SDK types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(kb): use ID! type for Linear GraphQL filter variables

* fix(kb): verify field type when reusing existing tag slots

Add fieldType check to the tag slot reuse logic so a connector with
a matching displayName but different fieldType falls through to fresh
slot allocation instead of silently reusing an incompatible slot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(kb): enable search on connector selector dropdowns

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 16:50:04 -07:00
Waleed
b4d9b8c396 feat(analytics): posthog audit — remove noise, add 10 new events (#3960)
* feat(analytics): posthog audit — remove noise, add 10 new events

Remove task_marked_read (fires automatically on every task view).

Add workspace_id to task_message_sent for group analytics.

New events:
- search_result_selected: block/tool/trigger/workflow/table/file/
  knowledge_base/workspace/task/page/docs with query_length
- workflow_imported: count + format (json/zip)
- workflow_exported: count + format (json/zip)
- folder_created / folder_deleted
- logs_filter_applied: status/workflow/folder/trigger/time
- knowledge_base_document_deleted
- scheduled_task_created / scheduled_task_deleted

* fix(analytics): use usePostHog + captureEvent in hooks, track custom date range

* fix(analytics): always fire scheduled_task_deleted regardless of workspaceId

* fix(analytics): correct format field logic and add missing useCallback deps
2026-04-04 16:49:52 -07:00
Waleed
ce53275e9d feat(knowledge): add Live sync option to KB connectors + fix embedding billing (#3959)
* feat(knowledge): add Live sync option to KB connector modal for Max/Enterprise users

Adds a "Live" (every 5 min) sync frequency option gated to Max and Enterprise plan users.
Includes client-side badge + disabled state, shared sync intervals constant, and server-side
plan validation on both POST and PATCH connector routes.

* fix(knowledge): record embedding usage cost for KB document processing

Adds billing tracking to the KB embedding pipeline, which was previously
generating OpenAI API calls with no cost recorded. Token counts are now
captured from the actual API response and recorded via recordUsage after
successful embedding insertion. BYOK workspaces are excluded from billing.
Applies to all execution paths: direct, BullMQ, and Trigger.dev.

* fix(knowledge): simplify embedding billing — use calculateCost, return modelName

- Use calculateCost() from @/providers/utils instead of inline formula, consistent
  with how LLM billing works throughout the platform
- Return modelName from GenerateEmbeddingsResult so billing uses the actual model
  (handles custom Azure deployments) instead of a hardcoded fallback string
- Fix docs-chunker.ts empty-path fallback to satisfy full GenerateEmbeddingsResult type

* fix(knowledge): remove dev bypass from hasLiveSyncAccess

* chore(knowledge): rename sync-intervals to consts, fix stale TSDoc comment

* improvement(knowledge): extract MaxBadge component, capture billing config once per document

* fix(knowledge): add knowledge-base to usage_log_source enum, fix docs-chunker type

* fix(knowledge): generate migration for knowledge-base usage_log_source enum value

* fix(knowledge): add knowledge-base to usage_log_source enum via drizzle-kit

* fix(knowledge): fix search embedding test mocks, parallelize billing lookups

* fix(knowledge): warn when embedding model has no pricing entry

* fix(knowledge): call checkAndBillOverageThreshold after embedding usage
2026-04-04 16:49:42 -07:00
abhinavDhulipala
7971a64e63 fix(setup): db migrate hard fail and correct ini env (#3946) 2026-04-04 16:22:19 -07:00
abhinavDhulipala
f39b4c74dc fix(setup): bun run prepare explicitly (#3947) 2026-04-04 16:13:53 -07:00
Waleed
0ba8ab1ec7 fix(posthog): upgrade SDKs and fix serverless event flushing (#3951)
* fix(posthog): upgrade SDKs and fix serverless event flushing

* fix(posthog): revert flushAt to 20 for long-running ECS container
2026-04-04 16:11:35 -07:00
Waleed
039e57541e fix(csp): allow Cloudflare Turnstile domains for script, frame, and connect (#3948) 2026-04-04 15:54:14 -07:00
Theodore Li
75f8c6ad7e fix(ui): persist active resource tab in url, fix internal markdown links (#3925)
* fix(ui): handle markdown internal links

* Fix lint

* Reference correct scroll container

* Add resource tab to url state, scroll correctly on new tab

* Handle delete all resource by clearing url

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-04 18:25:35 -04:00
Waleed
c2b12cf21f fix(captcha): use getResponsePromise for Turnstile execute-on-submit flow (#3943) 2026-04-04 12:34:53 -07:00
Waleed
4a9439e952 improvement(models): tighten model metadata and crawl discovery (#3942)
* improvement(models): tighten model metadata and crawl discovery

Made-with: Cursor

* revert hardcoded FF

* fix(models): narrow structured output ranking signal

Made-with: Cursor

* fix(models): remove generic best-for copy

Made-with: Cursor

* fix(models): restore best-for with stricter criteria

Made-with: Cursor

* fix

* models
2026-04-04 11:53:54 -07:00
Waleed
893e322a49 fix(envvars): restore workflowUserId fallback for scheduled execution env var resolution (#3941)
* fix(envvars): restore workflowUserId fallback for scheduled execution env var resolution

* test(envvars): add coverage for env var user resolution branches
2026-04-04 11:22:52 -07:00
Emir Karabeg
b0cb95be2f feat: mothership/copilot feedback (#3940)
* feat: mothership/copilot feedback

* fix(feedback): remove mutation object from useCallback deps
2026-04-04 10:46:49 -07:00
Waleed
6d00d6bf2c fix(modals): center modals in visible content area and remove open/close animation (#3937)
* fix(modals): center modals in visible content area accounting for sidebar and panel

* fix(modals): address pr feedback — comment clarity and document panel assumption

* fix(modals): remove open/close animation from modal content
2026-04-03 20:06:10 -07:00
Waleed
3267d8cc24 fix(modals): center modals in visible content area accounting for sidebar and panel (#3934)
* fix(modals): center modals in visible content area accounting for sidebar and panel

* fix(modals): address pr feedback — comment clarity and document panel assumption
2026-04-03 19:19:36 -07:00
Theodore Li
2e69f85364 Fix "fix in copilot" button (#3931)
* Fix "fix in copilot" button

* Auto send message to copilot for fix in copilot

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-03 22:11:45 -04:00
Waleed
57e5bac121 fix(mcp): resolve userId before JWT generation for agent block auth (#3932)
* fix(mcp): resolve userId before JWT generation for agent block auth

* test(mcp): add regression test for agent block JWT userId resolution
2026-04-03 19:05:10 -07:00
Theodore Li
8ce0299400 fix(ui) Fix oauth redirect on connector modal (#3926)
* Fix oauth redirect on connector modal

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-03 21:58:42 -04:00
Vikhyath Mondreti
a0796f088b improvement(mothership): workflow edits via sockets (#3927)
* improvement(mothership): workflow edits via sockets

* make embedded view join room

* fix cursor positioning bug
2026-04-03 18:44:14 -07:00
Waleed
98fe4cd40b refactor(stores): consolidate variables stores into stores/variables/ (#3930)
* refactor(stores): consolidate variables stores into stores/variables/

Move variable data store from stores/panel/variables/ to stores/variables/
since the panel variables tab no longer exists. Rename the modal UI store
to useVariablesModalStore to eliminate naming collision with the data store.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused workflowId variable in deleteVariable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:43:47 -07:00
Waleed
34d210c66c chore(stores): remove Zustand environment store and dead init scaffolding (#3929) 2026-04-03 17:54:49 -07:00
Waleed
2334f2dca4 fix(loading): remove jarring workflow loading spinners (#3928)
* fix(loading): remove jarring workflow loading spinners

* fix(loading): remove home page skeleton loading state

* fix(loading): remove plain spinner loading states from task and file view
2026-04-03 17:45:30 -07:00
Waleed
65fc138bfc improvement(stores): remove deployment state from Zustand in favor of React Query (#3923) 2026-04-03 17:44:10 -07:00
Waleed
e8f7fe0989 v0.6.22: agentmail, rootly, landing fixes, analytics, credentials block 2026-04-03 01:14:36 -07:00
Waleed
ace87791d8 feat(analytics): add PostHog product analytics (#3910)
* feat(analytics): add PostHog product analytics

* fix(posthog): fix workspace group via URL params, type errors, and clean up comments

* fix(posthog): address PR review - fix pre-tx event, auth_method, paused executions, enterprise cancellation, settings double-fire

* chore(posthog): remove unused identifyServerPerson

* fix(posthog): isolate processQueuedResumes errors, simplify settings posthog deps

* fix(posthog): correctly classify SSO auth_method, fix phantom empty-string workspace groups

* fix(posthog): remove usePostHog from memo'd TemplateCard, fix copilot chat phantom workspace group

* fix(posthog): eliminate all remaining phantom empty-string workspace groups

* fix(posthog): fix cancel route phantom group, remove redundant workspaceId shadow in catch block

* fix(posthog): use ids.length for block_removed guard to handle container blocks with descendants

* chore(posthog): remove unused removedBlockTypes variable

* fix(posthog): remove phantom $set person properties from subscription events

* fix(posthog): add passedKnowledgeBaseName to knowledge_base_opened effect deps

* fix(posthog): capture currentWorkflowId synchronously before async import to avoid stale closure

* fix(posthog): add typed captureEvent wrapper for React components, deduplicate copilot_panel_opened

* feat(posthog): add task_created and task_message_sent events, remove copilot_panel_opened

* feat(posthog): track task_renamed, task_deleted, task_marked_read, task_marked_unread

* feat(analytics): expand posthog event coverage with source tracking and lifecycle events

* fix(analytics): flush posthog events on SIGTERM before ECS task termination

* fix(analytics): fix posthog in useCallback deps and fire block events for bulk operations
2026-04-03 01:00:35 -07:00
Waleed
74af452175 feat(blocks): add Credential block (#3907)
* feat(blocks): add Credential block

* fix(blocks): explicit workspaceId guard in credential handler, clarify hasOAuthSelection

* feat(credential): add list operation with type/provider filters

* feat(credential): restrict to OAuth only, remove env vars and service accounts

* docs(credential): update screenshots

* fix(credential): remove stale isServiceAccount dep from overlayContent memo

* fix(credential): filter to oauth-only in handleComboboxChange matchedCred lookup
2026-04-02 23:15:15 -07:00
Waleed
ec51f73596 feat(email): abandoned checkout email, 80% free tier warning, credits exhausted email (#3908)
* feat(email): send plain personal email on abandoned checkout

* feat(email): lower free tier warning to 80% and add credits exhausted email

* feat(email): use wordmark in email header instead of icon-only logo

* fix(email): restore accidentally deleted social icons in email footer

* fix(email): prevent double email for free users at 80%, fix subject line

* improvement(emails): extract shared plain email styles and proFeatures constant, fix double email on 100% usage

* fix(email): filter subscription-mode checkout, skip already-subscribed users, fix preview text

* fix(email): use notifications type for onboarding followup to respect unsubscribe preferences

* fix(email): use limit instead of currentUsage in credits exhausted email body

* fix(email): use notifications type for abandoned checkout, clarify crosses80 comment

* chore(email): rename _constants.ts to constants.ts

* fix(email): use isProPlan to catch org-level subscriptions in abandoned checkout guard

* fix(email): align onboarding followup delay to 5 days for email/password users
2026-04-02 19:31:29 -07:00
Theodore Li
6866da590c fix(tools) Directly query db for custom tool id (#3875)
* Directly query db for custom tool id

* Switch back to inline imports

* Fix lint

* Fix test

* Fix greptile comments

* Fix lint

* Make userId and workspaceId required

* Add back nullable userId and workspaceId fields

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-02 22:13:37 -04:00
Waleed
f0d1950477 v0.6.21: concurrency FF, blog theme 2026-04-02 13:08:59 -07:00
Waleed
0fdd8ffb55 v0.6.20: oauth default credential name, models pages, new models, rippling and rootly integrations 2026-04-02 11:44:24 -07:00
Waleed
d581009099 v0.6.19: vllm fixes, loading improevments, reactquery standardization, new gpt 5.4 models, fireworks provider support, launchdarkly, tailscale, extend integrations 2026-03-31 20:17:00 -07:00
Waleed
7d0fdefb22 v0.6.18: file operations block, profound integration, edge connection improvements, copy logs, knowledgebase robustness 2026-03-30 21:35:41 -07:00
Waleed
73e00f53e1 v0.6.17: trigger.dev CI, workers FF 2026-03-30 09:33:30 -07:00
Vikhyath Mondreti
1d7ae906bc v0.6.16: bullmq optionality 2026-03-30 00:12:21 -07:00
Waleed
560fa75155 v0.6.15: workers, security hardening, sidebar improvements, chat fixes, profound 2026-03-29 23:02:19 -07:00
Waleed
14089f7dbb v0.6.14: performance improvements, connectors UX, collapsed sidebar actions 2026-03-27 13:07:59 -07:00
Waleed
e615816dce v0.6.13: emcn standardization, granola and ketch integrations, security hardening, connectors improvements 2026-03-27 00:16:37 -07:00
Waleed
ca87d7ce29 v0.6.12: billing, blogs UI 2026-03-26 01:19:23 -07:00
Waleed
6bebbc5e29 v0.6.11: billing fixes, rippling, hubspot, UI improvements, demo modal 2026-03-25 22:54:56 -07:00
Waleed
7b572f1f61 v0.6.10: tour fix, connectors reliability improvements, tooltip gif fixes 2026-03-24 21:38:19 -07:00
Vikhyath Mondreti
ed9a71f0af v0.6.9: general ux improvements for tables, mothership 2026-03-24 17:03:24 -07:00
Siddharth Ganesan
c78c870fda v0.6.8: mothership tool loop
v0.6.8: mothership tool loop
2026-03-24 04:06:19 -07:00
Waleed
19442f19e2 v0.6.7: kb improvements, edge z index fix, captcha, new trust center, block classifications 2026-03-21 12:43:33 -07:00
Waleed
1731a4d7f0 v0.6.6: landing improvements, styling consistency, mothership table renaming 2026-03-19 23:58:30 -07:00
Waleed
9fcd02fd3b v0.6.5: email validation, integrations page, mothership and custom tool fixes 2026-03-19 16:08:30 -07:00
Waleed
ff7b5b528c v0.6.4: subflows, docusign, ashby new tools, box, workday, billing bug fixes 2026-03-18 23:12:36 -07:00
Waleed
30f2d1a0fc v0.6.3: hubspot integration, kb block improvements 2026-03-18 11:19:55 -07:00
Waleed
4bd0731871 v0.6.2: mothership stability, chat iframe embedding, KB upserts, new blog post 2026-03-18 03:29:39 -07:00
Waleed
4f3bc37fe4 v0.6.1: added better auth admin plugin 2026-03-17 15:16:16 -07:00
Waleed
84d6fdc423 v0.6: mothership, tables, connectors 2026-03-17 12:21:15 -07:00
Vikhyath Mondreti
4c12914d35 v0.5.113: jira, ashby, google ads, grain updates 2026-03-12 22:54:25 -07:00
Waleed
e9bdc57616 v0.5.112: trace spans improvements, fathom integration, jira fixes, canvas navigation updates 2026-03-12 13:30:20 -07:00
Vikhyath Mondreti
36612ae42a v0.5.111: non-polling webhook execs off trigger.dev, gmail subject headers, webhook trigger configs (#3530) 2026-03-11 17:47:28 -07:00
Waleed
1c2c2c65d4 v0.5.110: webhook execution speedups, SSRF patches 2026-03-11 15:00:24 -07:00
Waleed
ecd3536a72 v0.5.109: obsidian and evernote integrations, slack fixes, remove memory instrumentation 2026-03-09 10:40:37 -07:00
Vikhyath Mondreti
8c0a2e04b1 v0.5.108: workflow input params in agent tools, bun upgrade, dropdown selectors for 14 blocks 2026-03-06 21:02:25 -08:00
Waleed
6586c5ce40 v0.5.107: new reddit, slack tools 2026-03-05 22:48:20 -08:00
Vikhyath Mondreti
3ce947566d v0.5.106: condition block and legacy kbs fixes, GPT 5.4 2026-03-05 17:30:05 -08:00
Waleed
70c36cb7aa v0.5.105: slack remove reaction, nested subflow locks fix, servicenow pagination, memory improvements 2026-03-04 22:38:26 -08:00
Waleed
f1ec5fe824 v0.5.104: memory improvements, nested subflows, careers page redirect, brandfetch, google meet 2026-03-03 23:45:29 -08:00
Waleed
e07e3c34cc v0.5.103: memory util instrumentation, API docs, amplitude, google pagespeed insights, pagerduty 2026-03-01 23:27:02 -08:00
Waleed
0d2e6ff31d v0.5.102: new integrations, new tools, ci speedups, memory leak instrumentation 2026-02-28 12:48:10 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
676 changed files with 29245 additions and 5926 deletions

View File

@@ -9,5 +9,26 @@ Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
## Styling
Never update global styles. Keep all styling local to components.
## ID Generation
Never use `crypto.randomUUID()`, `nanoid`, or the `uuid` package directly. Use the utilities from `@/lib/core/utils/uuid`:
- `generateId()` — UUID v4, use by default
- `generateShortId(size?)` — short URL-safe ID (default 21 chars), for compact identifiers
Both use `crypto.getRandomValues()` under the hood and work in all contexts including non-secure (HTTP) browsers.
```typescript
// ✗ Bad
import { nanoid } from 'nanoid'
import { v4 as uuidv4 } from 'uuid'
const id = crypto.randomUUID()
// ✓ Good
import { generateId, generateShortId } from '@/lib/core/utils/uuid'
const uuid = generateId()
const shortId = generateShortId()
const tiny = generateShortId(8)
```
## Package Manager
Use `bun` and `bunx`, not `npm` and `npx`.

View File

@@ -16,5 +16,26 @@ Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
## Styling
Never update global styles. Keep all styling local to components.
## ID Generation
Never use `crypto.randomUUID()`, `nanoid`, or the `uuid` package directly. Use the utilities from `@/lib/core/utils/uuid`:
- `generateId()` — UUID v4, use by default
- `generateShortId(size?)` — short URL-safe ID (default 21 chars), for compact identifiers
Both use `crypto.getRandomValues()` under the hood and work in all contexts including non-secure (HTTP) browsers.
```typescript
// ✗ Bad
import { nanoid } from 'nanoid'
import { v4 as uuidv4 } from 'uuid'
const id = crypto.randomUUID()
// ✓ Good
import { generateId, generateShortId } from '@/lib/core/utils/uuid'
const uuid = generateId()
const shortId = generateShortId()
const tiny = generateShortId(8)
```
## Package Manager
Use `bun` and `bunx`, not `npm` and `npx`.

View File

@@ -7,6 +7,7 @@ You are a professional software engineer. All code must follow best practices: a
- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`
- **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments
- **Styling**: Never update global styles. Keep all styling local to components
- **ID Generation**: Never use `crypto.randomUUID()`, `nanoid`, or `uuid` package. Use `generateId()` (UUID v4) or `generateShortId()` (compact) from `@/lib/core/utils/uuid`
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`
## Architecture

View File

@@ -7,6 +7,7 @@ You are a professional software engineer. All code must follow best practices: a
- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`
- **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments
- **Styling**: Never update global styles. Keep all styling local to components
- **ID Generation**: Never use `crypto.randomUUID()`, `nanoid`, or `uuid` package. Use `generateId()` (UUID v4) or `generateShortId()` (compact) from `@/lib/core/utils/uuid`
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`
## Architecture

View File

@@ -90,6 +90,7 @@ Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https:
git clone https://github.com/simstudioai/sim.git
cd sim
bun install
bun run prepare # Set up pre-commit hooks
```
2. Set up PostgreSQL with pgvector:
@@ -104,6 +105,11 @@ Or install manually via the [pgvector guide](https://github.com/pgvector/pgvecto
```bash
cp apps/sim/.env.example apps/sim/.env
# Create your secrets
perl -i -pe "s/your_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
perl -i -pe "s/your_internal_api_secret/$(openssl rand -hex 32)/" apps/sim/.env
perl -i -pe "s/your_api_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
# DB configs for migration
cp packages/db/.env.example packages/db/.env
# Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
```
@@ -111,7 +117,7 @@ cp packages/db/.env.example packages/db/.env
4. Run migrations:
```bash
cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
cd packages/db && bun run db:migrate
```
5. Start development servers:

View File

@@ -124,6 +124,29 @@ export function ConditionalIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function CredentialIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<circle cx='8' cy='15' r='4' stroke='currentColor' strokeWidth='1.75' />
<path d='M11.83 13.17L20 5' stroke='currentColor' strokeWidth='1.75' strokeLinecap='round' />
<path
d='M18 7l2 2'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M15 10l2 2'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
export function NoteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -4630,6 +4653,59 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function CloudFormationIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 80 80'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<g
id='Icon-Architecture/64/Arch_AWS-CloudFormation_64'
stroke='none'
strokeWidth='1'
fill='none'
fillRule='evenodd'
>
<path
d='M53,39.9632039 L58,39.9632039 L58,37.9601375 L53,37.9601375 L53,39.9632039 Z M28,51.9816019 L33,51.9816019 L33,49.9785356 L28,49.9785356 L28,51.9816019 Z M18,51.9816019 L25,51.9816019 L25,49.9785356 L18,49.9785356 L18,51.9816019 Z M18,45.9724029 L30,45.9724029 L30,43.9693366 L18,43.9693366 L18,45.9724029 Z M18,33.9540048 L27,33.9540048 L27,31.9509385 L18,31.9509385 L18,33.9540048 Z M18,39.9632039 L51,39.9632039 L51,37.9601375 L18,37.9601375 L18,39.9632039 Z M37,61.9969337 L14,61.9969337 L14,27.9448058 L37,27.9448058 L37,35.9570712 L39,35.9570712 L39,26.9432726 C39,26.3904263 38.552,25.9417395 38,25.9417395 L13,25.9417395 C12.447,25.9417395 12,26.3904263 12,26.9432726 L12,62.9984668 C12,63.5513131 12.447,64 13,64 L38,64 C38.552,64 39,63.5513131 39,62.9984668 L39,42.9678034 L37,42.9678034 L37,61.9969337 Z M68,36.9586044 C68,43.4305117 62.173,45.6819583 59.092,45.9683968 L43,45.9724029 L43,43.9693366 L59,43.9693366 C59.195,43.9463013 66,43.2121775 66,36.9586044 C66,31.2638867 60.863,30.1081175 59.834,29.9338507 C59.321,29.8467173 58.96,29.3820059 59.004,28.8632117 C59.005,28.8441826 59.007,28.826155 59.009,28.8081274 C58.954,25.5902013 56.981,24.584662 56.126,24.3002266 C54.53,23.769414 52.751,24.2771913 51.81,25.5391231 C51.591,25.8355769 51.229,25.9868085 50.861,25.9307226 C50.497,25.8756383 50.192,25.625255 50.068,25.2767214 C49.447,23.5360568 48.546,22.4083304 47.293,21.1534094 C44.159,18.0386412 39.905,17.1783242 35.925,18.8528877 C33.837,19.7332353 32.012,21.7282894 30.922,24.327268 L29.078,23.5500782 C30.37,20.4743699 32.584,18.0887179 35.15,17.007062 C39.905,15.0049972 44.971,16.0255595 48.704,19.7342369 C49.774,20.8068789 50.66,21.851478 51.35,23.2035478 C52.843,22.0978551 54.857,21.7673492 56.757,22.3993166 C59.189,23.2085554 60.727,25.3207889 60.975,28.1290879 C64.381,28.9884034 68,31.7115721 68,36.9586044 L68,36.9586044 Z'
id='AWS-CloudFormation_Icon_64_Squid'
fill='currentColor'
/>
</g>
</svg>
)
}
export function CloudWatchIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 80 80'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<g
id='Icon-Architecture/64/Arch_Amazon-CloudWatch_64'
stroke='none'
strokeWidth='1'
fill='none'
fillRule='evenodd'
transform='translate(40, 40) scale(1.25) translate(-40, -40)'
>
<path
d='M55.0592315,46.7773707 C55.0592315,42.8680281 51.8575588,39.6876305 47.9220646,39.6876305 C43.9865705,39.6876305 40.785903,42.8680281 40.785903,46.7773707 C40.785903,50.6867133 43.9865705,53.8671108 47.9220646,53.8671108 C51.8575588,53.8671108 55.0592315,50.6867133 55.0592315,46.7773707 M57.0697011,46.7773707 C57.0697011,51.7881194 52.9663327,55.8642207 47.9220646,55.8642207 C42.8788018,55.8642207 38.7754334,51.7881194 38.7754334,46.7773707 C38.7754334,41.7666219 42.8788018,37.6905206 47.9220646,37.6905206 C52.9663327,37.6905206 57.0697011,41.7666219 57.0697011,46.7773707 M65.5096522,60.4735504 L58.5011554,54.2026253 C57.9352082,54.9944794 57.2808004,55.7174332 56.5540156,56.3634982 L63.5524601,62.6334248 C64.1495696,63.1686502 65.0784065,63.1187225 65.6182176,62.5255808 C66.155013,61.9324392 66.1067617,61.010773 65.5096522,60.4735504 M47.9220646,57.6616197 C53.9645309,57.6616197 58.8801289,52.7786859 58.8801289,46.7773707 C58.8801289,40.7750569 53.9645309,35.8931217 47.9220646,35.8931217 C41.8806036,35.8931217 36.9650056,40.7750569 36.9650056,46.7773707 C36.9650056,52.7786859 41.8806036,57.6616197 47.9220646,57.6616197 M67.1119965,63.8626459 C66.4264264,64.6165549 65.47849,65 64.5285431,65 C63.7002296,65 62.8699057,64.708422 62.207456,64.1172774 L54.9305615,57.5987107 C52.9070239,58.8968321 50.505518,59.6587296 47.9220646,59.6587296 C40.7718297,59.6587296 34.9545361,53.8800921 34.9545361,46.7773707 C34.9545361,39.6746493 40.7718297,33.8960118 47.9220646,33.8960118 C55.0733048,33.8960118 60.8905985,39.6746493 60.8905985,46.7773707 C60.8905985,48.8154213 60.3990387,50.7366411 59.5465996,52.4511599 L66.8556616,58.9906963 C68.2750531,60.265851 68.3896499,62.4496907 67.1119965,63.8626459 M21.2803274,29.392529 C21.2803274,29.9117776 21.3124949,30.429029 21.3738143,30.9293051 C21.4089975,31.2138932 21.3205368,31.4984814 21.1295422,31.7131707 C20.9777518,31.8839236 20.7736891,31.9967603 20.550527,32.0347054 C18.0786547,32.6687878 14.0104695,34.5880104 14.0104695,40.3456782 C14.0104695,44.6933865 16.4240382,47.0929141 18.4495863,48.3411077 C19.1411878,48.7744806 19.9594489,49.0051468 20.8229456,49.0141338 L32.9450717,49.0251179 L32.9430613,51.0222278 L20.811888,51.0112437 C19.5664021,50.9982625 18.384246,50.6607509 17.3840374,50.0346569 C15.3765836,48.7974474 12,45.8896553 12,40.3456782 C12,33.66235 16.5999543,31.191925 19.3000149,30.319188 C19.2799102,30.0116331 19.2698579,29.702081 19.2698579,29.392529 C19.2698579,23.9324305 22.9982737,18.2696254 27.9420183,16.2215892 C33.7241287,13.8150717 39.8500294,15.0083449 44.3263399,19.4109737 C45.7135638,20.7749998 46.8545053,22.4316024 47.7300648,24.3478294 C48.9061895,23.3802296 50.355738,22.8460027 51.8836949,22.8460027 C54.8863312,22.8460027 58.2659305,25.1097268 58.8680661,30.0605622 C61.6797078,30.7046302 67.6206453,32.9553731 67.6206453,40.422567 C67.6206453,43.4042521 66.6797455,45.8666886 64.8230769,47.7419748 L63.3896121,46.3410022 C64.8632863,44.8531553 65.6101757,42.8620367 65.6101757,40.422567 C65.6101757,33.891019 60.1055101,32.2663701 57.737177,31.8719409 C57.4677741,31.827006 57.2295334,31.6752256 57.0757325,31.4515493 C56.9259525,31.2358614 56.8686541,30.9712444 56.9138897,30.7146157 C56.5851779,26.6604826 54.1605516,24.8431126 51.8836949,24.8431126 C50.4472144,24.8431126 49.1001998,25.5381069 48.1874466,26.7503526 C47.9652897,27.0439277 47.6044105,27.193711 47.2344841,27.139789 C46.8695838,27.085867 46.5629872,26.8362283 46.4373329,26.4917268 C45.6140456,24.2260057 44.4278686,22.3207628 42.9119745,20.8309188 C39.0327735,17.0154404 33.7281496,15.9809374 28.7170543,18.0649216 C24.5463352,19.7924217 21.2803274,24.7672224 21.2803274,29.392529'
id='Amazon-CloudWatch_Icon_64_Squid'
fill='currentColor'
/>
</g>
</svg>
)
}
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -27,7 +27,9 @@ import {
CirclebackIcon,
ClayIcon,
ClerkIcon,
CloudFormationIcon,
CloudflareIcon,
CloudWatchIcon,
ConfluenceIcon,
CursorIcon,
DatabricksIcon,
@@ -211,6 +213,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
clay: ClayIcon,
clerk: ClerkIcon,
cloudflare: CloudflareIcon,
cloudformation: CloudFormationIcon,
cloudwatch: CloudWatchIcon,
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
databricks: DatabricksIcon,

View File

@@ -0,0 +1,150 @@
---
title: Credential
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
The Credential block has two operations: **Select Credential** picks a single OAuth credential and outputs its ID reference for downstream blocks; **List Credentials** returns all OAuth credentials in the workspace (optionally filtered by provider) as an array for iteration.
<div className="flex justify-center">
<Image
src="/static/blocks/credential.png"
alt="Credential Block"
width={400}
height={300}
className="my-6"
/>
</div>
<Callout>
The Credential block outputs credential **ID references**, not secrets. Downstream blocks receive the ID and resolve the actual OAuth token securely during their own execution.
</Callout>
## Configuration Options
### Operation
| Value | Description |
|---|---|
| **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks |
| **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop |
### Credential (Select operation)
Select an OAuth credential from your workspace. The dropdown shows all connected OAuth accounts (Google, GitHub, Slack, etc.).
In advanced mode, paste a credential ID directly. You can copy a credential ID from your workspace's Credentials settings page.
### Provider (List operation)
Filter the returned OAuth credentials by provider. Select one or more providers from the dropdown — only providers you have credentials for will appear. Leave empty to return all OAuth credentials.
| Example | Returns |
|---|---|
| Gmail | Gmail credentials only |
| Slack | Slack credentials only |
| Gmail + Slack | Gmail and Slack credentials |
## Outputs
<Tabs items={['Select Credential', 'List Credentials']}>
<Tab>
| Output | Type | Description |
|---|---|---|
| `credentialId` | `string` | The credential ID — pipe this into other blocks' credential fields |
| `displayName` | `string` | Human-readable name (e.g. "waleed@company.com") |
| `providerId` | `string` | OAuth provider ID (e.g. `google-email`, `slack`) |
</Tab>
<Tab>
| Output | Type | Description |
|---|---|---|
| `credentials` | `json` | Array of OAuth credential objects (see shape below) |
| `count` | `number` | Number of credentials returned |
Each object in the `credentials` array:
| Field | Type | Description |
|---|---|---|
| `credentialId` | `string` | The credential ID |
| `displayName` | `string` | Human-readable name |
| `providerId` | `string` | OAuth provider ID |
</Tab>
</Tabs>
## Example Use Cases
**Shared credential across multiple blocks** — Define once, use everywhere
```
Credential (Select, Google) → Gmail (Send) & Google Drive (Upload) & Google Calendar (Create)
```
**Multi-account workflows** — Route to different credentials based on logic
```
Agent (Determine account) → Condition → Credential A or Credential B → Slack (Post)
```
**Iterate over all Gmail accounts**
```
Credential (List, Provider: Gmail) → ForEach Loop → Gmail (Send) using <loop.currentItem.credentialId>
```
<div className="flex justify-center">
<Image
src="/static/blocks/credential-loop.png"
alt="Credential List wired into a ForEach Loop"
width={900}
height={400}
className="my-6"
/>
</div>
## How to wire a Credential block
### Select Credential
1. Drop a **Credential** block and select your OAuth credential from the picker
2. In the downstream block, switch to **advanced mode** on its credential field
3. Enter `<credentialBlockName.credentialId>` as the value
<Tabs items={['Gmail', 'Slack']}>
<Tab>
In the Gmail block's credential field (advanced mode):
```
<myCredential.credentialId>
```
</Tab>
<Tab>
In the Slack block's credential field (advanced mode):
```
<myCredential.credentialId>
```
</Tab>
</Tabs>
### List Credentials
1. Drop a **Credential** block, set Operation to **List Credentials**
2. Optionally select one or more **Providers** to narrow results (only your connected providers appear)
3. Wire `<credentialBlockName.credentials>` into a **ForEach Loop** as the items source
4. Inside the loop, reference `<loop.currentItem.credentialId>` in downstream blocks' credential fields
## Best Practices
- **Define once, reference many times**: When five blocks use the same Google account, use one Credential block and wire all five to `<credential.credentialId>` instead of selecting the account five times
- **Outputs are safe to log**: The `credentialId` output is a UUID reference, not a secret. It is safe to inspect in execution logs
- **Use for environment switching**: Pair with a Condition block to route to a production or staging OAuth credential based on a workflow variable
- **Advanced mode is required**: Downstream blocks must be in advanced mode on their credential field to accept a dynamic reference
- **Use List + ForEach for fan-out**: When you need to run the same action across all accounts of a provider, List Credentials feeds naturally into a ForEach loop
- **Narrow by provider**: Use the Provider multiselect to filter to specific services — only providers you have credentials for are shown
<FAQ items={[
{ question: "Does the Credential block expose my secret or token?", answer: "No. The block outputs a credential ID (a UUID), not the actual OAuth token. Downstream blocks receive the ID and resolve the token securely in their own execution context. Secrets never appear in workflow state, logs, or the canvas." },
{ question: "What credential types does it support?", answer: "OAuth connected accounts only (Google, GitHub, Slack, etc.). Environment variables and service accounts cannot be resolved by ID in downstream blocks, so they are not supported." },
{ question: "How is Select different from just copying a credential ID into advanced mode?", answer: "Functionally identical — both pass the same credential ID to the downstream block. The Credential block adds value when you need to use one credential in many blocks (change it once), or when you want to select between credentials dynamically using a Condition block." },
{ question: "Can I list all OAuth credentials in my workspace?", answer: "Yes. Set the Operation to 'List Credentials'. Optionally filter by provider using the Provider multiselect. Wire the credentials output into a ForEach loop to process each credential individually." },
{ question: "Can I use a Credential block output in a Function block?", answer: "Yes. Reference <credential.credentialId> in your Function block's code. Note that the function will receive the raw UUID string — if you need the resolved token, the downstream block must handle the resolution (as integration blocks do). The Function block does not automatically resolve credential IDs." },
{ question: "What happens if the credential is deleted?", answer: "The Select operation will throw an error at execution time: 'Credential not found'. The List operation will simply omit the deleted credential from the results. Update the Credential block to select a valid credential before re-running." },
]} />

View File

@@ -4,6 +4,7 @@
"agent",
"api",
"condition",
"credential",
"evaluator",
"function",
"guardrails",

View File

@@ -20,7 +20,7 @@ The Response block formats and sends structured HTTP responses back to API calle
</div>
<Callout type="info">
Response blocks are terminal blocks - they end workflow execution and cannot connect to other blocks.
Response blocks are exit points — when a Response block executes, it ends the workflow and sends the HTTP response immediately. Multiple Response blocks can be placed on different branches (e.g. after a Router or Condition), but only the first one to execute determines the API response.
</Callout>
## Configuration Options
@@ -77,7 +77,11 @@ Condition (Error Detected) → Router → Response (400/500, Error Details)
## Outputs
Response blocks are terminal — no downstream blocks execute after them. However, the block does define outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
Response blocks are exit points — when one executes, no further blocks run. The block defines outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
<Callout type="warning">
If a Response block is placed on a parallel branch, there are no guarantees about whether other parallel blocks will run or not. Execution order across parallel branches is non-deterministic, so a parallel block may execute before or after the Response block on any given run. Avoid placing Response blocks in parallel with blocks that have important side effects.
</Callout>
## Variable References
@@ -110,10 +114,10 @@ Use the `<variable.name>` syntax to dynamically insert workflow variables into y
- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes
<FAQ items={[
{ question: "Can I have multiple Response blocks in a workflow?", answer: "No. The Response block is a single-instance block — only one is allowed per workflow. If you need different responses for different conditions, use a Condition or Router block upstream to determine what data reaches the single Response block." },
{ question: "Can I have multiple Response blocks in a workflow?", answer: "Yes. You can place multiple Response blocks on different branches (e.g. after a Router or Condition block). The first Response block to execute determines the API response and ends the workflow. This is useful for returning different responses based on conditions — for example, a 200 on the success branch and a 500 on the error branch." },
{ question: "What triggers require a Response block?", answer: "The Response block is designed for use with the API Trigger. When your workflow is invoked via the API, the Response block sends the structured HTTP response back to the caller. Other trigger types (like webhooks or schedules) do not require a Response block." },
{ question: "What is the difference between Builder and Editor mode?", answer: "Builder mode provides a visual interface for constructing your response structure with fields and types. Editor mode gives you a raw JSON code editor where you can write the response body directly. Builder mode is recommended for most use cases." },
{ question: "What is the default status code?", answer: "If you do not specify a status code, the Response block defaults to 200 (OK). You can set any valid HTTP status code including error codes like 400, 404, or 500." },
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are terminal — they end workflow execution and send the HTTP response. No further blocks can be connected after a Response block." },
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are exit points — they end workflow execution and send the HTTP response. No further blocks can execute after a Response block." },
]} />

View File

@@ -96,8 +96,9 @@ Understanding these core principles will help you build better workflows:
2. **Automatic Parallelization**: Independent blocks run concurrently without configuration
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
5. **State Persistence**: All block outputs and execution details are preserved for debugging
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
5. **Response Blocks as Exit Points**: When a Response block executes, the entire workflow stops and the API response is sent immediately. Multiple Response blocks can exist on different branches — the first one to execute wins
6. **State Persistence**: All block outputs and execution details are preserved for debugging
7. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
## Next Steps

View File

@@ -0,0 +1,183 @@
---
title: CloudFormation
description: Manage and inspect AWS CloudFormation stacks, resources, and drift
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="cloudformation"
color="linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[AWS CloudFormation](https://aws.amazon.com/cloudformation/) is an infrastructure-as-code service that lets you model, provision, and manage AWS resources by treating infrastructure as code. CloudFormation uses templates to describe the resources you need and their dependencies, so you can launch and configure them together as a stack.
With the CloudFormation integration, you can:
- **Describe Stacks**: List all stacks in a region or get detailed information about a specific stack, including its status, outputs, tags, and drift information
- **List Stack Resources**: Enumerate every resource in a stack with its logical ID, physical ID, type, status, and drift status
- **Describe Stack Events**: View the full event history for a stack to understand what happened during create, update, or delete operations
- **Detect Stack Drift**: Initiate drift detection to check whether any resources in a stack have been modified outside of CloudFormation
- **Drift Detection Status**: Poll the results of a drift detection operation to see which resources have drifted and how many
- **Get Template**: Retrieve the original template body (JSON or YAML) used to create or update a stack
- **Validate Template**: Check a CloudFormation template for syntax errors, required capabilities, parameters, and declared transforms before deploying
In Sim, the CloudFormation integration enables your agents to monitor infrastructure state, detect configuration drift, audit stack resources, and validate templates as part of automated SRE and DevOps workflows. This is especially powerful when combined with CloudWatch for observability and SNS for alerting, creating end-to-end infrastructure monitoring pipelines.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AWS CloudFormation into workflows. Describe stacks, list resources, detect drift, view stack events, retrieve templates, and validate templates. Requires AWS access key and secret access key.
## Tools
### `cloudformation_describe_stacks`
List and describe CloudFormation stacks
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `stackName` | string | No | Stack name or ID to describe \(omit to list all stacks\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `stacks` | array | List of CloudFormation stacks with status, outputs, and tags |
### `cloudformation_list_stack_resources`
List all resources in a CloudFormation stack
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `stackName` | string | Yes | Stack name or ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `resources` | array | List of stack resources with type, status, and drift information |
### `cloudformation_detect_stack_drift`
Initiate drift detection on a CloudFormation stack
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `stackName` | string | Yes | Stack name or ID to detect drift on |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `stackDriftDetectionId` | string | ID to use with Describe Stack Drift Detection Status to check results |
### `cloudformation_describe_stack_drift_detection_status`
Check the status of a stack drift detection operation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `stackDriftDetectionId` | string | Yes | The drift detection ID returned by Detect Stack Drift |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `stackId` | string | The stack ID |
| `stackDriftDetectionId` | string | The drift detection ID |
| `stackDriftStatus` | string | Drift status \(DRIFTED, IN_SYNC, NOT_CHECKED\) |
| `detectionStatus` | string | Detection status \(DETECTION_IN_PROGRESS, DETECTION_COMPLETE, DETECTION_FAILED\) |
| `detectionStatusReason` | string | Reason if detection failed |
| `driftedStackResourceCount` | number | Number of resources that have drifted |
| `timestamp` | number | Timestamp of the detection |
### `cloudformation_describe_stack_events`
Get the event history for a CloudFormation stack
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `stackName` | string | Yes | Stack name or ID |
| `limit` | number | No | Maximum number of events to return \(default: 50\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `events` | array | List of stack events with resource status and timestamps |
### `cloudformation_get_template`
Retrieve the template body for a CloudFormation stack
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `stackName` | string | Yes | Stack name or ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `templateBody` | string | The template body as a JSON or YAML string |
| `stagesAvailable` | array | Available template stages |
### `cloudformation_validate_template`
Validate a CloudFormation template for syntax and structural correctness
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `templateBody` | string | Yes | The CloudFormation template body \(JSON or YAML\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `description` | string | Template description |
| `parameters` | array | Template parameters with defaults and descriptions |
| `capabilities` | array | Required capabilities \(e.g., CAPABILITY_IAM\) |
| `capabilitiesReason` | string | Reason capabilities are required |
| `declaredTransforms` | array | Transforms used in the template \(e.g., AWS::Serverless-2016-10-31\) |

View File

@@ -0,0 +1,180 @@
---
title: CloudWatch
description: Query and monitor AWS CloudWatch logs, metrics, and alarms
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="cloudwatch"
color="linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)"
/>
## Usage Instructions
Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.
## Tools
### `cloudwatch_query_logs`
Run a CloudWatch Log Insights query against one or more log groups
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `logGroupNames` | array | Yes | Log group names to query |
| `queryString` | string | Yes | CloudWatch Log Insights query string |
| `startTime` | number | Yes | Start time as Unix epoch seconds |
| `endTime` | number | Yes | End time as Unix epoch seconds |
| `limit` | number | No | Maximum number of results to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | array | Query result rows |
| `statistics` | object | Query statistics \(bytesScanned, recordsMatched, recordsScanned\) |
| `status` | string | Query completion status |
### `cloudwatch_describe_log_groups`
List available CloudWatch log groups
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `prefix` | string | No | Filter log groups by name prefix |
| `limit` | number | No | Maximum number of log groups to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `logGroups` | array | List of CloudWatch log groups with metadata |
### `cloudwatch_get_log_events`
Retrieve log events from a specific CloudWatch log stream
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `logGroupName` | string | Yes | CloudWatch log group name |
| `logStreamName` | string | Yes | CloudWatch log stream name |
| `startTime` | number | No | Start time as Unix epoch seconds |
| `endTime` | number | No | End time as Unix epoch seconds |
| `limit` | number | No | Maximum number of events to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `events` | array | Log events with timestamp, message, and ingestion time |
### `cloudwatch_describe_log_streams`
List log streams within a CloudWatch log group
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `logGroupName` | string | Yes | CloudWatch log group name |
| `prefix` | string | No | Filter log streams by name prefix |
| `limit` | number | No | Maximum number of log streams to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `logStreams` | array | List of log streams with metadata |
### `cloudwatch_list_metrics`
List available CloudWatch metrics
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `namespace` | string | No | Filter by namespace \(e.g., AWS/EC2, AWS/Lambda\) |
| `metricName` | string | No | Filter by metric name |
| `recentlyActive` | boolean | No | Only show metrics active in the last 3 hours |
| `limit` | number | No | Maximum number of metrics to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `metrics` | array | List of metrics with namespace, name, and dimensions |
### `cloudwatch_get_metric_statistics`
Get statistics for a CloudWatch metric over a time range
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `namespace` | string | Yes | Metric namespace \(e.g., AWS/EC2, AWS/Lambda\) |
| `metricName` | string | Yes | Metric name \(e.g., CPUUtilization, Invocations\) |
| `startTime` | number | Yes | Start time as Unix epoch seconds |
| `endTime` | number | Yes | End time as Unix epoch seconds |
| `period` | number | Yes | Granularity in seconds \(e.g., 60, 300, 3600\) |
| `statistics` | array | Yes | Statistics to retrieve \(Average, Sum, Minimum, Maximum, SampleCount\) |
| `dimensions` | string | No | Dimensions as JSON \(e.g., \{"InstanceId": "i-1234"\}\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `label` | string | Metric label |
| `datapoints` | array | Datapoints with timestamp and statistics values |
### `cloudwatch_describe_alarms`
List and filter CloudWatch alarms
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `alarmNamePrefix` | string | No | Filter alarms by name prefix |
| `stateValue` | string | No | Filter by alarm state \(OK, ALARM, INSUFFICIENT_DATA\) |
| `alarmType` | string | No | Filter by alarm type \(MetricAlarm, CompositeAlarm\) |
| `limit` | number | No | Maximum number of alarms to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alarms` | array | List of CloudWatch alarms with state and configuration |

View File

@@ -45,6 +45,7 @@ List all cloud agents for the authenticated user with optional pagination. Retur
| `apiKey` | string | Yes | Cursor API key |
| `limit` | number | No | Number of agents to return \(default: 20, max: 100\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `prUrl` | string | No | Filter agents by pull request URL |
#### Output
@@ -173,4 +174,41 @@ Permanently delete a cloud agent. Returns API-aligned fields only.
| --------- | ---- | ----------- |
| `id` | string | Agent ID |
### `cursor_list_artifacts`
List generated artifact files for a cloud agent. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Cursor API key |
| `agentId` | string | Yes | Unique identifier for the cloud agent \(e.g., bc_abc123\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `artifacts` | array | List of artifact files |
| ↳ `path` | string | Artifact file path |
| ↳ `size` | number | File size in bytes |
### `cursor_download_artifact`
Download a generated artifact file from a cloud agent. Returns the file for execution storage.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Cursor API key |
| `agentId` | string | Yes | Unique identifier for the cloud agent \(e.g., bc_abc123\) |
| `path` | string | Yes | Absolute path of the artifact to download \(e.g., /src/index.ts\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | file | Downloaded artifact file stored in execution files |

View File

@@ -23,6 +23,8 @@
"clay",
"clerk",
"cloudflare",
"cloudformation",
"cloudwatch",
"confluence",
"cursor",
"databricks",

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,8 +1,5 @@
# Database (Required)
DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres"
# PostgreSQL Port (Optional) - defaults to 5432 if not specified
# POSTGRES_PORT=5432
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
# Authentication (Required unless DISABLE_AUTH=true)
BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation

View File

@@ -2,7 +2,7 @@
import { useEffect } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Navbar from '@/app/(landing)/components/navbar/navbar'
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
useEffect(() => {

View File

@@ -81,7 +81,7 @@ export function SocialLoginButtons({
const githubButton = (
<Button
variant='outline'
className='w-full rounded-[10px]'
className='w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
disabled={!githubAvailable || isGithubLoading}
onClick={signInWithGithub}
>
@@ -93,7 +93,7 @@ export function SocialLoginButtons({
const googleButton = (
<Button
variant='outline'
className='w-full rounded-[10px]'
className='w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
disabled={!googleAvailable || isGoogleLoading}
onClick={signInWithGoogle}
>

View File

@@ -28,7 +28,9 @@ export function SSOLoginButton({
router.push(ssoUrl)
}
const outlineBtnClasses = cn('w-full rounded-[10px]')
const outlineBtnClasses = cn(
'w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
)
return (
<Button

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Navbar from '@/app/(landing)/components/navbar/navbar'
import { SupportFooter } from './support-footer'
export interface StatusPageLayoutProps {

View File

@@ -1,16 +1,18 @@
'use client'
import { Suspense, useMemo, useRef, useState } from 'react'
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Input, Label } from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureEvent } from '@/lib/posthog/client'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
@@ -81,7 +83,12 @@ function SignupFormContent({
const router = useRouter()
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
const posthog = usePostHog()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
captureEvent(posthog, 'signup_page_viewed', {})
}, [posthog])
const [showPassword, setShowPassword] = useState(false)
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
@@ -92,8 +99,6 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const redirectUrl = useMemo(
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
@@ -251,27 +256,14 @@ function SignupFormContent({
let token: string | undefined
const widget = turnstileRef.current
if (turnstileSiteKey && widget) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
widget.reset()
token = await Promise.race([
new Promise<string>((resolve, reject) => {
captchaResolveRef.current = resolve
captchaRejectRef.current = reject
widget.execute()
}),
new Promise<string>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
}),
])
widget.execute()
token = await widget.getResponsePromise()
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
} finally {
clearTimeout(timeoutId)
captchaResolveRef.current = null
captchaRejectRef.current = null
}
}
@@ -528,10 +520,7 @@ function SignupFormContent({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={(token) => captchaResolveRef.current?.(token)}
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
options={{ execution: 'execute' }}
options={{ execution: 'execute', appearance: 'execute' }}
/>
)}

View File

@@ -1,584 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { motion, type Variants } from 'framer-motion'
/** Stagger between each block appearing (seconds). */
const ENTER_STAGGER = 0.06
/** Duration of each block's fade-in (seconds). */
const ENTER_DURATION = 0.3
/** Stagger between each block disappearing (seconds). */
const EXIT_STAGGER = 0.12
/** Duration of each block's fade-out (seconds). */
const EXIT_DURATION = 0.5
/** Shared corner radius for all decorative rects. */
const RX = '2.59574'
/** Hold time after the initial enter animation before cycling starts (ms). */
const INITIAL_HOLD_MS = 2500
/** Pause between an exit completing and the next enter starting (ms). */
const TRANSITION_PAUSE_MS = 400
/** Hold time between successive transitions (ms). */
const HOLD_BETWEEN_MS = 2500
/** Animation state for a block group. */
export type BlockAnimState = 'entering' | 'visible' | 'exiting' | 'hidden'
/** Positions around the hero where block groups can appear. */
export type BlockPosition = 'topRight' | 'left' | 'rightEdge' | 'rightSide' | 'topLeft'
/** Attributes for a single animated SVG rect. */
interface BlockRect {
opacity: number
width: string
height: string
fill: string
x?: string
y?: string
transform?: string
}
const containerVariants: Variants = {
hidden: {},
visible: { transition: { staggerChildren: ENTER_STAGGER } },
exit: { transition: { staggerChildren: EXIT_STAGGER } },
}
const blockVariants: Variants = {
hidden: { opacity: 0, transition: { duration: 0 } },
visible: (targetOpacity: number) => ({
opacity: targetOpacity,
transition: { duration: ENTER_DURATION },
}),
exit: {
opacity: 0,
transition: { duration: EXIT_DURATION },
},
}
/** Maps a BlockAnimState to the framer-motion animate value. */
function toAnimateValue(state: BlockAnimState): string {
if (state === 'entering' || state === 'visible') return 'visible'
if (state === 'exiting') return 'exit'
return 'hidden'
}
/** Shared SVG wrapper that staggers child rects in and out. */
function AnimatedBlocksSvg({
width,
height,
viewBox,
rects,
animState = 'entering',
}: {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
animState?: BlockAnimState
}) {
return (
<motion.svg
width={width}
height={height}
viewBox={viewBox}
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
initial='hidden'
animate={toAnimateValue(animState)}
variants={containerVariants}
>
{rects.map((r, i) => (
<motion.rect
key={i}
variants={blockVariants}
custom={r.opacity}
x={r.x}
y={r.y}
width={r.width}
height={r.height}
rx={RX}
fill={r.fill}
transform={r.transform}
/>
))}
</motion.svg>
)
}
/**
* Rect data for the top-right position.
* Two-row horizontal strip, ordered left-to-right.
*/
const TOP_RIGHT_RECTS: readonly BlockRect[] = [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FFCC02' },
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#FA4EDF' },
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#FA4EDF' },
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
]
/**
* Rect data for the top-left position.
* Same two-row structure as top-right with rotated colour palette:
* blue→green, green→yellow, yellow→pink, pink→blue.
*/
const TOP_LEFT_RECTS: readonly BlockRect[] = [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#00F701' },
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#00F701' },
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#FFCC02' },
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#FFCC02' },
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#FFCC02' },
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
]
/**
* Rect data for the left position.
* Two-column vertical strip, ordered top-to-bottom.
*/
const LEFT_RECTS: readonly BlockRect[] = [
{
opacity: 0.6,
width: '34.240',
height: '33.725',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.480',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.727 0)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.727 17.378)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.986',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 51.616)',
},
{
opacity: 0.6,
width: '16.8626',
height: '140.507',
fill: '#00F701',
transform: 'matrix(-1 0 0 1 33.986 85.335)',
},
{
opacity: 0.4,
x: '17.119',
y: '136.962',
width: '34.240',
height: '16.8626',
fill: '#FFCC02',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 1,
x: '17.119',
y: '136.962',
width: '16.8626',
height: '16.8626',
fill: '#FFCC02',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 0.5,
width: '34.240',
height: '33.725',
fill: '#00F701',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
]
/**
* Rect data for the right-side position (right edge of screenshot).
* Same two-column structure as left with rotated colours:
* pink→blue, green→pink, yellow→green.
*/
const RIGHT_SIDE_RECTS: readonly BlockRect[] = [
{
opacity: 0.6,
width: '34.240',
height: '33.725',
fill: '#2ABBF8',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.480',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.727 0)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.727 17.378)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.986',
fill: '#2ABBF8',
transform: 'matrix(0 1 1 0 0 51.616)',
},
{
opacity: 0.6,
width: '16.8626',
height: '140.507',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.986 85.335)',
},
{
opacity: 0.4,
x: '17.119',
y: '136.962',
width: '34.240',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 1,
x: '17.119',
y: '136.962',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 0.5,
width: '34.240',
height: '33.725',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
]
/**
* Rect data for the right-edge position (far right of screen).
* Two-column vertical strip, ordered top-to-bottom.
*/
const RIGHT_RECTS: readonly BlockRect[] = [
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '34.241',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 16.891 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.482',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.739 16.888)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 33.776)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.739 34.272)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0.012 68.510)',
},
{
opacity: 0.6,
width: '16.8626',
height: '102.384',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.787 102.384)',
},
{
opacity: 0.4,
x: '17.131',
y: '153.859',
width: '34.241',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.131 153.859)',
},
{
opacity: 1,
x: '17.131',
y: '153.859',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.131 153.859)',
},
]
/** Number of rects per position, used to compute animation durations. */
const RECT_COUNTS: Record<BlockPosition, number> = {
topRight: TOP_RIGHT_RECTS.length,
topLeft: TOP_LEFT_RECTS.length,
left: LEFT_RECTS.length,
rightSide: RIGHT_SIDE_RECTS.length,
rightEdge: RIGHT_RECTS.length,
}
/** Total enter animation time for a position (seconds). */
function enterTime(pos: BlockPosition): number {
return (RECT_COUNTS[pos] - 1) * ENTER_STAGGER + ENTER_DURATION
}
/** Total exit animation time for a position (seconds). */
function exitTime(pos: BlockPosition): number {
return (RECT_COUNTS[pos] - 1) * EXIT_STAGGER + EXIT_DURATION
}
/** A single step in the repeating animation cycle. */
type CycleStep =
| { action: 'exit'; position: BlockPosition }
| { action: 'enter'; position: BlockPosition }
| { action: 'hold'; ms: number }
/**
* The repeating cycle sequence. After all steps, the layout returns to its
* initial state (topRight + left + rightEdge) so the loop is seamless.
*
* Order: exit top → exit right-edge → enter right-side-of-preview →
* exit left → enter top-left → exit right-side → enter left →
* exit top-left → enter top-right → enter right-edge → back to initial.
*/
const CYCLE_STEPS: readonly CycleStep[] = [
{ action: 'exit', position: 'topRight' },
{ action: 'exit', position: 'rightEdge' },
{ action: 'enter', position: 'rightSide' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
{ action: 'exit', position: 'left' },
{ action: 'enter', position: 'topLeft' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
{ action: 'exit', position: 'rightSide' },
{ action: 'enter', position: 'left' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
{ action: 'exit', position: 'topLeft' },
{ action: 'enter', position: 'topRight' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
{ action: 'enter', position: 'rightEdge' },
{ action: 'hold', ms: HOLD_BETWEEN_MS },
]
/**
* Drives the block-cycling animation loop. Returns the current animation
* state for every position so each component can be driven declaratively.
*
* Lifecycle:
* 1. All three initial groups (topRight, left, rightEdge) enter together.
* 2. After a hold period the cycle begins, processing each step in order.
* 3. Repeats indefinitely, returning to the initial layout every cycle.
*/
export function useBlockCycle(): Record<BlockPosition, BlockAnimState> {
const [states, setStates] = useState<Record<BlockPosition, BlockAnimState>>({
topRight: 'entering',
left: 'entering',
rightEdge: 'entering',
rightSide: 'hidden',
topLeft: 'hidden',
})
useEffect(() => {
const cancelled = { current: false }
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const run = async () => {
const longestEnter = Math.max(
enterTime('topRight'),
enterTime('left'),
enterTime('rightEdge')
)
await delay(longestEnter * 1000)
if (cancelled.current) return
setStates({
topRight: 'visible',
left: 'visible',
rightEdge: 'visible',
rightSide: 'hidden',
topLeft: 'hidden',
})
await delay(INITIAL_HOLD_MS)
if (cancelled.current) return
while (!cancelled.current) {
for (const step of CYCLE_STEPS) {
if (cancelled.current) return
if (step.action === 'exit') {
setStates((prev) => ({ ...prev, [step.position]: 'exiting' }))
await delay(exitTime(step.position) * 1000)
if (cancelled.current) return
setStates((prev) => ({ ...prev, [step.position]: 'hidden' }))
await delay(TRANSITION_PAUSE_MS)
} else if (step.action === 'enter') {
setStates((prev) => ({ ...prev, [step.position]: 'entering' }))
await delay(enterTime(step.position) * 1000)
if (cancelled.current) return
setStates((prev) => ({ ...prev, [step.position]: 'visible' }))
await delay(TRANSITION_PAUSE_MS)
} else {
await delay(step.ms)
}
if (cancelled.current) return
}
}
}
run()
return () => {
cancelled.current = true
}
}, [])
return states
}
interface AnimatedBlockProps {
animState?: BlockAnimState
}
/** Two-row horizontal strip at the top-right of the hero. */
export function BlocksTopRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={295}
height={34}
viewBox='0 0 295 34'
rects={TOP_RIGHT_RECTS}
animState={animState}
/>
)
}
/** Two-row horizontal strip at the top-left of the hero. */
export function BlocksTopLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={295}
height={34}
viewBox='0 0 295 34'
rects={TOP_LEFT_RECTS}
animState={animState}
/>
)
}
/** Two-column vertical strip on the left edge of the screenshot. */
export function BlocksLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
height={226}
viewBox='0 0 34 226.021'
rects={LEFT_RECTS}
animState={animState}
/>
)
}
/** Two-column vertical strip on the right edge of the screenshot. */
export function BlocksRightSideAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
height={226}
viewBox='0 0 34 226.021'
rects={RIGHT_SIDE_RECTS}
animState={animState}
/>
)
}
/** Two-column vertical strip at the far-right edge of the screen. */
export function BlocksRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
height={205}
viewBox='0 0 34 204.769'
rects={RIGHT_RECTS}
animState={animState}
/>
)
}

View File

@@ -1,135 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import Link from 'next/link'
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
import {
BlocksLeftAnimated,
BlocksRightAnimated,
BlocksRightSideAnimated,
BlocksTopLeftAnimated,
BlocksTopRightAnimated,
useBlockCycle,
} from '@/app/(home)/components/hero/components/animated-blocks'
const LandingPreview = dynamic(
() =>
import('@/app/(home)/components/landing-preview/landing-preview').then(
(mod) => mod.LandingPreview
),
{
ssr: false,
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[var(--landing-bg)]' />,
}
)
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
const CTA_BASE =
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
export default function Hero() {
const blockStates = useBlockCycle()
return (
<section
id='hero'
aria-labelledby='hero-heading'
className='relative flex flex-col items-center overflow-hidden bg-[var(--landing-bg)] pt-[60px] pb-3 lg:pt-[100px]'
>
<p className='sr-only'>
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
1,000+ integrations and LLMs including OpenAI, Claude, Gemini, Mistral, and xAI to
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
SOC2 compliant.
</p>
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw]'
>
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
</div>
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-2.8vw] right-[-4vw] z-0 aspect-[471/470] w-[32.7vw]'
>
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
</div>
<div className='relative z-10 flex flex-col items-center gap-3'>
<h1
id='hero-heading'
className='text-balance font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
>
Build AI Agents
</h1>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[125%] tracking-[0.02em] lg:text-lg'>
Sim is the AI Workspace for Agent Builders.
</p>
<div className='mt-3 flex items-center gap-2'>
<DemoRequestModal>
<button
type='button'
className={`${CTA_BASE} border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`}
aria-label='Get a demo'
>
Get a demo
</button>
</DemoRequestModal>
<Link
href='/signup'
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
</div>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
>
<BlocksTopRightAnimated animState={blockStates.topRight} />
</div>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
>
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
</div>
<div className='relative z-10 mx-auto mt-[3.2vw] w-[78.9vw] px-[1.4vw]'>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
>
<BlocksLeftAnimated animState={blockStates.left} />
</div>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
>
<BlocksRightSideAnimated animState={blockStates.rightSide} />
</div>
<div className='relative z-10 overflow-hidden rounded border border-[var(--landing-bg-elevated)]'>
<LandingPreview />
</div>
</div>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
>
<BlocksRightAnimated animState={blockStates.rightEdge} />
</div>
</section>
)
}

View File

@@ -1,23 +0,0 @@
import Collaboration from '@/app/(home)/components/collaboration/collaboration'
import Enterprise from '@/app/(home)/components/enterprise/enterprise'
import Features from '@/app/(home)/components/features/features'
import Footer from '@/app/(home)/components/footer/footer'
import Hero from '@/app/(home)/components/hero/hero'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Pricing from '@/app/(home)/components/pricing/pricing'
import StructuredData from '@/app/(home)/components/structured-data'
import Templates from '@/app/(home)/components/templates/templates'
import Testimonials from '@/app/(home)/components/testimonials/testimonials'
export {
Collaboration,
Enterprise,
Features,
Footer,
Hero,
Navbar,
Pricing,
StructuredData,
Templates,
Testimonials,
}

View File

@@ -1,99 +0,0 @@
'use client'
import { memo, useCallback, useRef, useState } from 'react'
import { ArrowUp } from 'lucide-react'
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
const C = {
SURFACE: '#292929',
BORDER: '#3d3d3d',
TEXT_PRIMARY: '#e6e6e6',
} as const
/**
* Landing preview replica of the workspace Home initial view.
* Shows a greeting heading and a minimal chat input (no + or mic).
* On submit, stores the prompt and redirects to /signup.
*/
export const LandingPreviewHome = memo(function LandingPreviewHome() {
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const animatedPlaceholder = useAnimatedPlaceholder()
const isEmpty = inputValue.trim().length === 0
const handleSubmit = useCallback(() => {
if (isEmpty) return
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
const MAX_HEIGHT = 200
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, MAX_HEIGHT)}px`
}, [])
return (
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-6 pb-[2vh]'>
<p
role='presentation'
className='mb-6 max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
style={{ color: C.TEXT_PRIMARY }}
>
What should we get done?
</p>
<div className='w-full max-w-[32rem]'>
<div
className='cursor-text rounded-[20px] border px-2.5 py-2'
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
onClick={() => textareaRef.current?.focus()}
>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={animatedPlaceholder}
rows={1}
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
style={{
color: C.TEXT_PRIMARY,
caretColor: C.TEXT_PRIMARY,
maxHeight: `${MAX_HEIGHT}px`,
}}
/>
<div className='flex items-center justify-end'>
<button
type='button'
onClick={handleSubmit}
disabled={isEmpty}
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#808080' : '#e0e0e0',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
</button>
</div>
</div>
</div>
</div>
)
})

View File

@@ -1,169 +0,0 @@
'use client'
import { memo, useCallback, useRef, useState } from 'react'
import { ArrowUp } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createPortal } from 'react-dom'
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
/**
* Stores the prompt in browser storage and redirects to /signup.
* Shared by both the copilot panel and the landing home view.
*/
export function useLandingSubmit() {
const router = useRouter()
return useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed) return
LandingPromptStorage.store(trimmed)
router.push('/signup')
},
[router]
)
}
/**
* Lightweight static panel replicating the real workspace panel styling.
* The copilot tab is active with a functional user input.
* When submitted, stores the prompt and redirects to /signup (same as landing hero).
*
* Structure mirrors the real Panel component:
* aside > div.border-l.pt-[14px] > Header(px-8) > Tabs(px-8,pt-14) > Content(pt-12)
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
*/
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
const isEmpty = inputValue.trim().length === 0
const handleSubmit = useCallback(() => {
if (isEmpty) return
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
return (
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-3.5'>
{/* Header — More + Chat | Deploy + Run */}
<div className='flex flex-shrink-0 items-center justify-between px-2'>
<div className='pointer-events-none flex gap-1.5'>
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
</div>
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
<BubbleChatPreview className='h-[14px] w-[14px] text-[#e6e6e6]' />
</div>
</div>
<Link
href='/signup'
className='flex gap-1.5'
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setCursorPos(null)}
>
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
</div>
<div className='flex h-[30px] items-center gap-2 rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
</div>
</Link>
{cursorPos &&
createPortal(
<div
className='pointer-events-none fixed z-[9999]'
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
>
{/* Decorative color bars — mirrors hero top-right block sequence */}
<div className='flex h-[4px]'>
<div className='h-full w-[8px] bg-[#2ABBF8]' />
<div className='h-full w-[14px] bg-[#2ABBF8] opacity-60' />
<div className='h-full w-[8px] bg-[#00F701]' />
<div className='h-full w-[16px] bg-[#00F701] opacity-60' />
<div className='h-full w-[8px] bg-[#FFCC02]' />
<div className='h-full w-[10px] bg-[#FFCC02] opacity-60' />
<div className='h-full w-[8px] bg-[#FA4EDF]' />
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
</div>
<div className='flex items-center gap-[5px] bg-white px-1.5 py-1 font-medium text-[#1C1C1C] text-[11px]'>
Get started
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
</div>
</div>,
document.body
)}
</div>
{/* Tabs */}
<div className='flex flex-shrink-0 items-center px-2 pt-3.5'>
<div className='pointer-events-none flex gap-1'>
<div className='flex h-[28px] items-center rounded-[6px] border border-[#3d3d3d] bg-[#363636] px-2 py-[5px]'>
<span className='font-medium text-[#e6e6e6] text-[12.5px]'>Copilot</span>
</div>
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-2 py-[5px]'>
<span className='font-medium text-[#787878] text-[12.5px]'>Toolbar</span>
</div>
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-2 py-[5px]'>
<span className='font-medium text-[#787878] text-[12.5px]'>Editor</span>
</div>
</div>
</div>
{/* Tab content — copilot */}
<div className='flex flex-1 flex-col overflow-hidden pt-3'>
<div className='flex h-full flex-col'>
{/* Copilot header bar — matches mx-[-1px] in real copilot */}
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center rounded-[4px] border border-[#2c2c2c] bg-[#292929] px-3 py-1.5'>
<span className='truncate font-medium text-[#e6e6e6] text-[14px]'>New Chat</span>
</div>
{/* User input — matches real UserInput at p-[8px] inside copilot welcome state */}
<div className='px-2 pt-3 pb-2'>
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-1.5 py-1.5'>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder='Build an AI agent...'
rows={2}
className='mb-1.5 min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-0.5 py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
/>
<div className='flex items-center justify-end'>
<button
type='button'
onClick={handleSubmit}
disabled={isEmpty}
className='flex h-[22px] w-[22px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#808080' : '#e0e0e0',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={14} strokeWidth={2.25} color='#1b1b1b' />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
})

View File

@@ -1,147 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { motion, type Variants } from 'framer-motion'
import { LandingPreviewFiles } from '@/app/(home)/components/landing-preview/components/landing-preview-files/landing-preview-files'
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
import { LandingPreviewKnowledge } from '@/app/(home)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
import { LandingPreviewLogs } from '@/app/(home)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { LandingPreviewScheduledTasks } from '@/app/(home)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
import type { SidebarView } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewTables } from '@/app/(home)/components/landing-preview/components/landing-preview-tables/landing-preview-tables'
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
import {
EASE_OUT,
PREVIEW_WORKFLOWS,
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
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 panelVariants: 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 },
},
},
}
/**
* Interactive workspace preview for the hero section.
*
* Renders a lightweight replica of the Sim workspace with:
* - A sidebar with selectable workflows and workspace nav items
* - A ReactFlow canvas showing the active workflow's blocks and edges
* - Static previews of Tables, Files, Knowledge Base, Logs, and Scheduled Tasks
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
*
* Only workflow items, the home button, workspace nav items, and the copilot input
* are interactive. Animations only fire on initial load.
*/
export function LandingPreview() {
const [activeView, setActiveView] = useState<SidebarView>('workflow')
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
const isInitialMount = useRef(true)
useEffect(() => {
isInitialMount.current = false
}, [])
const handleSelectWorkflow = useCallback((id: string) => {
setActiveWorkflowId(id)
setActiveView('workflow')
}, [])
const handleSelectHome = useCallback(() => {
setActiveView('home')
}, [])
const handleSelectNav = useCallback((id: SidebarView) => {
setActiveView(id)
}, [])
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
const isWorkflowView = activeView === 'workflow'
function renderContent() {
switch (activeView) {
case 'workflow':
return <LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
case 'home':
return <LandingPreviewHome />
case 'tables':
return <LandingPreviewTables />
case 'files':
return <LandingPreviewFiles />
case 'knowledge':
return <LandingPreviewKnowledge />
case 'logs':
return <LandingPreviewLogs />
case 'scheduled-tasks':
return <LandingPreviewScheduledTasks />
}
}
return (
<motion.div
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[var(--landing-bg-surface)] antialiased'
initial='hidden'
animate='visible'
variants={containerVariants}
>
<motion.div className='hidden lg:flex' variants={sidebarVariants}>
<LandingPreviewSidebar
workflows={PREVIEW_WORKFLOWS}
activeWorkflowId={activeWorkflowId}
activeView={activeView}
onSelectWorkflow={handleSelectWorkflow}
onSelectHome={handleSelectHome}
onSelectNav={handleSelectNav}
/>
</motion.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-[8px] border border-[#2c2c2c] bg-[var(--landing-bg)]'>
<div
className={
isWorkflowView
? 'relative min-w-0 flex-1 overflow-hidden'
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
}
>
{renderContent()}
</div>
<motion.div
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
variants={panelVariants}
>
<LandingPreviewPanel />
</motion.div>
</div>
</div>
</motion.div>
)
}

View File

@@ -1,18 +0,0 @@
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
/**
* Landing page route-group layout.
*
* Applies landing-specific font CSS variables to the subtree:
* - `--font-season` (Season Sans): Headings and display text
* - `--font-martian-mono` (Martian Mono): Code snippets and technical accents
*
* Available to child components via Tailwind (`font-season`, `font-martian-mono`).
*
* SEO metadata for the `/` route is exported from `app/page.tsx` — not here.
* This layout only applies when a `page.tsx` exists inside the `(home)/` route group.
*/
export default function HomeLayout({ children }: { children: React.ReactNode }) {
return <div className={`${season.variable} ${martianMono.variable}`}>{children}</div>
}

View File

@@ -1,6 +1,6 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
export default async function StudioLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()

View File

@@ -1,32 +1,55 @@
import { Skeleton } from '@/components/emcn'
const SKELETON_CARD_COUNT = 6
export default function BlogLoading() {
return (
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
<Skeleton className='h-[48px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mt-3 h-[18px] w-[420px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<div className='mt-10 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
{Array.from({ length: SKELETON_CARD_COUNT }).map((_, i) => (
<div
key={i}
className='flex flex-col overflow-hidden rounded-xl border border-[var(--landing-border)]'
>
<Skeleton className='aspect-video w-full rounded-none bg-[var(--landing-bg-elevated)]' />
<div className='flex flex-1 flex-col p-4'>
<Skeleton className='mb-2 h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mb-1 h-[20px] w-[85%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mb-3 h-[14px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[14px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<div className='mt-3 flex items-center gap-2'>
<Skeleton className='h-[16px] w-[16px] rounded-full bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<section className='bg-[var(--landing-bg)]'>
{/* Header skeleton */}
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<Skeleton className='mb-5 h-[20px] w-[60px] rounded-md bg-[var(--landing-bg-elevated)]' />
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<Skeleton className='h-[40px] w-[240px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[18px] w-[320px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
</div>
{/* Content area with vertical border rails */}
<div className='mx-5 mt-8 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* Featured skeleton */}
<div className='flex'>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className='flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] p-6 md:border-l md:first:border-l-0'
>
<Skeleton className='aspect-video w-full rounded-[5px] bg-[var(--landing-bg-elevated)]' />
<div className='flex flex-col gap-2'>
<Skeleton className='h-[12px] w-[60px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[20px] w-[80%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[14px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
</div>
))}
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* List skeleton */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i}>
<div className='flex items-center gap-6 px-6 py-6'>
<Skeleton className='hidden h-[14px] w-[120px] rounded-[4px] bg-[var(--landing-bg-elevated)] md:block' />
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<Skeleton className='h-[18px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[14px] w-[90%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
<Skeleton className='hidden h-[80px] w-[140px] rounded-[5px] bg-[var(--landing-bg-elevated)] sm:block' />
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</div>
))}
</div>
</main>
</section>
)
}

View File

@@ -1,7 +1,7 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import { getAllPostMeta } from '@/lib/blog/registry'
import { PostGrid } from '@/app/(landing)/blog/post-grid'
export const metadata: Metadata = {
title: 'Blog',
@@ -34,8 +34,9 @@ export default async function BlogIndex({
const totalPages = Math.max(1, Math.ceil(sorted.length / perPage))
const start = (pageNum - 1) * perPage
const posts = sorted.slice(start, start + perPage)
// Tag filter chips are intentionally disabled for now.
// const tags = await getAllTags()
const featured = pageNum === 1 ? posts.slice(0, 3) : []
const remaining = pageNum === 1 ? posts.slice(3) : posts
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
@@ -45,54 +46,154 @@ export default async function BlogIndex({
}
return (
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
<section className='bg-[var(--landing-bg)]'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogJsonLd) }}
/>
<h1 className='mb-3 text-balance font-[500] text-[40px] text-[var(--landing-text)] leading-tight sm:text-[56px]'>
Blog
</h1>
<p className='mb-10 text-[var(--landing-text-muted)] text-lg'>
Announcements, insights, and guides for building AI agent workflows.
</p>
{/* Tag filter chips hidden until we have more posts */}
{/* <div className='mb-10 flex flex-wrap gap-3'>
<Link href='/blog' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
{tags.map((t) => (
<Link key={t.tag} href={`/blog?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
{t.tag} ({t.count})
</Link>
))}
</div> */}
{/* Section header */}
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<Badge
variant='blue'
size='md'
dot
className='mb-5 bg-white/10 font-season text-white uppercase tracking-[0.02em]'
>
Blog
</Badge>
{/* Grid layout for consistent rows */}
<PostGrid posts={posts} />
{totalPages > 1 && (
<div className='mt-10 flex items-center justify-center gap-3'>
{pageNum > 1 && (
<Link
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Previous
</Link>
)}
<span className='text-[var(--landing-text-muted)] text-sm'>
Page {pageNum} of {totalPages}
</span>
{pageNum < totalPages && (
<Link
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Next
</Link>
)}
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<h1 className='text-balance font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'>
Latest from Sim
</h1>
<p className='max-w-[360px] font-[430] font-season text-[#F6F6F0]/50 text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
Announcements, insights, and guides for building AI agent workflows.
</p>
</div>
)}
</main>
</div>
{/* Full-width top line */}
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* Content area with vertical border rails */}
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
{/* Featured posts */}
{featured.length > 0 && (
<>
<div className='flex'>
{featured.map((p, index) => (
<Link
key={p.slug}
href={`/blog/${p.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] p-6 transition-colors hover:bg-[var(--landing-bg-elevated)] md:border-l md:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<img
src={p.ogImage}
alt={p.title}
className='h-full w-full object-cover'
loading={index < 3 ? 'eager' : 'lazy'}
/>
</div>
<div className='flex flex-col gap-2'>
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
})}
</span>
<h3 className='font-[430] font-season text-lg text-white leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[#F6F6F0]/50 text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
)}
{remaining.map((p) => (
<div key={p.slug}>
<Link
href={`/blog/${p.slug}`}
className='group flex items-start gap-6 px-6 py-6 transition-colors hover:bg-[var(--landing-bg-elevated)] md:items-center'
>
{/* Date */}
<span className='hidden w-[120px] shrink-0 pt-1 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em] md:block'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
{/* Title + description */}
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em] md:hidden'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<h3 className='font-[430] font-season text-base text-white leading-tight tracking-[-0.01em] lg:text-lg'>
{p.title}
</h3>
<p className='line-clamp-2 text-[#F6F6F0]/40 text-sm leading-[150%]'>
{p.description}
</p>
</div>
{/* Image */}
<div className='hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
<img
src={p.ogImage}
alt={p.title}
className='h-full w-full object-cover'
loading='lazy'
/>
</div>
</Link>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</div>
))}
{/* Pagination */}
{totalPages > 1 && (
<div className='px-6 py-8'>
<div className='flex items-center justify-center gap-3'>
{pageNum > 1 && (
<Link
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Previous
</Link>
)}
<span className='text-[var(--landing-text-muted)] text-sm'>
Page {pageNum} of {totalPages}
</span>
{pageNum < totalPages && (
<Link
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Next
</Link>
)}
</div>
</div>
)}
</div>
{/* Full-width bottom line — overlaps last inner divider to avoid double border */}
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
</section>
)
}

View File

@@ -1,92 +0,0 @@
import Image from 'next/image'
import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
interface Author {
id: string
name: string
avatarUrl?: string
url?: string
}
interface Post {
slug: string
title: string
description: string
date: string
ogImage: string
author: Author
authors?: Author[]
featured?: boolean
}
export function PostGrid({ posts }: { posts: Post[] }) {
return (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
{posts.map((p, index) => (
<Link key={p.slug} href={`/blog/${p.slug}`} className='group flex flex-col'>
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-[var(--landing-bg-elevated)] transition-colors duration-300 hover:border-[var(--landing-border-strong)]'>
{/* Image container with fixed aspect ratio to prevent layout shift */}
<div className='relative aspect-video w-full overflow-hidden'>
<Image
src={p.ogImage}
alt={p.title}
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
unoptimized
priority={index < 6}
loading={index < 6 ? undefined : 'lazy'}
fill
style={{ objectFit: 'cover' }}
/>
</div>
<div className='flex flex-1 flex-col p-4'>
<div className='mb-2 text-[var(--landing-text-muted)] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<h3 className='mb-1 font-[500] text-[var(--landing-text)] text-lg leading-tight'>
{p.title}
</h3>
<p className='mb-3 line-clamp-3 flex-1 text-[var(--landing-text-muted)] text-sm'>
{p.description}
</p>
<div className='flex items-center gap-2'>
<div className='-space-x-1.5 flex'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 3)
.map((author, idx) => (
<Avatar key={idx} className='size-4 border border-[var(--landing-text)]'>
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
<AvatarFallback className='border border-[var(--landing-text)] bg-[var(--landing-bg-elevated)] text-[var(--landing-text-muted)] text-micro'>
{author?.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
))}
</div>
<span className='text-[var(--landing-text-muted)] text-xs'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 2)
.map((a) => a?.name)
.join(', ')}
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
<>
{' '}
and {(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
other
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 > 1
? 's'
: ''}
</>
)}
</span>
</div>
</div>
</div>
</Link>
))}
</div>
)
}

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { Badge } from '@/components/emcn'
interface DotGridProps {
className?: string
@@ -230,7 +230,7 @@ export default function Collaboration() {
<div className='relative overflow-hidden'>
<div className='grid grid-cols-1 md:grid-cols-[auto_1fr]'>
<div className='flex flex-col items-start gap-3 px-4 pt-[60px] pb-8 sm:gap-4 sm:px-8 md:gap-5 md:px-20 md:pt-[100px]'>
<div className='flex flex-col items-start gap-3 px-4 pt-[60px] pb-8 sm:gap-4 sm:px-8 md:gap-5 md:px-16 md:pt-[100px]'>
<Badge
variant='blue'
size='md'
@@ -249,6 +249,13 @@ export default function Collaboration() {
collaboration
</h2>
<p className='sr-only'>
Sim supports real-time multiplayer collaboration. Teams can build AI agents together
in a shared workspace with live cursors, presence indicators, and concurrent editing.
Features include role-based access control, shared workflows, and team workspace
management.
</p>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-base leading-[150%] tracking-[0.02em] md:text-lg'>
Grab your team. Build agents together <br className='hidden md:block' />
in real-time inside your workspace.
@@ -259,24 +266,32 @@ export default function Collaboration() {
className='group/cta mt-3 inline-flex h-[32px] cursor-none items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Build together
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
<svg
className='h-[10px] w-[10px] shrink-0'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<line
x1='0'
y1='5'
x2='9'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/cta:scale-x-100'
/>
<path
d='M3.5 2L6.5 5L3.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
/>
</svg>
</Link>
</div>
@@ -305,7 +320,7 @@ export default function Collaboration() {
href='/blog/multiplayer'
target='_blank'
rel='noopener noreferrer'
className='relative mx-4 mb-6 flex cursor-none items-center gap-3.5 rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] px-3 py-2.5 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-card)] sm:mx-8 md:absolute md:bottom-10 md:left-20 md:z-20 md:mx-0 md:mb-0'
className='relative mx-4 mb-6 flex cursor-none items-center gap-3.5 rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] px-3 py-2.5 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-card)] sm:mx-8 md:absolute md:bottom-10 md:left-16 md:z-20 md:mx-0 md:mb-0'
>
<div className='relative h-7 w-11 shrink-0'>
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />

View File

@@ -5,15 +5,6 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
export const DEMO_REQUEST_REGION_VALUES = [
'north_america',
'europe',
'asia_pacific',
'latin_america',
'middle_east_africa',
'other',
] as const
export const DEMO_REQUEST_COMPANY_SIZE_VALUES = [
'1_10',
'11_50',
@@ -24,15 +15,6 @@ export const DEMO_REQUEST_COMPANY_SIZE_VALUES = [
'10000_plus',
] as const
export const DEMO_REQUEST_REGION_OPTIONS = [
{ value: 'north_america', label: 'North America' },
{ value: 'europe', label: 'Europe' },
{ value: 'asia_pacific', label: 'Asia Pacific' },
{ value: 'latin_america', label: 'Latin America' },
{ value: 'middle_east_africa', label: 'Middle East & Africa' },
{ value: 'other', label: 'Other' },
] as const
export const DEMO_REQUEST_COMPANY_SIZE_OPTIONS = [
{ value: '1_10', label: '110' },
{ value: '11_50', label: '1150' },
@@ -73,9 +55,6 @@ export const demoRequestSchema = z.object({
.max(50, 'Phone number must be 50 characters or less')
.optional()
.transform((value) => (value && value.length > 0 ? value : undefined)),
region: z.enum(DEMO_REQUEST_REGION_VALUES, {
errorMap: () => ({ message: 'Please select a region' }),
}),
companySize: z.enum(DEMO_REQUEST_COMPANY_SIZE_VALUES, {
errorMap: () => ({ message: 'Please select company size' }),
}),
@@ -84,10 +63,6 @@ export const demoRequestSchema = z.object({
export type DemoRequestPayload = z.infer<typeof demoRequestSchema>
export function getDemoRequestRegionLabel(value: DemoRequestPayload['region']): string {
return DEMO_REQUEST_REGION_OPTIONS.find((option) => option.value === value)?.label ?? value
}
export function getDemoRequestCompanySizeLabel(value: DemoRequestPayload['companySize']): string {
return DEMO_REQUEST_COMPANY_SIZE_OPTIONS.find((option) => option.value === value)?.label ?? value
}

View File

@@ -2,9 +2,7 @@
import { useCallback, useState } from 'react'
import {
Button,
Combobox,
FormField,
Input,
Modal,
ModalBody,
@@ -17,10 +15,9 @@ import {
import { Check } from '@/components/emcn/icons'
import {
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
DEMO_REQUEST_REGION_OPTIONS,
type DemoRequestPayload,
demoRequestSchema,
} from '@/app/(home)/components/demo-request/consts'
} from '@/app/(landing)/components/demo-request/consts'
interface DemoRequestModalProps {
children: React.ReactNode
@@ -35,13 +32,11 @@ interface DemoRequestFormState {
lastName: string
companyEmail: string
phoneNumber: string
region: DemoRequestPayload['region'] | ''
companySize: DemoRequestPayload['companySize'] | ''
details: string
}
const SUBMIT_SUCCESS_MESSAGE = "We'll be in touch soon!"
const COMBOBOX_REGIONS = [...DEMO_REQUEST_REGION_OPTIONS]
const COMBOBOX_COMPANY_SIZES = [...DEMO_REQUEST_COMPANY_SIZE_OPTIONS]
const INITIAL_FORM_STATE: DemoRequestFormState = {
@@ -49,11 +44,37 @@ const INITIAL_FORM_STATE: DemoRequestFormState = {
lastName: '',
companyEmail: '',
phoneNumber: '',
region: '',
companySize: '',
details: '',
}
interface LandingFieldProps {
label: string
htmlFor: string
optional?: boolean
error?: string
children: React.ReactNode
}
function LandingField({ label, htmlFor, optional, error, children }: LandingFieldProps) {
return (
<div className='flex flex-col gap-1.5'>
<label
htmlFor={htmlFor}
className='font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
>
{label}
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
</label>
{children}
{error ? <p className='text-[12px] text-[var(--text-error)]'>{error}</p> : null}
</div>
)
}
const LANDING_INPUT =
'h-[32px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 font-[430] font-season text-[13.5px] text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none'
export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalProps) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState<DemoRequestFormState>(INITIAL_FORM_STATE)
@@ -117,7 +138,6 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
lastName: fieldErrors.lastName?.[0],
companyEmail: fieldErrors.companyEmail?.[0],
phoneNumber: fieldErrors.phoneNumber?.[0],
region: fieldErrors.region?.[0],
companySize: fieldErrors.companySize?.[0],
details: fieldErrors.details?.[0],
})
@@ -162,7 +182,9 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
<ModalContent size='lg' className={theme === 'dark' ? 'dark' : undefined}>
<ModalHeader>
<span className={submitSuccess ? 'sr-only' : undefined}>
{submitSuccess ? 'Demo request submitted' : 'Nearly there!'}
<span className='font-[430] font-season text-[15px] tracking-[-0.02em]'>
{submitSuccess ? 'Demo request submitted' : 'Talk to sales'}
</span>
</span>
</ModalHeader>
<div className='relative flex-1'>
@@ -176,37 +198,44 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
}
>
<ModalBody>
<div className='space-y-4'>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField htmlFor='firstName' label='First name' error={errors.firstName}>
<div className='space-y-3'>
<div className='grid gap-3 sm:grid-cols-2'>
<LandingField htmlFor='firstName' label='First name' error={errors.firstName}>
<Input
id='firstName'
value={form.firstName}
onChange={(event) => updateField('firstName', event.target.value)}
placeholder='First'
className={LANDING_INPUT}
/>
</FormField>
<FormField htmlFor='lastName' label='Last name' error={errors.lastName}>
</LandingField>
<LandingField htmlFor='lastName' label='Last name' error={errors.lastName}>
<Input
id='lastName'
value={form.lastName}
onChange={(event) => updateField('lastName', event.target.value)}
placeholder='Last'
className={LANDING_INPUT}
/>
</FormField>
</LandingField>
</div>
<FormField htmlFor='companyEmail' label='Company email' error={errors.companyEmail}>
<LandingField
htmlFor='companyEmail'
label='Company email'
error={errors.companyEmail}
>
<Input
id='companyEmail'
type='email'
value={form.companyEmail}
onChange={(event) => updateField('companyEmail', event.target.value)}
placeholder='Your work email'
className={LANDING_INPUT}
/>
</FormField>
</LandingField>
<FormField
<LandingField
htmlFor='phoneNumber'
label='Phone number'
optional
@@ -218,54 +247,48 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
value={form.phoneNumber}
onChange={(event) => updateField('phoneNumber', event.target.value)}
placeholder='Your phone number'
className={LANDING_INPUT}
/>
</FormField>
</LandingField>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField htmlFor='region' label='Region' error={errors.region}>
<Combobox
options={COMBOBOX_REGIONS}
value={form.region}
selectedValue={form.region}
onChange={(value) =>
updateField('region', value as DemoRequestPayload['region'])
}
placeholder='Select'
editable={false}
filterOptions={false}
/>
</FormField>
<FormField htmlFor='companySize' label='Company size' error={errors.companySize}>
<Combobox
options={COMBOBOX_COMPANY_SIZES}
value={form.companySize}
selectedValue={form.companySize}
onChange={(value) =>
updateField('companySize', value as DemoRequestPayload['companySize'])
}
placeholder='Select'
editable={false}
filterOptions={false}
/>
</FormField>
</div>
<LandingField htmlFor='companySize' label='Company size' error={errors.companySize}>
<Combobox
options={COMBOBOX_COMPANY_SIZES}
value={form.companySize}
selectedValue={form.companySize}
onChange={(value) =>
updateField('companySize', value as DemoRequestPayload['companySize'])
}
placeholder='Select'
editable={false}
filterOptions={false}
className='h-[32px] rounded-[5px] px-2.5 font-[430] font-season text-[13.5px]'
/>
</LandingField>
<FormField htmlFor='details' label='Details' error={errors.details}>
<LandingField htmlFor='details' label='Details' error={errors.details}>
<Textarea
id='details'
value={form.details}
onChange={(event) => updateField('details', event.target.value)}
placeholder='Tell us about your needs and questions'
className='min-h-[80px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-2 font-[430] font-season text-[13.5px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
/>
</FormField>
</LandingField>
</div>
</ModalBody>
<ModalFooter className='flex-col items-stretch gap-3'>
{submitError && <p className='text-[13px] text-[var(--text-error)]'>{submitError}</p>}
<Button type='submit' variant='primary' disabled={isSubmitting}>
<ModalFooter className='flex-col items-stretch gap-3 border-t-0 bg-transparent pt-0'>
{submitError && (
<p className='font-season text-[13px] text-[var(--text-error)]'>{submitError}</p>
)}
<button
type='submit'
disabled={isSubmitting}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] bg-[var(--text-primary)] font-[430] font-season text-[13.5px] text-[var(--bg)] transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50'
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</button>
</ModalFooter>
</form>
@@ -275,10 +298,10 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
<div className='flex h-20 w-20 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--bg-subtle)] text-[var(--text-primary)]'>
<Check className='h-10 w-10' />
</div>
<h2 className='mt-8 font-medium text-[34px] text-[var(--text-primary)] leading-[1.1] tracking-[-0.03em]'>
<h2 className='mt-8 font-[430] font-season text-[34px] text-[var(--text-primary)] leading-[1.1] tracking-[-0.03em]'>
{SUBMIT_SUCCESS_MESSAGE}
</h2>
<p className='mt-4 text-[17px] text-[var(--text-secondary)] leading-7'>
<p className='mt-4 font-season text-[15px] text-[var(--text-secondary)] leading-7'>
Our team will be in touch soon. If you have any questions, please email us at{' '}
<a
href='mailto:enterprise@sim.ai'

View File

@@ -15,12 +15,12 @@
import Image from 'next/image'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { Badge } from '@/components/emcn'
import { Lock } from '@/components/emcn/icons'
import { GithubIcon } from '@/components/icons'
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
import { AccessControlPanel } from '@/app/(home)/components/enterprise/components/access-control-panel'
import { AuditLogPreview } from '@/app/(home)/components/enterprise/components/audit-log-preview'
import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal'
import { AccessControlPanel } from '@/app/(landing)/components/enterprise/components/access-control-panel'
import { AuditLogPreview } from '@/app/(landing)/components/enterprise/components/audit-log-preview'
const ENTERPRISE_FEATURE_MARQUEE_STYLES = `
@keyframes enterprise-feature-marquee {
@@ -136,7 +136,7 @@ export default function Enterprise() {
aria-labelledby='enterprise-heading'
className='bg-[var(--landing-bg-section)]'
>
<div className='px-4 pt-[60px] pb-10 sm:px-8 sm:pt-20 sm:pb-0 md:px-20 md:pt-[100px]'>
<div className='px-4 pt-[60px] pb-10 sm:px-8 sm:pt-20 sm:pb-0 md:px-16 md:pt-[100px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-5'>
<Badge
variant='blue'
@@ -230,23 +230,32 @@ export default function Enterprise() {
className='group/cta inline-flex h-[32px] cursor-pointer items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Book a demo
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
<svg
className='h-[10px] w-[10px] shrink-0'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<line
x1='0'
y1='5'
x2='9'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/cta:scale-x-100'
/>
<path
d='M3.5 2L6.5 5L3.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
/>
</svg>
</button>
</DemoRequestModal>
</div>

View File

@@ -17,7 +17,6 @@ import {
SlackIcon,
xAIIcon,
} from '@/components/icons'
import { CsvIcon, JsonIcon, MarkdownIcon, PdfIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
interface FeaturesPreviewProps {
@@ -25,7 +24,7 @@ interface FeaturesPreviewProps {
}
export function FeaturesPreview({ activeTab }: FeaturesPreviewProps) {
const isWorkspaceTab = activeTab <= 4
const isWorkspaceTab = activeTab <= 3
return (
<div className='relative h-[350px] w-full md:h-[560px]'>
@@ -66,7 +65,7 @@ const CARD_GAP = 8
const GRID_STEP = CARD_SIZE + CARD_GAP
const GRID_PAD = 8
type CardVariant = 'prompt' | 'table' | 'workflow' | 'knowledge' | 'logs' | 'file'
type CardVariant = 'prompt' | 'table' | 'workflow' | 'logs' | 'file'
interface CardDef {
row: number
@@ -80,19 +79,19 @@ const MOTHERSHIP_CARDS: CardDef[] = [
{ row: 0, col: 0, variant: 'prompt', label: 'prompt.md' },
{ row: 1, col: 0, variant: 'table', label: 'Leads' },
{ row: 0, col: 1, variant: 'workflow', label: 'Email Bot', color: '#7C3AED' },
{ row: 1, col: 1, variant: 'knowledge', label: 'Company KB' },
{ row: 1, col: 1, variant: 'file', label: 'handbook.md' },
{ row: 2, col: 0, variant: 'logs', label: 'Run Logs' },
{ row: 0, col: 2, variant: 'file', label: 'notes.md' },
{ row: 2, col: 1, variant: 'workflow', label: 'Onboarding', color: '#2563EB' },
{ row: 1, col: 2, variant: 'table', label: 'Contacts' },
{ row: 2, col: 2, variant: 'file', label: 'report.pdf' },
{ row: 3, col: 0, variant: 'table', label: 'Tickets' },
{ row: 0, col: 3, variant: 'knowledge', label: 'Product Wiki' },
{ row: 0, col: 3, variant: 'file', label: 'wiki.md' },
{ row: 3, col: 1, variant: 'logs', label: 'Audit Trail' },
{ row: 1, col: 3, variant: 'workflow', label: 'Support', color: '#059669' },
{ row: 2, col: 3, variant: 'file', label: 'data.csv' },
{ row: 3, col: 2, variant: 'table', label: 'Users' },
{ row: 3, col: 3, variant: 'knowledge', label: 'HR Docs' },
{ row: 3, col: 3, variant: 'file', label: 'policies.pdf' },
{ row: 0, col: 4, variant: 'workflow', label: 'Pipeline', color: '#DC2626' },
{ row: 1, col: 4, variant: 'logs', label: 'API Logs' },
{ row: 2, col: 4, variant: 'table', label: 'Orders' },
@@ -100,7 +99,7 @@ const MOTHERSHIP_CARDS: CardDef[] = [
{ row: 0, col: 5, variant: 'logs', label: 'Deploys' },
{ row: 1, col: 5, variant: 'table', label: 'Campaigns' },
{ row: 2, col: 5, variant: 'workflow', label: 'Intake', color: '#D97706' },
{ row: 3, col: 5, variant: 'knowledge', label: 'Research' },
{ row: 3, col: 5, variant: 'file', label: 'research.pdf' },
{ row: 4, col: 0, variant: 'file', label: 'readme.md' },
{ row: 4, col: 1, variant: 'table', label: 'Revenue' },
{ row: 4, col: 2, variant: 'workflow', label: 'Sync', color: '#0891B2' },
@@ -110,27 +109,25 @@ const MOTHERSHIP_CARDS: CardDef[] = [
{ row: 0, col: 6, variant: 'table', label: 'Analytics' },
{ row: 1, col: 6, variant: 'workflow', label: 'Digest', color: '#6366F1' },
{ row: 0, col: 7, variant: 'file', label: 'brief.md' },
{ row: 2, col: 6, variant: 'knowledge', label: 'Playbooks' },
{ row: 2, col: 6, variant: 'file', label: 'playbook.md' },
{ row: 1, col: 7, variant: 'logs', label: 'Webhooks' },
{ row: 3, col: 6, variant: 'file', label: 'export.csv' },
{ row: 2, col: 7, variant: 'workflow', label: 'Alerts', color: '#E11D48' },
{ row: 4, col: 6, variant: 'logs', label: 'Metrics' },
{ row: 3, col: 7, variant: 'table', label: 'Feedback' },
{ row: 4, col: 7, variant: 'knowledge', label: 'Runbooks' },
{ row: 4, col: 7, variant: 'file', label: 'runbook.md' },
]
const EXPAND_TARGETS: Record<number, { row: number; col: number }> = {
1: { row: 1, col: 0 },
2: { row: 0, col: 2 },
3: { row: 1, col: 1 },
4: { row: 2, col: 0 },
3: { row: 2, col: 0 },
}
const EXPAND_ROW_COUNTS: Record<number, number> = {
1: 8,
2: 10,
3: 10,
4: 7,
3: 7,
}
function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive: boolean }) {
@@ -146,7 +143,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
const [revealedRows, setRevealedRows] = useState(0)
const isMothership = activeTab === 0 && isActive
const isExpandTab = activeTab >= 1 && activeTab <= 4 && isActive
const isExpandTab = activeTab >= 1 && activeTab <= 3 && isActive
const expandTarget = EXPAND_TARGETS[activeTab] ?? null
useEffect(() => {
@@ -292,8 +289,7 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive
>
{expandedTab === 1 && <MockFullTable revealedRows={revealedRows} />}
{expandedTab === 2 && <MockFullFiles />}
{expandedTab === 3 && <MockFullKnowledgeBase revealedRows={revealedRows} />}
{expandedTab === 4 && <MockFullLogs revealedRows={revealedRows} />}
{expandedTab === 3 && <MockFullLogs revealedRows={revealedRows} />}
</motion.div>
)}
</div>
@@ -393,8 +389,6 @@ function MiniCardIcon({ variant, color }: { variant: CardVariant; color?: string
/>
)
}
case 'knowledge':
return <Database className={cls} />
case 'logs':
return <Library className={cls} />
}
@@ -410,8 +404,6 @@ function MiniCardBody({ variant, color }: { variant: CardVariant; color?: string
return <TableCardBody />
case 'workflow':
return <WorkflowCardBody color={color ?? '#7C3AED'} />
case 'knowledge':
return <KnowledgeCardBody />
case 'logs':
return <LogsCardBody />
}
@@ -498,21 +490,6 @@ function WorkflowCardBody({ color }: { color: string }) {
)
}
const KB_WIDTHS = [70, 85, 55, 80, 48] as const
function KnowledgeCardBody() {
return (
<div className='flex flex-col gap-[5px] px-2 py-1.5'>
{KB_WIDTHS.map((w, i) => (
<div key={i} className='flex items-center gap-1'>
<div className='h-[3px] w-[3px] flex-shrink-0 rounded-full bg-[#D4D4D4]' />
<div className='h-[1.5px] rounded-full bg-[#E8E8E8]' style={{ width: `${w}%` }} />
</div>
))}
</div>
)
}
const LOG_ENTRIES = [
{ color: '#22C55E', width: 65 },
{ color: '#22C55E', width: 78 },
@@ -579,33 +556,6 @@ The team agreed to prioritize the new onboarding flow. Key decisions:
Follow up with engineering on the timeline for the API v2 migration. Draft the proposal for the board meeting next week.`
const MOCK_KB_COLUMNS = ['Name', 'Size', 'Tokens', 'Chunks', 'Status'] as const
const KB_FILE_ICONS: Record<string, React.ComponentType<SVGProps<SVGSVGElement>>> = {
pdf: PdfIcon,
md: MarkdownIcon,
csv: CsvIcon,
json: JsonIcon,
}
function getKBFileIcon(filename: string) {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
return KB_FILE_ICONS[ext] ?? File
}
const MOCK_KB_DATA = [
['product-specs.pdf', '4.2 MB', '12.4k', '86', 'enabled'],
['eng-handbook.md', '1.8 MB', '8.2k', '54', 'enabled'],
['api-reference.json', '920 KB', '4.1k', '32', 'enabled'],
['release-notes.md', '340 KB', '2.8k', '18', 'enabled'],
['onboarding-guide.pdf', '2.1 MB', '6.5k', '42', 'processing'],
['data-export.csv', '560 KB', '3.4k', '24', 'enabled'],
['runbook.md', '280 KB', '1.9k', '14', 'enabled'],
['compliance.pdf', '180 KB', '1.2k', '8', 'disabled'],
['style-guide.md', '410 KB', '2.6k', '20', 'enabled'],
['metrics.csv', '1.4 MB', '5.8k', '38', 'enabled'],
] as const
const MD_COMPONENTS: Components = {
h1: ({ children }) => (
<p
@@ -677,106 +627,6 @@ function MockFullFiles() {
)
}
const KB_STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
enabled: { bg: '#DCFCE7', text: '#166534', label: 'Enabled' },
disabled: { bg: '#F3F4F6', text: '#6B7280', label: 'Disabled' },
processing: { bg: '#F3E8FF', text: '#7C3AED', label: 'Processing' },
}
function MockFullKnowledgeBase({ revealedRows }: { revealedRows: number }) {
return (
<div className='flex h-full flex-col'>
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
<div className='flex items-center gap-1.5'>
<Database className='h-[14px] w-[14px] text-[#999]' />
<span className='text-[#999] text-[13px]'>Knowledge Base</span>
<span className='text-[#D4D4D4] text-[13px]'>/</span>
<span className='font-medium text-[#1C1C1C] text-[13px]'>Company KB</span>
</div>
</div>
<div className='flex h-[36px] shrink-0 items-center border-[#E5E5E5] border-b px-6'>
<div className='flex items-center gap-1.5'>
<div className='flex h-[24px] items-center gap-1 rounded-[6px] border border-[#E5E5E5] px-2 text-[#999] text-[12px]'>
Sort
</div>
<div className='flex h-[24px] items-center gap-1 rounded-[6px] border border-[#E5E5E5] px-2 text-[#999] text-[12px]'>
Filter
</div>
</div>
</div>
<div className='flex-1 overflow-hidden'>
<table className='w-full table-fixed border-separate border-spacing-0 text-[13px]'>
<colgroup>
<col style={{ width: 40 }} />
{MOCK_KB_COLUMNS.map((col) => (
<col key={col} />
))}
</colgroup>
<thead>
<tr>
<th className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-1 py-[7px] text-center align-middle'>
<div className='flex items-center justify-center'>
<div className='h-[13px] w-[13px] rounded-[2px] border border-[#D4D4D4]' />
</div>
</th>
{MOCK_KB_COLUMNS.map((col) => (
<th
key={col}
className='border-[#E5E5E5] border-r border-b bg-[#FAFAFA] px-2 py-[7px] text-left align-middle'
>
<span className='font-base text-[#999] text-[13px]'>{col}</span>
</th>
))}
</tr>
</thead>
<tbody>
{MOCK_KB_DATA.slice(0, revealedRows).map((row, i) => {
const status = KB_STATUS_STYLES[row[4]] ?? KB_STATUS_STYLES.enabled
const DocIcon = getKBFileIcon(row[0])
return (
<motion.tr
key={i}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<td className='border-[#E5E5E5] border-r border-b px-1 py-[7px] text-center align-middle'>
<span className='text-[#999] text-[11px] tabular-nums'>{i + 1}</span>
</td>
<td className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'>
<span className='flex items-center gap-2 text-[#1C1C1C] text-[13px]'>
<DocIcon className='h-[14px] w-[14px] shrink-0' />
<span className='truncate'>{row[0]}</span>
</span>
</td>
{row.slice(1, 4).map((cell, j) => (
<td
key={j}
className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'
>
<span className='text-[#999] text-[13px]'>{cell}</span>
</td>
))}
<td className='border-[#E5E5E5] border-r border-b px-2 py-[7px] align-middle'>
<span
className='inline-flex items-center rounded-full px-2 py-0.5 font-medium text-[11px]'
style={{ backgroundColor: status.bg, color: status.text }}
>
{status.label}
</span>
</td>
</motion.tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
const MOCK_LOG_COLORS = [
'#7C3AED',
'#2563EB',

View File

@@ -4,8 +4,8 @@ import { useRef, useState } from 'react'
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import Image from 'next/image'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { FeaturesPreview } from '@/app/(home)/components/features/components/features-preview'
import { Badge } from '@/components/emcn'
import { FeaturesPreview } from '@/app/(landing)/components/features/components/features-preview'
function hexToRgba(hex: string, alpha: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16)
@@ -14,7 +14,19 @@ function hexToRgba(hex: string, alpha: number): string {
return `rgba(${r},${g},${b},${alpha})`
}
const FEATURE_TABS = [
interface FeatureTab {
label: string
mobileLabel?: string
color: string
badgeColor?: string
title: string
description: string
cta: string
segments: number[][]
hideOnMobile?: boolean
}
const FEATURE_TABS: FeatureTab[] = [
{
label: 'Mothership',
color: '#FA4EDF',
@@ -75,27 +87,6 @@ const FEATURE_TABS = [
[1, 10],
],
},
{
label: 'Knowledge Base',
mobileLabel: 'Knowledge',
color: '#8B5CF6',
title: 'Your context engine',
description:
'Sync institutional knowledge from 30+ live connectors — Notion, Drive, Slack, Confluence, and more — so every agent draws from the same truth across your entire organization.',
cta: 'Explore knowledge base',
segments: [
[0.3, 10],
[0.25, 8],
[0.4, 10],
[0.5, 10],
[0.65, 10],
[0.8, 10],
[0.9, 12],
[1, 10],
[0.95, 10],
[1, 10],
],
},
{
label: 'Logs',
hideOnMobile: true,
@@ -138,36 +129,6 @@ function ScrollLetter({ scrollYProgress, charIndex, children }: ScrollLetterProp
return <motion.span style={{ opacity }}>{children}</motion.span>
}
function DotGrid({
cols,
rows,
width,
borderLeft,
}: {
cols: number
rows: number
width?: number
borderLeft?: boolean
}) {
return (
<div
aria-hidden='true'
className={`h-full shrink-0 bg-[var(--landing-bg-section)] p-1.5 ${borderLeft ? 'border-[var(--divider)] border-l' : ''}`}
style={{
width: width ? `${width}px` : undefined,
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gap: 4,
placeItems: 'center',
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#DEDEDE]' />
))}
</div>
)
}
export default function Features() {
const sectionRef = useRef<HTMLDivElement>(null)
const [activeTab, setActiveTab] = useState(0)
@@ -183,7 +144,7 @@ export default function Features() {
aria-labelledby='features-heading'
className='relative overflow-hidden bg-[var(--landing-bg-section)]'
>
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
<div aria-hidden='true' className='absolute top-0 left-0 hidden w-full lg:block'>
<Image
src='/landing/features-transition.svg'
alt=''
@@ -194,7 +155,7 @@ export default function Features() {
</div>
<div className='relative z-10 pt-[60px] lg:pt-[100px]'>
<div ref={sectionRef} className='flex flex-col items-start gap-5 px-6 lg:px-20'>
<div ref={sectionRef} className='flex flex-col items-start gap-5 px-6 lg:px-16'>
<Badge
variant='blue'
size='md'
@@ -210,9 +171,17 @@ export default function Features() {
>
Workspace
</Badge>
<p className='sr-only'>
Sim's workspace includes four core features: Mothership, an AI command center for
natural-language control of your entire workspace; Tables, a built-in database for
filtering, sorting, and wiring data directly into workflows; Files, a shared document
store for uploading, creating, and sharing documents, spreadsheets, and media across
teams and agents; and Logs, full execution tracing with inputs, outputs, cost, and
duration for every run.
</p>
<h2
id='features-heading'
className='max-w-[900px] text-balance font-[430] font-season text-[28px] text-[var(--landing-text-dark)] leading-[110%] tracking-[-0.02em] md:text-[40px]'
className='max-w-[900px] text-balance font-[430] font-season text-[24px] text-[var(--landing-text-dark)] leading-[110%] tracking-[-0.02em] md:text-[36px]'
>
{HEADING_LETTERS.map((char, i) => (
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
@@ -226,45 +195,36 @@ export default function Features() {
</h2>
</div>
<div className='relative mt-10 pb-10 lg:mt-[73px] lg:pb-20'>
<div className='relative mt-10 pb-[60px] lg:mt-[73px] lg:pb-[100px]'>
<div
aria-hidden='true'
className='absolute top-0 bottom-0 left-[80px] z-20 hidden w-px bg-[var(--divider)] lg:block'
className='absolute top-0 bottom-0 left-16 z-20 hidden w-px bg-[var(--divider)] lg:block'
/>
<div
aria-hidden='true'
className='absolute top-0 right-[80px] bottom-0 z-20 hidden w-px bg-[var(--divider)] lg:block'
className='absolute top-0 right-16 bottom-0 z-20 hidden w-px bg-[var(--divider)] lg:block'
/>
<div className='flex h-[68px] border border-[var(--divider)] lg:overflow-hidden'>
<div className='h-full shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid cols={3} rows={8} width={24} />
</div>
<div className='hidden h-full lg:block'>
<DotGrid cols={10} rows={8} width={80} />
</div>
</div>
<div
aria-hidden='true'
className='h-full w-[24px] shrink-0 bg-[var(--landing-bg-section)] lg:w-16'
/>
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
{FEATURE_TABS.map((tab, index) => (
<button
key={tab.label}
id={`feature-tab-${index}`}
type='button'
role='tab'
aria-selected={index === activeTab}
aria-controls='features-panel'
onClick={() => setActiveTab(index)}
className={`relative h-full flex-1 items-center justify-center whitespace-nowrap px-3 font-medium font-season text-[var(--landing-text-dark)] text-caption uppercase lg:px-0 lg:text-sm${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[var(--divider)] border-l' : ''}`}
className={`relative h-full min-w-0 flex-1 items-center justify-center px-2 font-medium font-season text-[var(--landing-text-dark)] text-caption uppercase lg:px-0 lg:text-sm${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[var(--divider)] border-l' : ''}`}
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
>
{tab.mobileLabel ? (
<>
<span className='lg:hidden'>{tab.mobileLabel}</span>
<span className='hidden lg:inline'>{tab.label}</span>
</>
) : (
tab.label
)}
<span className='truncate'>{tab.label}</span>
{index === activeTab && (
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
{tab.segments.map(([opacity, width], i) => (
@@ -284,17 +244,18 @@ export default function Features() {
))}
</div>
<div className='h-full shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid cols={3} rows={8} width={24} />
</div>
<div className='hidden h-full lg:block'>
<DotGrid cols={10} rows={8} width={80} />
</div>
</div>
<div
aria-hidden='true'
className='h-full w-[24px] shrink-0 border-[var(--divider)] border-l bg-[var(--landing-bg-section)] lg:w-16'
/>
</div>
<div className='mt-8 flex flex-col gap-6 px-6 lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[120px]'>
<div
id='features-panel'
role='tabpanel'
aria-labelledby={`feature-tab-${activeTab}`}
className='mt-8 flex flex-col gap-6 px-6 lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[104px]'
>
<div className='flex flex-col items-start justify-between gap-6 pt-5 lg:h-[560px] lg:gap-0'>
<div className='flex flex-col items-start gap-4'>
<h3 className='font-[430] font-season text-[24px] text-[var(--landing-text-dark)] leading-[120%] tracking-[-0.02em] lg:text-[28px]'>
@@ -306,26 +267,9 @@ export default function Features() {
</div>
<Link
href='/signup'
className='group/cta inline-flex h-[32px] items-center gap-1.5 rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-2.5 font-[430] font-season text-sm text-white transition-colors hover:border-[var(--landing-bg-elevated)] hover:bg-[var(--landing-bg-elevated)]'
className='inline-flex h-[32px] items-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-2.5 font-[430] font-season text-sm text-white transition-colors hover:border-[var(--landing-bg-elevated)] hover:bg-[var(--landing-bg-elevated)]'
>
{FEATURE_TABS[activeTab].cta}
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
fill='none'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
</Link>
</div>

View File

@@ -3,7 +3,7 @@
import { useCallback, useRef, useState } from 'react'
import { ArrowUp } from 'lucide-react'
import Link from 'next/link'
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
const MAX_HEIGHT = 120
@@ -41,14 +41,21 @@ export function FooterCTA() {
}, [])
return (
<div className='flex flex-col items-center px-4 pt-[120px] pb-[100px] sm:px-8 md:px-20'>
<h2 className='text-balance text-center font-[430] font-season text-[28px] text-[var(--landing-text-dark)] leading-[100%] tracking-[-0.02em] sm:text-[32px] md:text-[36px]'>
<section
id='cta'
aria-labelledby='cta-heading'
className='flex flex-col items-center px-4 pt-[90px] pb-[90px] sm:px-8 sm:pt-[120px] sm:pb-[120px] md:px-16 md:pt-[150px] md:pb-[150px]'
>
<h2
id='cta-heading'
className='text-balance text-center font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] sm:text-[32px] md:text-[36px]'
>
What should we get done?
</h2>
<div className='mt-8 w-full max-w-[42rem]'>
<div
className='cursor-text rounded-[20px] border border-[var(--landing-bg-skeleton)] bg-white px-2.5 py-2 shadow-sm'
className='cursor-text rounded-[20px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-2.5 py-2'
onClick={() => textareaRef.current?.focus()}
>
<textarea
@@ -57,10 +64,11 @@ export function FooterCTA() {
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
aria-label='Describe what you want to build'
placeholder={animatedPlaceholder}
rows={2}
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-1 py-1 font-body text-[var(--landing-text-dark)] text-base leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[var(--landing-text-muted)] focus-visible:ring-0'
style={{ caretColor: '#1C1C1C', maxHeight: `${MAX_HEIGHT}px` }}
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-1 py-1 font-body text-[var(--landing-text)] text-base leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[var(--landing-text-muted)] focus-visible:ring-0'
style={{ caretColor: '#FFFFFF', maxHeight: `${MAX_HEIGHT}px` }}
/>
<div className='flex items-center justify-end'>
<button
@@ -70,11 +78,11 @@ export function FooterCTA() {
aria-label='Submit message'
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#C0C0C0' : '#1C1C1C',
background: isEmpty ? '#555555' : '#FFFFFF',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={16} strokeWidth={2.25} color='#FFFFFF' />
<ArrowUp size={16} strokeWidth={2.25} color={isEmpty ? '#888888' : '#1C1C1C'} />
</button>
</div>
</div>
@@ -85,17 +93,17 @@ export function FooterCTA() {
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
className={`${CTA_BUTTON} border-[var(--landing-border-subtle)] text-[var(--landing-text-dark)] transition-colors hover:bg-[var(--landing-bg-skeleton)]`}
className={`${CTA_BUTTON} border-[var(--landing-border-strong)] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`}
>
Docs
</a>
<Link
href='/signup'
className={`${CTA_BUTTON} gap-2 border-[var(--landing-bg)] bg-[var(--landing-bg)] text-white transition-colors hover:border-[var(--landing-bg-elevated)] hover:bg-[var(--landing-bg-elevated)]`}
className={`${CTA_BUTTON} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
>
Get started
</Link>
</div>
</div>
</section>
)
}

View File

@@ -1,6 +1,6 @@
import Image from 'next/image'
import Link from 'next/link'
import { FooterCTA } from '@/app/(home)/components/footer/footer-cta'
import { FooterCTA } from '@/app/(landing)/components/footer/footer-cta'
const LINK_CLASS =
'text-sm text-[var(--landing-text-muted)] transition-colors hover:text-[var(--landing-text)]'
@@ -9,17 +9,17 @@ interface FooterItem {
label: string
href: string
external?: boolean
arrow?: boolean
externalArrow?: boolean
}
const PRODUCT_LINKS: FooterItem[] = [
{ label: 'Pricing', href: '/#pricing' },
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
{ label: 'Self Hosting', href: 'https://docs.sim.ai/self-hosting', external: true },
{ label: 'MCP', href: 'https://docs.sim.ai/mcp', external: true },
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
{ label: 'Tables', href: 'https://docs.sim.ai/tables', external: true },
{ label: 'API', href: 'https://docs.sim.ai/api-reference/getting-started', external: true },
{ label: 'Status', href: 'https://status.sim.ai', external: true },
{ label: 'Status', href: 'https://status.sim.ai', external: true, externalArrow: true },
]
const RESOURCES_LINKS: FooterItem[] = [
@@ -29,7 +29,7 @@ const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Models', href: '/models' },
// { label: 'Academy', href: '/academy' },
{ label: 'Partners', href: '/partners' },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true, externalArrow: true },
{ label: 'Changelog', href: '/changelog' },
]
@@ -47,7 +47,7 @@ const BLOCK_LINKS: FooterItem[] = [
]
const INTEGRATION_LINKS: FooterItem[] = [
{ label: 'All Integrations', href: '/integrations' },
{ label: 'All Integrations', href: '/integrations', arrow: true },
{ label: 'Confluence', href: 'https://docs.sim.ai/tools/confluence', external: true },
{ label: 'Slack', href: 'https://docs.sim.ai/tools/slack', external: true },
{ label: 'GitHub', href: 'https://docs.sim.ai/tools/github', external: true },
@@ -71,10 +71,20 @@ const INTEGRATION_LINKS: FooterItem[] = [
]
const SOCIAL_LINKS: FooterItem[] = [
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true },
{ label: 'LinkedIn', href: 'https://www.linkedin.com/company/simstudioai/', external: true },
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true },
{ label: 'GitHub', href: 'https://github.com/simstudioai/sim', external: true },
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true, externalArrow: true },
{
label: 'LinkedIn',
href: 'https://www.linkedin.com/company/simstudioai/',
external: true,
externalArrow: true,
},
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true, externalArrow: true },
{
label: 'GitHub',
href: 'https://github.com/simstudioai/sim',
external: true,
externalArrow: true,
},
]
const LEGAL_LINKS: FooterItem[] = [
@@ -82,25 +92,62 @@ const LEGAL_LINKS: FooterItem[] = [
{ label: 'Privacy Policy', href: '/privacy' },
]
function ChevronArrow({ external }: { external?: boolean }) {
return (
<svg
className={`h-3 w-3 shrink-0${external ? ' -rotate-45' : ''}`}
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<line
x1='0'
y1='5'
x2='9'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M3.5 2L6.5 5L3.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='transition-transform duration-200 ease-out group-hover/link:translate-x-[30%]'
/>
</svg>
)
}
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
return (
<div>
<h3 className='mb-4 font-medium text-[var(--landing-text)] text-sm'>{title}</h3>
<div className='flex flex-col gap-2.5'>
{items.map(({ label, href, external }) =>
{items.map(({ label, href, external, arrow, externalArrow }) =>
external ? (
<a
key={label}
href={href}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
className={`${LINK_CLASS}${externalArrow ? ' group/link inline-flex items-center gap-1' : ''}`}
>
{label}
{externalArrow && <ChevronArrow external />}
</a>
) : (
<Link key={label} href={href} className={LINK_CLASS}>
<Link
key={label}
href={href}
className={`${LINK_CLASS}${arrow ? ' group/link inline-flex items-center gap-1.5' : ''}`}
>
{label}
{arrow && <ChevronArrow />}
</Link>
)
)}
@@ -117,13 +164,31 @@ export default function Footer({ hideCTA }: FooterProps) {
return (
<footer
role='contentinfo'
className={`bg-[var(--landing-bg-section)] pb-10 font-[430] font-season text-sm${hideCTA ? ' pt-10' : ''}`}
className={`bg-[var(--landing-bg)] pb-10 font-[430] font-season text-sm${hideCTA ? ' pt-10' : ''}`}
>
{!hideCTA && <FooterCTA />}
<div className='px-4 sm:px-8 md:px-20'>
<div className='relative overflow-hidden rounded-lg bg-[var(--landing-bg)] px-6 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10'>
<div className='relative px-[1.6vw] sm:px-8 lg:px-16'>
<div
aria-hidden='true'
className='absolute top-0 left-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
/>
<div
aria-hidden='true'
className='absolute top-0 right-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
/>
<div
aria-hidden='true'
className='absolute bottom-0 left-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
/>
<div
aria-hidden='true'
className='absolute right-0 bottom-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
/>
<div className='relative z-10 border border-[var(--landing-bg-elevated)] px-6 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10'>
<nav
aria-label='Footer navigation'
itemScope
itemType='https://schema.org/SiteNavigationElement'
className='relative z-[1] grid grid-cols-2 gap-x-8 gap-y-10 sm:grid-cols-3 lg:grid-cols-7'
>
<div className='col-span-2 flex flex-col gap-6 sm:col-span-1'>
@@ -145,29 +210,6 @@ export default function Footer({ hideCTA }: FooterProps) {
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
<FooterColumn title='Legal' items={LEGAL_LINKS} />
</nav>
{/* <svg
aria-hidden='true'
className='pointer-events-none absolute bottom-0 left-[-60px] hidden w-[85%] sm:block'
viewBox='0 0 1800 316'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M18.3562 305V48.95A30.594 30.594 0 0 1 48.95 18.356H917.05A30.594 30.594 0 0 1 947.644 48.95V273H1768C1777.11 273 1784.5 280.387 1784.5 289.5C1784.5 298.613 1777.11 306 1768 306H96.8603C78.635 306 63.8604 310 63.8604 305H18.3562'
stroke='#2A2A2A'
strokeWidth='2'
/>
<rect
x='58'
y='58'
width='849.288'
height='199.288'
rx='14'
stroke='#2A2A2A'
strokeWidth='2'
/>
</svg> */}
</div>
</div>
</footer>

View File

@@ -0,0 +1,97 @@
'use client'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal'
const LandingPreview = dynamic(
() =>
import('@/app/(landing)/components/landing-preview/landing-preview').then(
(mod) => mod.LandingPreview
),
{
ssr: false,
loading: () => <div className='aspect-[1116/615] w-full rounded bg-[var(--landing-bg)]' />,
}
)
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
const CTA_BASE =
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
export default function Hero() {
return (
<section
id='hero'
aria-labelledby='hero-heading'
itemScope
itemType='https://schema.org/WebApplication'
className='relative flex flex-col items-center overflow-hidden bg-[var(--landing-bg)] pt-[60px] lg:pt-[100px]'
>
<p className='sr-only'>
Sim is an open-source AI agent platform. Sim lets teams build AI agents and run an agentic
workforce by connecting 1,000+ integrations and LLMs including OpenAI, Anthropic Claude,
Google Gemini, Mistral, and xAI Grok to deploy and orchestrate agentic workflows. Users
create agents, workflows, knowledge bases, tables, and docs. Sim is trusted by over 100,000
builders at startups and Fortune 500 companies. Sim is SOC2 compliant.
</p>
<div className='relative z-10 flex flex-col items-center gap-3'>
<h1
id='hero-heading'
itemProp='name'
className='text-balance font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
>
Build AI Agents
</h1>
<p
itemProp='description'
className='whitespace-nowrap text-center font-[430] font-season text-[4.4vw] text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] leading-[125%] tracking-[0.02em] sm:whitespace-normal sm:text-lg lg:text-xl'
>
Sim is the AI Workspace for Agent Builders
</p>
<div className='mt-3 flex items-center gap-2'>
<DemoRequestModal theme='light'>
<button
type='button'
className={`${CTA_BASE} border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`}
aria-label='Get a demo'
>
Get a demo
</button>
</DemoRequestModal>
<Link
href='/signup'
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
</div>
<div className='relative z-10 mx-auto mt-6 w-[92vw] px-[1.6vw] lg:mt-[3.2vw] lg:w-full lg:px-16'>
<div
aria-hidden='true'
className='absolute top-0 left-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
/>
<div
aria-hidden='true'
className='absolute top-0 right-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
/>
<div
aria-hidden='true'
className='absolute bottom-0 left-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
/>
<div
aria-hidden='true'
className='absolute right-0 bottom-0 z-20 hidden h-px w-[calc(4rem+4px)] bg-[var(--landing-bg-elevated)] lg:block'
/>
<div className='relative z-10 overflow-hidden rounded border border-[var(--landing-bg-elevated)]'>
<LandingPreview />
</div>
</div>
</section>
)
}

View File

@@ -1,4 +1,27 @@
import Collaboration from '@/app/(landing)/components/collaboration/collaboration'
import Enterprise from '@/app/(landing)/components/enterprise/enterprise'
import ExternalRedirect from '@/app/(landing)/components/external-redirect'
import Features from '@/app/(landing)/components/features/features'
import Footer from '@/app/(landing)/components/footer/footer'
import Hero from '@/app/(landing)/components/hero/hero'
import LegalLayout from '@/app/(landing)/components/legal-layout'
import Navbar from '@/app/(landing)/components/navbar/navbar'
import Pricing from '@/app/(landing)/components/pricing/pricing'
import StructuredData from '@/app/(landing)/components/structured-data'
import Templates from '@/app/(landing)/components/templates/templates'
import Testimonials from '@/app/(landing)/components/testimonials/testimonials'
export { LegalLayout, ExternalRedirect }
export {
Collaboration,
Enterprise,
ExternalRedirect,
Features,
Footer,
Hero,
LegalLayout,
Navbar,
Pricing,
StructuredData,
Templates,
Testimonials,
}

View File

@@ -3,11 +3,11 @@ import { DocxIcon, PdfIcon } from '@/components/icons/document-icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import {
LandingPreviewResource,
ownerCell,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
/** Generic audio/zip icon using basic SVG since no dedicated component exists */
function AudioIcon({ className }: { className?: string }) {

View File

@@ -0,0 +1,479 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ArrowUp, Table } from 'lucide-react'
import { Blimp, Checkbox, ChevronDown } from '@/components/emcn'
import { TypeBoolean, TypeNumber, TypeText } from '@/components/emcn/icons'
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
const C = {
SURFACE: '#292929',
BORDER: '#3d3d3d',
TEXT_PRIMARY: '#e6e6e6',
TEXT_BODY: '#cdcdcd',
TEXT_SECONDARY: '#b3b3b3',
TEXT_TERTIARY: '#939393',
TEXT_ICON: '#939393',
} 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 textareaRef = useRef<HTMLTextAreaElement>(null)
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
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, 200)}px`
}, [])
if (chatPhase !== 'input') {
const isResponding = chatPhase === 'responding' || chatPhase === 'done'
const showToolCall = chatPhase === 'tool-call' || isResponding
return (
<div className='flex min-w-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 */}
<motion.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-[#363636] px-3.5 py-2'>
<p
className='font-body text-[14px] leading-[1.5]'
style={{ color: C.TEXT_PRIMARY }}
>
{AUTO_PROMPT}
</p>
</div>
</motion.div>
{/* Assistant — no bubble, full-width prose */}
<AnimatePresence>
{showToolCall && (
<motion.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 h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Blimp className='h-[16px] w-[16px]' style={{ color: C.TEXT_ICON }} />
</div>
<span className='font-base text-sm' style={{ color: C.TEXT_BODY }}>
Mothership
</span>
<ChevronDown
className='h-[7px] w-[9px] transition-transform duration-150'
style={{
color: C.TEXT_ICON,
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='h-[15px] w-[15px]'
style={{ color: C.TEXT_TERTIARY }}
/>
}
title='Read Customer Leads'
/>
</div>
</div>
</div>
{/* Response prose — full width, no card */}
{isResponding && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
>
<ChatMarkdown
content={MOCK_RESPONSE}
visibleLength={responseTypedLength}
isTyping={chatPhase === 'responding'}
/>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Resource panel — slides in from right */}
<AnimatePresence>
{showResourcePanel && (
<motion.div
className='hidden h-full flex-shrink-0 overflow-hidden border-[#2c2c2c] border-l lg:flex'
initial={{ width: 0, opacity: 0 }}
animate={{ width: '55%', opacity: 1 }}
transition={{ duration: 0.35, ease: EASE_OUT }}
>
<MiniTablePanel />
</motion.div>
)}
</AnimatePresence>
</div>
)
}
return (
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-6 pb-[2vh]'>
<motion.p
role='presentation'
className='mb-6 max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
style={{ color: C.TEXT_PRIMARY }}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT }}
>
What should we get done?
</motion.p>
<motion.div
className='w-full max-w-[32rem]'
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1, ease: EASE_OUT }}
>
<div
className='cursor-text rounded-[20px] border px-2.5 py-2'
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
onClick={() => textareaRef.current?.focus()}
>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => {
if (!autoType) setInputValue(e.target.value)
}}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={animatedPlaceholder}
rows={1}
readOnly={autoType}
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
style={{
color: C.TEXT_PRIMARY,
caretColor: autoType ? 'transparent' : C.TEXT_PRIMARY,
maxHeight: '200px',
}}
/>
<div className='flex items-center justify-end'>
<button
type='button'
onClick={handleSubmit}
disabled={isEmpty}
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#808080' : '#e0e0e0',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
</button>
</div>
</div>
</motion.div>
</div>
)
})
/**
* 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 h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>{icon}</div>
<span className='font-base text-[13px]' style={{ color: C.TEXT_SECONDARY }}>
{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] leading-[1.6]' style={{ color: C.TEXT_PRIMARY }}>
<span dangerouslySetInnerHTML={{ __html: rendered }} />
{isTyping && (
<motion.span
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[#e6e6e6]'
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(--landing-bg)]'>
<div className='flex items-center gap-2 border-[#2c2c2c] border-b px-3 py-2'>
<Table className='h-[14px] w-[14px]' style={{ color: C.TEXT_ICON }} />
<span className='font-medium text-sm' style={{ color: C.TEXT_PRIMARY }}>
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-[#2c2c2c] border-r border-b bg-[#1e1e1e] p-0 text-left'
>
<div className='flex items-center gap-1 px-2 py-1.5'>
<Icon className='h-3 w-3 shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='font-medium text-[11px]' style={{ color: C.TEXT_PRIMARY }}>
{col.label}
</span>
<ChevronDown
className='ml-auto h-[6px] w-[8px]'
style={{ color: '#636363' }}
/>
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{MINI_TABLE_ROWS.map((row, i) => (
<motion.tr
key={i}
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-[#2c2c2c] border-r border-b px-2 py-1.5'
style={{ color: C.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>
)
})}
</motion.tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -13,8 +13,8 @@ import {
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
} 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='h-[14px] w-[14px]' />

View File

@@ -59,7 +59,7 @@ const MOCK_LOGS: LogRow[] = [
workflowColor: '#33C482',
date: 'Apr 1 09:15 AM',
status: 'error',
cost: '318 credits',
cost: '1 credit',
trigger: 'api',
triggerLabel: 'API',
duration: '2.7s',
@@ -70,7 +70,7 @@ const MOCK_LOGS: LogRow[] = [
workflowColor: '#a855f7',
date: 'Apr 1 08:30 AM',
status: 'completed',
cost: '89 credits',
cost: '2 credits',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '0.8s',
@@ -81,7 +81,7 @@ const MOCK_LOGS: LogRow[] = [
workflowColor: '#f97316',
date: 'Mar 31 10:14 PM',
status: 'completed',
cost: '241 credits',
cost: '7 credits',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '4.1s',
@@ -92,7 +92,7 @@ const MOCK_LOGS: LogRow[] = [
workflowColor: '#ec4899',
date: 'Mar 31 08:45 PM',
status: 'completed',
cost: '112 credits',
cost: '2 credits',
trigger: 'manual',
triggerLabel: 'Manual',
duration: '0.9s',
@@ -103,7 +103,7 @@ const MOCK_LOGS: LogRow[] = [
workflowColor: '#0ea5e9',
date: 'Mar 31 07:22 PM',
status: 'completed',
cost: '197 credits',
cost: '3 credits',
trigger: 'api',
triggerLabel: 'API',
duration: '1.6s',
@@ -114,7 +114,7 @@ const MOCK_LOGS: LogRow[] = [
workflowColor: '#f59e0b',
date: 'Mar 31 06:11 PM',
status: 'error',
cost: '284 credits',
cost: '1 credit',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '3.2s',

View File

@@ -0,0 +1,474 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ArrowUp } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Blimp, BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
import { AgentIcon, HubspotIcon, OpenAIIcon, SalesforceIcon } from '@/components/icons'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import {
EASE_OUT,
type EditorPromptData,
getEditorPrompt,
getWorkflowAnimationTiming,
type PreviewWorkflow,
TYPE_INTERVAL_MS,
TYPE_START_BUFFER_MS,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
type PanelTab = 'copilot' | 'editor'
const EDITOR_BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
agent: AgentIcon,
mothership: Blimp,
}
const TABS_WITH_TOOLBAR: { id: PanelTab | 'toolbar'; label: string; disabled?: boolean }[] = [
{ id: 'copilot', label: 'Copilot' },
{ id: 'toolbar', label: 'Toolbar', disabled: true },
{ id: 'editor', label: 'Editor' },
]
/**
* Stores the prompt in browser storage and redirects to /signup.
* Shared by both the copilot panel and the landing home view.
*/
export function useLandingSubmit() {
const router = useRouter()
return useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed) return
LandingPromptStorage.store(trimmed)
router.push('/signup')
},
[router]
)
}
interface LandingPreviewPanelProps {
activeWorkflow?: PreviewWorkflow
animationKey?: number
onHighlightBlock?: (blockId: string | null) => void
}
/**
* Workspace panel replica with switchable Copilot / Editor tabs.
*
* On every workflow switch (`animationKey` change):
* 1. Resets to Copilot tab.
* 2. Waits for blocks + edges to finish animating.
* 3. Slides the tab indicator to Editor and types the agent's prompt.
* 4. Highlights the agent block with the blue ring on the canvas.
*/
export const LandingPreviewPanel = memo(function LandingPreviewPanel({
activeWorkflow,
animationKey = 0,
onHighlightBlock,
}: LandingPreviewPanelProps) {
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
const [activeTab, setActiveTab] = useState<PanelTab>('copilot')
const [typedLength, setTypedLength] = useState(0)
const workflowRef = useRef(activeWorkflow)
workflowRef.current = activeWorkflow
const typeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const editorPrompt = activeWorkflow ? getEditorPrompt(activeWorkflow) : null
const userSwitchedTabRef = useRef(false)
const handleTabSwitch = useCallback(
(tab: PanelTab) => {
userSwitchedTabRef.current = true
setActiveTab(tab)
if (tab === 'editor' && editorPrompt) {
onHighlightBlock?.(editorPrompt.blockId)
} else {
onHighlightBlock?.(null)
}
},
[editorPrompt, onHighlightBlock]
)
useEffect(() => {
if (userSwitchedTabRef.current) return
setActiveTab('copilot')
setTypedLength(0)
onHighlightBlock?.(null)
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
const workflow = workflowRef.current
if (!workflow) return
const prompt = workflow ? getEditorPrompt(workflow) : null
if (!prompt) return
const { editorDelay } = getWorkflowAnimationTiming(workflow)
const switchTimer = setTimeout(() => {
if (userSwitchedTabRef.current) return
setActiveTab('editor')
onHighlightBlock?.(prompt.blockId)
}, editorDelay)
const typeTimer = setTimeout(() => {
if (userSwitchedTabRef.current) return
let charIndex = 0
typeIntervalRef.current = setInterval(() => {
charIndex++
setTypedLength(charIndex)
if (charIndex >= prompt.prompt.length) {
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
typeIntervalRef.current = null
}
}, TYPE_INTERVAL_MS)
}, editorDelay + TYPE_START_BUFFER_MS)
return () => {
clearTimeout(switchTimer)
clearTimeout(typeTimer)
if (typeIntervalRef.current) {
clearInterval(typeIntervalRef.current)
typeIntervalRef.current = null
}
}
}, [animationKey, onHighlightBlock])
const isEmpty = inputValue.trim().length === 0
const handleSubmit = useCallback(() => {
if (isEmpty) return
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
return (
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-3.5'>
{/* Header */}
<div className='flex flex-shrink-0 items-center justify-between px-2'>
<div className='pointer-events-none flex gap-1.5'>
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
</div>
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
<BubbleChatPreview className='h-[14px] w-[14px] text-[#e6e6e6]' />
</div>
</div>
<Link
href='/signup'
className='flex gap-1.5'
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setCursorPos(null)}
>
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
</div>
<div className='flex h-[30px] items-center gap-2 rounded-[5px] bg-[#33C482] px-2.5 transition-colors hover:bg-[#2DAC72]'>
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
</div>
</Link>
{cursorPos &&
createPortal(
<div
className='pointer-events-none fixed z-[9999]'
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
>
<div className='flex h-[4px]'>
<div className='h-full w-[8px] bg-[#2ABBF8]' />
<div className='h-full w-[14px] bg-[#2ABBF8] opacity-60' />
<div className='h-full w-[8px] bg-[#00F701]' />
<div className='h-full w-[16px] bg-[#00F701] opacity-60' />
<div className='h-full w-[8px] bg-[#FFCC02]' />
<div className='h-full w-[10px] bg-[#FFCC02] opacity-60' />
<div className='h-full w-[8px] bg-[#FA4EDF]' />
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
</div>
<div className='flex items-center gap-[5px] bg-white px-1.5 py-1 font-medium text-[#1C1C1C] text-[11px]'>
Get started
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
</div>
</div>,
document.body
)}
</div>
{/* Tabs with sliding active indicator */}
<div className='flex flex-shrink-0 items-center px-2 pt-3.5'>
<div className='flex gap-1'>
{TABS_WITH_TOOLBAR.map((tab) => {
if (tab.disabled) {
return (
<div
key={tab.id}
className='pointer-events-none flex h-[28px] items-center rounded-md border border-transparent px-2 py-[5px]'
>
<span className='font-medium text-[#787878] text-[12.5px]'>{tab.label}</span>
</div>
)
}
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
type='button'
onClick={() => handleTabSwitch(tab.id as PanelTab)}
className='relative flex h-[28px] items-center rounded-md border border-transparent px-2 py-[5px] font-medium text-[12.5px] transition-colors hover:border-[#3d3d3d] hover:bg-[#363636] hover:text-[#e6e6e6]'
style={{ color: isActive ? '#e6e6e6' : '#787878' }}
>
{isActive && (
<motion.div
layoutId='panel-tab-indicator'
className='absolute inset-0 rounded-md border border-[#3d3d3d] bg-[#363636]'
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
<span className='relative z-10'>{tab.label}</span>
</button>
)
})}
</div>
</div>
{/* Tab content with cross-fade */}
<div className='flex flex-1 flex-col overflow-hidden pt-3'>
<AnimatePresence mode='wait'>
{activeTab === 'copilot' && (
<motion.div
key='copilot'
className='flex h-full flex-col'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
>
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center justify-between gap-2 border border-[#2c2c2c] bg-[#292929] px-3 py-1.5'>
<span className='min-w-0 flex-1 truncate font-medium text-[#e6e6e6] text-[14px]'>
New Chat
</span>
</div>
<div className='px-2 pt-3 pb-2'>
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-1.5 py-1.5'>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder='Build an AI agent...'
rows={2}
className='mb-1.5 min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-0.5 py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
/>
<div className='flex items-center justify-end'>
<button
type='button'
onClick={handleSubmit}
disabled={isEmpty}
className='flex h-[22px] w-[22px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#808080' : '#e0e0e0',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={14} strokeWidth={2.25} color='#1b1b1b' />
</button>
</div>
</div>
</div>
</motion.div>
)}
{activeTab === 'editor' && (
<motion.div
key='editor'
className='flex h-full flex-col'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
>
<EditorTabContent editorPrompt={editorPrompt} typedLength={typedLength} />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
)
})
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
hubspot: HubspotIcon,
salesforce: SalesforceIcon,
}
const MODEL_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
'gpt-': OpenAIIcon,
}
function getModelIcon(model: string) {
const lower = model.toLowerCase()
for (const [prefix, icon] of Object.entries(MODEL_ICON_MAP)) {
if (lower.startsWith(prefix)) return icon
}
return null
}
interface EditorTabContentProps {
editorPrompt: EditorPromptData | null
typedLength: number
}
/**
* Editor tab replicating the real agent editor layout:
* header bar, then scrollable sub-block fields.
*/
function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps) {
if (!editorPrompt) {
return (
<div className='flex flex-1 items-center justify-center'>
<span className='font-medium text-[#787878] text-[13px]'>Select a block to edit</span>
</div>
)
}
const { blockName, blockType, bgColor, prompt, model, tools } = editorPrompt
const visibleText = prompt.slice(0, typedLength)
const isTyping = typedLength < prompt.length
const BlockIcon = EDITOR_BLOCK_ICONS[blockType]
const ModelIcon = model ? getModelIcon(model) : null
return (
<div className='flex h-full flex-col'>
{/* Editor header */}
<div className='mx-[-1px] flex flex-shrink-0 items-center gap-2 border border-[#2c2c2c] bg-[#292929] px-3 py-1.5'>
{BlockIcon && (
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm'
style={{ background: bgColor }}
>
<BlockIcon className='h-[12px] w-[12px] text-white' />
</div>
)}
<span className='min-w-0 flex-1 truncate font-medium text-[#e6e6e6] text-sm'>
{blockName}
</span>
</div>
{/* Sub-block fields */}
<div className='flex-1 overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2'>
<div className='flex flex-col gap-4'>
{/* System Prompt */}
<div className='flex flex-col gap-2.5'>
<div className='flex items-center pl-0.5'>
<span className='font-medium text-[#e6e6e6] text-small'>System Prompt</span>
</div>
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-2 py-2'>
<p className='min-h-[48px] whitespace-pre-wrap break-words font-medium font-sans text-[#e6e6e6] text-sm leading-[1.5]'>
{visibleText}
{isTyping && (
<motion.span
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[#e6e6e6]'
animate={{ opacity: [1, 0] }}
transition={{
duration: 0.6,
repeat: Number.POSITIVE_INFINITY,
repeatType: 'reverse',
}}
/>
)}
</p>
</div>
</div>
{/* Model */}
{model && (
<div className='flex flex-col gap-2.5'>
<div className='flex items-center pl-0.5'>
<span className='font-medium text-[#e6e6e6] text-small'>Model</span>
</div>
<div className='flex h-[32px] items-center gap-2 rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-2'>
{ModelIcon && <ModelIcon className='h-[14px] w-[14px] text-[#e6e6e6]' />}
<span className='flex-1 truncate font-medium text-[#e6e6e6] text-sm'>{model}</span>
<ChevronDown className='h-[7px] w-[9px] text-[#636363]' />
</div>
</div>
)}
{/* Tools */}
{tools.length > 0 && (
<div className='flex flex-col gap-2.5'>
<div className='flex items-center pl-0.5'>
<span className='font-medium text-[#e6e6e6] text-small'>Tools</span>
</div>
<div className='flex flex-wrap gap-[5px]'>
{tools.map((tool) => {
const ToolIcon = TOOL_ICONS[tool.type]
return (
<div
key={tool.type}
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
>
{ToolIcon && (
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: tool.bgColor }}
>
<ToolIcon className='h-[10px] w-[10px] text-white' />
</div>
)}
<span className='font-normal text-[#e6e6e6] text-[12px]'>{tool.name}</span>
</div>
)
})}
</div>
</div>
)}
{/* Temperature */}
<div className='flex flex-col gap-2.5'>
<div className='flex items-center justify-between pl-0.5'>
<span className='font-medium text-[#e6e6e6] text-small'>Temperature</span>
<span className='font-medium text-[#787878] text-small'>0.7</span>
</div>
<div className='relative h-[6px] rounded-full bg-[#3d3d3d]'>
<div className='h-full w-[70%] rounded-full bg-[#e6e6e6]' />
<div
className='-translate-y-1/2 absolute top-1/2 h-[14px] w-[14px] rounded-full border-[#e6e6e6] border-[2px] bg-[#292929]'
style={{ left: 'calc(70% - 7px)' }}
/>
</div>
</div>
{/* Response Format */}
<div className='flex flex-col gap-2.5'>
<div className='flex items-center pl-0.5'>
<span className='font-medium text-[#e6e6e6] text-small'>Response Format</span>
</div>
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-2 py-2'>
<span className='font-mono text-[#787878] text-[12px]'>plain text</span>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -2,8 +2,8 @@ import { Calendar } from '@/components/emcn/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const CAL_ICON = <Calendar className='h-[14px] w-[14px]' />

View File

@@ -11,7 +11,7 @@ import {
Table,
} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
export type SidebarView =
| 'home'

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Checkbox } from '@/components/emcn'
import {
ChevronDown,
@@ -15,11 +16,11 @@ import { cn } from '@/lib/core/utils/cn'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import {
LandingPreviewResource,
ownerCell,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
const CELL_CHECKBOX =
@@ -525,28 +526,59 @@ function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
)
}
export function LandingPreviewTables() {
interface LandingPreviewTablesProps {
autoOpenTableId?: string | null
}
const tableViewTransition = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
transition: { duration: 0.25, ease: [0.16, 1, 0.3, 1] as const },
} as const
export function LandingPreviewTables({ autoOpenTableId }: LandingPreviewTablesProps = {}) {
const [openTableId, setOpenTableId] = useState<string | null>(null)
if (openTableId !== null) {
return (
<SpreadsheetView
tableId={openTableId}
tableName={TABLE_METAS[openTableId] ?? 'Table'}
onBack={() => setOpenTableId(null)}
/>
)
}
useEffect(() => {
if (!autoOpenTableId) return
const timer = setTimeout(() => {
setOpenTableId(autoOpenTableId)
}, 800)
return () => clearTimeout(timer)
}, [autoOpenTableId])
return (
<LandingPreviewResource
icon={Table}
title='Tables'
createLabel='New table'
searchPlaceholder='Search tables...'
columns={LIST_COLUMNS}
rows={LIST_ROWS}
onRowClick={(id) => setOpenTableId(id)}
/>
<AnimatePresence mode='wait'>
{openTableId !== null ? (
<motion.div
key={`spreadsheet-${openTableId}`}
className='flex h-full flex-1 flex-col'
{...tableViewTransition}
>
<SpreadsheetView
tableId={openTableId}
tableName={TABLE_METAS[openTableId] ?? 'Table'}
onBack={() => setOpenTableId(null)}
/>
</motion.div>
) : (
<motion.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)}
/>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import ReactFlow, {
applyEdgeChanges,
@@ -16,22 +16,24 @@ import ReactFlow, {
ReactFlowProvider,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { PreviewBlockNode } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
import { PreviewBlockNode } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
import {
EASE_OUT,
type PreviewWorkflow,
toReactFlowElements,
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
interface FitViewOptions {
padding?: number
maxZoom?: number
minZoom?: number
}
interface LandingPreviewWorkflowProps {
workflow: PreviewWorkflow
animate?: boolean
fitViewOptions?: FitViewOptions
highlightedBlockId?: string | null
}
/**
@@ -88,21 +90,35 @@ function PreviewEdge({
const NODE_TYPES: NodeTypes = { previewBlock: PreviewBlockNode }
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
const PRO_OPTIONS = { hideAttribution: true }
const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1 } as const
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, fitViewOptions }: LandingPreviewWorkflowProps) {
function PreviewFlow({
workflow,
animate = false,
fitViewOptions,
highlightedBlockId,
}: LandingPreviewWorkflowProps) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => toReactFlowElements(workflow, animate),
[workflow, animate]
() => toReactFlowElements(workflow, animate, highlightedBlockId),
[workflow, animate, highlightedBlockId]
)
const [nodes, setNodes] = useState<Node[]>(initialNodes)
const [edges, setEdges] = useState<Edge[]>(initialEdges)
useEffect(() => {
setNodes((prev) =>
prev.map((node) => ({
...node,
data: { ...node.data, isHighlighted: highlightedBlockId === node.id },
}))
)
}, [highlightedBlockId])
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
@@ -114,6 +130,7 @@ function PreviewFlow({ workflow, animate = false, fitViewOptions }: LandingPrevi
)
const resolvedFitViewOptions = fitViewOptions ?? DEFAULT_FIT_VIEW_OPTIONS
const minZoom = fitViewOptions?.minZoom ?? 0.5
return (
<ReactFlow
@@ -135,6 +152,7 @@ function PreviewFlow({ workflow, animate = false, fitViewOptions }: LandingPrevi
preventScrolling={false}
autoPanOnNodeDrag={false}
proOptions={PRO_OPTIONS}
minZoom={minZoom}
fitView
fitViewOptions={resolvedFitViewOptions}
className='h-full w-full bg-[var(--landing-bg)]'
@@ -151,11 +169,17 @@ export function LandingPreviewWorkflow({
workflow,
animate = false,
fitViewOptions,
highlightedBlockId,
}: LandingPreviewWorkflowProps) {
return (
<div className='h-full w-full'>
<ReactFlowProvider key={workflow.id}>
<PreviewFlow workflow={workflow} animate={animate} fitViewOptions={fitViewOptions} />
<PreviewFlow
workflow={workflow}
animate={animate}
fitViewOptions={fitViewOptions}
highlightedBlockId={highlightedBlockId}
/>
</ReactFlowProvider>
</div>
)

View File

@@ -39,7 +39,7 @@ import {
BLOCK_STAGGER,
EASE_OUT,
type PreviewTool,
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/** Map block type strings to their icon components. */
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -105,6 +105,7 @@ interface PreviewBlockData {
hideSourceHandle?: boolean
index?: number
animate?: boolean
isHighlighted?: boolean
}
/**
@@ -137,6 +138,7 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
hideSourceHandle,
index = 0,
animate = false,
isHighlighted = false,
} = data
const Icon = BLOCK_ICONS[blockType]
const delay = animate ? index * BLOCK_STAGGER : 0
@@ -264,6 +266,10 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
isConnectableEnd={false}
/>
)}
{isHighlighted && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[#33b4ff] ring-[1.75px]' />
)}
</div>
</motion.div>
)

View File

@@ -66,7 +66,11 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'claude-sonnet-4.6' },
{ title: 'System Prompt', value: 'Triage incoming IT...' },
{
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: '#10B981' }],
position: { x: 420, y: 40 },
@@ -91,7 +95,7 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
}
/**
* Self-healing CRM workflow Schedule -> Mothership
* Self-healing CRM workflow Schedule -> Agent
*/
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
id: 'wf-self-healing-crm',
@@ -111,20 +115,27 @@ const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
hideTargetHandle: true,
},
{
id: 'mothership-1',
id: 'agent-crm',
name: 'CRM Agent',
type: 'mothership',
bgColor: '#33C482',
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
type: 'agent',
bgColor: '#701ffc',
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: '#FF7A59' },
{ name: 'Salesforce', type: 'salesforce', bgColor: '#E0E0E0' },
],
position: { x: 420, y: 180 },
position: { x: 420, y: 140 },
hideSourceHandle: true,
},
],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-crm' }],
}
/**
@@ -154,7 +165,11 @@ const CUSTOMER_SUPPORT_WORKFLOW: PreviewWorkflow = {
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gpt-5.4' },
{ title: 'System Prompt', value: 'Resolve customer issues...' },
{
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: '#10B981' },
@@ -228,7 +243,8 @@ const EDGE_STYLE = { stroke: '#454545', strokeWidth: 1.5 } as const
*/
export function toReactFlowElements(
workflow: PreviewWorkflow,
animate = false
animate = false,
highlightedBlockId?: string | null
): {
nodes: Node[]
edges: Edge[]
@@ -250,6 +266,7 @@ export function toReactFlowElements(
hideSourceHandle: block.hideSourceHandle,
index,
animate,
isHighlighted: highlightedBlockId === block.id,
},
draggable: true,
selectable: false,
@@ -278,3 +295,74 @@ export function toReactFlowElements(
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
const promptRow = block.rows.find((r) => r.title === 'Prompt' || r.title === 'System Prompt')
if (promptRow) {
const modelRow = block.rows.find((r) => r.title === 'Model')
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) }
}
/** 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
}

View File

@@ -0,0 +1,322 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion, type Variants } from 'framer-motion'
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 { LandingPreviewPanel } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { LandingPreviewScheduledTasks } from '@/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewSidebar } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { 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,
getWorkflowStepDuration,
PREVIEW_WORKFLOWS,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
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 panelVariants: 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' | 'tables' | 'home' | 'logs'
workflowId?: string
tableId?: 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
/** 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')!),
},
]
/**
* 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.
*/
export function LandingPreview() {
const [activeView, setActiveView] = useState<SidebarView>('workflow')
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
const animationKeyRef = useRef(0)
const [animationKey, setAnimationKey] = useState(0)
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null)
const [autoTableId, setAutoTableId] = useState<string | null>(null)
const [autoTypeHome, setAutoTypeHome] = useState(false)
const [isDesktop, setIsDesktop] = useState(true)
const demoIndexRef = useRef(0)
const demoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const autoCycleActiveRef = useRef(true)
const isDesktopRef = useRef(true)
const clearDemoTimer = useCallback(() => {
if (demoTimerRef.current) {
clearTimeout(demoTimerRef.current)
demoTimerRef.current = null
}
}, [])
const applyDemoStep = useCallback((step: DemoStep) => {
setAutoTableId(null)
setAutoTypeHome(false)
if (step.type === 'workflow' && step.workflowId) {
setActiveWorkflowId(step.workflowId)
setActiveView('workflow')
animationKeyRef.current += 1
setAnimationKey(animationKeyRef.current)
} else if (step.type === 'tables') {
setActiveView('tables')
setAutoTableId(step.tableId ?? null)
} 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(() => {
const desktop = window.matchMedia('(min-width: 1024px)').matches
isDesktopRef.current = desktop
setIsDesktop(desktop)
if (!desktop) return
applyDemoStep(DESKTOP_STEPS[0])
scheduleNextStep()
return clearDemoTimer
}, [applyDemoStep, scheduleNextStep, clearDemoTimer])
const stopAutoCycle = useCallback(() => {
autoCycleActiveRef.current = false
clearDemoTimer()
}, [clearDemoTimer])
const handleSelectWorkflow = useCallback(
(id: string) => {
stopAutoCycle()
setAutoTableId(null)
setAutoTypeHome(false)
setHighlightedBlockId(null)
setActiveWorkflowId(id)
setActiveView('workflow')
animationKeyRef.current += 1
setAnimationKey(animationKeyRef.current)
},
[stopAutoCycle]
)
const handleSelectHome = useCallback(() => {
stopAutoCycle()
setAutoTableId(null)
setAutoTypeHome(false)
setHighlightedBlockId(null)
setActiveView('home')
}, [stopAutoCycle])
const handleSelectNav = useCallback(
(id: SidebarView) => {
stopAutoCycle()
setAutoTableId(null)
setAutoTypeHome(false)
setHighlightedBlockId(null)
setActiveView(id)
},
[stopAutoCycle]
)
const handleHighlightBlock = useCallback((blockId: string | null) => {
setHighlightedBlockId(blockId)
}, [])
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
const isWorkflowView = activeView === 'workflow'
return (
<motion.div
className='dark flex aspect-[1116/615] w-full overflow-hidden rounded bg-[var(--landing-bg-surface)] antialiased'
initial={isDesktop ? 'hidden' : false}
animate='visible'
variants={containerVariants}
>
<motion.div className='hidden lg:flex' variants={sidebarVariants}>
<LandingPreviewSidebar
workflows={PREVIEW_WORKFLOWS}
activeWorkflowId={activeWorkflowId}
activeView={activeView}
onSelectWorkflow={handleSelectWorkflow}
onSelectHome={handleSelectHome}
onSelectNav={handleSelectNav}
/>
</motion.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-[#2c2c2c] bg-[var(--landing-bg)]'>
<div
className={
isWorkflowView
? 'relative min-w-0 flex-1 overflow-hidden'
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
}
>
{isDesktop ? (
<AnimatePresence mode='wait'>
{activeView === 'workflow' && (
<motion.div
key={`wf-${activeWorkflow.id}-${animationKey}`}
className='h-full w-full'
{...viewTransition}
>
<LandingPreviewWorkflow
workflow={activeWorkflow}
animate
highlightedBlockId={highlightedBlockId}
/>
</motion.div>
)}
{activeView === 'home' && (
<motion.div
key={`home-${animationKey}`}
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewHome autoType={autoTypeHome} />
</motion.div>
)}
{activeView === 'tables' && (
<motion.div
key={`tables-${animationKey}`}
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewTables autoOpenTableId={autoTableId} />
</motion.div>
)}
{activeView === 'files' && (
<motion.div
key='files'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewFiles />
</motion.div>
)}
{activeView === 'knowledge' && (
<motion.div
key='knowledge'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewKnowledge />
</motion.div>
)}
{activeView === 'logs' && (
<motion.div key='logs' className='flex h-full w-full flex-col' initial={false}>
<LandingPreviewLogs />
</motion.div>
)}
{activeView === 'scheduled-tasks' && (
<motion.div
key='scheduled-tasks'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewScheduledTasks />
</motion.div>
)}
</AnimatePresence>
) : (
<div className='h-full w-full'>
<LandingPreviewWorkflow workflow={activeWorkflow} />
</div>
)}
</div>
<motion.div
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
variants={panelVariants}
>
<LandingPreviewPanel
activeWorkflow={activeWorkflow}
animationKey={animationKey}
onHighlightBlock={handleHighlightBlock}
/>
</motion.div>
</div>
</div>
</motion.div>
)
}

View File

@@ -1,7 +1,7 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
import { isHosted } from '@/lib/core/config/feature-flags'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
interface LegalLayoutProps {
title: string

View File

@@ -44,9 +44,9 @@ function BlogCard({
unoptimized
/>
</div>
<div className='flex-shrink-0 px-2.5 py-1.5'>
<div className='flex-shrink-0 px-2.5 py-2'>
<span
className='font-[430] font-season text-[var(--landing-text-body)] leading-[140%]'
className='block truncate font-[430] font-season text-[var(--landing-text-body)] leading-[140%]'
style={{ fontSize: titleSize }}
>
{title}
@@ -66,7 +66,7 @@ export function BlogDropdown({ posts }: BlogDropdownProps) {
if (!featured) return null
return (
<div className='w-[560px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-4 shadow-overlay'>
<div className='w-[560px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-2 shadow-overlay'>
<div className='grid grid-cols-3 gap-2'>
<BlogCard
slug={featured.slug}

View File

@@ -37,8 +37,8 @@ const RESOURCE_CARDS = [
export function DocsDropdown() {
return (
<div className='w-[480px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-4 shadow-overlay'>
<div className='grid grid-cols-2 gap-2.5'>
<div className='w-[480px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg)] p-2 shadow-overlay'>
<div className='grid grid-cols-2 gap-2'>
{PREVIEW_CARDS.map((card) => (
<a
key={card.title}

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { GithubOutlineIcon } from '@/components/icons'
import { getFormattedGitHubStars } from '@/app/(home)/actions/github'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
const logger = createLogger('github-stars')
@@ -31,7 +31,7 @@ export function GitHubStars() {
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-2 px-3'
className='flex h-[30px] items-center gap-2 self-center rounded-[5px] px-3 transition-colors duration-200 group-hover:bg-[var(--landing-bg-elevated)]'
aria-label={`GitHub repository — ${stars} stars`}
>
<GithubOutlineIcon className='h-[14px] w-[14px]' />

View File

@@ -10,9 +10,9 @@ import { cn } from '@/lib/core/utils/cn'
import {
BlogDropdown,
type NavBlogPost,
} from '@/app/(home)/components/navbar/components/blog-dropdown'
import { DocsDropdown } from '@/app/(home)/components/navbar/components/docs-dropdown'
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
} from '@/app/(landing)/components/navbar/components/blog-dropdown'
import { DocsDropdown } from '@/app/(landing)/components/navbar/components/docs-dropdown'
import { GitHubStars } from '@/app/(landing)/components/navbar/components/github-stars'
import { getBrandConfig } from '@/ee/whitelabeling'
type DropdownId = 'docs' | 'blog' | null
@@ -29,10 +29,9 @@ const NAV_LINKS: NavLink[] = [
{ label: 'Docs', href: 'https://docs.sim.ai', external: true, icon: 'chevron', dropdown: 'docs' },
{ label: 'Blog', href: '/blog', icon: 'chevron', dropdown: 'blog' },
{ label: 'Pricing', href: '/#pricing' },
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
]
const LOGO_CELL = 'flex items-center pl-5 lg:pl-20 pr-5'
const LOGO_CELL = 'flex items-center pl-5 lg:pl-16 pr-5'
const LINK_CELL = 'flex items-center px-3.5'
interface NavbarProps {
@@ -49,7 +48,6 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
const useHomeLinks = isAuthenticated || isBrowsingHome
const logoHref = useHomeLinks ? '/?home' : '/'
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -91,12 +89,10 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
return () => mq.removeEventListener('change', handler)
}, [])
const anyHighlighted = activeDropdown !== null || hoveredLink !== null
return (
<nav
aria-label='Primary navigation'
className='relative flex h-[52px] border-[var(--landing-bg-elevated)] border-b-[1px] bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)] text-sm'
className='relative flex h-[58px] border-[var(--landing-bg-elevated)] border-b-[1px] bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)] text-sm'
itemScope
itemType='https://schema.org/SiteNavigationElement'
>
@@ -134,13 +130,9 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
const hasDropdown = !!dropdown
const isActive = hasDropdown && activeDropdown === dropdown
const isThisHovered = hoveredLink === label
const isHighlighted = isActive || isThisHovered
const isDimmed = anyHighlighted && !isHighlighted
const linkClass = cn(
icon ? `${LINK_CELL} gap-2` : LINK_CELL,
'transition-colors duration-200',
isDimmed && 'text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)]'
'h-[30px] self-center rounded-[5px] transition-colors duration-200 group-hover:bg-[var(--landing-bg-elevated)]'
)
const chevron = icon === 'chevron' && <NavChevron open={isActive} />
@@ -148,7 +140,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
return (
<li
key={label}
className='relative flex'
className='group relative flex'
onMouseEnter={() => openDropdown(dropdown)}
onMouseLeave={scheduleClose}
>
@@ -157,51 +149,44 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
href={href}
target='_blank'
rel='noopener noreferrer'
className={cn(linkClass, 'h-full cursor-pointer')}
itemProp='url'
className={cn(linkClass, 'cursor-pointer')}
>
{label}
{chevron}
</a>
) : (
<Link href={href} className={cn(linkClass, 'h-full cursor-pointer')}>
<Link href={href} itemProp='url' className={cn(linkClass, 'cursor-pointer')}>
{label}
{chevron}
</Link>
)}
<div
className={cn(
'-mt-0.5 absolute top-full left-0 z-50',
isActive
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)}
style={{
transform: isActive ? 'translateY(0)' : 'translateY(-6px)',
transition: 'opacity 200ms ease, transform 200ms ease',
}}
>
{dropdown === 'docs' && <DocsDropdown />}
{dropdown === 'blog' && <BlogDropdown posts={blogPosts} />}
</div>
{isActive && (
<div className='-mt-0.5 pointer-events-auto absolute top-full left-0 z-50'>
{dropdown === 'docs' && <DocsDropdown />}
{dropdown === 'blog' && <BlogDropdown posts={blogPosts} />}
</div>
)}
</li>
)
}
return (
<li
key={label}
className='flex'
onMouseEnter={() => setHoveredLink(label)}
onMouseLeave={() => setHoveredLink(null)}
>
<li key={label} className='group flex'>
{external ? (
<a href={href} target='_blank' rel='noopener noreferrer' className={linkClass}>
<a
href={href}
target='_blank'
rel='noopener noreferrer'
itemProp='url'
className={linkClass}
>
{label}
{chevron}
</a>
) : (
<Link href={href} className={linkClass} aria-label={label}>
<Link href={href} itemProp='url' className={linkClass} aria-label={label}>
{label}
{chevron}
</Link>
@@ -209,14 +194,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
</li>
)
})}
<li
className={cn(
'flex transition-opacity duration-200',
anyHighlighted && hoveredLink !== 'github' && 'opacity-60'
)}
onMouseEnter={() => setHoveredLink('github')}
onMouseLeave={() => setHoveredLink(null)}
>
<li className='group flex'>
<GitHubStars />
</li>
</ul>
@@ -225,7 +203,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
<div
className={cn(
'hidden items-center gap-2 pr-20 pl-5 lg:flex',
'hidden items-center gap-2 pr-16 pl-5 lg:flex',
isSessionPending && 'invisible'
)}
>
@@ -271,7 +249,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
<div
className={cn(
'fixed inset-x-0 top-[52px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[var(--landing-bg)] font-[430] font-season text-sm transition-all duration-200 lg:hidden',
'fixed inset-x-0 top-[58px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[var(--landing-bg)] font-[430] font-season text-sm transition-all duration-200 lg:hidden',
mobileMenuOpen ? 'visible opacity-100' : 'invisible opacity-0'
)}
>

View File

@@ -1,6 +1,8 @@
'use client'
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal'
interface PricingTier {
id: string
@@ -83,7 +85,7 @@ const PRICING_TIERS: PricingTier[] = [
'SSO & SCIM · SOC2',
'Self hosting · Dedicated support',
],
cta: { label: 'Book a demo', action: 'demo-request' },
cta: { label: 'Get a demo', action: 'demo-request' },
},
]
@@ -110,7 +112,21 @@ function PricingCard({ tier }: PricingCardProps) {
const isPro = tier.id === 'pro'
return (
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
<article
className='flex flex-1 flex-col'
aria-labelledby={`${tier.id}-heading`}
itemScope
itemType='https://schema.org/Offer'
>
<meta itemProp='name' content={`${tier.name} Plan`} />
<meta
itemProp='price'
content={
tier.price === 'Free' ? '0' : tier.price === 'Custom' ? '' : tier.price.replace('$', '')
}
/>
<meta itemProp='priceCurrency' content='USD' />
<meta itemProp='availability' content='https://schema.org/InStock' />
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[var(--landing-border-light)] border-b-0 bg-white p-5'>
<div className='flex flex-col'>
<h3
@@ -196,7 +212,7 @@ export default function Pricing() {
aria-labelledby='pricing-heading'
className='bg-[var(--landing-bg-section)]'
>
<div className='px-4 pt-[60px] pb-10 sm:px-8 sm:pt-20 sm:pb-0 md:px-20 md:pt-[100px]'>
<div className='px-4 pt-[60px] pb-[60px] sm:px-8 sm:pt-20 sm:pb-20 md:px-16 md:pt-[100px] md:pb-[100px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-5'>
<Badge
variant='blue'
@@ -213,6 +229,12 @@ export default function Pricing() {
>
Pricing
</h2>
<p className='sr-only'>
Sim pricing: Community plan is free with 1,000 credits and 5GB storage. Pro plan is $25
per month with 6,000 credits and 50GB storage. Max plan is $100 per month with 25,000
credits and 500GB storage. Enterprise pricing is custom with SSO, SCIM, SOC2 compliance,
self-hosting, and dedicated support. All plans include CLI, SDK, and MCP access.
</p>
</div>
<div className='mt-8 grid grid-cols-1 gap-4 sm:mt-10 sm:grid-cols-2 md:mt-12 lg:grid-cols-4'>

View File

@@ -4,7 +4,7 @@
* Renders a `<script type="application/ld+json">` with Schema.org markup.
* Single source of truth for machine-readable page metadata.
*
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, FAQPage.
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, SoftwareSourceCode, FAQPage.
*
* AI crawler behavior (2025-2026):
* - Google AI Overviews / Bing Copilot parse JSON-LD from their search indexes.
@@ -170,6 +170,15 @@ export default function StructuredData() {
},
],
},
{
'@type': 'SoftwareSourceCode',
'@id': 'https://sim.ai/#source',
codeRepository: 'https://github.com/simstudioai/sim',
programmingLanguage: ['TypeScript', 'Python'],
runtimePlatform: 'Node.js',
license: 'https://opensource.org/licenses/AGPL-3.0',
isPartOf: { '@id': 'https://sim.ai/#software' },
},
{
'@type': 'FAQPage',
'@id': 'https://sim.ai/#faq',
@@ -214,6 +223,30 @@ export default function StructuredData() {
text: 'Sim offers SOC2 compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
},
},
{
'@type': 'Question',
name: 'Is Sim open source?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes. Sim is fully open source under the AGPL-3.0 license. The source code is available on GitHub at github.com/simstudioai/sim. You can self-host Sim or use the hosted version at sim.ai.',
},
},
{
'@type': 'Question',
name: 'What integrations does Sim support?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim supports 1,000+ integrations including Slack, Gmail, GitHub, Notion, Airtable, Supabase, HubSpot, Salesforce, Jira, Linear, Google Drive, Google Sheets, Confluence, Discord, Microsoft Teams, Outlook, Telegram, Stripe, Pinecone, and Firecrawl. New integrations are added regularly.',
},
},
{
'@type': 'Question',
name: 'Can I self-host Sim?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes. Sim can be self-hosted using Docker. Documentation is available at docs.sim.ai/self-hosting. Enterprise customers can also get dedicated infrastructure and on-premise deployment.',
},
},
],
},
],

View File

@@ -1,4 +1,4 @@
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/**
* OCR Invoice to DB Start Agent (Textract) Supabase

View File

@@ -1,21 +1,21 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AnimatePresence, type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Badge, ChevronDown } from '@/components/emcn'
import { LandingWorkflowSeedStorage } from '@/lib/core/utils/browser-storage'
import { cn } from '@/lib/core/utils/cn'
import { TEMPLATE_WORKFLOWS } from '@/app/(home)/components/templates/template-workflows'
import { TEMPLATE_WORKFLOWS } from '@/app/(landing)/components/templates/template-workflows'
const logger = createLogger('LandingTemplates')
const LandingPreviewWorkflow = dynamic(
() =>
import(
'@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
'@/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
).then((mod) => mod.LandingPreviewWorkflow),
{
ssr: false,
@@ -236,73 +236,6 @@ const DEPTH_CONFIGS: Record<string, DepthConfig> = {
},
}
const SCROLL_BLOCK_RX = '2.59574'
/**
* Two-row horizontal block strip for the scroll-driven reveal in the templates section.
* Same structural pattern as the hero's top-right blocks with matching colours:
* blue (left) pink (middle) green (right).
*/
const SCROLL_BLOCK_RECTS = [
{ opacity: 0.6, x: '-34.24', y: '0', width: '34.24', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '-17.38', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '33.73', fill: '#2ABBF8' },
{ opacity: 0.6, x: '0', y: '0', width: '85.34', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
{ opacity: 0.6, x: '34.24', y: '0', width: '34.24', height: '33.73', fill: '#2ABBF8' },
{ opacity: 1, x: '34.24', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '51.62', y: '16.86', width: '16.86', height: '16.86', fill: '#2ABBF8' },
{ opacity: 1, x: '68.48', y: '0', width: '54.65', height: '16.86', fill: '#FA4EDF' },
{ opacity: 0.6, x: '106.27', y: '0', width: '34.24', height: '33.73', fill: '#FA4EDF' },
{ opacity: 0.6, x: '106.27', y: '0', width: '51.10', height: '16.86', fill: '#FA4EDF' },
{ opacity: 1, x: '123.65', y: '16.86', width: '16.86', height: '16.86', fill: '#FA4EDF' },
{ opacity: 0.6, x: '157.37', y: '0', width: '34.24', height: '16.86', fill: '#FA4EDF' },
{ opacity: 1, x: '157.37', y: '0', width: '16.86', height: '16.86', fill: '#FA4EDF' },
{ opacity: 0.6, x: '209.0', y: '0', width: '68.48', height: '16.86', fill: '#00F701' },
{ opacity: 0.6, x: '209.14', y: '0', width: '16.86', height: '33.73', fill: '#00F701' },
{ opacity: 0.6, x: '243.23', y: '0', width: '34.24', height: '33.73', fill: '#00F701' },
{ opacity: 1, x: '243.23', y: '0', width: '16.86', height: '16.86', fill: '#00F701' },
{ opacity: 0.6, x: '260.10', y: '0', width: '34.04', height: '16.86', fill: '#00F701' },
{ opacity: 1, x: '260.61', y: '16.86', width: '16.86', height: '16.86', fill: '#00F701' },
] as const
const SCROLL_BLOCK_MAX_X = Math.max(...SCROLL_BLOCK_RECTS.map((r) => Number.parseFloat(r.x)))
const SCROLL_REVEAL_START = 0.05
const SCROLL_REVEAL_SPAN = 0.7
const SCROLL_FADE_IN = 0.03
function getScrollBlockThreshold(x: string): number {
const normalized = Number.parseFloat(x) / SCROLL_BLOCK_MAX_X
return SCROLL_REVEAL_START + (1 - normalized) * SCROLL_REVEAL_SPAN
}
interface ScrollBlockRectProps {
scrollYProgress: MotionValue<number>
rect: (typeof SCROLL_BLOCK_RECTS)[number]
}
/** Renders a single SVG rect whose opacity is driven by scroll progress. */
function ScrollBlockRect({ scrollYProgress, rect }: ScrollBlockRectProps) {
const threshold = getScrollBlockThreshold(rect.x)
const opacity = useTransform(
scrollYProgress,
[threshold, threshold + SCROLL_FADE_IN],
[0, rect.opacity]
)
return (
<motion.rect
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
rx={SCROLL_BLOCK_RX}
fill={rect.fill}
style={{ opacity }}
/>
)
}
function buildBottomWallStyle(config: DepthConfig) {
let pos = 0
const stops: string[] = []
@@ -317,36 +250,9 @@ function buildBottomWallStyle(config: DepthConfig) {
}
}
interface DotGridProps {
className?: string
cols: number
rows: number
gap?: number
}
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
return (
<div
aria-hidden='true'
className={className}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gap,
placeItems: 'center',
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[var(--landing-bg-elevated)]' />
))}
</div>
)
}
const TEMPLATES_PANEL_ID = 'templates-panel'
export default function Templates() {
const sectionRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [isPreparingTemplate, setIsPreparingTemplate] = useState(false)
const [isMobile, setIsMobile] = useState(false)
@@ -360,11 +266,6 @@ export default function Templates() {
return () => mq.removeEventListener('change', handler)
}, [])
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start 0.9', 'start 0.2'],
})
const activeWorkflow = TEMPLATE_WORKFLOWS[activeIndex]
const activeDepth = DEPTH_CONFIGS[activeWorkflow.id]
@@ -408,12 +309,7 @@ export default function Templates() {
])
return (
<section
ref={sectionRef}
id='templates'
aria-labelledby='templates-heading'
className='mt-10 mb-20'
>
<section id='templates' aria-labelledby='templates-heading' className='pt-[60px] lg:pt-[100px]'>
<p className='sr-only'>
Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR
processing, release management, meeting follow-ups, resume scanning, email triage,
@@ -421,35 +317,15 @@ export default function Templates() {
and knowledge base Q&amp;A. Each template connects real integrations and LLMs pick one,
customise it, and deploy in minutes.
</p>
<ul className='sr-only'>
{TEMPLATE_WORKFLOWS.map((workflow) => (
<li key={workflow.id}>{workflow.name}</li>
))}
</ul>
<div className='bg-[var(--landing-bg)]'>
<DotGrid
className='overflow-hidden border-[var(--landing-bg-elevated)] border-y bg-[var(--landing-bg)] p-1.5'
cols={160}
rows={1}
gap={6}
/>
<div className='relative overflow-hidden'>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 right-0 z-20 hidden lg:block'
>
<svg
width={329}
height={34}
viewBox='-34 0 329 34'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
>
{SCROLL_BLOCK_RECTS.map((r, i) => (
<ScrollBlockRect key={i} scrollYProgress={scrollYProgress} rect={r} />
))}
</svg>
</div>
<div className='px-5 pt-[60px] lg:px-20 lg:pt-[100px]'>
<div className='px-5 lg:px-16'>
<div className='flex flex-col items-start gap-5'>
<Badge
variant='blue'
@@ -480,24 +356,10 @@ export default function Templates() {
</div>
<div className='mt-10 flex border-[var(--landing-bg-elevated)] border-y lg:mt-[73px]'>
<div className='shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid
className='h-full w-[24px] overflow-hidden border-[var(--landing-bg-elevated)] border-r p-1'
cols={2}
rows={55}
gap={4}
/>
</div>
<div className='hidden h-full lg:block'>
<DotGrid
className='h-full w-[80px] overflow-hidden border-[var(--landing-bg-elevated)] border-r p-1.5'
cols={8}
rows={55}
gap={6}
/>
</div>
</div>
<div
aria-hidden='true'
className='w-[24px] shrink-0 border-[var(--landing-bg-elevated)] border-r lg:w-16'
/>
<div className='flex min-w-0 flex-1 flex-col lg:flex-row'>
<div
@@ -575,7 +437,7 @@ export default function Templates() {
<LandingPreviewWorkflow
workflow={workflow}
animate
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
fitViewOptions={{ padding: 0.15, minZoom: 0.1, maxZoom: 0.8 }}
/>
</div>
<div className='p-3'>
@@ -617,46 +479,55 @@ export default function Templates() {
className='group/cta absolute top-4 right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-1.5 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
<svg
className='h-[10px] w-[10px] shrink-0'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<line
x1='0'
y1='5'
x2='9'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/cta:scale-x-100'
/>
<path
d='M3.5 2L6.5 5L3.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
className='transition-transform duration-200 ease-out group-hover/cta:translate-x-[30%]'
/>
</svg>
</button>
</div>
</div>
<div className='shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid
className='h-full w-[24px] overflow-hidden border-[var(--landing-bg-elevated)] border-l p-1'
cols={2}
rows={55}
gap={4}
/>
</div>
<div className='hidden h-full lg:block'>
<DotGrid
className='h-full w-[80px] overflow-hidden border-[var(--landing-bg-elevated)] border-l p-1.5'
cols={8}
rows={55}
gap={6}
/>
</div>
</div>
<div
aria-hidden='true'
className='w-[24px] shrink-0 border-[var(--landing-bg-elevated)] border-l lg:w-16'
/>
</div>
<div className='relative pb-[60px] lg:pb-[100px]'>
<div
aria-hidden='true'
className='absolute top-0 bottom-0 left-[calc(4rem-1px)] hidden w-px bg-[var(--landing-bg-elevated)] lg:block'
/>
<div
aria-hidden='true'
className='absolute top-0 right-[calc(4rem-1px)] bottom-0 hidden w-px bg-[var(--landing-bg-elevated)] lg:block'
/>
<div
aria-hidden='true'
className='absolute right-16 bottom-0 left-16 hidden h-px bg-[var(--landing-bg-elevated)] lg:block'
/>
</div>
</div>
</div>

View File

@@ -27,7 +27,9 @@ import {
CirclebackIcon,
ClayIcon,
ClerkIcon,
CloudFormationIcon,
CloudflareIcon,
CloudWatchIcon,
ConfluenceIcon,
CursorIcon,
DatabricksIcon,
@@ -211,6 +213,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
clay: ClayIcon,
clerk: ClerkIcon,
cloudflare: CloudflareIcon,
cloudformation: CloudFormationIcon,
cloudwatch: CloudWatchIcon,
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
databricks: DatabricksIcon,

View File

@@ -1912,6 +1912,100 @@
"integrationType": "developer-tools",
"tags": ["cloud", "monitoring"]
},
{
"type": "cloudformation",
"slug": "cloudformation",
"name": "CloudFormation",
"description": "Manage and inspect AWS CloudFormation stacks, resources, and drift",
"longDescription": "Integrate AWS CloudFormation into workflows. Describe stacks, list resources, detect drift, view stack events, retrieve templates, and validate templates. Requires AWS access key and secret access key.",
"bgColor": "linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)",
"iconName": "CloudFormationIcon",
"docsUrl": "https://docs.sim.ai/tools/cloudformation",
"operations": [
{
"name": "Describe Stacks",
"description": "List and describe CloudFormation stacks"
},
{
"name": "List Stack Resources",
"description": "List all resources in a CloudFormation stack"
},
{
"name": "Describe Stack Events",
"description": "Get the event history for a CloudFormation stack"
},
{
"name": "Detect Stack Drift",
"description": "Initiate drift detection on a CloudFormation stack"
},
{
"name": "Drift Detection Status",
"description": "Check the status of a stack drift detection operation"
},
{
"name": "Get Template",
"description": "Retrieve the template body for a CloudFormation stack"
},
{
"name": "Validate Template",
"description": "Validate a CloudFormation template for syntax and structural correctness"
}
],
"operationCount": 7,
"triggers": [],
"triggerCount": 0,
"authType": "none",
"category": "tools",
"integrationType": "developer-tools",
"tags": ["cloud"]
},
{
"type": "cloudwatch",
"slug": "cloudwatch",
"name": "CloudWatch",
"description": "Query and monitor AWS CloudWatch logs, metrics, and alarms",
"longDescription": "Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.",
"bgColor": "linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)",
"iconName": "CloudWatchIcon",
"docsUrl": "https://docs.sim.ai/tools/cloudwatch",
"operations": [
{
"name": "Query Logs (Insights)",
"description": "Run a CloudWatch Log Insights query against one or more log groups"
},
{
"name": "Describe Log Groups",
"description": "List available CloudWatch log groups"
},
{
"name": "Get Log Events",
"description": "Retrieve log events from a specific CloudWatch log stream"
},
{
"name": "Describe Log Streams",
"description": "List log streams within a CloudWatch log group"
},
{
"name": "List Metrics",
"description": "List available CloudWatch metrics"
},
{
"name": "Get Metric Statistics",
"description": "Get statistics for a CloudWatch metric over a time range"
},
{
"name": "Describe Alarms",
"description": "List and filter CloudWatch alarms"
}
],
"operationCount": 7,
"triggers": [],
"triggerCount": 0,
"authType": "none",
"category": "tools",
"integrationType": "analytics",
"tags": ["cloud", "monitoring"]
},
{
"type": "confluence_v2",
"slug": "confluence",
@@ -2233,9 +2327,17 @@
{
"name": "Delete Agent",
"description": "Permanently delete a cloud agent. This action cannot be undone."
},
{
"name": "List Artifacts",
"description": "List generated artifact files for a cloud agent."
},
{
"name": "Download Artifact",
"description": "Download a generated artifact file from a cloud agent."
}
],
"operationCount": 7,
"operationCount": 9,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",

View File

@@ -1,7 +1,7 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()

View File

@@ -0,0 +1,15 @@
'use client'
import { useEffect } from 'react'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
export function LandingAnalytics() {
const posthog = usePostHog()
useEffect(() => {
captureEvent(posthog, 'landing_page_viewed', {})
}, [posthog])
return null
}

View File

@@ -3,7 +3,7 @@ import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import {
Collaboration,
Enterprise,
// Enterprise,
Features,
Footer,
Hero,
@@ -12,7 +12,8 @@ import {
StructuredData,
Templates,
Testimonials,
} from '@/app/(home)/components'
} from '@/app/(landing)/components'
import { LandingAnalytics } from '@/app/(landing)/landing-analytics'
/**
* Landing page root component.
@@ -45,18 +46,26 @@ export default async function Landing() {
>
Skip to main content
</a>
<LandingAnalytics />
<StructuredData />
<header>
<Navbar blogPosts={blogPosts} />
</header>
<main id='main-content'>
<Hero />
<Templates />
<Features />
<Collaboration />
<Enterprise />
<Pricing />
<Testimonials />
<article itemScope itemType='https://schema.org/WebPage'>
<meta itemProp='name' content='Sim — Build AI Agents & Run Your Agentic Workforce' />
<meta
itemProp='description'
content='Sim is the open-source platform to build AI agents and run your agentic workforce.'
/>
<Hero />
<Templates />
<Features />
<Collaboration />
{/* <Enterprise /> */}
<Pricing />
<Testimonials />
</article>
</main>
<Footer />
</div>

View File

@@ -1,4 +1,6 @@
import type { Metadata } from 'next'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
export const metadata: Metadata = {
metadataBase: new URL('https://sim.ai'),
@@ -13,6 +15,15 @@ export const metadata: Metadata = {
},
}
/**
* Landing page route-group layout.
*
* Applies landing-specific font CSS variables to the subtree:
* - `--font-season` (Season Sans): Headings and display text
* - `--font-martian-mono` (Martian Mono): Code snippets and technical accents
*
* Available to child components via Tailwind (`font-season`, `font-martian-mono`).
*/
export default function LandingLayout({ children }: { children: React.ReactNode }) {
return children
return <div className={`${season.variable} ${martianMono.variable}`}>{children}</div>
}

View File

@@ -18,6 +18,7 @@ import {
formatPrice,
formatTokenCount,
formatUpdatedAt,
getEffectiveMaxOutputTokens,
getModelBySlug,
getPricingBounds,
getProviderBySlug,
@@ -198,7 +199,8 @@ export default async function ModelPage({
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{model.summary} {model.bestFor}
{model.summary}
{model.bestFor ? ` ${model.bestFor}` : ''}
</p>
<div className='mt-8 flex flex-wrap gap-3'>
@@ -229,13 +231,11 @@ export default async function ModelPage({
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
compact
/>
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
<StatCard
label='Context window'
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
compact
/>
</section>
@@ -280,12 +280,12 @@ export default async function ModelPage({
label='Max output'
value={
model.capabilities.maxOutputTokens
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
: 'Standard defaults'
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
: 'Not published'
}
/>
<DetailItem label='Provider' value={provider.name} />
<DetailItem label='Best for' value={model.bestFor} />
{model.bestFor ? <DetailItem label='Best for' value={model.bestFor} /> : null}
</div>
</section>

View File

@@ -1,7 +1,7 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
export default async function ModelsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils'
describe('model catalog capability facts', () => {
it.concurrent(
'shows structured outputs support and published max output tokens for gpt-4o',
() => {
const model = getModelBySlug('openai', 'gpt-4o')
expect(model).not.toBeNull()
expect(model).toBeDefined()
const capabilityFacts = buildModelCapabilityFacts(model!)
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
const maxOutputTokens = capabilityFacts.find((fact) => fact.label === 'Max output tokens')
expect(getEffectiveMaxOutputTokens(model!.capabilities)).toBe(16384)
expect(structuredOutputs?.value).toBe('Supported')
expect(maxOutputTokens?.value).toBe('16k')
}
)
it.concurrent('preserves native structured outputs labeling for claude models', () => {
const model = getModelBySlug('anthropic', 'claude-sonnet-4-6')
expect(model).not.toBeNull()
expect(model).toBeDefined()
const capabilityFacts = buildModelCapabilityFacts(model!)
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
expect(structuredOutputs?.value).toBe('Supported (native)')
})
it.concurrent('does not invent a max output token limit when one is not published', () => {
expect(getEffectiveMaxOutputTokens({})).toBeNull()
})
it.concurrent('keeps best-for copy for clearly differentiated models only', () => {
const researchModel = getModelBySlug('google', 'deep-research-pro-preview-12-2025')
const generalModel = getModelBySlug('xai', 'grok-4-latest')
expect(researchModel).not.toBeNull()
expect(generalModel).not.toBeNull()
expect(researchModel?.bestFor).toContain('research workflows')
expect(generalModel?.bestFor).toBeUndefined()
})
})

View File

@@ -112,7 +112,7 @@ export interface CatalogModel {
capabilities: ModelCapabilities
capabilityTags: string[]
summary: string
bestFor: string
bestFor?: string
searchText: string
}
@@ -190,6 +190,14 @@ export function formatCapabilityBoolean(
return value ? positive : negative
}
function supportsCatalogStructuredOutputs(capabilities: ModelCapabilities): boolean {
return !capabilities.deepResearch
}
export function getEffectiveMaxOutputTokens(capabilities: ModelCapabilities): number | null {
return capabilities.maxOutputTokens ?? null
}
function trimTrailingZeros(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
@@ -326,7 +334,7 @@ function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
tags.push('Tool choice')
}
if (capabilities.nativeStructuredOutputs) {
if (supportsCatalogStructuredOutputs(capabilities)) {
tags.push('Structured outputs')
}
@@ -365,7 +373,7 @@ function buildBestForLine(model: {
pricing: PricingInfo
capabilities: ModelCapabilities
contextWindow: number | null
}): string {
}): string | null {
const { pricing, capabilities, contextWindow } = model
if (capabilities.deepResearch) {
@@ -376,10 +384,6 @@ function buildBestForLine(model: {
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
}
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
if (contextWindow && contextWindow >= 1000000) {
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
}
@@ -388,7 +392,11 @@ function buildBestForLine(model: {
return 'Best for production workflows that need reliable typed outputs.'
}
return 'Best for general-purpose AI workflows inside Sim.'
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
return null
}
function buildModelSummary(
@@ -437,6 +445,11 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
const shortId = stripProviderPrefix(provider.id, model.id)
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
const capabilityTags = buildCapabilityTags(mergedCapabilities)
const bestFor = buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
})
const displayName = formatModelDisplayName(provider.id, model.id)
const modelSlug = slugify(shortId)
const href = `/models/${providerSlug}/${modelSlug}`
@@ -461,11 +474,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
model.contextWindow ?? null,
capabilityTags
),
bestFor: buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
}),
...(bestFor ? { bestFor } : {}),
searchText: [
provider.name,
providerDisplayName,
@@ -683,6 +692,7 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel):
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
const { capabilities } = model
const supportsStructuredOutputs = supportsCatalogStructuredOutputs(capabilities)
return [
{
@@ -711,7 +721,11 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
},
{
label: 'Structured outputs',
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
value: supportsStructuredOutputs
? capabilities.nativeStructuredOutputs
? 'Supported (native)'
: 'Supported'
: 'Not supported',
},
{
label: 'Tool choice',
@@ -732,8 +746,8 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
{
label: 'Max output tokens',
value: capabilities.maxOutputTokens
? formatTokenCount(capabilities.maxOutputTokens)
: 'Standard defaults',
? formatTokenCount(getEffectiveMaxOutputTokens(capabilities))
: 'Not published',
},
]
}
@@ -752,8 +766,8 @@ export function getProviderCapabilitySummary(provider: CatalogProvider): Capabil
const reasoningCount = provider.models.filter(
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
).length
const structuredCount = provider.models.filter(
(model) => model.capabilities.nativeStructuredOutputs
const structuredCount = provider.models.filter((model) =>
supportsCatalogStructuredOutputs(model.capabilities)
).length
const deepResearchCount = provider.models.filter(
(model) => model.capabilities.deepResearch

View File

@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
import { getNavBlogPosts } from '@/lib/blog/registry'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
export const metadata: Metadata = {
title: 'Partner Program',

View File

@@ -10,7 +10,7 @@
* @see stores/constants.ts for the source of truth
*/
:root {
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */

View File

@@ -1,7 +1,7 @@
import type React from 'react'
import { getNavBlogPosts } from '@/lib/blog/registry'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()

View File

@@ -7,6 +7,7 @@ import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-c
import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { getRedisClient } from '@/lib/core/config/redis'
import { captureServerEvent } from '@/lib/posthog/server'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -180,6 +181,17 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
logger.info(`Deleted A2A agent: ${agentId}`)
captureServerEvent(
auth.userId,
'a2a_agent_deleted',
{
agent_id: agentId,
workflow_id: existingAgent.workflowId,
workspace_id: existingAgent.workspaceId,
},
{ groups: { workspace: existingAgent.workspaceId } }
)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting agent:', error)
@@ -251,6 +263,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
}
logger.info(`Published A2A agent: ${agentId}`)
captureServerEvent(
auth.userId,
'a2a_agent_published',
{
agent_id: agentId,
workflow_id: existingAgent.workflowId,
workspace_id: existingAgent.workspaceId,
},
{ groups: { workspace: existingAgent.workspaceId } }
)
return NextResponse.json({ success: true, isPublished: true })
}
@@ -273,6 +295,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
}
logger.info(`Unpublished A2A agent: ${agentId}`)
captureServerEvent(
auth.userId,
'a2a_agent_unpublished',
{
agent_id: agentId,
workflow_id: existingAgent.workflowId,
workspace_id: existingAgent.workspaceId,
},
{ groups: { workspace: existingAgent.workspaceId } }
)
return NextResponse.json({ success: true, isPublished: false })
}

View File

@@ -9,11 +9,12 @@ import { a2aAgent, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
import { sanitizeAgentName } from '@/lib/a2a/utils'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateId } from '@/lib/core/utils/uuid'
import { captureServerEvent } from '@/lib/posthog/server'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -172,7 +173,7 @@ export async function POST(request: NextRequest) {
skillTags
)
const agentId = uuidv4()
const agentId = generateId()
const agentName = name || sanitizeAgentName(wf.name)
const [agent] = await db
@@ -201,6 +202,16 @@ export async function POST(request: NextRequest) {
logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
captureServerEvent(
auth.userId,
'a2a_agent_created',
{ agent_id: agentId, workflow_id: workflowId, workspace_id: workspaceId },
{
groups: { workspace: workspaceId },
setOnce: { first_a2a_agent_created_at: new Date().toISOString() },
}
)
return NextResponse.json({ success: true, agent }, { status: 201 })
} catch (error) {
logger.error('Error creating agent:', error)

View File

@@ -4,7 +4,6 @@ import { a2aAgent, a2aPushNotificationConfig, a2aTask, workflow } from '@sim/db/
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { A2A_DEFAULT_TIMEOUT, A2A_MAX_HISTORY_LENGTH } from '@/lib/a2a/constants'
import { notifyTaskStateChange } from '@/lib/a2a/push-notifications'
import {
@@ -18,6 +17,7 @@ import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redi
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
@@ -400,11 +400,11 @@ async function handleMessageSend(
const message = params.message
const taskId = message.taskId || generateTaskId()
const contextId = message.contextId || uuidv4()
const contextId = message.contextId || generateId()
// Distributed lock to prevent concurrent task processing
const lockKey = `a2a:task:${taskId}:lock`
const lockValue = uuidv4()
const lockValue = generateId()
const acquired = await acquireLock(lockKey, lockValue, 60)
if (!acquired) {
@@ -628,12 +628,12 @@ async function handleMessageStream(
}
const message = params.message
const contextId = message.contextId || uuidv4()
const contextId = message.contextId || generateId()
const taskId = message.taskId || generateTaskId()
// Distributed lock to prevent concurrent task processing
const lockKey = `a2a:task:${taskId}:lock`
const lockValue = uuidv4()
const lockValue = generateId()
const acquired = await acquireLock(lockKey, lockValue, 300)
if (!acquired) {
@@ -1427,7 +1427,7 @@ async function handlePushNotificationSet(
.where(eq(a2aPushNotificationConfig.id, existingConfig.id))
} else {
await db.insert(a2aPushNotificationConfig).values({
id: uuidv4(),
id: generateId(),
taskId: params.id,
url: config.url,
token: config.token || null,

View File

@@ -1,7 +1,7 @@
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
import { v4 as uuidv4 } from 'uuid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
/** A2A v0.3 JSON-RPC method names */
export const A2A_METHODS = {
@@ -85,7 +85,7 @@ export function isJSONRPCRequest(obj: unknown): obj is JSONRPCRequest {
}
export function generateTaskId(): string {
return uuidv4()
return generateId()
}
export function createTaskStatus(state: TaskState): { state: TaskState; timestamp: string } {

View File

@@ -2,7 +2,6 @@ import { db } from '@sim/db'
import { academyCertificate, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getCourseById } from '@/lib/academy/content'
@@ -10,6 +9,7 @@ import type { CertificateMetadata } from '@/lib/academy/types'
import { getSession } from '@/lib/auth'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateShortId } from '@/lib/core/utils/uuid'
const logger = createLogger('AcademyCertificatesAPI')
@@ -106,7 +106,7 @@ export async function POST(req: NextRequest) {
const [certificate] = await db
.insert(academyCertificate)
.values({
id: nanoid(),
id: generateShortId(),
userId: session.user.id,
courseId,
status: 'active',
@@ -211,5 +211,5 @@ export async function GET(req: NextRequest) {
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
function generateCertificateNumber(): string {
const year = new Date().getFullYear()
return `SIM-${year}-${nanoid(8).toUpperCase()}`
return `SIM-${year}-${generateShortId(8).toUpperCase()}`
}

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
const logger = createLogger('ShopifyAuthorize')
@@ -161,7 +162,7 @@ export async function GET(request: NextRequest) {
const baseUrl = getBaseUrl()
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify`
const state = crypto.randomUUID()
const state = generateId()
const oauthUrl =
`https://${cleanShop}/admin/oauth/authorize?` +

View File

@@ -17,6 +17,7 @@ import {
hasUsableSubscriptionStatus,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('SwitchPlan')
@@ -173,6 +174,13 @@ export async function POST(request: NextRequest) {
interval: targetInterval,
})
captureServerEvent(
userId,
'subscription_changed',
{ from_plan: sub.plan ?? 'unknown', to_plan: targetPlanName, interval: targetInterval },
{ set: { plan: targetPlanName } }
)
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
} catch (error) {
logger.error('Failed to switch subscription', {

View File

@@ -1,4 +1,4 @@
import { randomInt, randomUUID } from 'crypto'
import { randomInt } from 'crypto'
import { db } from '@sim/db'
import { chat, verification } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -10,6 +10,7 @@ import { getRedisClient } from '@/lib/core/config/redis'
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
import { getStorageMethod } from '@/lib/core/storage'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateId } from '@/lib/core/utils/uuid'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { setChatAuthCookie } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -61,7 +62,7 @@ async function storeOTP(email: string, chatId: string, otp: string): Promise<voi
await db.transaction(async (tx) => {
await tx.delete(verification).where(eq(verification.identifier, identifier))
await tx.insert(verification).values({
id: randomUUID(),
id: generateId(),
identifier,
value,
expiresAt,

View File

@@ -1,4 +1,3 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -7,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateId } from '@/lib/core/utils/uuid'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ChatFiles } from '@/lib/uploads'
@@ -103,7 +103,7 @@ export async function POST(
)
}
const executionId = randomUUID()
const executionId = generateId()
const loggingSession = new LoggingSession(
deployment.workflowId,
executionId,
@@ -150,7 +150,7 @@ export async function POST(
return addCorsHeaders(createErrorResponse('No input provided', 400), request)
}
const executionId = randomUUID()
const executionId = generateId()
const loggingSession = new LoggingSession(deployment.workflowId, executionId, 'chat', requestId)

View File

@@ -27,6 +27,8 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { generateId } from '@/lib/core/utils/uuid'
import { captureServerEvent } from '@/lib/posthog/server'
import {
authorizeWorkflowByWorkspacePermission,
resolveWorkflowIdForUser,
@@ -188,7 +190,23 @@ export async function POST(req: NextRequest) {
.warn('Failed to resolve workspaceId from workflow')
}
const userMessageIdToUse = userMessageId || crypto.randomUUID()
captureServerEvent(
authenticatedUserId,
'copilot_chat_sent',
{
workflow_id: workflowId,
workspace_id: resolvedWorkspaceId ?? '',
has_file_attachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
has_contexts: Array.isArray(contexts) && contexts.length > 0,
mode,
},
{
groups: resolvedWorkspaceId ? { workspace: resolvedWorkspaceId } : undefined,
setOnce: { first_copilot_use_at: new Date().toISOString() },
}
)
const userMessageIdToUse = userMessageId || generateId()
const reqLogger = logger.withMetadata({
requestId: tracker.requestId,
messageId: userMessageIdToUse,
@@ -389,8 +407,8 @@ export async function POST(req: NextRequest) {
}
if (stream) {
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
const executionId = generateId()
const runId = generateId()
const sseStream = createSSEStream({
requestPayload,
userId: authenticatedUserId,
@@ -420,7 +438,7 @@ export async function POST(req: NextRequest) {
if (!result.success) return
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),
id: generateId(),
role: 'assistant' as const,
content: result.content,
timestamp: new Date().toISOString(),
@@ -498,8 +516,8 @@ export async function POST(req: NextRequest) {
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
}
const nsExecutionId = crypto.randomUUID()
const nsRunId = crypto.randomUUID()
const nsExecutionId = generateId()
const nsRunId = generateId()
if (actualChatId) {
await createRunSegment({
@@ -559,7 +577,7 @@ export async function POST(req: NextRequest) {
}
const assistantMessage = {
id: crypto.randomUUID(),
id: generateId(),
role: 'assistant',
content: responseData.content,
timestamp: new Date().toISOString(),

View File

@@ -304,7 +304,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
},
}
@@ -349,7 +348,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
lastSaved: 1640995200000,
},
},
@@ -370,7 +368,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
lastSaved: 1640995200000,
}),
}
@@ -473,7 +470,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
edges: undefined,
loops: null,
parallels: undefined,
deploymentStatuses: null,
},
}
@@ -508,7 +504,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: false,
deploymentStatuses: {},
lastSaved: 1640995200000,
})
})
@@ -768,10 +763,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deploymentStatuses: {
production: 'deployed',
staging: 'pending',
},
deployedAt: '2024-01-01T10:00:00.000Z',
},
}
@@ -816,10 +807,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deploymentStatuses: {
production: 'deployed',
staging: 'pending',
},
deployedAt: '2024-01-01T10:00:00.000Z',
lastSaved: 1640995200000,
})

View File

@@ -82,7 +82,6 @@ export async function POST(request: NextRequest) {
loops: checkpointState?.loops || {},
parallels: checkpointState?.parallels || {},
isDeployed: checkpointState?.isDeployed || false,
deploymentStatuses: checkpointState?.deploymentStatuses || {},
lastSaved: Date.now(),
...(checkpointState?.deployedAt &&
checkpointState.deployedAt !== null &&

View File

@@ -11,6 +11,7 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CopilotFeedbackAPI')
@@ -76,6 +77,12 @@ export async function POST(req: NextRequest) {
duration: tracker.getDuration(),
})
captureServerEvent(authenticatedUserId, 'copilot_feedback_submitted', {
is_positive: isPositiveFeedback,
has_text_feedback: !!feedback,
has_workflow_yaml: !!workflowYaml,
})
return NextResponse.json({
success: true,
feedbackId: feedbackRecord.feedbackId,

View File

@@ -3,10 +3,10 @@ import { member, templateCreators } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateId } from '@/lib/core/utils/uuid'
import type { CreatorProfileDetails } from '@/app/_types/creator-profile'
const logger = createLogger('CreatorProfilesAPI')
@@ -147,7 +147,7 @@ export async function POST(request: NextRequest) {
}
// Create the profile
const profileId = uuidv4()
const profileId = generateId()
const now = new Date()
const details: CreatorProfileDetails = {}

View File

@@ -9,6 +9,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('CredentialSetInvite')
@@ -105,12 +106,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const body = await req.json()
const { email } = createInviteSchema.parse(body)
const token = crypto.randomUUID()
const token = generateId()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
const invitation = {
id: crypto.randomUUID(),
id: generateId(),
credentialSetId: id,
email: email || null,
token,

View File

@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { generateId } from '@/lib/core/utils/uuid'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetMembers')
@@ -167,7 +168,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
const requestId = crypto.randomUUID().slice(0, 8)
const requestId = generateId().slice(0, 8)
// Use transaction to ensure member deletion + webhook sync are atomic
await db.transaction(async (tx) => {

View File

@@ -10,6 +10,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateId } from '@/lib/core/utils/uuid'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetInviteToken')
@@ -125,11 +126,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
}
const now = new Date()
const requestId = crypto.randomUUID().slice(0, 8)
const requestId = generateId().slice(0, 8)
await db.transaction(async (tx) => {
await tx.insert(credentialSetMember).values({
id: crypto.randomUUID(),
id: generateId(),
credentialSetId: invitation.credentialSetId,
userId: session.user.id,
status: 'active',

Some files were not shown because too many files have changed in this diff Show More