Compare commits

..

73 Commits

Author SHA1 Message Date
Waleed
30f2d1a0fc v0.6.3: hubspot integration, kb block improvements 2026-03-18 11:19:55 -07:00
Theodore Li
5332614a19 fix(mothership): mothership-ran workflows show workflow validation errors (#3634)
* fix(mothership): mothership-ran workflows show workflow validation errors

* Distinguish errors from 5xxs

* Unify workflow event handling

* Fix run from block

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-18 13:55:59 -04:00
Waleed
ff5d90e0c0 fix(knowledge): infer MIME type from file extension in create/upsert tools (#3651)
* fix(knowledge): infer MIME type from file extension in create/upsert tools

Both create_document and upsert_document forced .txt extension and
text/plain MIME type regardless of the document name. Now the tools
infer the correct MIME type from the file extension (html, md, csv,
json, yaml, xml) and only default to .txt when no extension is given.

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

* refactor(knowledge): reuse existing getMimeTypeFromExtension from uploads

Replace duplicate EXTENSION_MIME_MAP and getMimeTypeFromExtension with
the existing, more comprehensive version from lib/uploads/utils/file-utils.

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

* fix(knowledge): fix btoa stack overflow and duplicate encoding in create_document

Same fixes as upsert_document: use loop-based String.fromCharCode
instead of spread, consolidate duplicate TextEncoder calls, and
check byte length instead of character length for 1MB limit.

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

* fix(knowledge): allowlist text-compatible MIME types in inferDocumentFileInfo

Use an explicit allowlist instead of only checking for octet-stream,
preventing binary MIME types (image/jpeg, audio/mpeg, etc.) from
leaking through when a user names a document with a binary extension.

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

* fix(knowledge): remove pdf/rtf from allowlist, normalize unrecognized extensions

- Remove application/pdf and application/rtf from TEXT_COMPATIBLE_MIME_TYPES
  since these tools pass plain text content, not binary
- Normalize unrecognized extensions (e.g. report.v2) to .txt instead of
  preserving the original extension with text/plain MIME type

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

* fix(knowledge): handle dotfile names to avoid empty base in filename

Dotfiles like .env would produce an empty base, resulting in '.txt'.
Now falls back to the original name so .env becomes .env.txt.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:49:18 -07:00
Waleed
8b245693e2 fix(hubspot): add missing tickets and oauth scopes to OAuth config (#3653) 2026-03-18 10:34:49 -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
60bb9422ca feat(blog): add v0.6 blog post and email broadcast (#3636)
* chore(blog): add v0.6 blog post and email broadcast scaffolding

* mothership blog

* turned on mothership blog

* small change

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-03-18 03:25:59 -07:00
Waleed
8a4c161ec4 feat(home): resizable chat/resource panel divider (#3648)
* feat(home): resizable chat/resource panel divider

* fix(home): address PR review comments

- Remove aria-hidden from resize handle outer div so separator role is visible to AT
- Add viewport-resize re-clamping in useMothershipResize to prevent panel exceeding max % after browser window narrows
- Change default MothershipView width from 60% to 50%

* refactor(home): eradicate useEffect anti-patterns per you-might-not-need-an-effect

- use-chat: remove messageQueue→ref sync Effect; inline assignment like other refs
- use-chat: replace activeResourceId selection Effect with useMemo (derived value, avoids
  extra re-render cycle; activeResourceIdRef now tracks effective value for API payloads)
- use-chat: replace 3x useLayoutEffect ref-sync (processSSEStream, finalize, sendMessage)
  with direct render-phase assignment — consistent with existing resourcesRef pattern
- user-input: fold onEditValueConsumed callback into existing render-phase guard; remove Effect
- home: move isResourceAnimatingIn 400ms timer into expandResource/handleResourceEvent event
  handlers where setIsResourceAnimatingIn(true) is called; remove reactive Effect watcher

* fix(home): revert default width to 60%, reduce max resize to 63%

* improvement(react): replace useEffect anti-patterns with better React primitives

* improvement(react): replace useEffect anti-patterns with better React primitives

* improvement(home): use pointer events for resize handle (touch + mouse support)

* fix(autosave): store idle-reset timer ref to prevent status corruption on rapid saves

* fix(home): move onEditValueConsumed call out of render phase into useEffect

* fix(home): add pointercancel handler; fix(settings): sync name on profile refetch

* fix(home): restore cleanupRef assignment dropped during AbortController refactor
2026-03-18 02:57:44 -07:00
Waleed
b84f30e9e7 fix(db): reduce connection pool sizes to prevent exhaustion (#3649) 2026-03-18 02:20:55 -07:00
Waleed
28de28899a improvement(landing): added enterprise section (#3637)
* improvement(landing): added enterprise section

* make components interactive

* added more things to pricing sheet

* remove debug log

* fix(landing): remove dead DotGrid component and fix enterprise CTA to use Link

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:38:16 -07:00
Siddharth Ganesan
168cd585cb feat(mothership): request ids (#3645)
* Include rid

* Persist rid

* fix ui

* address comments

* update types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-03-17 18:24:27 -07:00
Waleed
5f89c7140c feat(knowledge): add upsert document operation (#3644)
* feat(knowledge): add upsert document operation to Knowledge block

Add a "Create or Update" (upsert) document capability that finds an
existing document by ID or filename, deletes it if found, then creates
a new document and queues re-processing. Includes new tool, API route,
block wiring, and typed interfaces.

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

* fix(knowledge): address review comments on upsert document

- Reorder create-then-delete to prevent data loss if creation fails
- Move Zod validation before workflow authorization for validated input
- Fix btoa stack overflow for large content using loop-based encoding

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

* fix(knowledge): guard against empty createDocumentRecords result

Add safety check before accessing firstDocument to prevent TypeError
and data loss if createDocumentRecords unexpectedly returns empty.

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

* fix(knowledge): prevent documentId fallthrough and use byte-count limit

- Use if/else so filename lookup only runs when no documentId is provided,
  preventing stale IDs from silently replacing unrelated documents
- Check utf8 byte length instead of character count for 1MB size limit,
  correctly handling multi-byte characters (CJK, emoji)

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

* fix(knowledge): rollback on delete failure, deduplicate sub-block IDs

- Add compensating rollback: if deleteDocument throws after create
  succeeds, clean up the new record to prevent orphaned pending docs
- Merge duplicate name/content sub-blocks into single entries with
  array conditions, matching the documentTags pattern

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

* lint

* lint

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:14:29 -07:00
Waleed
2bc11a70ba waleedlatif1/hangzhou v2 (#3647)
* feat(admin): add user search by email and ID, remove table border

- Replace Load Users button with a live search input; query fires on any input
- Email search uses listUsers with contains operator
- User ID search (UUID format) uses admin.getUser directly for exact lookup
- Remove outer border on user table that rendered white in dark mode
- Reset pagination to page 0 on new search

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

* fix(admin): replace live search with explicit search button

- Split searchInput (controlled input) from searchQuery (committed value)
  so the hook only fires on Search click or Enter, not every keystroke
- Gate table render on searchQuery.length > 0 to prevent stale results
  showing after input is cleared

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 18:00:55 -07:00
PlaneInABottle
67478bbc80 fix(logs): add durable execution diagnostics foundation (#3564)
* fix(logs): persist execution diagnostics markers

Store last-started and last-completed block markers with finalization metadata so later read surfaces can explain how a run ended without reconstructing executor state.

* fix(executor): preserve durable diagnostics ordering

Await only the persistence needed to keep diagnostics durable before terminal completion while keeping callback failures from changing execution behavior.

* fix(logs): preserve fallback diagnostics semantics

Keep successful fallback output and accumulated cost intact while tightening progress-write draining and deduplicating trace span counting for diagnostics helpers.

* fix(api): restore async execute route test mock

Add the missing AuthType export to the hybrid auth mock so the async execution route test exercises the 202 queueing path instead of crashing with a 500 in CI.

* fix(executor): align async block error handling

* fix(logs): tighten marker ordering scope

Allow same-millisecond marker writes to replace prior markers and drop the unused diagnostics read helper so this PR stays focused on persistence rather than unread foundation code.

* fix(logs): remove unused finalization type guard

Drop the unused  helper so this PR only ships the persistence-side status types it actually uses.

* fix(executor): await subflow diagnostics callbacks

Ensure empty-subflow and subflow-error lifecycle callbacks participate in progress-write draining before terminal finalization while still swallowing callback failures.

---------

Co-authored-by: test <test@example.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-03-17 17:24:40 -07:00
Waleed
c9f082da1a feat(csp): allow chat UI to be embedded in iframes (#3643)
* feat(csp): allow chat UI to be embedded in iframes

Mirror the existing form embed CSP pattern for chat pages: add
getChatEmbedCSPPolicy() with frame-ancestors *, configure /chat/:path*
headers in next.config.ts without X-Frame-Options, and early-return in
proxy.ts so chat routes skip the strict runtime CSP.

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

* refactor(csp): extract shared getEmbedCSPPolicy helper

Deduplicate getChatEmbedCSPPolicy and getFormEmbedCSPPolicy into a
shared private helper to prevent future divergence.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:12:34 -07:00
Waleed
75a3e2c3a8 fix(workspace): prevent stale placeholder data from corrupting workflow registry on switch 2026-03-17 16:20:06 -07:00
Siddharth Ganesan
cdd0f75cd5 fix(mothership): fix mothership file uploads (#3640)
* Fix files

* Fix

* Fix
2026-03-17 16:19:47 -07:00
Waleed
4f3bc37fe4 v0.6.1: added better auth admin plugin 2026-03-17 15:16:16 -07:00
Waleed
25a03f1f3c feat(auth): migrate to better-auth admin plugin with unified Admin tab (#3612)
* feat(auth): migrate to better-auth admin plugin

* feat(settings): add unified Admin tab with user management

Consolidate superuser features into a single Admin settings tab:
- Super admin mode toggle (moved from General)
- Workflow import (moved from Debug)
- User management via better-auth admin (list, set role, ban/unban)

Replace Debug tab with Admin tab gated by requiresAdminRole.
Add React Query hooks for admin user operations.

* fix(db): backfill existing super users to admin role in migration

Add UPDATE statement to promote is_super_user=true rows to role='admin'
before dropping the is_super_user column, preventing silent demotion.

* fix(admin): resolve type errors in admin tab

- Fix cn import path to @/lib/core/utils/cn
- Use valid Badge variants (blue/gray/red/green instead of secondary/destructive)
- Type setRole param as 'user' | 'admin' union

* improvement(auth): remove /api/user/super-user route, use session role

Include user.role in customSession so it's available client-side.
Replace all useSuperUserStatus() calls with session.user.role === 'admin'.
Delete the now-redundant /api/user/super-user endpoint.

* chore(auth): remove redundant role override in customSession

The admin plugin already includes role on the user object.
No need to manually spread it in customSession.

* improvement(queries): clean up admin-users hooks per React Query best practices

- Remove unsafe unknown/Record casting, use better-auth typed response
- Add placeholderData: keepPreviousData for paginated variable-key query
- Remove nullable types where defaults are always applied

* fix(admin): address review feedback on admin tab

- Fix superUserModeEnabled default to false (matches sidebar behavior)
- Reset banReason when switching ban target to prevent state bleed
- Guard admin section render with session role check for direct URL access

* fix(settings): align superUserModeEnabled default to false everywhere

Three places defaulted to true while admin tab and sidebar used false.
Align all to false so new admins see consistent behavior.

* fix(admin): fix stale pendingUserId, add isPending guard and error feedback

- Only read mutation.variables when mutation isPending (prevents stale ID)
- Add isPending guard to super user mode toggle (prevents concurrent mutations)
- Show inline error message when setRole/ban/unban mutations fail

* fix(admin): concurrent pending users Set, session loading guard, domain blocking

- Replace pendingUserId scalar with pendingUserIds Set (useMemo) so concurrent
  mutations across different users each disable their own row correctly
- Add sessionLoading guard to admin section redirect to prevent flash on direct
  /settings/admin navigation before session resolves
- Add BLOCKED_SIGNUP_DOMAINS env var and before-hook for email domain denylist,
  parsed once at module init as a Set for O(1) per-request lookups
- Add trailing newline to migration file

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

* fix(admin): close OAuth domain bypass, fix stale errors, deduplicate icon

- Add databaseHooks.user.create.before to enforce BLOCKED_SIGNUP_DOMAINS at
  the model level, covering all signup vectors (email, OAuth, social) not just
  /sign-up paths
- Call .reset() on each mutation before firing to clear stale error state from
  previous operations
- Change Admin nav icon from ShieldCheck to Lock to avoid duplicate with
  Access Control tab

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:04:54 -07:00
Siddharth Ganesan
35c42ba227 fix(mothership): fix tool call scheduling (#3635)
* Fix mothership tool scheduling

* Fix
2026-03-17 13:30:09 -07:00
Waleed
84d6fdc423 v0.6: mothership, tables, connectors 2026-03-17 12:21:15 -07:00
Theodore Li
3bd2750d22 fix(ui): ensure new resource tab button is always visible (#3633)
* fix(ui): ensure new resource tab button is always visible

* Fix vertical scroll input scrolling tabs horizontally

* Fix incorrect tool tip on file edit button

* Fix lint, attach scroll listener to tabs themselves

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-17 14:33:58 -04:00
Theodore Li
70d8df5a19 fix(ui): add back file split view (#3632)
* fix(ui): add back file split view

* Open md in split view

* Fix lint

* Default to preview

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-17 13:48:56 -04:00
Siddharth Ganesan
101fcec135 fix(mothership): stream management (#3623)
* Fix

* Fix

* Fix

* Fix

* Fix lint

* Fix
2026-03-17 10:42:13 -07:00
Waleed
1873f2d775 improvement(mothership): tool display titles, html sanitization, and ui fixes (#3631)
* improvement(mothership): tool display titles, html sanitization, and ui fixes

- Use TOOL_UI_METADATA as fallback for tool display titles (fast_edit shows "Editing workflow" instead of "Fast Edit")
- Harden HTML-to-text extraction with replaceUntilStable to prevent nested tag injection
- Decode HTML entities in a single pass to avoid double-unescaping
- Fix Google Drive/Docs query escaping for backslashes in folder IDs
- Replace regex with indexOf for email sender/display name parsing
- Update embedded workflow run tooltip to "Run workflow"

* fix(security): decode entities before tag stripping and cap loop iterations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:31:58 -07:00
Waleed
3e3c160789 fix(embedded): autolayout viewport calculation for resource view (#3629)
* fix(embedded): autolayout viewport calculation for resource view

* fix(embedded): default mothership view to 60% width, remove minimum
2026-03-17 09:30:10 -07:00
Siddharth Ganesan
8fa4f3fdbb fix(mothership): thinking and subagent text (#3613)
* Thinking v0

* Change

* Fix

* improvement(ui/ux): mothership chat experience

* user input animation

* improvement(landing): desktop complete

* auth and 404

* mobile friendliness and home templates

* improvement(home): templates

* fix: feature flags

* address comments

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-03-17 07:55:50 -07:00
Theodore Li
b3d9e54bb2 fix(ui) fix task switch causing duplicate text renderings (#3624)
* Fix task switch causing duplicate text renderings

* Fix lint

* Pass expectedGen

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-17 05:33:16 -04:00
Waleed
b930ee311f improvement(tables): tables multi-select, keyboard shortcuts, and docs (#3615) 2026-03-17 01:54:37 -07:00
Vikhyath Mondreti
e804ea356c fix(embedded): block layout should not be dependent on viewport (#3621)
* fix(embedded): block layout should not be dependent on viewport

* address comments
2026-03-16 23:16:33 -07:00
Theodore Li
2a7b07e3b4 Fix row_count context (#3622)
Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-17 01:35:09 -04:00
Theodore Li
974cc66b0e fix(ui) add embedded workflow notifications, switch tab on workflow run (#3618)
* Include notification view in embedded workflow view

* fix(ui) fix workflow not showing up when mothership calls run

* Wire up fix in mothership

* Refresh events after workflow run

* Fix so run workflow switches tabs as well

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 23:22:31 -04:00
Theodore Li
c867801988 fix(ui) Live update resources in resource main view (#3617)
* Live update resources in resource main view

* Stop updating on read tool calls

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 21:46:59 -04:00
Theodore Li
c090c821be fix(mothership): add promptForToolApproval to prevent tool hang in mothership chat (#3616)
Tools with requiresConfirmation (e.g. user_table) blocked indefinitely
in mothership because the frontend has no approval UI. Added a new
promptForToolApproval orchestrator option so mothership auto-executes
these tools while copilot continues to prompt for user approval.

Made-with: Cursor

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 20:44:29 -04:00
Theodore Li
36e502a068 fix(workflow) fix mothership double-running workflows (#3614)
* fix(workflow) fix mothership double-running workflows

* Remove interactive override

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 20:11:26 -04:00
Vikhyath Mondreti
6df65127fb feat(tables): upload csvs (#3607)
* feat(tables): upload csvs

* address comments

* address comments

* user id attribution

* fix boolean coercion
2026-03-16 16:32:14 -07:00
Waleed
738d51af0d fix(ui): render block handles underneath current block content (#3611)
* fix(ui): render block handles underneath current block content

* fix(ui): use z-[0] instead of z-[-1] for handle z-index
2026-03-16 15:13:40 -07:00
Theodore Li
b0870f4afa fix(subagent, streaming) fix deploy subagent and task streamnig (#3610)
* Fix deploy subagent

* fix(stream) handle task switching   (#3609)

* Fix task switching causing stream to abort

* Process all task streams all the time

* Process task streams that are in the background

---------

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

* Always return isDeployed for undeploy chat

* Fix lint

---------

Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 18:13:18 -04:00
Waleed
395a61d1b6 fix(deploy): consolidate deployment detection into single source of truth (#3606)
* fix(deploy): consolidate deployment detection into single source of truth

* fix(deploy): address PR review feedback

- Remove redundant isLoading check (subset of isPending in RQ v5)
- Make deployedState prop nullable to avoid non-null assertion
- Destructure mutateAsync to eliminate ESLint suppression
- Guard debounced invalidation against stale workflowId on switch

* fix(deploy): clean up self-explanatory comments and fix unstable mutation dep

- Remove self-explanatory comments in deploy.tsx, tool-input.tsx, use-child-workflow.ts
- Tighten non-obvious comments in use-change-detection.ts
- Destructure mutate/isPending in WorkflowToolDeployBadge to avoid unstable
  mutation object in useCallback deps (TanStack Query no-unstable-deps pattern)

* lint

* fix(deploy): skip expensive state merge when deployedState is null

Avoid running mergeSubblockStateWithValues on every render for
non-deployed workflows where changeDetected always returns false.

* fix(deploy): add missing workflow table import in deploy route

Pre-existing type error — workflow table was used but not imported.

* fix(deploy): forward AbortSignal in fetchDeployedWorkflowState

Match the pattern used by all other fetch helpers in the file so
in-flight requests are cancelled on component unmount or query re-trigger.

* perf(deploy): parallelize all DB queries in checkNeedsRedeployment

All three queries (active version, normalized data, workflow variables)
now run concurrently via Promise.all, saving one DB round trip on the
common path.

* fix(deploy): use sequential-then-parallel pattern in checkNeedsRedeployment

* fix(deploy): use client-side comparison for editor header, remove server polling

The lastSaved-based server polling was triggering API calls on every
local store mutation (before socket persistence), wasting requests and
checking stale DB state. Revert the editor header to pure client-side
hasWorkflowChanged comparison — zero network during editing, instant
badge updates. Child workflow badges still use server-side
useDeploymentInfo (they don't have Zustand state).

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

* fix(deploy): suppress transient Update flash during deployed state refetch

Guard change detection on isFetching (not just isLoading) so the
comparison is suppressed during background refetches after mutations,
preventing a brief Update→Live badge flicker.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:38:57 -07:00
Waleed
680c9cddf0 improvement(ui): align all public pages with dark landing theme and improve whitelabeling (#3604)
* fix(sidebar): show icon-sized skeletons in collapsed settings sidebar

* fix(sidebar): hide expanded skeletons during pre-hydration in collapsed settings

* fix(sidebar): align collapsed settings skeletons with actual icon positions

* fix(sidebar): match collapsed skeleton section grouping with loaded layout

* fix(sidebar): hoist skeleton section counts to module constant

* improvement(ui): align all public pages with dark landing theme and improve whitelabeling

* fix(ui): address PR review comments - restore StatusPageLayout wrapper, improve whitelabel detection

* fix(ui): add missing dark class to 404 page, add relative positioning to invite layout

* fix(ui): fallback to white button when whitelabeled without primary color

* improvement(seo): add x-default hreflang, speakable schema to landing and blog posts

* fix(ui): fix OTP/input light-mode styles and add missing header classes on standalone auth pages

* undo hardcoded ff config

* update old components/ui usage to emcn

* fix(ui): add SupportFooter to InviteLayout, remove duplicates from unsubscribe page

* fix(ui): fix invite layout flex centering, use BrandedButton on 404 page

* fix(ui): fix OTP group styling, props spread order in BrandedButton, invite header shrink-0

* fix: enterprise hydration error

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-03-16 14:02:05 -07:00
Waleed
6818c510c7 improvement(connectors): audit and harden all 30 knowledge base connectors (#3603)
* improvement(connectors): audit and harden all 30 knowledge base connectors

* fix(oauth): update Notion test to match Basic Auth + JSON body config

* fix(connectors): address PR review comments for hubspot, jira, salesforce

- HubSpot: revert to Search API (POST /search) to restore lastmodifieddate DESCENDING sorting
- Salesforce: restore ArticleBody field and add it to HTML_FIELDS for proper stripping
- Jira: add zero-remaining guard to prevent requesting 0 maxResults

* fix(salesforce): revert ArticleBody — not a standard KnowledgeArticleVersion field

ArticleBody is not a standard field on KnowledgeArticleVersion per Salesforce
API docs. Article body content lives in custom fields on org-specific __kav
objects. Including ArticleBody in the SOQL query would cause runtime errors.

* fix(connectors): address second round of PR review comments

- OneDrive: use Buffer.subarray for byte-accurate truncation instead of
  character-count slice
- Reddit: deduplicate comment extraction — fetchPostComments now calls
  extractComments instead of duplicating the logic
- Webflow: replace crude value.includes('<') with regex /<[a-z][^>]*>/i
  to avoid false positives on plain text containing '<'
- Jira: add response.ok check in getJiraCloudId before parsing JSON to
  surface real HTTP errors instead of misleading "No Jira resources found"

* fix(jira,outlook): replace raw fetch in downloadJiraAttachments, fix Outlook URL encoding

- Jira: replace bare fetch() with fetchWithRetry in downloadJiraAttachments
  for retry logic on transient errors and rate limits
- Outlook: use URLSearchParams in validateConfig $search URL construction
  to match buildInitialUrl and produce RFC 3986 compliant encoding
2026-03-15 05:51:37 -07:00
Emir Karabeg
38c892230a improvement: landing, sidebar, globals, buttons (#3590)
* improvement: landing, sidebar, globals, buttons

* improvement(landing): features

* improvement: features, footer, tab modals

* migrations

* temp: enterprise link

* addressed comments
2026-03-15 04:55:28 -07:00
Waleed
aa1f60540d fix(kb): auto-refresh documents after connector sync and remove redundant invalidations (#3601) 2026-03-15 04:24:06 -07:00
Vikhyath Mondreti
8906439a7e fix(notifications): credentials connection notifs showing up in right resource (#3599)
* fix(notifications): credentials connection notifs showing up in right resource

* fix new label

* address comments

* reset ref correctly:
2026-03-15 01:01:11 -07:00
Vikhyath Mondreti
ad68dc16f2 improvement(grain): make trigger names in line with API since resource type not known (#3598)
* improvement(grain): make trigger names in line with API since resource type not known

* address comments
2026-03-15 00:45:32 -07:00
Waleed
7ecd377c41 fix(tasks-ui): update in progress to be amber, added status to dropdown menu, fixed scheduled tasks modal (#3597)
* fix(tasks-ui): update in progress to be amber, added status to dropdownmenu

* fix play button, scheduled tasks modal
2026-03-14 23:56:18 -07:00
Theodore Li
743742d058 Show rendered md by default (#3594)
* Show rendered md by default

* Lint fix

* chore(file-viewer): remove dead PREVIEW_ONLY_EXTENSIONS export

Co-authored-by: Theodore Li <TheodoreSpeaks@users.noreply.github.com>

* Fix new file logic to default to text

---------

Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Theodore Li <TheodoreSpeaks@users.noreply.github.com>
2026-03-14 22:17:40 -04:00
Theodore Li
b7b575c4d2 fix(ui) reduce redundant rerenders (#3593)
* Improve rerendering of resource view

* Lint fix

* Remove console log

* Fix panel logic

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-14 22:02:21 -04:00
Siddharth Ganesan
aad620c456 fix(mothership): run workflow tools (run from block, run until block) (#3595)
* Fix redis queuing and run

* Fix dynimp
2026-03-14 18:57:55 -07:00
Vikhyath Mondreti
f57294936b fix(embedded): viewport options breaking autolayout (#3596) 2026-03-14 18:57:36 -07:00
Waleed
8837f14194 feat(home): expand template examples with 83 categorized templates (#3592)
* feat(home): expand template examples with 83 categorized templates

- Extract template data into consts.ts with rich categorization (category, modules, tags)
- Expand from 6 to 83 templates across 7 categories: sales, support, engineering, marketing, productivity, operations
- Add show more/collapse UI with category groupings for non-featured templates
- Add connector-powered knowledge search templates (Gmail, Slack, Notion, Jira, Linear, Salesforce, etc.)
- Add platform-native templates (document summarizer, bulk data classifier, knowledge assistant, etc.)
- Optimize prompts for mothership execution with explicit resource creation and integration names
- Add tags field for future cross-cutting filtering by persona, pattern, and domain
- React 19.2.1 → 19.2.4 upgrade

* fix(home): remove WhatsApp customer notifications template

* fix(home): add aria-expanded to toggle button, skip popular in expanded view

* fix(home): fix category display order, add aria-label to template cards
2026-03-14 17:53:12 -07:00
Siddharth Ganesan
f077751ce8 fix(mothership): file materialization tools (#3586)
* Fix ope

* File upload fixes

* Fix lint

* Materialization shows up

* Snapshot

* Fix

* Nuke migrations

* Add migs

* migs

---------

Co-authored-by: Waleed <walif6@gmail.com>
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-03-14 16:56:44 -07:00
Vikhyath Mondreti
75bdf46e6b improvement(promos): promo codes should be only stripe codes (#3591)
* improvement(promos): promo codes should be only stripe codes

* address comments
2026-03-14 16:28:18 -07:00
Waleed
952915abfc fix(sidebar): collapsed sidebar shows single icons with hover dropdown menus (#3588)
* fix(sidebar): collapsed sidebar shows single icons with hover dropdown menus

* fix(sidebar): truncate long names in collapsed dropdown menus

* fix(sidebar): address PR review — extract components, fix reactive subscription

* fix(sidebar): support touch/keyboard for collapsed menus, document auto-collapse

* fix(sidebar): remove dead CSS selector for sidebar-collapse-remove

* fix(sidebar): add aria-label to collapsed menu trigger buttons

* fix(sidebar): use useLayoutEffect for attribute removal, remove dead branch
2026-03-14 15:21:41 -07:00
Waleed
cbc9f4248c improvement(cleanup): remove unused old ui components (#3589) 2026-03-14 15:08:44 -07:00
Theodore Li
5ba3118495 feat(byok-migration) byok migration script (#3584)
* Add byok migration script

* Fix lint

* Add skipping if byok already provided

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-14 16:11:21 -04:00
Theodore Li
00ff21ab9c fix(workflow) Fix embedded workflow logs (#3587)
* Extract workflow run logic into shared util

* Fix lint

* Fix isRunning being stuck in true

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-14 16:05:53 -04:00
Vikhyath Mondreti
a2f8ed06c8 fix lint 2026-03-14 12:12:36 -07:00
Theodore Li
f347e3fca0 fix(firecrawl) fix firecrawl scrape credit usage calculation (#3583)
* fix(firecrawl) fix firecrawl scrape credit usage calculation

* Update apps/sim/tools/firecrawl/scrape.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Fix syntax

---------

Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-14 14:27:22 -04:00
PlaneInABottle
e13f52fea2 fix(tools): support stringified HTTP request tables (#3565)
* fix(tools): support stringified HTTP request tables

Accept stored header and query tables after they are reloaded from UI JSON so HTTP requests keep their query strings and URL-encoded body handling intact.

* test: mock AuthType in async execute route

* test(tools): cover invalid stringified HTTP inputs

---------

Co-authored-by: test <test@example.com>
2026-03-14 11:20:11 -07:00
PlaneInABottle
e6b2b739cf fix(execution): report cancellation durability truthfully (#3550)
* fix(cancel): report cancellation durability truthfully

Return explicit durability results for execution cancellation so success only reflects persisted cancellation state instead of best-effort Redis availability.

* fix: hoist cancellation test mocks

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

* fix(sim): harden execution cancel durability

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

* fix(sim): fallback manual cancel without redis

Abort active manual SSE executions locally when Redis cannot durably record the cancellation marker so the run still finalizes as cancelled instead of completing normally.

* test: mock AuthType in async execute route

Keep the rebased async execute route test aligned with the current hybrid auth module exports so it exercises the queueing path instead of failing at import time.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: test <test@example.com>
2026-03-14 11:18:15 -07:00
Vikhyath Mondreti
9ae656c0d5 fix(files): default file name (#3585) 2026-03-14 11:15:00 -07:00
Vikhyath Mondreti
c738226c06 fix(file): bun issues with new file creation (#3582)
* fix(files): new file bun error

* update constant

* fix types
2026-03-14 10:56:13 -07:00
Waleed
8f15be23a0 fix(ashby): add secretToken to webhook creation and fix trigger UX (#3580)
* fix(ashby): add secretToken to webhook creation and fix trigger UX

* fix(toast): restore useMemo for context value to prevent unnecessary re-renders

* fix(notifications): track exit animation timeout so pauseAll can cancel it

* fix(notifications): use isPausedRef to guard exit timeout instead of synthetic timer keys

* fix(notifications): clear pending timers when notification stack empties
2026-03-14 06:26:06 -07:00
Waleed
b2d146ca0a improvement(mothership): message queueing for home chat (#3576)
* improvement(mothership): message queueing for home chat

* fix(mothership): address PR review — move FileAttachmentForApi to types, defer onEditValueConsumed to effect, await sendMessage in sendNow

* fix(mothership): replace updater side-effect with useEffect ref sync, move sendMessageRef to useLayoutEffect

* fix(mothership): clear message queue on chat switch while sending

* fix(mothership): remove stale isSending from handleKeyDown deps

* fix(mothership): guard sendNow against double-click duplicate sends

* fix(mothership): simplify queue callbacks — drop redundant deps and guard ref

- Remove `setMessageQueue` from useCallback deps (stable setter, never changes)
- Replace `sendNowProcessingRef` double-click guard with eager `messageQueueRef` update
- Simplify `editQueuedMessage` with same eager-ref pattern for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(mothership): clear edit value on nav, stop queue drain on send failure

- Reset editingInputValue when chatId changes so stale edit text
  doesn't leak into the next chat
- Pass error flag to finalize so queue is cleared (not drained) when
  sendMessage fails — prevents cascading failures on auth expiry or
  rate limiting from silently consuming every queued message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(mothership): eagerly update messageQueueRef in removeFromQueue

Match the pattern used by sendNow and editQueuedMessage — update the
ref synchronously so finalize's microtask cannot read a stale queue
and drain a message the user just removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(mothership): mark onSendNow as explicit fire-and-forget

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 06:24:00 -07:00
Waleed
d06aa1de7e fix(connectors): align connector scopes with oauth config and fix kb modal UX (#3573)
* fix(connectors): align connector scopes with oauth config and fix kb modal UX

* fix(connectors): restore onCheckedChange for keyboard accessibility

* feat(connectors): add dynamic selectors to knowledge base connector config

Replace manual ID text inputs with dynamic selector dropdowns that fetch
options from the existing selector registry. Users can toggle between
selector and manual input via canonical pairs (basic/advanced mode).

Adds selector support to 12 connectors: Airtable (cascading base→table),
Slack, Gmail, Google Calendar, Linear (cascading team→project), Jira,
Confluence, MS Teams (cascading team→channel), Notion, Asana, Webflow,
and Outlook. Dependency clearing propagates across canonical siblings to
prevent stale cross-mode data on submit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* updated animated blocks UI

* fix(connectors): clear canonical siblings of dependents and resolve active mode values

Fixes three issues from PR review:
- Dependency clearing now includes canonical siblings of dependent fields
  (e.g., changing base clears both tableSelector AND tableIdOrName)
- Selector context and depsResolved now resolve dependency values through
  the active canonical mode, not just the raw depFieldId
- Tooltip text changed from "Switch to manual ID" to "Switch to manual input"
  to correctly describe dropdown fallbacks (e.g., Outlook folder)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: linter class ordering fixes and docs link update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(connectors): reset apiKeyFocused on connector re-selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 03:31:07 -07:00
Siddharth Ganesan
5b9f0d73c2 feat(mothership): mothership (#3411)
* Fix lint

* improvement(sidebar): loading

* fix(sidebar): use client-generated UUIDs for stable optimistic updates (#3439)

* fix(sidebar): use client-generated UUIDs for stable optimistic updates

* fix(folders): use zod schema validation for folder create API

Replace inline UUID regex with zod schema validation for consistency
with other API routes. Update test expectations accordingly.

* fix(sidebar): add client UUID to single workflow duplicate hook

The useDuplicateWorkflow hook was missing newId: crypto.randomUUID(),
causing the same temp-ID-swap issue for single workflow duplication
from the context menu.

* fix(folders): avoid unnecessary Set re-creation in replaceOptimisticEntry

Only create new expandedFolders/selectedFolders Sets when tempId
differs from data.id. In the common happy path (client-generated UUIDs),
this avoids unnecessary Zustand state reference changes and re-renders.

* Mothership block logs

* Fix mothership block logs

* improvement(knowledge): make connector-synced document chunks readonly (#3440)

* improvement(knowledge): make connector-synced document chunks readonly

* fix(knowledge): enforce connector chunk readonly on server side

* fix(knowledge): disable toggle and delete actions for connector-synced chunks

* Job exeuction logs

* Job logs

* fix(connectors): remove unverifiable requiredScopes for Linear connector

* fix(connectors): remove legacy requiredScopes from Jira and Confluence connectors

Jira and Confluence OAuth tokens don't return legacy scope names like
read:jira-work or read:confluence-content.all, causing the 'Update access'
banner to always appear. Set requiredScopes to empty array like Linear.

* feat(tasks): add rename to task context menu (#3442)

* Revert "fix(connectors): remove legacy requiredScopes from Jira and Confluence connectors"

This reverts commit a0be3ff414.

* fix(connectors): restore Linear connector requiredScopes

Linear OAuth does return scopes in the token response. The previous
fix of emptying requiredScopes was based on an incorrect assumption.
Restoring requiredScopes: ['read'] as it should work correctly.

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

* fix(knowledge): pass workspaceId to useOAuthCredentials in connector card

The ConnectorCard was calling useOAuthCredentials(providerId) without
a workspaceId, causing the credentials API to return an empty array.
This meant the credential lookup always failed, getMissingRequiredScopes
received undefined, and the "Update access" banner always appeared.

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

* Fix oauth link callback from mothership task

* feat(connectors): add Fireflies connector and API key auth support (#3448)

* feat(connectors): add Fireflies connector and API key auth support

Extend the connector system to support both OAuth and API key authentication
via a discriminated union (`ConnectorAuthConfig`). Add Fireflies as the first
API key connector, syncing meeting transcripts via the Fireflies GraphQL API.

Schema changes:
- Make `credentialId` nullable (null for API key connectors)
- Add `encryptedApiKey` column (AES-256-GCM encrypted, null for OAuth)

This eliminates the `'_apikey_'` sentinel and inline `sourceConfig._encryptedApiKey`
patterns, giving each auth mode its own clean column.

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

* fix(fireflies): allow 0 for maxTranscripts (means unlimited)

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

---------

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

* Add context

* fix(fireflies): correct types from live API validation (#3450)

* fix(fireflies): correct types from live API validation

- speakers.id is number, not string (API returns 0, 1, 2...)
- summary.action_items is a single string, not string[]
- Update formatTranscriptContent to handle action_items as string

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

* fix(fireflies): correct tool types from live API validation

- FirefliesSpeaker.id: string -> number
- FirefliesSentence.speaker_id: string -> number
- FirefliesSpeakerAnalytics.speaker_id: string -> number
- FirefliesSummary.action_items: string[] -> string
- FirefliesSummary.outline: string[] -> string
- FirefliesSummary.shorthand_bullet: string[] -> string
- FirefliesSummary.bullet_gist: string[] -> string
- FirefliesSummary.topics_discussed: string[] -> string

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

---------

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

* feat(knowledge): add connector tools and expand document metadata (#3452)

* feat(knowledge): add connector tools and expand document metadata

* fix(knowledge): address PR review feedback on new tools

* fix(knowledge): remove unused params from get_document transform

* refactor, improvement

* fix: correct knowledge block canonical pair pattern and subblock migration

- Rename manualDocumentId to documentId (advanced subblock ID should match
  canonicalParamId, consistent with airtable/gmail patterns)
- Fix documentSelector.dependsOn to reference knowledgeBaseSelector (basic
  depends on basic, not advanced)
- Remove unnecessary documentId migration (ID unchanged from main)

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

* lint

* fix: resolve post-merge test and lint failures

- airtable: sync tableSelector condition with tableId (add getSchema)
- backfillCanonicalModes test: add documentId mode to prevent false backfill
- schedule PUT test: use invalid action string now that disable is valid
- schedule execute tests: add ne mock, sourceType field, use
  mockReturnValueOnce for two db.update calls
- knowledge tools: fix biome formatting (single-line arrow functions)

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

* Fixes

* Fixes

* Clean vfs

* Fix

* Fix lint

* fix(connectors): add rate limiting, concurrency controls, and bug fixes (#3457)

* fix(connectors): add rate limiting, concurrency controls, and bug fixes across knowledge connectors

- Add Retry-After header support to fetchWithRetry for all 18 connectors
- Batch concurrent API calls (concurrency 5) in Dropbox, Google Docs, Google Drive, OneDrive, SharePoint
- Batch concurrent API calls (concurrency 3) in Notion to match 3 req/s limit
- Cache GitHub tree in syncContext to avoid re-fetching on every pagination page
- Batch GitHub blob fetches with concurrency 5
- Fix GitHub base64 decoding: atob() → Buffer.from() for UTF-8 safety
- Fix HubSpot OAuth scope: 'tickets' → 'crm.objects.tickets.read' (v3 API)
- Fix HubSpot syncContext key: totalFetched → totalDocsFetched for consistency
- Add jitter to nextSyncAt (10% of interval, capped at 5min) to prevent thundering herd
- Fix Date consistency in connector DELETE route

* fix(connectors): address PR review feedback on retry and SharePoint batching

- Remove 120s cap on Retry-After — pass all values through to retry loop
- Add maxDelayMs guard: if Retry-After exceeds maxDelayMs, throw immediately
  instead of hammering with shorter intervals (addresses validate timeout concern)
- Add early exit in SharePoint batch loop when maxFiles limit is reached
  to avoid unnecessary API calls

* fix(connectors): cap Retry-After at maxDelayMs instead of aborting

Match Google Cloud SDK behavior: when Retry-After exceeds maxDelayMs,
cap the wait to maxDelayMs and log a warning, rather than throwing
immediately. This ensures retries are bounded in duration while still
respecting server guidance within the configured limit.

* fix(connectors): add early-exit guard to Dropbox, Google Docs, OneDrive batch loops

Match the SharePoint fix — skip remaining batches once maxFiles limit
is reached to avoid unnecessary API calls.

* improvement(turbo): align turborepo config with best practices (#3458)

* improvement(turbo): align turborepo config with best practices

* fix(turbo): address PR review feedback

* fix(turbo): add lint:check task for read-only lint+format CI checks

lint:check previously delegated to format:check which only checked
formatting. Now it runs biome check (no --write) which enforces both
lint rules and formatting without mutating files.

* upgrade turbo

* improvement(perf): apply react and js performance optimizations across codebase (#3459)

* improvement(perf): apply react and js performance optimizations across codebase

- Parallelize independent DB queries with Promise.all in API routes
- Defer PostHog and OneDollarStats via dynamic import() to reduce bundle size
- Use functional setState in countdown timers to prevent stale closures
- Replace O(n*m) .filter().find() with Set-based O(n) lookups in undo-redo
- Use .toSorted() instead of .sort() for immutable state operations
- Use lazy initializers for useState(new Set()) across 20 components
- Remove useMemo wrapping trivially cheap expressions (typeof, ternary, template strings)
- Add passive: true to scroll event listener

* fix(perf): address PR review feedback

- Extract IIFE Set patterns to named consts for readability in use-undo-redo
- Hoist Set construction above loops in BATCH_UPDATE_PARENT cases
- Add .catch() error handler to PostHog dynamic import
- Convert session-provider posthog import to dynamic import() to complete bundle split

* fix(analytics): add .catch() to onedollarstats dynamic import

* improvement(resource): tables, files

* improvement(resources): all outer page structure complete

* refactor(queries): comprehensive TanStack Query best practices audit (#3460)

* refactor: comprehensive TanStack Query best practices audit and migration

- Add AbortSignal forwarding to all 41 queryFn implementations for proper request cancellation
- Migrate manual fetch patterns to useMutation hooks (useResetPassword, useRedeemReferralCode, usePurchaseCredits, useImportWorkflow, useOpenBillingPortal, useAllowedMcpDomains)
- Migrate standalone hooks to TanStack Query (use-next-available-slot, use-mcp-server-test, use-webhook-management, use-referral-attribution)
- Fix query key factories: add missing `all` keys, replace inline keys with factory methods
- Fix optimistic mutations: use onSettled instead of onSuccess for cache reconciliation
- Replace overly broad cache invalidations with targeted key invalidation
- Remove keepPreviousData from static-key queries where it provides no benefit
- Add staleTime to queries missing explicit cache duration
- Fix `any` type in UpdateSettingParams with proper GeneralSettings typing
- Remove dead code: loadingWebhooks/checkedWebhooks from subblock store, unused helper functions
- Update settings components (general, debug, referral-code, credit-balance, subscription, mcp) to use mutation state instead of manual useState for loading/error/success

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

* fix: remove unstable mutation object from useCallback deps

openBillingPortal mutation object is not referentially stable,
but .mutate() is stable in TanStack Query v5. Remove from deps
to prevent unnecessary handleBadgeClick recreations.

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

* fix: add missing byWorkflows invalidation to useUpdateTemplate

The onSettled handler was missing the byWorkflows() invalidation
that was dropped during the onSuccess→onSettled migration. Without
this, the deploy modal (useTemplateByWorkflow) would show stale data
after a template update.

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

* docs: add TanStack Query best practices to CLAUDE.md and cursor rules

Add comprehensive React Query best practices covering:
- Hierarchical query key factories with intermediate plural keys
- AbortSignal forwarding in all queryFn implementations
- Targeted cache invalidation over broad .all invalidation
- onSettled for optimistic mutation cache reconciliation
- keepPreviousData only on variable-key queries
- No manual fetch in components rule
- Stable mutation references in useCallback deps

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

* fix: address PR review feedback

- Fix syncedRef regression in use-webhook-management: only set
  syncedRef.current=true when webhook is found, so re-sync works
  after webhook creation (e.g., post-deploy)
- Remove redundant detail(id) invalidation from useUpdateTemplate
  onSettled since onSuccess already populates cache via setQueryData

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

* fix: address second round of PR review feedback

- Reset syncedRef when blockId changes in use-webhook-management so
  component reuse with a different block syncs the new webhook
- Add response.ok check in postAttribution so non-2xx responses
  throw and trigger TanStack Query retry logic

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

* fix: use lists() prefix invalidation in useCreateWorkspaceCredential

Use workspaceCredentialKeys.lists() instead of .list(workspaceId) so
filtered list queries are also invalidated on credential creation,
matching the pattern used by update and delete mutations.

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

* fix: address third round of PR review feedback

- Add nullish coalescing fallback for bonusAmount in referral-code
  to prevent rendering "undefined" when server omits the field
- Reset syncedRef when queryEnabled becomes false so webhook data
  re-syncs when the query is re-enabled without component remount

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

* fix: address fourth round of PR review feedback

- Add AbortSignal to testMcpServerConnection for consistency
- Wrap handleTestConnection in try/catch for mutateAsync error handling
- Replace broad subscriptionKeys.all with targeted users()/usage() invalidation
- Add intermediate users() key to subscription key factory for prefix matching
- Add comment documenting syncedRef null-webhook behavior
- Fix api-keys.ts silent error swallowing on non-ok responses
- Move deployments.ts cache invalidation from onSuccess to onSettled

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

* fix: achieve full TanStack Query best practices compliance

- Add intermediate plural keys to api-keys, deployments, and schedules
  key factories for prefix-based invalidation support
- Change copilot-keys from refetchQueries to invalidateQueries
- Add signal parameter to organization.ts fetch functions (better-auth
  client does not support AbortSignal, documented accordingly)
- Move useCreateMcpServer invalidation from onSuccess to onSettled

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

---------

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

* ran lint

* Fix tables row count

* Update mothership to match copilot in logs

* improvement(resource): layout

* fix(knowledge): compute KB tokenCount from documents instead of stale column (#3463)

The knowledge_base.token_count column was initialized to 0 and never
updated. Replace with COALESCE(SUM(document.token_count), 0) in all
read queries, which already JOIN on documents with GROUP BY.

* improvement(resources): layout and items

* feat(knowledge): add v1 knowledge base API, Obsidian/Evernote connectors, and docs (#3465)

* feat(knowledge): add v1 knowledge base API, Obsidian/Evernote connectors, and docs

- Add v1 REST API for knowledge bases (CRUD, document management, vector search)
- Add Obsidian and Evernote knowledge base connectors
- Add file type validation to v1 file and document upload endpoints
- Update OpenAPI spec with knowledge base endpoints and schemas
- Add connectors documentation page
- Apply query hook formatting improvements

* fix(knowledge): address PR review feedback

- Remove validateFileType from v1/files route (general file upload, not document-only)
- Reject tag filters when searching multiple KBs (tag defs are KB-specific)
- Cache tag definitions to avoid duplicate getDocumentTagDefinitions call
- Fix Obsidian connector silent empty results when syncContext is undefined

* improvement(connectors): add syncContext to getDocument, clean up caching

- Update docs to say 20+ connectors
- Add syncContext param to ConnectorConfig.getDocument interface
- Use syncContext in Evernote getDocument to cache tag/notebook maps
- Replace index-based cache check with Map keyed by KB ID in search route

* fix(knowledge): address second round of PR review feedback

- Fix Zod .default('text') overriding tag definition's actual fieldType
- Fix encodeURIComponent breaking multi-level folder paths in Obsidian
- Use 413 instead of 400 for file-too-large in document upload
- Add knowledge-bases to API reference docs navigation

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

* fix(knowledge): prevent cross-workspace KB access in search

Filter accessible KBs by matching workspaceId from the request,
preventing users from querying KBs in other workspaces they have
access to but didn't specify.

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

* fix(knowledge): audit resourceId, SSRF protection, recursion depth limit

- Fix recordAudit using knowledgeBaseId instead of newDocument.id
- Add SSRF validation to Obsidian connector (reject private/loopback URLs)
- Add max recursion depth (20) to listVaultFiles

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

* fix(obsidian): remove SSRF check that blocks localhost usage

The Obsidian connector is designed to connect to the Local REST API
plugin running on localhost (127.0.0.1:27124). The SSRF check was
incorrectly blocking this primary use case.

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

---------

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

* improvement(resources): segmented API

* fix(execution): ensure background tasks await post-execution DB status updates (#3466)

The fire-and-forget IIFE in execution-core.ts for post-execution logging could be abandoned when trigger.dev tasks exit, leaving executions permanently stuck in "running" status. Store the promise on LoggingSession so background tasks can optionally await it before returning.

* improvement(resource): sorting and icons

* fix(resource): sorting

* improvement(settings): fix mcp modal, add option to edit JSON and add Sim as an MCP client (#3467)

* improvement(settings): fix mcp modal, add option to edit JSON and add Sim as an MCP client

* added docs link in sidebar

* ack comments

* ack comments

* fixed error msg

* feat(mothership): billing (#3464)

* Billing update

* more billing improvements

* credits UI

* credit purchase safety

* progress

* ui improvements

* fix cancel sub

* fix types

* fix daily refresh for teams

* make max features differentiated

* address bugbot comments

* address greptile comments

* revert isHosted

* address more comments

* fix org refresh bar

* fix ui rounding

* fix minor rounding

* fix upgrade issue for legacy plans

* fix formatPlanName

* fix email dispay names

* fix legacy team reference bugs

* referral bonus in credits

* fix org upgrade bug

* improve logs

* respect toggle for paid users

* fix landing page pro features and usage limit checks

* fixed query and usage

* add unit test

* address more comments

* enterprise guard

* fix limits bug

* pass period start/end for overage

* fix(sidebar): restore drag-and-drop for workflows and folders (#3470)

* fix(sidebar): restore drag-and-drop for workflows and folders

Made-with: Cursor

* update docs, unrelated

* improvement(tables): consolidation

* feat(schedules): add schedule creator modal for standalone jobs

Add modal to create standalone scheduled jobs from the Schedules page.
Includes POST API endpoint, useCreateSchedule mutation hook, and full
modal with schedule type selection, timezone, lifecycle, and live preview.

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

* feat(schedules): add edit support with context menu for standalone jobs

* style(schedules): apply linter formatting

* improvement: tables, favicon

* feat(files): inline file viewer with text editing (#3475)

* feat(files): add inline file viewer with text editing and create file modal

Add file preview/edit functionality to the workspace files page. Text files
(md, json, txt, yaml, etc.) open in an editable textarea with Cmd/Ctrl+S save.
PDFs render in an iframe. New file button creates empty .md files via a modal.
Uses ResourceHeader breadcrumbs and ResourceOptionsBar for save/download/delete.

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

* improvement(files): add UX polish, PR review fixes, and context menu

- Add unsaved changes guard modal (matching credentials manager pattern)
- Add delete confirmation modal for both viewer and context menu
- Add save status feedback (Save → Saving... → Saved)
- Add right-click context menu with Open, Download, Delete actions
- Add 50MB file size limit on content update API
- Add storage quota check before content updates
- Add response.ok guard on download to prevent corrupt files
- Add skeleton loading for pending file selection (prevents flicker)
- Fix updateContent in handleSave dependency array

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

* fix(files): propagate save errors and remove redundant sizeDiff

- Remove try/catch in TextEditor.handleSave so errors propagate to
  parent, which correctly shows save failure status
- Remove redundant inner sizeDiff declaration that shadowed outer scope

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

* fix(files): remove unused textareaRef

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

* fix(files): move Cmd+S to parent, add save error feedback, hide save for non-text files

- Move Cmd+S keyboard handler from TextEditor to Files so it goes
  through the parent handleSave with proper status management
- Add 'error' save status with red "Save failed" label that auto-resets
- Only show Save button for text-editable file types (md, txt, json, etc.)

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

* improvement(files): add save tooltip, deduplicate text-editable extensions

- Add Tooltip on Save button showing Cmd+S / Ctrl+S shortcut
- Export TEXT_EDITABLE_EXTENSIONS from file-viewer and reuse in files.tsx
  instead of duplicating the list inline

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

* refactor: extract isMacPlatform to shared utility

Move isMacPlatform() from global-commands-provider.tsx to
lib/core/utils/platform.ts so it can be reused by files.tsx tooltip
without duplication.

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

* refactor(files): deduplicate delete modal, use shared formatFileSize

- Extract DeleteConfirmModal component to eliminate duplicate modal
  markup between viewer and list modes
- Replace local formatFileSize with shared utility from file-utils.ts

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

* fix(files): fix a11y label lint error and remove mutation object from useCallback deps

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

* fix(files): add isDirty guard on handleSave, return proper HTTP status codes

Prevents "Saving → Saved" flash when pressing Cmd+S with no changes.
Returns 404 for file-not-found and 402 for quota-exceeded instead of 500.

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

* fix(files): reset isDirty/saveStatus on delete and discard, remove deprecated navigator.platform

- Clear isDirty and saveStatus when deleting the currently-viewed file to
  prevent spurious beforeunload prompts
- Reset saveStatus on discard to prevent stale "Save failed" when opening
  another file
- Remove deprecated navigator.platform, userAgent fallback covers all cases

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

* fix(files): prevent concurrent saves on rapid Cmd+S, add YAML MIME types

- Add saveStatus === 'saving' guard to handleSave to prevent duplicate
  concurrent PUT requests from rapid keyboard shortcuts
- Add yaml/yml MIME type mappings to getMimeTypeFromExtension

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

* refactor(files): reuse shared extension constants, parallelize cancelQueries

- Replace hand-rolled SUPPORTED_EXTENSIONS with composition from existing
  SUPPORTED_DOCUMENT/AUDIO/VIDEO_EXTENSIONS in validation.ts
- Parallelize sequential cancelQueries calls in delete mutation onMutate

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

* fix(files): guard handleCreate against duplicate calls while pending

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

* fix(files): show upload progress on the Upload button, not New file

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

* fix(files): use ref-based guard for create pending state to avoid stale closure

The uploadFile.isPending check was stale because the mutation object
is excluded from useCallback deps (per codebase convention). Using a
ref ensures the guard works correctly across rapid Enter key presses.

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

* cleanup(files): use shared icon import, remove no-op props, wrap handler in useCallback

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

---------

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

* improvement: tables, dropdown

* improvement(docs): align sidebar method badges and polish API reference styling (#3484)

* improvement(docs): align sidebar method badges and polish API reference styling

* fix(docs): revert className prop on DocsPage for CI compatibility

* fix(docs): restore oneOf schema for delete rows and use rem units in CSS

* fix(docs): replace :has() selectors with direct className for reliable prod layout

The API docs layout was intermittently narrow in production because CSS
:has(.api-page-header) selectors are unreliable in Tailwind v4 production
builds. Apply className="openapi-page" directly to DocsPage and replace
all 64 :has() selectors with .openapi-page class targeting.

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

* fix(docs): bypass TypeScript check for className prop on DocsPage

Use spread with type assertion to pass className to DocsPage, working
around a CI type resolution issue where the prop exists at runtime but
is not recognized by TypeScript in the Vercel build environment.

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

* fix(docs): use inline style tag for grid layout, revert CSS to :has() selectors

The className prop on DocsPage doesn't exist in the fumadocs-ui version
resolved on Vercel, so .openapi-page was never applied and all 64 CSS
rules broke. Revert to :has(.api-page-header) selectors for styling and
use an inline <style> tag for the critical grid-column layout override,
which is SSR'd and doesn't depend on any CSS selector matching.

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

* fix(docs): add pill styling to footer navigation method badges

The footer nav badges (POST, GET, etc.) had color from data-method rules
but lacked the structural pill styling (padding, border-radius, font-size).

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

---------

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

* fix(docs): use named grid lines instead of numeric column indices (#3487)

Root cause: the fumadocs grid template has 3 columns in production but
5 columns in local dev. Our CSS used `grid-column: 3 / span 2` which
targeted the wrong column in the 3-column grid, placing content in
the near-zero-width TOC column instead of the main content column.

Fix: use `grid-column: main-start / toc-end` which uses CSS named grid
lines from grid-template-areas, working regardless of column count.

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

* improvement(resource): layout

* improvement: icon, resource header options

* improvement: icons

* fix(files): icon

* feat(tables): column operations, row ordering, V1 API (#3488)

* feat(tables): add column operations, row ordering, V1 columns API, and OpenAPI spec

Adds column rename/delete/type change/constraint updates to the tables module,
row ordering via position column, UI metadata schema, V1 public API for column
operations with rate limiting and audit logging, and OpenAPI documentation.

Key changes:
- Service-layer column operations with validation (name pattern, type compatibility, unique/required constraints)
- Position column on user_table_rows with composite index for efficient ordering
- V1 /api/v1/tables/{tableId}/columns endpoint (POST/PATCH/DELETE) with rate limiting and audit
- Shared Zod schemas extracted to table/utils.ts using COLUMN_TYPES constant
- Targeted React Query invalidation (row vs schema mutations) with consistent onSettled usage
- OpenAPI 3.1.0 spec for columns endpoint with code samples
- Position field added to all row response mappings for consistency
- Sort fallback to position ordering when buildSortClause returns null

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

* fix(tables): use specific error prefixes instead of broad "Cannot" match

Prevents internal TypeErrors (e.g. "Cannot read properties of undefined")
from leaking as 400 responses. Now matches only domain-specific errors:
"Cannot delete the last column" and "Cannot set column".

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

* fix(tables): reject Infinity and NaN in number type compatibility check

Number.isFinite rejects Infinity, -Infinity, and NaN, preventing
non-finite values from passing column type validation.

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

* fix(tables): invalidate table list on row create/delete for stale rowCount

Row create and delete mutations now invalidate the table list cache since
it includes a computed rowCount. Row updates (which don't change count)
continue to only invalidate row queries.

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

* fix(tables): add column name length check, deduplicate name gen, reset pagination on clear

- Add MAX_COLUMN_NAME_LENGTH validation to addTableColumn (was missing,
  renameColumn already had it)
- Extract generateColumnName helper to eliminate triplicated logic across
  handleAddColumn, handleInsertColumnLeft, handleInsertColumnRight
- Reset pagination to page 0 when clearing sort/filter to prevent showing
  empty pages after narrowing filters are removed

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

* fix: hoist tableId above try block in V1 columns route, add detail invalidation to invalidateRowCount

- V1 columns route: `tableId` was declared inside `try` but referenced in
  `catch` logger.error, causing undefined in error logs. Hoisted `await params`
  above try in all three handlers (POST, PATCH, DELETE).
- invalidateRowCount: added `tableKeys.detail(tableId)` invalidation since the
  single-table GET response includes `rowCount`, which becomes stale after
  row create/delete without this.

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

* fix: add position to all row mutation responses, remove dead filter code

- Add `position` field to POST (single + batch) and PATCH row responses
  across both internal and V1 routes, matching GET responses and OpenAPI spec.
- Remove unused `filterConfig`, `handleFilterToggle`, `handleFilterClear`,
  and `activeFilters` — dead code left over from merge conflict resolution.
  `handleFilterApply` (the one actually wired to JSX) is preserved.

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

* fix: invalidateTableSchema now also invalidates table list cache

Column add/rename/delete/update mutations now invalidate tableKeys.list()
since the list endpoint returns schema.columns for each table. Without this,
the sidebar table list would show stale column schemas until staleTime expires.

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

* fix: replace window.prompt/confirm with emcn Modal dialogs

Replace non-standard browser dialogs with proper emcn Modal components
to match the existing codebase pattern (e.g. delete table confirmation).

- Column rename: Modal with Input field + Enter key support
- Column delete: Modal with destructive confirmation

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

---------

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

* update schedule creation ui and run lint

* improvement: logs

* improvement(tables): multi-select and efficiencies

* Table tools

* improvement(folder-selection): folder deselection + selection order should match visual

* fix(selections): more nested folder inaccuracies

* Tool updates

* Store tool call results

* fix(landing): wire agent input to mothership

* feat(mothership): resource viewer

* fix tests

* fix(streaming): smoother streaming with throttled rendering, ResizeObserver scroll, and batched updates (#3471)

* fix(streaming): smoother streaming with throttled rendering, ResizeObserver scroll, and batched updates

- Add useThrottledValue hook (100ms trailing-edge throttle) to gate DOM re-renders during streaming across all chat surfaces
- Replace 100ms setInterval scroll polling with ResizeObserver-based auto-scroll, programmatic scroll timestamp tracking, and nested [data-scrollable] region handling
- Extract processContentBuffer from inline content handler for cleaner code organization in copilot SSE handlers
- Add RAF-based update batching (50ms max interval) to floating chat and home chat streaming paths
- Add useProgressiveList hook for progressive rendering of long conversation histories via requestAnimationFrame

Made-with: Cursor

* ack PR comments

* fix search modal

* more comments

* ack comments

* count

* ack comments

* ack comment

* improvement(mothership): worklfow resource

* Fix tool call persistence in chat

* Tool results

* Fix error status

* File uploads to mothership

* feat(templates): landing page templates workflow states

* improvement(mothership): chat stability

* improvement(mothership): chat history and stability

* improvement(tables): click-to-select navigation, inline rename, column resize (#3496)

* improvement(tables): click-to-select navigation, inline rename, column resize

* fix(tables): address PR review comments

- Add doneRef guard to useInlineRename preventing Enter+blur double-fire
- Fix PATCH error handler: return 500 for non-validation errors, fix unreachable logger.error
- Stop click propagation on breadcrumb rename input

* fix(tables): add rows-affected check in renameTable service

Prevents silent no-op when tableId doesn't match any record.

* fix(tables): useMemo deps + placeholder memo initialCharacter check

- Use primitive editingId/editValue in useMemo deps instead of whole
  useInlineRename object (which creates a new ref every render)
- Add initialCharacter comparison to placeholderPropsAreEqual, matching
  the existing pattern in dataRowPropsAreEqual

* fix(tables): address round 2 review comments

- Mirror name validation (regex + max length) in PatchTableSchema so
  validateTableName failures return 400 instead of 500
- Add .returning() + rows-affected check to renameWorkspaceFile,
  matching the renameTable pattern
- Check response.ok before parsing JSON in useRenameWorkspaceFile,
  matching the useRenameTable pattern

* refactor(tables): reuse InlineRenameInput in BreadcrumbSegment

Replace duplicated inline input markup with the shared component.
Eliminates redundant useRef, useEffect, and input boilerplate.

* fix(tables): set doneRef in cancelRename to prevent blur-triggered save

Escape → cancelRename → input unmounts → blur → submitRename would
save instead of canceling. Now cancelRename sets doneRef like
submitRename does, blocking the subsequent blur handler.

* fix(tables): pointercancel cleanup + typed FileConflictError

- Add pointercancel handler to column resize to prevent listener leaks
  when system interrupts the pointer (touch-action override, etc.)
- Replace stringly-typed error.message.includes('already exists') with
  FileConflictError class for refactor-safe 409 status detection

* fix(tables): stable useCallback dep + rename shadowed variable

- Use listRename.startRename (stable ref) instead of whole listRename
  object in handleContextMenuRename deps
- Rename inner 'target' to 'origin' in arrow-key handler to avoid
  shadowing the outer HTMLElement 'target'

* fix(tables): move class below imports, stable submitRename, clear editingCell

- Move FileConflictError below import statements (import-first convention)
- Make submitRename a stable useCallback([]) by reading editingId and
  editValue through refs (matches existing onSaveRef pattern)
- Add setEditingCell(null) to handleEmptyRowClick for symmetry with
  handleCellClick

* feat(tables): persist column widths in table metadata

Column widths now survive navigation and page reloads. On resize-end,
widths are debounced (500ms) and saved to the table's metadata field
via a new PUT /api/table/[tableId]/metadata endpoint. On load, widths
are seeded from the server once via React Query.

* fix type checking for file viewer

* fix(tables): address review feedback — 4 fixes

1. headerRename.onSave now uses the fileId parameter directly instead
   of the selectedFile closure, preventing rename-wrong-file race
2. updateMetadataMutation uses ref pattern matching mutateRef/createRef
3. Type-to-enter filters non-numeric chars for number columns, non-date
   chars for date columns
4. renameValue only passed to actively-renaming ColumnHeaderMenu,
   preserving React.memo for other columns

* fix(tables): position-based gap rows, insert above/below, consistency fixes

- Fix gap row insert shifting: only shift rows when target position is
  occupied, preventing unnecessary displacement of rows below
- Switch to position-based indexing throughout (positionMap, maxPosition)
  instead of array-index for correct sparse position handling
- Add insert row above/below to context menu
- Use CellContent for pending values in PositionGapRows (matching PlaceholderRows)
- Add belowHeader selection overlay logic to PositionGapRows
- Remove unnecessary 500ms debounce on column width persistence

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

* fix cells nav w keyboard

* added preview panel for html, markdown rendering, completed table

---------

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

* fix(tables): one small tables ting (#3497)

* feat(exa-hosted-key): Restore exa hosted key (#3499)

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

* improvement(ui): consistent styling

* styling alignment

* improvements(tables): styling improvements

* improve resizer for file preview for html files

* updated document icon

* fix(credentials): exclude regular login methods from credential sync

* update docs

* upgrade turbo

* improvement: tables, chat

* Fix table column delete

* small table rename bug, files updates not persisting

* Table batch ops

* fix(credentials): block usage at execution layer without perms + fix invites

* feat(hosted-key-services) Add hosted key for multiple services (#3461)

* feat(hosted keys): Implement serper hosted key

* Handle required fields correctly for hosted keys

* Add rate limiting (3 tries, exponential backoff)

* Add custom pricing, switch to exa as first hosted key

* Add telemetry

* Consolidate byok type definitions

* Add warning comment if default calculation is used

* Record usage to user stats table

* Fix unit tests, use cost property

* Include more metadata in cost output

* Fix disabled tests

* Fix spacing

* Fix lint

* Move knowledge cost restructuring away from generic block handler

* Migrate knowledge unit tests

* Lint

* Fix broken tests

* Add user based hosted key throttling

* Refactor hosted key handling. Add optimistic handling of throttling for custom throttle rules.

* Remove research as hosted key. Recommend BYOK if throtttling occurs

* Make adding api keys adjustable via env vars

* Remove vestigial fields from research

* Make billing actor id required for throttling

* Switch to round robin for api key distribution

* Add helper method for adding hosted key cost

* Strip leading double underscores to avoid breaking change

* Lint fix

* Remove falsy check in favor for explicit null check

* Add more detailed metrics for different throttling types

* Fix _costDollars field

* Handle hosted agent tool calls

* Fail loudly if cost field isn't found

* Remove any type

* Fix type error

* Fix lint

* Fix usage log double logging data

* Fix test

* Add browseruse hosted key

* Add firecrawl and serper hosted keys

* feat(hosted key): Add exa hosted key (#3221)

* feat(hosted keys): Implement serper hosted key

* Handle required fields correctly for hosted keys

* Add rate limiting (3 tries, exponential backoff)

* Add custom pricing, switch to exa as first hosted key

* Add telemetry

* Consolidate byok type definitions

* Add warning comment if default calculation is used

* Record usage to user stats table

* Fix unit tests, use cost property

* Include more metadata in cost output

* Fix disabled tests

* Fix spacing

* Fix lint

* Move knowledge cost restructuring away from generic block handler

* Migrate knowledge unit tests

* Lint

* Fix broken tests

* Add user based hosted key throttling

* Refactor hosted key handling. Add optimistic handling of throttling for custom throttle rules.

* Remove research as hosted key. Recommend BYOK if throtttling occurs

* Make adding api keys adjustable via env vars

* Remove vestigial fields from research

* Make billing actor id required for throttling

* Switch to round robin for api key distribution

* Add helper method for adding hosted key cost

* Strip leading double underscores to avoid breaking change

* Lint fix

* Remove falsy check in favor for explicit null check

* Add more detailed metrics for different throttling types

* Fix _costDollars field

* Handle hosted agent tool calls

* Fail loudly if cost field isn't found

* Remove any type

* Fix type error

* Fix lint

* Fix usage log double logging data

* Fix test

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>

* Fail fast on cost data not being found

* Add hosted key for google services

* Add hosting configuration and pricing logic for ElevenLabs TTS tools

* Add linkup hosted key

* Add jina hosted key

* Add hugging face hosted key

* Add perplexity hosting

* Add broader metrics for throttling

* Add skill for adding hosted key

* Lint, remove vestigial hosted keys not implemented

* Revert agent changes

* fail fast

* Fix build issue

* Fix build issues

* Fix type error

* Remove byok types that aren't implemented

* Address feedback

* Use default model when model id isn't provided

* Fix cost default issues

* Remove firecrawl error suppression

* Restore original behavior for hugging face

* Add mistral hosted key

* Remove hugging face hosted key

* Fix pricing mismatch is mistral and perplexity

* Add hosted keys for parallel and brand fetch

* Add brandfetch hosted key

* Update types

* Change byok name to parallel_ai

* Add telemetry on unknown models

---------

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

* improvement(settings): SSR prefetch, code splitting, dedicated skeletons

* fix: bust browser cache for workspace file downloads

The downloadFile function was using a plain fetch() that honored the
aggressive cache headers, causing newly created files to download empty.

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

* fix(settings): use emcn Skeleton in extracted skeleton files

* fix(settings): extract shared response mappers to prevent server/client shape drift

Addresses PR review feedback — prefetch.ts duplicated response mapping logic from client hooks. Extracted mapGeneralSettingsResponse and mapUserProfileResponse as shared functions used by both client fetch and server prefetch.

* update byok page

* fix(settings): include theme sync in client-side prefetch queryFn

Hover-based prefetchGeneralSettings now calls syncThemeToNextThemes, matching the useGeneralSettings hook behavior so theme updates aren't missed when prefetch refreshes stale cache.

* fix(byok): use EMCN Input for search field instead of ui Input

Replace @/components/ui Input with the already-imported EmcnInput for design-system consistency.

* fix(byok): use ui Input for search bar to match other settings pages

* fix(settings): use emcn Input for file input in general settings

* improvement(settings): add search bar to skeleton loading states

Skeletons now include the search bar (and action button where applicable) so the layout matches the final component 1:1. Eliminates layout shift when the dynamic chunk loads — search bar area is already reserved by the skeleton.

* fix(settings): align skeleton layouts with actual component structures

- Fix list item gap from 12px to 8px across all skeletons (API keys, custom tools, credentials, MCP)
- Add OAuth icon placeholder to credential skeleton
- Fix credential button group gap from 8px to 4px
- Remove incorrect gap-[4px] from credential-sets text column
- Rebuild debug skeleton to match real layout (description + input/button row)
- Add scrollable wrapper to BYOK skeleton with more representative item count

* chore: lint fixes

* improvement(sidebar): match workspace switcher popover width to sidebar

Use Radix UI's built-in --radix-popover-trigger-width CSS variable
instead of hardcoded 160px so the popover matches the trigger width
and responds to sidebar resizing.

* revert hardcoded ff

* fix: copilot, improvement: tables, mothership

* feat: inline chunk editor and table batch ops with undo/redo (#3504)

* feat: inline chunk editor and table batch operations with undo/redo

Replace modal-based chunk editing/creation with inline editor following
the files tab pattern (state-based view toggle with ResourceHeader).
Add batch update API endpoint, undo/redo support, and Popover-based
context menus for tables.

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

* fix: remove icons from table context menu PopoverItems

Icons were incorrectly carried over from the DropdownMenu migration.
PopoverItems in this codebase use text-only labels.

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

* fix: restore DropdownMenu for table context menu

The table-level context menu was incorrectly migrated to Popover during
conflict resolution. Only the row-level context menu uses Popover; the
table context menu should remain DropdownMenu with icons, matching the
base branch.

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

* fix: bound cross-page chunk navigation polling to max 50 retries

Prevent indefinite polling if page data never loads during
chunk navigation across page boundaries.

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

* fix: navigate to last page after chunk creation for multi-page documents

After creating a chunk, navigate to the last page (where new chunks
append) before selecting it. This prevents the editor from showing
"Loading chunk..." when the new chunk is not on the current page.
The loading state breadcrumb remains as an escape hatch for edge cases.

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

* fix: add duplicate rowId validation to BatchUpdateByIdsSchema

Adds a .refine() check to reject duplicate rowIds in batch update
requests, consistent with the positions uniqueness check on batch insert.

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

* fix: address PR review comments

- Fix disableEdit logic: use || instead of && so connector doc chunks
  cannot be edited from context menu (row click still opens viewer)
- Add uniqueness validation for rowIds in BatchUpdateByIdsSchema
- Fix inconsistent bg token: bg-background → bg-[var(--bg)] in Pagination

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

* fix: remove duplicate rowId uniqueness refine on BatchUpdateByIdsSchema

The refine was applied both on the inner updates array and the outer
object. Keep only the inner array refine which is cleaner.

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

* fix: address additional PR review comments

- Fix stale rowId after create-row redo: patch undo stack with new row
  ID using patchUndoRowId so subsequent undo targets the correct row
- Fix text color tokens in Pagination: use CSS variable references
  (text-[var(--text-body)], text-[var(--text-secondary)]) instead of
  Tailwind semantic tokens for consistency with the rest of the file

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

* fix: remove dead code and fix type errors in table context menu

Remove unused `onAddData` prop and `isEmptyCell` variable from row context
menu (introduced in PR but never wired to JSX). Fix type errors in
optimistic update spreads by removing unnecessary `as Record<string, unknown>`
casts that lost the RowData type.

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

* fix: prevent false "Saved" status on invalid content and mark fire-and-forget goToPage calls

ChunkEditor.handleSave now throws on empty/oversized content instead of
silently returning, so the parent's catch block correctly sets saveStatus
to 'error'. Also added explicit `void` to unawaited goToPage(1) calls
in filter handlers to signal intentional fire-and-forget.

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

* fix: handle stale totalPages in handleChunkCreated for new-page edge case

When creating a chunk that spills onto a new page, totalPages in the
closure is stale. Now polls displayChunksRef for the new chunk, and if
not found, checks totalPagesRef for an updated page count and navigates
to the new last page before continuing to poll.

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

---------

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

* Streaming fix -- need to test more

* Make mothership block use long input instead of prompt input

* improvement(billing): isAnnual metadata + docs updates (#3506)

* improvement(billing): on demand toggling and infinite limits

* store stripe metadata to distinguish annual vs monthly

* udpate docs

* address bugbot

* Add piping

* feat(clean-hosted-keys) Remove eleven labs, browseruse. Tweak firecrawl and mistral key impl (#3503)

* Remove eleven labs, browseruse, and firecrawl

* Remove creditsUsed output

* Add back mistral hosting for mistral blocks

* Add back firecrawl since they queue up concurrent requests

* Fix price calculation, remove agent since its super long running and will clog up queue

* Define hosting per tool

* Remove redundant token finding

---------

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

* Update vfs to handle hosted keys

* improvement(tables): fix cell editing flash, batch API docs, and UI polish (#3507)

* fix: show text cursor in chunk editor and ensure textarea fills container

Add cursor-text to the editor wrapper so the whole area shows a text
cursor. Click on empty space focuses the textarea. Changed textarea from
h-full/w-full to flex-1/min-h-0 so it properly fills the flex container.

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

* improvement(tables): fix cell editing flash, add batch API docs, and UI polish

Fix stale-data flash when saving inline cell edits by using TanStack Query's
isPending+variables pattern instead of manual cache writes. Also adds OpenAPI
docs for batch table endpoints, DatePicker support in row modal, duplicate row
in context menu, and styling improvements.

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

* fix: remove dead resolveColumnFromEvent callback

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

* fix: unify paste undo into single create-rows action

Batch-created rows from paste now push one `create-rows` undo entry
instead of N individual `create-row` entries, so a single Ctrl+Z
reverses the entire paste operation.

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

* fix: validate dates in inline editor and displayToStorage

InlineDateEditor now validates computed values via Date.parse before
saving, preventing invalid strings like "hello" from being sent to the
server. displayToStorage now rejects out-of-range month/day values
(e.g. 13/32) instead of producing invalid YYYY-MM-DD strings.

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

* fix: accept ISO date format in inline date editor

Fall back to raw draft input when displayToStorage returns null, so
valid ISO dates like "2024-03-15" pasted or typed directly are
accepted instead of silently discarded. Date.parse still validates
the final value.

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

* fix: add ISO date support to displayToStorage and fix picker Escape

displayToStorage now recognizes YYYY-MM-DD input directly, so ISO
dates typed or pasted work correctly for both saving and picker sync.

DatePicker Escape now refocuses the input instead of saving, so the
user can press Escape again to cancel or Enter to confirm — matching
the expected cancel behavior.

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

* fix: remove dead paste boundary check

The totalR guard in handlePaste could never trigger since totalR
included pasteRows.length, making targetRow always < totalR.
Remove the unused variable and simplify the selection focus calc.

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

* update openapi

---------

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

* fix dysfunctional unique operation in tables

* feat(autosave): files and chunk editor autosave with debounce + refetch  (#3508)

* feat(files): debounced autosave while editing

* address review comments

* more comments

* fix: unique constraint check crash and copilot table initial rows

- Fix TypeError in updateColumnConstraints: db.execute() returns a
  plain array with postgres-js, not { rows: [...] }. The .rows.length
  access always crashed, making "Set unique" completely broken.

- Add initialRowCount: 20 to copilot table creation so tables created
  via chat have the same empty rows as tables created from the UI.

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

* Fix signaling

* revert: remove initialRowCount from copilot table creation

Copilot populates its own data after creating a table, so pre-creating
20 empty rows causes data to start at position 21 with empty rows above.
initialRowCount only makes sense for the manual UI creation flow.

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

* improvement: chat, workspace header

* chat metadata

* Fix schema mismatch (#3510)

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

* Fixes

* fix: manual table creation starts with 1 row, 1 column

Manual tables now create with a single 'name' column and 1 row instead
of 2 columns and 20 rows. Copilot tables remain at 0 rows, 0 columns.

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

* fix: horizontal scroll in embedded table by replacing overflow-hidden with overflow-clip

Cell content spans used Tailwind's `truncate` (overflow: hidden), creating
scroll containers that consumed trackpad wheel events on macOS without
propagating to the actual scroll ancestor. Replaced with overflow-clip
which clips identically but doesn't create a scroll container. Also moved
focus target from outer container to the scroll div for correctness.

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

* Fix tool call ordering

* Fix tests

* feat: add task multi-select, context menu, and subscription UI updates

Add shift-click range selection, cmd/ctrl-click toggle, and right-click
context menu for tasks in sidebar matching workflow/folder patterns.
Update subscription settings tab UI.

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

* fix(credentials): autosync behaviour cross workspace (#3511)

* fix(credentials): autosync behaviour cross workspace

* address comments

* fix(api-key-reminder) Add reminder on hosted keys that api key isnt needed (#3512)

* Add reminder on hosted keys that api key isnt needed

* Fix test case

---------

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

* improvement: sidebar, chat

* Usage limit

* Plan prompt

* fix(sidebar): workspace header collapse

* fix(sidebar): task navigation

* Subagent tool call persistence

* Don't drop suabgent text

* improvement(ux): streaming

* improvement: thinking

* fix(random): optimized kb connector sync engine, rerenders in tables, files, editors, chat (#3513)

* optimized kb connector sync engine, rerenders in tables, files, editors, chat

* refactor(sidebar): rename onTaskClick to onMultiSelectClick for clarity

Made-with: Cursor

* ack comments, add docsFailed

* feat(email-footer) Add "sent with sim ai" for free users (#3515)

* Add "sent with sim ai" for free users

* Only add prompt injection on free tier

* Add try catch around billing info fetch

---------

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

* improvement: modals

* ran migrations

* fix(mothership): fix hardcoded workflow color, tables drag line overflowing

* feat(mothership): file attachment indicators, persistence, and chat input improvements

- Show image thumbnails and file-icon cards above user messages in mothership chat
- Persist file attachment metadata (key, filename, media_type, size) in DB with user messages
- Restore attachments from history via /api/files/serve/ URLs so they survive refresh/navigation
- Unify all chat file inputs to use shared CHAT_ACCEPT_ATTRIBUTE constant
- Fix file thumbnail overflow: use flex-wrap instead of hidden horizontal scroll
- Compact attachment cards in floating workflow chat messages

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

* improvement: search modal

* improvement(usage): free plan to 1000 credits  (#3516)

* improvement(billing): free plan to five dollars

* fix comment

* remove per month terminology from marketing

* generate migration

* remove migration

* add migration back

* feat(workspace): add workspace color changing, consolidate update hooks, fix popover dismiss

- Add workspace color change via context menu, reusing workflow ColorGrid UI
- Consolidate useUpdateWorkspaceName + useUpdateWorkspaceColor into useUpdateWorkspace
- Fix popover hover submenu dismiss by using DismissableLayerBranch with pointerEvents
- Remove passthrough wrapper for export, reuse Workspace type for capturedWorkspaceRef
- Reorder log columns: workflow first, merge date+time into single column

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

* Update oauth cred tool

* fix(diff-controls): fixed positioning for copilot diff controls

* fix(font): added back old font for emcn code editor

* improvement: panel, special tags

* improvement: chat

* improvement: loading and file dropping

* feat(templates): create home templates

* fix(uploads): resolve .md file upload rejection and deduplicate file type utilities

Browsers report empty or application/octet-stream MIME types for .md files,
causing copilot uploads to be rejected. Added resolveFileType() utility that
falls back to extension-based MIME resolution at both client and server
boundaries. Consolidated duplicate MIME mappings into module-level constants,
removed duplicate isImageFileType from copilot module, and replaced hardcoded
ALLOWED_EXTENSIONS with composition from shared validation constants. Also
switched file attachment previews to use shared getDocumentIcon utility.

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

* fix(home): prevent initial view from being scrollable

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

* autofill fixes

* added back integrations page, reverted secrets page back to old UI

* Fix workspace dropdown getting cut off when sidebar is collapsed

* fix(mothership): lint (#3517)

* fix(mothership): lint

* fix typing

* fix tests

* fix stale query

* fix plan display name

* Feat/add mothership manual workflow runs (#3520)

* Add run and open workflow buttons in workflow preview

* Send log request message after manual workflow run

* Make edges in embedded workflow non-editable

* Change chat to pass in log as additional context

* Revert "Change chat to pass in log as additional context"

This reverts commit e957dffb2f.

* Revert "Send log request message after manual workflow run"

This reverts commit 0fb92751f0.

* Move run and workflow icons to tab bar

* Simplify boolean condition

---------

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

* feat(resource-tab-scroll): Allow vertical scrolling to scroll resource tab

* fix(remove-speed-hosted-key) Remove maps speed limit hosted key, it's deprecated (#3521)

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

* improvement: home, sidebar

* fix(download-file): render correct file download link for mothership (#3522)

* fix(download-file): render correct file download link for mothership

* Fix uunecessary call

* Use simple strip instead of db lookup and moving behavior

* Make regex strip more strict

---------

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

* improvement: schedules, auto-scroll

* fix(settings): navigate back to origin page instead of always going home

Use sessionStorage to store the return URL when entering settings, and
use router.replace for tab switches so history doesn't accumulate.

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

* fix(schedules): release lastQueuedAt lock on all exit paths to prevent stuck schedules

Multiple error/early-return paths in executeScheduleJob and executeJobInline
were exiting without clearing lastQueuedAt, causing the dueFilter to permanently
skip those schedules — resulting in stale "X hours ago" display for nextRunAt.

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

* feat(mothership): inline rename for resource tabs + workspace_file rename tool

- Add double-click inline rename on file and table resource tabs
- Wire useInlineRename + useRenameWorkspaceFile/useRenameTable mutations
- Add rename operation to workspace_file copilot tool (schema, server, router)
- Add knowledge base resource support (type, extraction, rendering, actions)
- Accept optional className on InlineRenameInput for context-specific sizing

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

* revert: remove inline rename UI from resource tabs

Keep the workspace_file rename tool for the mothership agent.
Only the UI-side inline rename (double-click tabs) is removed.

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

* feat(mothership): knowledge base resource extraction + Resource/ResourceTable refactor

- Extract KB resources from knowledge subagent respond format (knowledge_bases array)
- Add knowledge_base tool to RESOURCE_TOOL_NAMES and TOOL_UI_METADATA
- Extract ResourceTable as independently composable memoized component
- Move contentOverride/overlay to Resource shell level (not table primitive)
- Remove redundant disableHeaderSort and loadingRows props
- Rename internal sort state for clarity (sort → internalSort, sortOverride → externalSort)
- Export ResourceTable and ResourceTableProps from barrel

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

* fix(logs) Run workflows client side in mothership to transmit logs (#3529)

* Run workflows client side in mothership to transmit logs

* Initialize set as constant, prevent duplicate execution

* Fix lint

---------

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

* fix(import) fix missing file

* fix(resource): Hide resources that have been deleted (#3528)

* Hide resources that have been deleted

* Handle table, workflow not found

* Add animation to prevent flash when previous resource was deleted

* Fix animation playing on every switch

* Run workflows client side in mothership to transmit logs

* Fix race condition for animation

* Use shared workflow tool util file

---------

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

* fix: chat scrollbar on sidebar collapse/open

* edit existing workflow should bring up artifact

* fix(agent) subagent and main agent text being merged without spacing

* feat(mothership): remove resource-level delete tools from copilot

Remove delete operations for workflows, folders, tables, and files
from the mothership copilot to prevent destructive actions via AI.
Row-level and column-level deletes are preserved.

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

* fix: stop sidebar from auto-collapsing when resource panel appears (#3540)

The sidebar was forcibly collapsed whenever a resource (e.g. workflow)
first appeared in the resource panel during a task. This was disruptive
on larger screens where users want to keep both the sidebar and resource
panel visible simultaneously.

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

* fix(mothership): insert copilot-created workflows at top of list (#3537)

* feat(mothership): remove resource-level delete tools from copilot

Remove delete operations for workflows, folders, tables, and files
from the mothership copilot to prevent destructive actions via AI.
Row-level and column-level deletes are preserved.

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

* fix(mothership): insert copilot-created workflows at top of list

* fix(mothership): server-side top-insertion sort order and deduplicate registry logic

* fix(mothership): include folder sort orders when computing top-insertion position

* fix(mothership): use getNextWorkflowColor instead of hardcoded color

---------

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

* fix(stop) Add stop of motehership ran workflows, persist stop messages (#3538)

* Connect play stop workflow in embedded view to workflow

* Fix stop not actually stoping workflow

* Fix ui not showing stopped by user

* Lint fix

* Plumb cancellation through system

* Stopping mothership chat stops workflow

* Remove extra fluff

* Persist blocks on cancellation

* Add root level stopped by user

---------

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

* fix(autolayout): targetted autolayout heuristic restored (#3536)

* fix(autolayout): targetted autolayout heuristic restored

* fix autolayout boundary cases

* more fixes

* address comments

* on conflict updates

* address more comments

* fix relative position scope

* fix tye omission

* address bugbot comment

* Credential tags

* Credential id field

* feat(mothership): server-persisted unread task indicators via SSE (#3549)

* feat(mothership): server-persisted unread task indicators via SSE

Replace fragile client-side polling + timer-based green flash with
server-persisted lastSeenAt semantics, real-time SSE push via Redis
pub/sub, and dot overlay UI on the Blimp icon.

- Add lastSeenAt column to copilotChats for server-persisted read state
- Add Redis/local pub/sub singleton for task status events (started,
  completed, created, deleted, renamed)
- Add SSE endpoint (GET /api/mothership/events) with heartbeat and
  workspace-scoped filtering
- Add mark-read endpoint (POST /api/mothership/chats/read)
- Publish SSE events from chat, rename, delete, and auto-title handlers
- Add useTaskEvents hook for client-side SSE subscription
- Add useMarkTaskRead mutation with optimistic update
- Replace timer logic in sidebar with TaskStatus state machine
  (running/unread/idle) and dot overlay using brand color variables
- Mark tasks read on mount and stream completion in home page
- Fix security: add userId check to delete WHERE clause
- Fix: bump updatedAt on stream completion
- Fix: set lastSeenAt on rename to prevent false-positive unread

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

* fix: address PR review feedback

- Return 404 when delete finds no matching chat (was silent no-op)
- Move log after ownership check so it only fires on actual deletion
- Publish completed SSE event from stop route so sidebar dot clears on abort

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

* fix: backfill last_seen_at in migration to prevent false unread dots

Existing rows would have last_seen_at = NULL after migration, causing
all past completed tasks to show as unread. Backfill sets last_seen_at
to updated_at for all existing rows.

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

* fix: timestamp mismatch on task creation + wasSendingRef leak across navigation

- Pass updatedAt explicitly alongside lastSeenAt on chat creation so
  both use the same JS timestamp (DB defaultNow() ran later, causing
  updatedAt > lastSeenAt → false unread)
- Reset wasSendingRef when chatId changes to prevent a stale true
  from task A triggering a redundant markRead on task B

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

* fix: mark-read fires for inline-created chats + encode workspaceId in SSE URL

Expose resolvedChatId from useChat so home.tsx can mark-read even when
chatId prop stays undefined after replaceState URL update. Also
URL-encode workspaceId in EventSource URL as a defensive measure.

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

* fix: auto-focus home input on initial view + fix sidebar task click handling

Auto-focus the textarea when the initial home view renders. Also fix
sidebar task click to always call onMultiSelectClick so selection state
stays consistent.

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

* fix: auto-title sets lastSeenAt + move started event inside DB guard

Auto-title now sets both updatedAt and lastSeenAt (matching the rename
route pattern) to prevent false-positive unread dots. Also move the
'started' SSE event inside the if(updated) guard so it only fires when
the DB update actually matched a row.

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

* modified tasks multi select to be just like workflows

* fix

* refactor: extract generic pub/sub and SSE factories + fixes

- Extract createPubSubChannel factory (lib/events/pubsub.ts) to eliminate
  duplicated Redis/EventEmitter boilerplate between task and MCP pub/sub
- Extract createWorkspaceSSE factory (lib/events/sse-endpoint.ts) to share
  auth, heartbeat, and cleanup logic across SSE endpoints
- Fix auto-title race suppressing unread status by removing updatedAt/lastSeenAt
  from title-only DB update
- Fix wheel event listener leak in ResourceTabs (RefCallback cleanup was silently
  discarded)
- Fix getFullSelection() missing taskIds (inconsistent with hasAnySelection)
- Deduplicate SSE_RESPONSE_HEADERS to spread from shared SSE_HEADERS
- Hoist isSttAvailable to module-level constant to avoid per-render IIFE

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

---------

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

* feat(logs): add workflow trigger type for sub-workflow executions (#3554)

* feat(logs): add workflow trigger type for sub-workflow executions

* fix(logs): align workflow filter color with blue-secondary badge variant

* feat(tab) allow user to control resource tabs

* Make resources persist to backend

* Use colored squares for workflows

* Add click and drag functionality to resource

* Fix expanding panel logic

* Reduce duplication, reading resource also opens up resource panel

* Move resource dropdown to own file

* Handle renamed resources

* Clicking already open tab should just switch to tab

---------

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

* Fix new resource tab button not appearing on tasks

* improvement(ui): dropdown menus, icons, globals

* improvement: notifications, terminal, globals

* reverted task logic

* feat(context) pass resource tab as context (#3555)

* feat(context) add currenttly open resource file to context for agent

* Simplify resource resolution

* Skip initialize vfs

* Restore ff

* Add back try catch

* Remove redundant code

* Remove json serialization/deserialization loop

---------

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

* Feat(references) add at to reference sim resources(#3560)


* feat(chat) add at sign

* Address bugbot issues

* Remove extra chatcontext defs

* Add table and file to schema

* Add icon to chip for files

---------

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

* improvement(refactor): move to soft deletion of resources + reliability improvements (#3561)

* improvement(deletion): migrate to soft deletion of resources

* progress

* scoping fixes

* round of fixes

* deduplicated name on workflow import

* fix tests

* add migration

* cleanup dead code

* address bugbot comments

* optimize query

* feat(sim-mailer): email inbox for mothership with chat history and plan gating (#3558)

* feat(sim-mailer): email inbox for mothership with chat history and plan gating

* revert hardcoded ff

* fix(inbox): address PR review comments - plan enforcement, idempotency, webhook auth

- Enforce Max plan at API layer: hasInboxAccess() now checks subscription tier (>= 25k credits or enterprise)
- Add idempotency guard to executeInboxTask() to prevent duplicate emails on Trigger.dev retries
- Add AGENTMAIL_WEBHOOK_SECRET env var for webhook signature verification (Bearer token)

* improvement(inbox): harden security and efficiency from code audit

- Use crypto.timingSafeEqual for webhook secret comparison (prevents timing attacks)
- Atomic claim in executor: WHERE status='received' prevents duplicate processing on retries
- Parallelize hasInboxAccess + getUserEntityPermissions in all API routes (reduces latency)
- Truncate email body at webhook insertion (50k char limit, prevents unbounded DB storage)
- Harden escapeAttr with angle bracket and single quote escaping
- Rename use-inbox.ts to inbox.ts (matches hooks/queries/ naming convention)

* fix(inbox): replace Bearer token auth with proper Svix HMAC-SHA256 webhook verification

- Use per-workspace webhook secret from DB instead of global env var
- Verify AgentMail/Svix signatures: HMAC-SHA256 over svix-id.timestamp.body
- Timing-safe comparison via crypto.timingSafeEqual
- Replay protection via timestamp tolerance (5 min window)
- Join mothershipInboxWebhook in workspace lookup (zero additional DB calls)
- Remove dead AGENTMAIL_WEBHOOK_SECRET env var
- Select only needed workspace columns in webhook handler

* fix(inbox): require webhook secret — reject requests when secret is missing

Previously, if the webhook secret was missing from the DB (corrupted state),
the handler would skip verification entirely and process the request
unauthenticated. Now all three conditions are hard requirements: secret must
exist in DB, Svix headers must be present, and signature must verify.

* fix(inbox): address second round of PR review comments

- Exclude rejected tasks from rate limit count to prevent DoS via spam
- Strip raw HTML from LLM output before marked.parse to prevent XSS in emails
- Track responseSent flag to prevent duplicate emails when DB update fails after send

* fix(inbox): address third round of PR review comments

- Use dynamic isHosted from feature-flags instead of hardcoded true
- Atomic JSON append for chat message persistence (eliminates read-modify-write race)
- Handle cutIndex === 0 in stripQuotedReply (body starts with quote)
- Clean up orphan mothershipInboxWebhook row on enableInbox rollback
- Validate status query parameter against enum in tasks API

* fix(inbox): validate cursor param, preserve code blocks in HTML stripping

- Validate cursor date before using in query (return 400 for invalid)
- Split on fenced code blocks before stripping HTML tags to preserve
  code examples in email responses

* fix(inbox): return 500 on webhook server errors to enable Svix retries

* fix(inbox): remove isHosted guard from hasInboxAccess — feature flag is sufficient

* fix(inbox): prevent double-enable from deleting webhook secret row

* fix(inbox): null-safe stripThinkingTags, encode URL params, surface remove-sender errors

- Guard against null result.content in stripThinkingTags
- Use encodeURIComponent on all AgentMail API path parameters
- Surface handleRemoveSender errors to the user instead of swallowing

* improvement(inbox): remove unused types, narrow SELECT queries, fix optimistic ID collision

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

* fix(inbox): add keyboard accessibility to clickable task rows

* fix(inbox): use Svix library for webhook verification, fix responseSent flag, prevent inbox enumeration

- Replace manual HMAC-SHA256 verification with official Svix library per AgentMail docs
- Fix responseSent flag: only set true when email delivery actually succeeds
- Return consistent 401 for unknown inbox and bad signature to prevent enumeration
- Make AgentMailInbox.organization_id optional to match API docs

* chore(db): rebase inbox migration onto feat/mothership-copilot (0172 → 0173)

Sync schema with target branch and regenerate migration as 0173
to avoid conflicts with 0172_silky_magma on feat/mothership-copilot.

* fix(db): rebase inbox migration to 0173 after feat/mothership-copilot divergence

Target branch added 0172_silky_magma, so our inbox migration is now 0173_youthful_stryfe.

* fix(db): regenerate inbox migration after rebase on feat/mothership-copilot

* fix(inbox): case-insensitive email match and sanitize javascript: URIs in email HTML

- Use lower() in isSenderAllowed SQL to match workspace members regardless
  of email case stored by auth provider
- Strip javascript:, vbscript:, and data: URIs from marked HTML output to
  prevent XSS in outbound email responses

* fix(inbox): case-insensitive email match in resolveUserId

Consistent with the isSenderAllowed fix — uses lower() so mixed-case
stored emails match correctly, preventing silent fallback to workspace owner.

---------

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

* Kb args

* refactor(resource): remove logs-specific escape hatches from Resource abstraction

Logs now composes ResourceHeader + ResourceOptionsBar + ResourceTable directly
instead of using Resource with contentOverride/overlay escape hatches. Removes
contentOverride, onLoadMore, hasMore, isLoadingMore from ResourceProps. Adds
ColumnOption to barrel export and fixes table.tsx internal import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(sim-mailer): download email attachments and pass to LLM as multimodal content

Attachments were only passed as metadata text in the email body. Now downloads
actual file bytes from AgentMail, converts via createFileContent (same path as
interactive chat), and sends as fileAttachments to the orchestrator. Also
parallelizes attachment fetching with workspace context loading, and downloads
multiple attachments concurrently via Promise.allSettled.

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

* feat(connector): add Gmail knowledge base connector with thread-based sync and filtering

Syncs email threads from Gmail into knowledge bases with configurable filters:
label scoping, date range presets, promotions/social exclusion, Gmail search
syntax support, and max thread caps to keep KB size manageable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(connector): add Outlook knowledge base connector with conversation grouping and filtering

Syncs email conversations from Outlook/Office 365 via Microsoft Graph API.
Groups messages by conversationId into single documents. Configurable filters:
folder selection, date range presets, Focused Inbox, KQL search syntax, and
max conversation caps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* cleanup resource definition

* feat(connectors): add 8 knowledge base connectors — Zendesk, Intercom, ServiceNow, Google Sheets, Microsoft Teams, Discord, Google Calendar, Reddit

Each connector syncs documents into knowledge bases with configurable filtering:

- Zendesk: Help Center articles + support tickets with status/locale filters
- Intercom: Articles + conversations with state filtering
- ServiceNow: KB articles + incidents with state/priority/category filters
- Google Sheets: Spreadsheet tabs as LLM-friendly row-by-row documents
- Microsoft Teams: Channel messages (Slack-like pattern) via Graph API
- Discord: Channel messages with bot token auth
- Google Calendar: Events with date range presets and attendee metadata
- Reddit: Subreddit posts with top comments, sort/time filters

All connectors validated against official API docs with bug fixes applied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(inbox): fetch real attachment binary from presigned URL and persist for chat display

The AgentMail attachment endpoint returns JSON metadata with a download_url,
not raw binary. We were base64-encoding the JSON text and sending it to the
LLM, causing provider rejection. Now we parse the metadata, fetch the actual
file from the presigned URL, upload it to copilot storage, and persist it on
the chat message so images render inline with previews.

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

* added agentmail domain for mailer

* added docs for sim mailer

* fix(resource) handle resource deletion  deletion (#3568)

* Add handle dragging tab to input chat

* Add back delete tools

* Handle deletions properly with resources view

* Fix lint

* Add permisssions checking

* Skip resource_added event when resource is deleted

* Pass workflow id as context

---------

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

* update docs styling, add delete confirmation on inbox

* Fix fast edit route

* updated docs styling, added FAQs, updated content

* upgrade turbo

* fix(knowledge) use consistent empty state for documents page

Replace the centered "No documents yet" text with the standard Resource
table empty state (column headers + create row), matching all other
resource pages. Move "Upload documents" from header action to table
create row as "New documents".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(notifications): polish modal styling, credential display, and trigger filters (#3571)

* fix(notifications): polish modal styling, credential display, and trigger filters

- Show credential display name instead of raw account ID in Slack account selector
- Fix label styling to use default Label component (text-primary) for consistency
- Fix modal body spacing with proper top padding after tab bar
- Replace list-card skeleton with form-field skeleton matching actual layout
- Replace custom "Select a Slack account first" box with disabled Combobox (dependsOn pattern)
- Use proper Label component in WorkflowSelector with consistent gap spacing
- Add overflow badge pattern (slice + +N) to level and trigger filter badges
- Use dynamic trigger options from getTriggerOptions() instead of hardcoded CORE_TRIGGER_TYPES
- Relax API validation to accept integration trigger types (z.string instead of z.enum)
- Deduplicate account rows from credential leftJoin in accounts API
- Extract getTriggerOptions() to module-level constants to avoid per-render calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(notifications): address PR review feedback

- Restore accountId in displayName fallback chain (credentialDisplayName || accountId || providerId)
- Add .default([]) to triggerFilter in create schema to preserve backward compatibility
- Treat empty triggerFilter as "match all" in notification matching logic
- Remove unreachable overflow badge for levelFilter (only 2 possible values)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(settings): add spacing to Sim Keys toggle and replace Sim Mailer icon with Send

Add 24px top margin to the "Allow personal Sim keys" toggle so it doesn't
sit right below the empty state. Replace the Mail envelope icon for Sim
Mailer with a new Send (paper plane) icon matching the emcn icon style.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* standardize back buttons in settings

* feat(restore) Add restore endpoints and ui (#3570)

* Add restore endpoints and ui

* Derive toast from notification

* Auth user if workspaceid not found

* Fix recently deleted ui

* Add restore error toast

* Fix deleted at timestamp mismatch

---------

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

* fix type errors

* Lint

* improvements: ui/ux around mothership

* reactquery best practices, UI alignment in restore

* clamp logs panel

* subagent thinking text

* fix build, speedup tests by up to 40%

* Fix fast edit

* Add download file shortcut on mothership file view

* fix: SVG file support in mothership chat and file serving

- Send SVGs as document/text-xml to Claude instead of unsupported
  image/svg+xml, so the mothership can actually read SVG content
- Serve SVGs inline with proper content type and CSP sandbox so
  chat previews render correctly
- Add SVG preview support in file viewer (sandboxed iframe)
- Derive IMAGE_MIME_TYPES from MIME_TYPE_MAPPING to reduce duplication
- Add missing webp to contentTypeMap, SAFE_INLINE_TYPES, binaryExtensions
- Consolidate PREVIEWABLE_EXTENSIONS into preview-panel exports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace image/* wildcard with explicit supported types in file picker

The image/* accept attribute allowed users to select BMP, TIFF, HEIC,
and other image types that are rejected server-side. Replace with the
exact set of supported image MIME types and extensions to match the
copilot upload validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Context tags

* Fix lint

* improvement: chat and terminal

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Theodore Li <teddy@zenobiapay.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Theodore Li <theodoreqili@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-13 21:02:08 -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
532 changed files with 72536 additions and 8430 deletions

View File

@@ -117,6 +117,8 @@ export const {service}Connector: ConnectorConfig = {
The add-connector modal renders these automatically — no custom UI needed.
Three field types are supported: `short-input`, `dropdown`, and `selector`.
```typescript
// Text input
{
@@ -141,6 +143,136 @@ The add-connector modal renders these automatically — no custom UI needed.
}
```
## Dynamic Selectors (Canonical Pairs)
Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`.
The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`.
### Rules
1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`.
2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required.
3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`.
4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children.
### Selector canonical pair example (Airtable base → table cascade)
```typescript
configFields: [
// Base: selector (basic) + manual (advanced)
{
id: 'baseSelector',
title: 'Base',
type: 'selector',
selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts
canonicalParamId: 'baseId',
mode: 'basic',
placeholder: 'Select a base',
required: true,
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
canonicalParamId: 'baseId',
mode: 'advanced',
placeholder: 'e.g. appXXXXXXXXXXXXXX',
required: true,
},
// Table: selector depends on base (basic) + manual (advanced)
{
id: 'tableSelector',
title: 'Table',
type: 'selector',
selectorKey: 'airtable.tables',
canonicalParamId: 'tableIdOrName',
mode: 'basic',
dependsOn: ['baseSelector'], // References the selector field ID
placeholder: 'Select a table',
required: true,
},
{
id: 'tableIdOrName',
title: 'Table Name or ID',
type: 'short-input',
canonicalParamId: 'tableIdOrName',
mode: 'advanced',
placeholder: 'e.g. Tasks',
required: true,
},
// Non-selector fields stay as-is
{ id: 'maxRecords', title: 'Max Records', type: 'short-input', ... },
]
```
### Selector with domain dependency (Jira/Confluence pattern)
When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`.
```typescript
configFields: [
{
id: 'domain',
title: 'Jira Domain',
type: 'short-input',
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'projectSelector',
title: 'Project',
type: 'selector',
selectorKey: 'jira.projects',
canonicalParamId: 'projectKey',
mode: 'basic',
dependsOn: ['domain'],
placeholder: 'Select a project',
required: true,
},
{
id: 'projectKey',
title: 'Project Key',
type: 'short-input',
canonicalParamId: 'projectKey',
mode: 'advanced',
placeholder: 'e.g. ENG, PROJ',
required: true,
},
]
```
### How `dependsOn` maps to `SelectorContext`
The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`):
```
oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId,
siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId
```
### Available selector keys
Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors:
| SelectorKey | Context Deps | Returns |
|-------------|-------------|---------|
| `airtable.bases` | credential | Base ID + name |
| `airtable.tables` | credential, `baseId` | Table ID + name |
| `slack.channels` | credential | Channel ID + name |
| `gmail.labels` | credential | Label ID + name |
| `google.calendar` | credential | Calendar ID + name |
| `linear.teams` | credential | Team ID + name |
| `linear.projects` | credential, `teamId` | Project ID + name |
| `jira.projects` | credential, `domain` | Project key + name |
| `confluence.spaces` | credential, `domain` | Space key + name |
| `notion.databases` | credential | Database ID + name |
| `asana.workspaces` | credential | Workspace GID + name |
| `microsoft.teams` | credential | Team ID + name |
| `microsoft.channels` | credential, `teamId` | Channel ID + name |
| `webflow.sites` | credential | Site ID + name |
| `outlook.folders` | credential | Folder ID + name |
## ExternalDocument Shape
Every document returned from `listDocuments`/`getDocument` must include:
@@ -287,6 +419,12 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
- [ ] **Auth configured correctly:**
- OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
- API key: `auth.label` and `auth.placeholder` set appropriately
- [ ] **Selector fields configured correctly (if applicable):**
- Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`)
- `required` is identical on both fields in each canonical pair
- `selectorKey` exists in `hooks/selectors/registry.ts`
- `dependsOn` references selector field IDs (not `canonicalParamId`)
- Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS`
- [ ] `listDocuments` handles pagination and computes content hashes
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
- [ ] `metadata` includes source-specific data for tag mapping

View File

@@ -0,0 +1,316 @@
---
description: Validate an existing knowledge base connector against its service's API docs
argument-hint: <service-name> [api-docs-url]
---
# Validate Connector Skill
You are an expert auditor for Sim knowledge base connectors. Your job is to thoroughly validate that an existing connector is correct, complete, and follows all conventions.
## Your Task
When the user asks you to validate a connector:
1. Read the service's API documentation (via Context7 or WebFetch)
2. Read the connector implementation, OAuth config, and registry entries
3. Cross-reference everything against the API docs and Sim conventions
4. Report all issues found, grouped by severity (critical, warning, suggestion)
5. Fix all issues after reporting them
## Step 1: Gather All Files
Read **every** file for the connector — do not skip any:
```
apps/sim/connectors/{service}/{service}.ts # Connector implementation
apps/sim/connectors/{service}/index.ts # Barrel export
apps/sim/connectors/registry.ts # Connector registry entry
apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc.
apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.)
apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS
apps/sim/lib/oauth/types.ts # OAuthService union type
apps/sim/components/icons.tsx # Icon definition for the service
```
If the connector uses selectors, also read:
```
apps/sim/hooks/selectors/registry.ts # Selector key definitions
apps/sim/hooks/selectors/types.ts # SelectorKey union type
apps/sim/lib/workflows/subblocks/context.ts # SELECTOR_CONTEXT_FIELDS
```
## Step 2: Pull API Documentation
Fetch the official API docs for the service. This is the **source of truth** for:
- Endpoint URLs, HTTP methods, and auth headers
- Required vs optional parameters
- Parameter types and allowed values
- Response shapes and field names
- Pagination patterns (cursor, offset, next token)
- Rate limits and error formats
- OAuth scopes and their meanings
Use Context7 (resolve-library-id → query-docs) or WebFetch to retrieve documentation. If both fail, note which claims are based on training knowledge vs verified docs.
## Step 3: Validate API Endpoints
For **every** API call in the connector (`listDocuments`, `getDocument`, `validateConfig`, and any helper functions), verify against the API docs:
### URLs and Methods
- [ ] Base URL is correct for the service's API version
- [ ] Endpoint paths match the API docs exactly
- [ ] HTTP method is correct (GET, POST, PUT, PATCH, DELETE)
- [ ] Path parameters are correctly interpolated and URI-encoded where needed
- [ ] Query parameters use correct names and formats per the API docs
### Headers
- [ ] Authorization header uses the correct format:
- OAuth: `Authorization: Bearer ${accessToken}`
- API Key: correct header name per the service's docs
- [ ] `Content-Type` is set for POST/PUT/PATCH requests
- [ ] Any service-specific headers are present (e.g., `Notion-Version`, `Dropbox-API-Arg`)
- [ ] No headers are sent that the API doesn't support or silently ignores
### Request Bodies
- [ ] POST/PUT body fields match API parameter names exactly
- [ ] Required fields are always sent
- [ ] Optional fields are conditionally included (not sent as `null` or empty unless the API expects that)
- [ ] Field value types match API expectations (string vs number vs boolean)
### Input Sanitization
- [ ] User-controlled values interpolated into query strings are properly escaped:
- OData `$filter`: single quotes escaped with `''` (e.g., `externalId.replace(/'/g, "''")`)
- SOQL: single quotes escaped with `\'`
- GraphQL variables: passed as variables, not interpolated into query strings
- URL path segments: `encodeURIComponent()` applied
- [ ] URL-type config fields (e.g., `siteUrl`, `instanceUrl`) are normalized:
- Strip `https://` / `http://` prefix if the API expects bare domains
- Strip trailing `/`
- Apply `.trim()` before validation
### Response Parsing
- [ ] Response structure is correctly traversed (e.g., `data.results` vs `data.items` vs `data`)
- [ ] Field names extracted match what the API actually returns
- [ ] Nullable fields are handled with `?? null` or `|| undefined`
- [ ] Error responses are checked before accessing data fields
## Step 4: Validate OAuth Scopes (if OAuth connector)
Scopes must be correctly declared and sufficient for all API calls the connector makes.
### Connector requiredScopes
- [ ] `requiredScopes` in the connector's `auth` config lists all scopes needed by the connector
- [ ] Each scope in `requiredScopes` is a real, valid scope recognized by the service's API
- [ ] No invalid, deprecated, or made-up scopes are listed
- [ ] No unnecessary excess scopes beyond what the connector actually needs
### Scope Subset Validation (CRITICAL)
- [ ] Every scope in `requiredScopes` exists in the OAuth provider's `scopes` array in `lib/oauth/oauth.ts`
- [ ] Find the provider in `OAUTH_PROVIDERS[providerGroup].services[serviceId].scopes`
- [ ] Verify: `requiredScopes``OAUTH_PROVIDERS scopes` (every required scope is present in the provider config)
- [ ] If a required scope is NOT in the provider config, flag as **critical** — the connector will fail at runtime
### Scope Sufficiency
For each API endpoint the connector calls:
- [ ] Identify which scopes are required per the API docs
- [ ] Verify those scopes are included in the connector's `requiredScopes`
- [ ] If the connector calls endpoints requiring scopes not in `requiredScopes`, flag as **warning**
### Token Refresh Config
- [ ] Check the `getOAuthTokenRefreshConfig` function in `lib/oauth/oauth.ts` for this provider
- [ ] `useBasicAuth` matches the service's token exchange requirements
- [ ] `supportsRefreshTokenRotation` matches whether the service issues rotating refresh tokens
- [ ] Token endpoint URL is correct
## Step 5: Validate Pagination
### listDocuments Pagination
- [ ] Cursor/pagination parameter name matches the API docs
- [ ] Response pagination field is correctly extracted (e.g., `next_cursor`, `nextPageToken`, `@odata.nextLink`, `offset`)
- [ ] `hasMore` is correctly determined from the response
- [ ] `nextCursor` is correctly passed back for the next page
- [ ] `maxItems` / `maxRecords` cap is correctly applied across pages using `syncContext.totalDocsFetched`
- [ ] Page size is within the API's allowed range (not exceeding max page size)
- [ ] Last page precision: when a `maxItems` cap exists, the final page request uses `Math.min(PAGE_SIZE, remaining)` to avoid fetching more records than needed
- [ ] No off-by-one errors in pagination tracking
- [ ] The connector does NOT hit known API pagination limits silently (e.g., HubSpot search 10k cap)
### Pagination State Across Pages
- [ ] `syncContext` is used to cache state across pages (user names, field maps, instance URLs, portal IDs, etc.)
- [ ] Cached state in `syncContext` is correctly initialized on first page and reused on subsequent pages
## Step 6: Validate Data Transformation
### ExternalDocument Construction
- [ ] `externalId` is a stable, unique identifier from the source API
- [ ] `title` is extracted from the correct field and has a sensible fallback (e.g., `'Untitled'`)
- [ ] `content` is plain text — HTML content is stripped using `htmlToPlainText` from `@/connectors/utils`
- [ ] `mimeType` is `'text/plain'`
- [ ] `contentHash` is computed using `computeContentHash` from `@/connectors/utils`
- [ ] `sourceUrl` is a valid, complete URL back to the original resource (not relative)
- [ ] `metadata` contains all fields referenced by `mapTags` and `tagDefinitions`
### Content Extraction
- [ ] Rich text / HTML fields are converted to plain text before indexing
- [ ] Important content is not silently dropped (e.g., nested blocks, table cells, code blocks)
- [ ] Content is not silently truncated without logging a warning
- [ ] Empty/blank documents are properly filtered out
- [ ] Size checks use `Buffer.byteLength(text, 'utf8')` not `text.length` when comparing against byte-based limits (e.g., `MAX_FILE_SIZE` in bytes)
## Step 7: Validate Tag Definitions and mapTags
### tagDefinitions
- [ ] Each `tagDefinition` has an `id`, `displayName`, and `fieldType`
- [ ] `fieldType` matches the actual data type: `'text'` for strings, `'number'` for numbers, `'date'` for dates, `'boolean'` for booleans
- [ ] Every `id` in `tagDefinitions` is returned by `mapTags`
- [ ] No `tagDefinition` references a field that `mapTags` never produces
### mapTags
- [ ] Return keys match `tagDefinition` `id` values exactly
- [ ] Date values are properly parsed using `parseTagDate` from `@/connectors/utils`
- [ ] Array values are properly joined using `joinTagArray` from `@/connectors/utils`
- [ ] Number values are validated (not `NaN`)
- [ ] Metadata field names accessed in `mapTags` match what `listDocuments`/`getDocument` store in `metadata`
## Step 8: Validate Config Fields and Validation
### configFields
- [ ] Every field has `id`, `title`, `type`
- [ ] `required` is set explicitly (not omitted)
- [ ] Dropdown fields have `options` with `label` and `id` for each option
- [ ] Selector fields follow the canonical pair pattern:
- A `type: 'selector'` field with `selectorKey`, `canonicalParamId`, `mode: 'basic'`
- A `type: 'short-input'` field with the same `canonicalParamId`, `mode: 'advanced'`
- `required` is identical on both fields in the pair
- [ ] `selectorKey` values exist in the selector registry
- [ ] `dependsOn` references selector field `id` values, not `canonicalParamId`
### validateConfig
- [ ] Validates all required fields are present before making API calls
- [ ] Validates optional numeric fields (checks `Number.isNaN`, positive values)
- [ ] Makes a lightweight API call to verify access (e.g., fetch 1 record, get profile)
- [ ] Uses `VALIDATE_RETRY_OPTIONS` for retry budget
- [ ] Returns `{ valid: true }` on success
- [ ] Returns `{ valid: false, error: 'descriptive message' }` on failure
- [ ] Catches exceptions and returns user-friendly error messages
- [ ] Does NOT make expensive calls (full data listing, large queries)
## Step 9: Validate getDocument
- [ ] Fetches a single document by `externalId`
- [ ] Returns `null` for 404 / not found (does not throw)
- [ ] Returns the same `ExternalDocument` shape as `listDocuments`
- [ ] Handles all content types that `listDocuments` can produce (e.g., if `listDocuments` returns both pages and blogposts, `getDocument` must handle both — not hardcode one endpoint)
- [ ] Forwards `syncContext` if it needs cached state (user names, field maps, etc.)
- [ ] Error handling is graceful (catches, logs, returns null or throws with context)
- [ ] Does not redundantly re-fetch data already included in the initial API response (e.g., if comments come back with the post, don't fetch them again separately)
## Step 10: Validate General Quality
### fetchWithRetry Usage
- [ ] All external API calls use `fetchWithRetry` from `@/lib/knowledge/documents/utils`
- [ ] No raw `fetch()` calls to external APIs
- [ ] `VALIDATE_RETRY_OPTIONS` used in `validateConfig`
- [ ] If `validateConfig` calls a shared helper (e.g., `linearGraphQL`, `resolveId`), that helper must accept and forward `retryOptions` to `fetchWithRetry`
- [ ] Default retry options used in `listDocuments`/`getDocument`
### API Efficiency
- [ ] APIs that support field selection (e.g., `$select`, `sysparm_fields`, `fields`) should request only the fields the connector needs — in both `listDocuments` AND `getDocument`
- [ ] No redundant API calls: if a helper already fetches data (e.g., site metadata), callers should reuse the result instead of making a second call for the same information
- [ ] Sequential per-item API calls (fetching details for each document in a loop) should be batched with `Promise.all` and a concurrency limit of 3-5
### Error Handling
- [ ] Individual document failures are caught and logged without aborting the sync
- [ ] API error responses include status codes in error messages
- [ ] No unhandled promise rejections in concurrent operations
### Concurrency
- [ ] Concurrent API calls use reasonable batch sizes (3-5 is typical)
- [ ] No unbounded `Promise.all` over large arrays
### Logging
- [ ] Uses `createLogger` from `@sim/logger` (not `console.log`)
- [ ] Logs sync progress at `info` level
- [ ] Logs errors at `warn` or `error` level with context
### Registry
- [ ] Connector is exported from `connectors/{service}/index.ts`
- [ ] Connector is registered in `connectors/registry.ts`
- [ ] Registry key matches the connector's `id` field
## Step 11: Report and Fix
### Report Format
Group findings by severity:
**Critical** (will cause runtime errors, data loss, or auth failures):
- Wrong API endpoint URL or HTTP method
- Invalid or missing OAuth scopes (not in provider config)
- Incorrect response field mapping (accessing wrong path)
- SOQL/query fields that don't exist on the target object
- Pagination that silently hits undocumented API limits
- Missing error handling that would crash the sync
- `requiredScopes` not a subset of OAuth provider scopes
- Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping
**Warning** (incorrect behavior, data quality issues, or convention violations):
- HTML content not stripped via `htmlToPlainText`
- `getDocument` not forwarding `syncContext`
- `getDocument` hardcoded to one content type when `listDocuments` returns multiple (e.g., only pages but not blogposts)
- Missing `tagDefinition` for metadata fields returned by `mapTags`
- Incorrect `useBasicAuth` or `supportsRefreshTokenRotation` in token refresh config
- Invalid scope names that the API doesn't recognize (even if silently ignored)
- Private resources excluded from name-based lookup despite scopes being available
- Silent data truncation without logging
- Size checks using `text.length` (character count) instead of `Buffer.byteLength` (byte count) for byte-based limits
- URL-type config fields not normalized (protocol prefix, trailing slashes cause API failures)
- `VALIDATE_RETRY_OPTIONS` not threaded through helper functions called by `validateConfig`
**Suggestion** (minor improvements):
- Missing incremental sync support despite API supporting it
- Overly broad scopes that could be narrowed (not wrong, but could be tighter)
- Source URL format could be more specific
- Missing `orderBy` for deterministic pagination
- Redundant API calls that could be cached in `syncContext`
- Sequential per-item API calls that could be batched with `Promise.all` (concurrency 3-5)
- API supports field selection but connector fetches all fields (e.g., missing `$select`, `sysparm_fields`, `fields`)
- `getDocument` re-fetches data already included in the initial API response (e.g., comments returned with post)
- Last page of pagination requests full `PAGE_SIZE` when fewer records remain (`Math.min(PAGE_SIZE, remaining)`)
### Fix All Issues
After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity.
### Validation Output
After fixing, confirm:
1. `bun run lint` passes
2. TypeScript compiles clean
3. Re-read all modified files to verify fixes are correct
## Checklist Summary
- [ ] Read connector implementation, types, utils, registry, and OAuth config
- [ ] Pulled and read official API documentation for the service
- [ ] Validated every API endpoint URL, method, headers, and body against API docs
- [ ] Validated input sanitization: no query/filter injection, URL fields normalized
- [ ] Validated OAuth scopes: `requiredScopes` ⊆ OAuth provider `scopes` in `oauth.ts`
- [ ] Validated each scope is real and recognized by the service's API
- [ ] Validated scopes are sufficient for all API endpoints the connector calls
- [ ] Validated token refresh config (`useBasicAuth`, `supportsRefreshTokenRotation`)
- [ ] Validated pagination: cursor names, page sizes, hasMore logic, no silent caps
- [ ] Validated data transformation: plain text extraction, HTML stripping, content hashing
- [ ] Validated tag definitions match mapTags output, correct fieldTypes
- [ ] Validated config fields: canonical pairs, selector keys, required flags
- [ ] Validated validateConfig: lightweight check, error messages, retry options
- [ ] Validated getDocument: null on 404, all content types handled, no redundant re-fetches, syncContext forwarding
- [ ] Validated fetchWithRetry used for all external calls (no raw fetch), VALIDATE_RETRY_OPTIONS threaded through helpers
- [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched
- [ ] Validated error handling: graceful failures, no unhandled rejections
- [ ] Validated logging: createLogger, no console.log
- [ ] Validated registry: correct export, correct key
- [ ] Reported all issues grouped by severity
- [ ] Fixed all critical and warning issues
- [ ] Ran `bun run lint` after fixes
- [ ] Verified TypeScript compiles clean

View File

@@ -1,9 +1,21 @@
import type { ReactNode } from 'react'
import type { Viewport } from 'next'
export default function RootLayout({ children }: { children: ReactNode }) {
return children
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
],
}
export const metadata = {
metadataBase: new URL('https://docs.sim.ai'),
title: {
@@ -12,6 +24,9 @@ export const metadata = {
},
description:
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
applicationName: 'Sim Docs',
generator: 'Next.js',
referrer: 'origin-when-cross-origin' as const,
keywords: [
'AI agents',
'agentic workforce',
@@ -37,17 +52,28 @@ export const metadata = {
manifest: '/favicon/site.webmanifest',
icons: {
icon: [
{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' },
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
{ url: '/favicon/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
{ url: '/favicon/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
],
apple: '/favicon/apple-touch-icon.png',
shortcut: '/favicon/favicon.ico',
shortcut: '/icon.svg',
},
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Sim Docs',
},
formatDetection: {
telephone: false,
},
other: {
'apple-mobile-web-app-capable': 'yes',
'mobile-web-app-capable': 'yes',
'msapplication-TileColor': '#33C482',
},
openGraph: {
type: 'website',
locale: 'en_US',

View File

@@ -1,18 +1,7 @@
'use client'
import { memo } from 'react'
import { memo, useEffect, useState } from 'react'
/** Shared corner radius from Figma export for all decorative rects. */
const RX = '2.59574'
const ENTER_STAGGER = 0.06
const ENTER_DURATION = 0.3
const EXIT_STAGGER = 0.12
const EXIT_DURATION = 0.5
const INITIAL_HOLD = 3000
const HOLD_BETWEEN = 3000
const TRANSITION_PAUSE = 400
interface BlockRect {
opacity: number
width: string
@@ -23,8 +12,6 @@ interface BlockRect {
transform?: string
}
type AnimState = 'visible' | 'exiting' | 'hidden'
const RECTS = {
topRight: [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
@@ -67,76 +54,33 @@ const RECTS = {
fill: '#FA4EDF',
},
],
left: [
{
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)',
},
bottomLeft: [
{ 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: '#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)',
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',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
],
right: [
bottomRight: [
{
opacity: 0.6,
width: '16.8626',
@@ -175,68 +119,33 @@ const RECTS = {
{
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',
height: '34.24',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.787 102.384)',
transform: 'matrix(-1 0 0 1 33.787 68)',
},
{
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)',
fill: '#1A8FCC',
transform: 'matrix(-1 0 0 1 33.787 85)',
},
],
} as const satisfies Record<string, readonly BlockRect[]>
type Position = keyof typeof RECTS
function enterTime(pos: Position): number {
return (RECTS[pos].length - 1) * ENTER_STAGGER + ENTER_DURATION
}
function exitTime(pos: Position): number {
return (RECTS[pos].length - 1) * EXIT_STAGGER + EXIT_DURATION
}
interface BlockGroupProps {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
animState: AnimState
globalOpacity: number
}
const GLOBAL_OPACITY = 0.55
const BlockGroup = memo(function BlockGroup({
width,
height,
viewBox,
rects,
animState,
globalOpacity,
}: BlockGroupProps) {
const isVisible = animState === 'visible'
const isExiting = animState === 'exiting'
}: {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
}) {
return (
<svg
width={width}
@@ -245,7 +154,7 @@ const BlockGroup = memo(function BlockGroup({
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
style={{ opacity: globalOpacity }}
style={{ opacity: GLOBAL_OPACITY }}
>
{rects.map((r, i) => (
<rect
@@ -257,114 +166,29 @@ const BlockGroup = memo(function BlockGroup({
rx={RX}
fill={r.fill}
transform={r.transform}
style={{
opacity: isVisible ? r.opacity : 0,
transition: `opacity ${isExiting ? EXIT_DURATION : ENTER_DURATION}s ease ${
isVisible ? i * ENTER_STAGGER : isExiting ? i * EXIT_STAGGER : 0
}s`,
}}
opacity={r.opacity}
/>
))}
</svg>
)
})
function useGroupState(): [AnimState, (s: AnimState) => void] {
return useState<AnimState>('visible')
}
function useBlockCycle() {
const [topRight, setTopRight] = useGroupState()
const [left, setLeft] = useGroupState()
const [right, setRight] = useGroupState()
useEffect(() => {
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) return
const cancelled = { current: false }
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
async function exit(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
if (cancelled.current) return
setter('exiting')
await wait(exitTime(pos) * 1000)
if (cancelled.current) return
setter('hidden')
await wait(pauseAfter)
}
async function enter(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
if (cancelled.current) return
setter('visible')
await wait(enterTime(pos) * 1000 + pauseAfter)
}
const run = async () => {
await wait(INITIAL_HOLD)
while (!cancelled.current) {
await exit(setTopRight, 'topRight', TRANSITION_PAUSE)
await exit(setLeft, 'left', HOLD_BETWEEN)
await enter(setLeft, 'left', TRANSITION_PAUSE)
await enter(setTopRight, 'topRight', TRANSITION_PAUSE)
await exit(setRight, 'right', HOLD_BETWEEN)
await enter(setRight, 'right', HOLD_BETWEEN)
}
}
run()
return () => {
cancelled.current = true
}
}, [])
return { topRight, left, right } as const
}
/**
* Ambient animated block decorations for the docs layout.
* Adapts the landing page's colorful block patterns with slightly reduced
* opacity and the same staggered enter/exit animation cycle.
*/
export function AnimatedBlocks() {
const states = useBlockCycle()
return (
<div
className='pointer-events-none fixed inset-0 z-0 hidden overflow-hidden lg:block'
aria-hidden='true'
>
<div className='absolute top-[93px] right-0 w-[calc(140px+10.76vw)] max-w-[295px]'>
<BlockGroup
width={295}
height={34}
viewBox='0 0 295 34'
rects={RECTS.topRight}
animState={states.topRight}
globalOpacity={0.75}
/>
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.topRight} />
</div>
<div className='-translate-y-1/2 absolute top-[50%] left-0 w-[calc(16px+1.25vw)] max-w-[34px] scale-x-[-1]'>
<BlockGroup
width={34}
height={226}
viewBox='0 0 34 226.021'
rects={RECTS.left}
animState={states.left}
globalOpacity={0.75}
/>
<div className='-left-24 absolute bottom-0 w-[calc(140px+10.76vw)] max-w-[295px] rotate-180'>
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.bottomLeft} />
</div>
<div className='-translate-y-1/2 absolute top-[50%] right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
<BlockGroup
width={34}
height={205}
viewBox='0 0 34 204.769'
rects={RECTS.right}
animState={states.right}
globalOpacity={0.75}
/>
<div className='-bottom-2 absolute right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
<BlockGroup width={34} height={102} viewBox='0 0 34 102' rects={RECTS.bottomRight} />
</div>
</div>
)

View File

@@ -13,6 +13,7 @@
"mailer",
"skills",
"knowledgebase",
"tables",
"variables",
"credentials",
"execution",

View File

@@ -0,0 +1,158 @@
---
title: Tables
description: Store, query, and manage structured data directly within your workspace
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
Tables let you store and manage structured data directly in your workspace. Use them to maintain reference data, collect workflow outputs, or build lightweight databases — all without leaving Sim.
<Image src="/static/tables/tables-overview.png" alt="Tables view showing structured data with typed columns for name, title, company, role, and more" width={800} height={500} />
Each table has a schema of typed columns, supports filtering and sorting, and is fully accessible through the [Tables API](/docs/en/api-reference/(generated)/tables).
## Creating a Table
1. Open the **Tables** section from your workspace sidebar
2. Click **New table**
3. Name your table and start adding columns
Tables start with a single text column. Add more columns by clicking **New column** in the column header area.
## Column Types
Each column has a type that determines how values are stored and validated.
| Type | Description | Example Values |
|------|-------------|----------------|
| **Text** | Free-form string | `"Acme Corp"`, `"hello@example.com"` |
| **Number** | Numeric value | `42`, `3.14`, `-100` |
| **Boolean** | True or false | `true`, `false` |
| **Date** | Date value | `2026-03-16` |
| **JSON** | Structured object or array | `{"key": "value"}`, `[1, 2, 3]` |
<Callout type="info">
Column types are enforced on input. For example, typing into a Number column is restricted to digits, dots, and minus signs. Non-numeric values entered via paste are coerced to `0`.
</Callout>
## Working with Rows
### Adding Rows
- Click **New row** below the last row to append a new row
- Press **Shift + Enter** while a cell is selected to insert a row below
- Paste tabular data (from a spreadsheet or TSV) to bulk-create rows
### Editing Cells
Click a cell to select it, then press **Enter**, **F2**, or start typing to edit. Press **Escape** to cancel, or **Tab** to save and move to the next cell.
### Selecting Rows
Click a row's checkbox to select it. Selecting additional checkboxes adds to the selection without clearing previous selections.
| Action | Behavior |
|--------|----------|
| Click checkbox | Toggle that row's selection |
| Shift + click checkbox | Select range from last clicked to current |
| Click header checkbox | Select all / deselect all |
| Shift + Space | Toggle row selection from keyboard |
### Deleting Rows
Right-click a selected row (or group of selected rows) and choose **Delete row** from the context menu.
## Filtering and Sorting
Use the toolbar above the table to filter and sort your data.
- **Filter**: Set conditions on any column (e.g., "Name contains Acme"). Multiple filters are combined with AND logic.
- **Sort**: Order rows by any column, ascending or descending.
Filters and sorts are applied in real time and do not modify the underlying data.
## Keyboard Shortcuts
All shortcuts work when the table is focused and no cell is being edited.
<Callout type="info">
**Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux.
</Callout>
### Navigation
| Shortcut | Action |
|----------|--------|
| Arrow keys | Move one cell |
| `Mod` + Arrow keys | Jump to edge of table |
| `Tab` / `Shift` + `Tab` | Move to next / previous cell |
| `Escape` | Clear selection |
### Selection
| Shortcut | Action |
|----------|--------|
| `Shift` + Arrow keys | Extend selection by one cell |
| `Mod` + `Shift` + Arrow keys | Extend selection to edge |
| `Mod` + `A` | Select all rows |
| `Shift` + `Space` | Toggle current row selection |
### Editing
| Shortcut | Action |
|----------|--------|
| `Enter` or `F2` | Start editing selected cell |
| `Escape` | Cancel editing |
| Type any character | Start editing with that character |
| `Shift` + `Enter` | Insert new row below |
| `Space` | Expand row details |
### Clipboard
| Shortcut | Action |
|----------|--------|
| `Mod` + `C` | Copy selected cells |
| `Mod` + `X` | Cut selected cells |
| `Mod` + `V` | Paste |
| `Delete` / `Backspace` | Clear selected cells (all columns when using checkbox selection) |
### History
| Shortcut | Action |
|----------|--------|
| `Mod` + `Z` | Undo |
| `Mod` + `Shift` + `Z` | Redo |
| `Mod` + `Y` | Redo (alternative) |
## Using Tables in Workflows
Tables can be read from and written to within your workflows using the **Table** block. Common patterns include:
- **Lookup**: Query a table for reference data (e.g., pricing rules, customer metadata)
- **Write-back**: Store workflow outputs in a table for later review or reporting
- **Iteration**: Process each row in a table as part of a batch workflow
## API Access
Tables are fully accessible through the REST API. You can create, read, update, and delete both tables and rows programmatically.
See the [Tables API Reference](/docs/en/api-reference/(generated)/tables) for endpoints, parameters, and examples.
## Best Practices
- **Use typed columns** to enforce data integrity — prefer Number and Boolean over storing everything as Text
- **Name columns descriptively** so they are self-documenting when referenced in workflows
- **Use JSON columns sparingly** — they are flexible but harder to filter and sort against
- **Leverage the API** for bulk imports rather than manually entering large datasets
<FAQ items={[
{ question: "Is there a row limit per table?", answer: "Tables are designed for working datasets. For very large datasets (100k+ rows), consider paginating API reads or splitting data across multiple tables." },
{ question: "Can I import data from a spreadsheet?", answer: "Yes. Copy rows from any spreadsheet application and paste them directly into the table. Column values will be validated against the column types." },
{ question: "Do tables support formulas?", answer: "Tables store raw data and do not support computed formulas. Use workflow logic (Function block or Agent block) to derive computed values and write them back to the table." },
{ question: "Can multiple workflows write to the same table?", answer: "Yes. Table writes are atomic at the row level, so multiple workflows can safely write to the same table concurrently." },
{ question: "How do I reference a table from a workflow?", answer: "Use the Table block in your workflow. Select the target table from the dropdown, choose an operation (read, write, update), and configure the parameters." },
{ question: "Are tables shared across workspace members?", answer: "Yes. Tables are workspace-scoped and accessible to all members with appropriate permissions." },
{ question: "Can I undo changes?", answer: "In the table editor, Cmd/Ctrl+Z undoes recent cell edits, row insertions, and row deletions. API-driven changes are not covered by the editor's undo history." },
]} />

View File

@@ -29,8 +29,8 @@
"next": "16.1.6",
"next-themes": "^0.4.6",
"postgres": "^3.4.5",
"react": "19.2.1",
"react-dom": "19.2.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"shiki": "4.0.0",
"tailwind-merge": "^3.0.2"
},

View File

@@ -1,21 +1,42 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"name": "Sim Documentation — Build AI Agents & Run Your Agentic Workforce",
"short_name": "Sim Docs",
"description": "Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"src": "/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"src": "/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/favicon/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"theme_color": "#33C482",
"background_color": "#ffffff",
"display": "standalone"
"display": "standalone",
"categories": ["productivity", "developer", "business"],
"lang": "en-US",
"dir": "ltr"
}

14
apps/docs/public/icon.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="#0B0B0B"/>
<g transform="translate(2.75,2.75) scale(0.0473)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M107.822 93.7612C107.822 97.3481 106.403 100.792 103.884 103.328L103.523 103.692C101.006 106.236 97.5855 107.658 94.0236 107.658H13.4455C6.02456 107.658 0 113.718 0 121.191V208.332C0 215.806 6.02456 221.866 13.4455 221.866H99.9622C107.383 221.866 113.4 215.806 113.4 208.332V126.745C113.4 123.419 114.71 120.228 117.047 117.874C119.377 115.527 122.546 114.207 125.849 114.207H207.777C215.198 114.207 221.214 108.148 221.214 100.674V13.5333C221.214 6.05956 215.198 0 207.777 0H121.26C113.839 0 107.822 6.05956 107.822 13.5333V93.7612ZM134.078 18.55H194.952C199.289 18.55 202.796 22.0893 202.796 26.4503V87.7574C202.796 92.1178 199.289 95.6577 194.952 95.6577H134.078C129.748 95.6577 126.233 92.1178 126.233 87.7574V26.4503C126.233 22.0893 129.748 18.55 134.078 18.55Z" fill="#33C482"/>
<path d="M207.878 129.57H143.554C135.756 129.57 129.434 135.937 129.434 143.791V207.784C129.434 215.638 135.756 222.005 143.554 222.005H207.878C215.677 222.005 221.999 215.638 221.999 207.784V143.791C221.999 135.937 215.677 129.57 207.878 129.57Z" fill="#33C482"/>
<path d="M207.878 129.266H143.554C135.756 129.266 129.434 135.632 129.434 143.487V207.479C129.434 215.333 135.756 221.699 143.554 221.699H207.878C215.677 221.699 221.999 215.333 221.999 207.479V143.487C221.999 135.632 215.677 129.266 207.878 129.266Z" fill="url(#paint0_linear)" fill-opacity="0.2"/>
</g>
<defs>
<linearGradient id="paint0_linear" x1="129.434" y1="129.266" x2="185.629" y2="185.33" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

View File

@@ -2,32 +2,22 @@
import { useEffect } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav'
function isColorDark(hexColor: string): boolean {
const hex = hexColor.replace('#', '')
const r = Number.parseInt(hex.substr(0, 2), 16)
const g = Number.parseInt(hex.substr(2, 2), 16)
const b = Number.parseInt(hex.substr(4, 2), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance < 0.5
}
import Navbar from '@/app/(home)/components/navbar/navbar'
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
useEffect(() => {
const rootStyle = getComputedStyle(document.documentElement)
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
if (brandBackground && isColorDark(brandBackground)) {
document.body.classList.add('auth-dark-bg')
} else {
document.body.classList.remove('auth-dark-bg')
document.documentElement.classList.add('dark')
return () => {
document.documentElement.classList.remove('dark')
}
}, [])
return (
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
<Nav hideAuthButtons={true} variant='auth' />
<AuthBackground className='dark font-[430] font-season'>
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
<header className='shrink-0 bg-[#1C1C1C]'>
<Navbar logoOnly />
</header>
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>{children}</div>
</div>

View File

@@ -1,44 +1,93 @@
export default function AuthBackgroundSVG() {
return (
<svg
aria-hidden='true'
className='pointer-events-none fixed inset-0 h-full w-full'
style={{ zIndex: 5 }}
viewBox='0 0 1880 960'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid slice'
>
{/* Right side paths - extended to connect */}
<path
d='M1393.53 42.8889C1545.99 173.087 1688.28 339.75 1878.44 817.6'
stroke='#E7E4EF'
strokeWidth='2'
/>
<path d='M1624.21 960L1625.78 0' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1832.67 715.81L1880 716.031' stroke='#E7E4EF' strokeWidth='2' />
<path d='M1393.4 40V0' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1393.03' cy='40.0186' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1625.28' cy='303.147' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='1837.37' cy='715.81' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<>
{/* Top-left card outline */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-3vw] left-[-10vw] z-[5] aspect-[344/328] w-[38vw]'
>
<svg
viewBox='0 0 344 328'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
className='h-full w-full'
>
<path
d='M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z'
fill='#1C1C1C'
stroke='#323232'
strokeOpacity='0.4'
strokeWidth='1'
/>
</svg>
</div>
{/* Left side paths - extended to connect */}
<path
d='M160 157.764C319.811 136.451 417.278 102.619 552.39 0'
stroke='#E7E4EF'
strokeWidth='2'
/>
<path d='M310.22 803.025V0' stroke='#E7E4EF' strokeWidth='2' />
<path
d='M160 530.184C256.142 655.353 308.338 749.141 348.382 960'
stroke='#E7E4EF'
strokeWidth='2'
/>
<path d='M160 157.764V960' stroke='#E7E4EF' strokeWidth='2' />
<path d='M-50 157.764L160 157.764' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='160' cy='157.764' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='310.22' cy='803.025' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
<circle cx='160' cy='530.184' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
</svg>
{/* Top-right card outline */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-4vw] right-[-14vw] z-[5] aspect-[471/470] w-[42vw]'
>
<svg
viewBox='0 0 471 470'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
className='h-full w-full'
>
<path
d='M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z'
fill='#1C1C1C'
stroke='#323232'
strokeOpacity='0.4'
strokeWidth='1'
/>
</svg>
</div>
{/* Bottom-left card outline (mirrored) */}
<div
aria-hidden='true'
className='pointer-events-none absolute bottom-[-6vw] left-[-12vw] z-[5] aspect-[471/470] w-[36vw] rotate-180'
>
<svg
viewBox='0 0 471 470'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
className='h-full w-full'
>
<path
d='M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z'
fill='#1C1C1C'
stroke='#323232'
strokeOpacity='0.4'
strokeWidth='1'
/>
</svg>
</div>
{/* Bottom-right card outline (mirrored) */}
<div
aria-hidden='true'
className='pointer-events-none absolute right-[-12vw] bottom-[-5vw] z-[5] aspect-[344/328] w-[34vw] rotate-180'
>
<svg
viewBox='0 0 344 328'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
className='h-full w-full'
>
<path
d='M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z'
fill='#1C1C1C'
stroke='#323232'
strokeOpacity='0.4'
strokeWidth='1'
/>
</svg>
</div>
</>
)
}

View File

@@ -8,10 +8,10 @@ type AuthBackgroundProps = {
export default function AuthBackground({ className, children }: AuthBackgroundProps) {
return (
<div className={cn('relative min-h-screen w-full overflow-hidden', className)}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
<div className={cn('fixed inset-0 overflow-hidden', className)}>
<div className='-z-50 pointer-events-none absolute inset-0 bg-[#1C1C1C]' />
<AuthBackgroundSVG />
<div className='relative z-20'>{children}</div>
<div className='relative z-20 h-full overflow-auto'>{children}</div>
</div>
)
}

View File

@@ -2,36 +2,20 @@
import { forwardRef, useState } from 'react'
import { ArrowRight, ChevronRight, Loader2 } from 'lucide-react'
import { Button, type ButtonProps as EmcnButtonProps } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
import { useBrandConfig } from '@/ee/whitelabeling'
export interface BrandedButtonProps extends Omit<EmcnButtonProps, 'variant' | 'size'> {
/** Shows loading spinner and disables button */
export interface BrandedButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean
/** Text to show when loading (appends "..." automatically) */
loadingText?: string
/** Show arrow animation on hover (default: true) */
showArrow?: boolean
/** Make button full width (default: true) */
fullWidth?: boolean
}
/**
* Branded button for auth and status pages.
* Automatically detects whitelabel customization and applies appropriate styling.
*
* @example
* ```tsx
* // Primary branded button with arrow
* <BrandedButton onClick={handleSubmit}>Sign In</BrandedButton>
*
* // Loading state
* <BrandedButton loading loadingText="Signing in">Sign In</BrandedButton>
*
* // Without arrow animation
* <BrandedButton showArrow={false}>Continue</BrandedButton>
* ```
* Default: white button matching the landing page "Get started" style.
* Whitelabel: uses the brand's primary color as background with white text.
*/
export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
(
@@ -49,7 +33,8 @@ export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
},
ref
) => {
const buttonClass = useBrandedButtonClass()
const brand = useBrandConfig()
const hasCustomColor = brand.isWhitelabeled && Boolean(brand.theme?.primaryColor)
const [isHovered, setIsHovered] = useState(false)
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
@@ -63,15 +48,32 @@ export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
}
return (
<Button
<button
ref={ref}
variant='branded'
size='branded'
{...props}
disabled={disabled || loading}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(buttonClass, 'group', fullWidth && 'w-full', className)}
{...props}
className={cn(
'group inline-flex h-[32px] items-center justify-center gap-[8px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px] transition-colors disabled:cursor-not-allowed disabled:opacity-50',
!hasCustomColor &&
'border-[#FFFFFF] bg-[#FFFFFF] text-black hover:border-[#E0E0E0] hover:bg-[#E0E0E0]',
fullWidth && 'w-full',
className
)}
style={
hasCustomColor
? {
backgroundColor: isHovered
? (brand.theme?.primaryHoverColor ?? brand.theme?.primaryColor)
: brand.theme?.primaryColor,
borderColor: isHovered
? (brand.theme?.primaryHoverColor ?? brand.theme?.primaryColor)
: brand.theme?.primaryColor,
color: '#FFFFFF',
}
: undefined
}
>
{loading ? (
<span className='flex items-center gap-2'>
@@ -92,7 +94,7 @@ export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
) : (
children
)}
</Button>
</button>
)
}
)

View File

@@ -1,10 +1,9 @@
'use client'
import { type ReactNode, useEffect, useState } from 'react'
import { Button } from '@/components/emcn'
import { GithubIcon, GoogleIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { client } from '@/lib/auth/auth-client'
import { inter } from '@/app/_styles/fonts/inter/inter'
interface SocialLoginButtonsProps {
githubAvailable: boolean
@@ -82,7 +81,7 @@ export function SocialLoginButtons({
const githubButton = (
<Button
variant='outline'
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
className='w-full rounded-[10px]'
disabled={!githubAvailable || isGithubLoading}
onClick={signInWithGithub}
>
@@ -94,7 +93,7 @@ export function SocialLoginButtons({
const googleButton = (
<Button
variant='outline'
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
className='w-full rounded-[10px]'
disabled={!googleAvailable || isGoogleLoading}
onClick={signInWithGoogle}
>
@@ -110,7 +109,7 @@ export function SocialLoginButtons({
}
return (
<div className={`${inter.className} grid gap-3 font-light`}>
<div className='grid gap-3 font-light'>
{googleAvailable && googleButton}
{githubAvailable && githubButton}
{children}

View File

@@ -1,7 +1,7 @@
'use client'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
@@ -38,7 +38,7 @@ export function SSOLoginButton({
'flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200'
)
const outlineBtnClasses = cn('w-full rounded-[10px] shadow-sm hover:bg-gray-50')
const outlineBtnClasses = cn('w-full rounded-[10px]')
return (
<Button

View File

@@ -1,69 +1,41 @@
'use client'
import type { ReactNode } from 'react'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav'
import Navbar from '@/app/(home)/components/navbar/navbar'
import { SupportFooter } from './support-footer'
export interface StatusPageLayoutProps {
/** Page title displayed prominently */
title: string
/** Description text below the title */
description: string | ReactNode
/** Content to render below the title/description (usually buttons) */
children?: ReactNode
/** Whether to show the support footer (default: true) */
showSupportFooter?: boolean
/** Whether to hide the nav bar (useful for embedded forms) */
hideNav?: boolean
}
/**
* Unified layout for status/error pages (404, form unavailable, chat error, etc.).
* Uses AuthBackground and Nav for consistent styling with auth pages.
*
* @example
* ```tsx
* <StatusPageLayout
* title="Page Not Found"
* description="The page you're looking for doesn't exist."
* >
* <BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
* </StatusPageLayout>
* ```
*/
export function StatusPageLayout({
title,
description,
children,
showSupportFooter = true,
hideNav = false,
}: StatusPageLayoutProps) {
return (
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
{!hideNav && <Nav hideAuthButtons={true} variant='auth' />}
<AuthBackground className='dark font-[430] font-season'>
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
<header className='shrink-0 bg-[#1C1C1C]'>
<Navbar logoOnly />
</header>
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
{title}
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
{description}
</p>
</div>
{children && (
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
{children}
</div>
)}
{children && <div className='mt-8 w-full max-w-[410px] space-y-3'>{children}</div>}
</div>
</div>
</div>

View File

@@ -1,37 +1,22 @@
'use client'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { useBrandConfig } from '@/ee/whitelabeling'
export interface SupportFooterProps {
/** Position style - 'fixed' for pages without AuthLayout, 'absolute' for pages with AuthLayout */
position?: 'fixed' | 'absolute'
}
/**
* Support footer component for auth and status pages.
* Displays a "Need help? Contact support" link using branded support email.
*
* @example
* ```tsx
* // Fixed position (for standalone pages)
* <SupportFooter />
*
* // Absolute position (for pages using AuthLayout)
* <SupportFooter position="absolute" />
* ```
*/
export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
const brandConfig = useBrandConfig()
return (
<div
className={`${inter.className} auth-text-muted right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed ${position}`}
className={`right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[#999] text-[13px] leading-relaxed ${position}`}
>
Need help?{' '}
<a
href={`mailto:${brandConfig.supportEmail}`}
className='auth-link underline-offset-4 transition hover:underline'
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
>
Contact support
</a>

View File

@@ -6,21 +6,19 @@ import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalHeader,
} from '@/components/emcn'
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
@@ -385,17 +383,17 @@ export default function LoginPage({
return (
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Sign in
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Enter your details
</p>
</div>
{/* SSO Login Button (primary top-only when it is the only method) */}
{showTopSSO && (
<div className={`${inter.className} mt-8`}>
<div className='mt-8'>
<SSOLoginButton
callbackURL={callbackUrl}
variant='primary'
@@ -406,14 +404,14 @@ export default function LoginPage({
{/* Password reset success message */}
{resetSuccessMessage && (
<div className={`${inter.className} mt-1 space-y-1 text-[#4CAF50] text-xs`}>
<div className='mt-1 space-y-1 text-[#4CAF50] text-xs'>
<p>{resetSuccessMessage}</p>
</div>
)}
{/* Email/Password Form - show unless explicitly disabled */}
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
<form onSubmit={onSubmit} className='mt-8 space-y-8'>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
@@ -430,10 +428,9 @@ export default function LoginPage({
value={email}
onChange={handleEmailChange}
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
'border-red-500 focus:border-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
@@ -450,7 +447,7 @@ export default function LoginPage({
<button
type='button'
onClick={() => setForgotPasswordOpen(true)}
className='font-medium text-muted-foreground text-xs transition hover:text-foreground'
className='font-medium text-[#999] text-xs transition hover:text-[#ECECEC]'
>
Forgot password?
</button>
@@ -468,16 +465,16 @@ export default function LoginPage({
value={password}
onChange={handlePasswordChange}
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
'pr-10',
showValidationError &&
passwordErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
'border-red-500 focus:border-red-500'
)}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -506,18 +503,18 @@ export default function LoginPage({
{/* Divider - show when we have multiple auth methods */}
{showDivider && (
<div className={`${inter.className} relative my-6 font-light`}>
<div className='relative my-6 font-light'>
<div className='absolute inset-0 flex items-center'>
<div className='auth-divider w-full border-t' />
<div className='w-full border-[#2A2A2A] border-t' />
</div>
<div className='relative flex justify-center text-sm'>
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
<span className='bg-[#1C1C1C] px-4 font-[340] text-[#999]'>Or continue with</span>
</div>
</div>
)}
{showBottomSection && (
<div className={cn(inter.className, !emailEnabled ? 'mt-8' : undefined)}>
<div className={cn(!emailEnabled ? 'mt-8' : undefined)}>
<SocialLoginButtons
googleAvailable={googleAvailable}
githubAvailable={githubAvailable}
@@ -537,26 +534,24 @@ export default function LoginPage({
{/* Only show signup link if email/password signup is enabled */}
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<div className='pt-6 text-center font-light text-[14px]'>
<span className='font-normal'>Don't have an account? </span>
<Link
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
>
Sign up
</Link>
</div>
)}
<div
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
>
<div className='absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[#999] text-[13px] leading-relaxed sm:px-8 md:px-[44px]'>
By signing in, you agree to our{' '}
<Link
href='/terms'
target='_blank'
rel='noopener noreferrer'
className='auth-link underline-offset-4 transition hover:underline'
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
>
Terms of Service
</Link>{' '}
@@ -565,64 +560,58 @@ export default function LoginPage({
href='/privacy'
target='_blank'
rel='noopener noreferrer'
className='auth-link underline-offset-4 transition hover:underline'
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
>
Privacy Policy
</Link>
</div>
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>
<DialogHeader>
<DialogTitle className='font-semibold text-black text-xl tracking-tight'>
Reset Password
</DialogTitle>
<DialogDescription className='text-muted-foreground text-sm'>
<Modal open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<ModalContent className='dark' size='sm'>
<ModalHeader>Reset Password</ModalHeader>
<ModalBody>
<ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'>
Enter your email address and we'll send you a link to reset your password if your
account exists.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
</ModalDescription>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='reset-email'>Email</Label>
</div>
<Input
id='reset-email'
value={forgotPasswordEmail}
onChange={(e) => setForgotPasswordEmail(e.target.value)}
placeholder='Enter your email'
required
type='email'
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
resetStatus.type === 'error' &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
<Input
id='reset-email'
value={forgotPasswordEmail}
onChange={(e) => setForgotPasswordEmail(e.target.value)}
placeholder='Enter your email'
required
type='email'
className={cn(
resetStatus.type === 'error' && 'border-red-500 focus:border-red-500'
)}
/>
{resetStatus.type === 'error' && (
<div className='mt-1 text-red-400 text-xs'>
<p>{resetStatus.message}</p>
</div>
)}
/>
{resetStatus.type === 'error' && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
</div>
{resetStatus.type === 'success' && (
<div className='mt-1 text-[#4CAF50] text-xs'>
<p>{resetStatus.message}</p>
</div>
)}
<BrandedButton
type='button'
onClick={handleForgotPassword}
disabled={isSubmittingReset}
loading={isSubmittingReset}
loadingText='Sending'
>
Send Reset Link
</BrandedButton>
</div>
{resetStatus.type === 'success' && (
<div className='mt-1 space-y-1 text-[#4CAF50] text-xs'>
<p>{resetStatus.message}</p>
</div>
)}
<BrandedButton
type='button'
onClick={handleForgotPassword}
disabled={isSubmittingReset}
loading={isSubmittingReset}
loadingText='Sending'
>
Send Reset Link
</BrandedButton>
</div>
</DialogContent>
</Dialog>
</ModalBody>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -6,8 +6,6 @@ import Image from 'next/image'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
const SCOPE_DESCRIPTIONS: Record<string, string> = {
@@ -129,10 +127,10 @@ export default function OAuthConsentPage() {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Authorize Application
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Loading application details...
</p>
</div>
@@ -144,14 +142,14 @@ export default function OAuthConsentPage() {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Authorization Error
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
{error}
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<div className='mt-8 w-full max-w-[410px] space-y-3'>
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
</div>
</div>
@@ -172,11 +170,11 @@ export default function OAuthConsentPage() {
className='rounded-[10px]'
/>
) : (
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-[#2A2A2A] font-medium text-[#999] text-[18px]'>
{(clientName ?? '?').charAt(0).toUpperCase()}
</div>
)}
<ArrowLeftRight className='h-5 w-5 text-muted-foreground' />
<ArrowLeftRight className='h-5 w-5 text-[#999]' />
<Image
src='/new/logo/colorized-bg.svg'
alt='Sim'
@@ -187,19 +185,17 @@ export default function OAuthConsentPage() {
</div>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Authorize Application
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<span className='font-medium text-foreground'>{clientName}</span> is requesting access to
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
<span className='font-medium text-[#ECECEC]'>{clientName}</span> is requesting access to
your account
</p>
</div>
{session?.user && (
<div
className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`}
>
<div className='mt-5 flex items-center gap-3 rounded-lg border border-[#2A2A2A] px-4 py-3'>
{session.user.image ? (
<Image
src={session.user.image}
@@ -210,7 +206,7 @@ export default function OAuthConsentPage() {
unoptimized
/>
) : (
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-[#2A2A2A] font-medium text-[#999] text-[13px]'>
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
</div>
)}
@@ -218,12 +214,12 @@ export default function OAuthConsentPage() {
{session.user.name && (
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
)}
<p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p>
<p className='truncate text-[#999] text-[13px]'>{session.user.email}</p>
</div>
<button
type='button'
onClick={handleSwitchAccount}
className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline'
className='ml-auto text-[#999] text-[13px] underline-offset-2 transition-colors hover:text-[#ECECEC] hover:underline'
>
Switch
</button>
@@ -231,15 +227,12 @@ export default function OAuthConsentPage() {
)}
{scopes.length > 0 && (
<div className={`${inter.className} mt-5 w-full max-w-[410px]`}>
<div className='mt-5 w-full max-w-[410px]'>
<div className='rounded-lg border p-4'>
<p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p>
<ul className='space-y-2'>
{scopes.map((s) => (
<li
key={s}
className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground'
>
<li key={s} className='flex items-start gap-2 font-normal text-[#999] text-[13px]'>
<span className='mt-0.5 text-green-500'>&#10003;</span>
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
</li>
@@ -249,7 +242,7 @@ export default function OAuthConsentPage() {
</div>
)}
<div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}>
<div className='mt-6 flex w-full max-w-[410px] gap-3'>
<Button
variant='outline'
size='md'

View File

@@ -4,8 +4,6 @@ import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
const logger = createLogger('ResetPasswordPage')
@@ -76,15 +74,15 @@ function ResetPasswordContent() {
return (
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Reset your password
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Enter a new password for your account
</p>
</div>
<div className={`${inter.className} mt-8`}>
<div className='mt-8'>
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
@@ -94,10 +92,10 @@ function ResetPasswordContent() {
/>
</div>
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<div className='pt-6 text-center font-light text-[14px]'>
<Link
href='/login'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
>
Back to login
</Link>

View File

@@ -2,10 +2,8 @@
import { useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
interface RequestResetFormProps {
@@ -33,7 +31,7 @@ export function RequestResetForm({
}
return (
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
<form onSubmit={handleSubmit} className={cn('space-y-8', className)}>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
@@ -47,9 +45,8 @@ export function RequestResetForm({
type='email'
disabled={isSubmitting}
required
className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
/>
<p className='text-muted-foreground text-sm'>
<p className='text-[#999] text-sm'>
We'll send a password reset link to this email address.
</p>
</div>
@@ -142,7 +139,7 @@ export function SetNewPasswordForm({
}
return (
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
<form onSubmit={handleSubmit} className={cn('space-y-8', className)}>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
@@ -160,16 +157,12 @@ export function SetNewPasswordForm({
onChange={(e) => setPassword(e.target.value)}
required
placeholder='Enter new password'
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
validationMessage &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
className={cn('pr-10', validationMessage && 'border-red-500 focus:border-red-500')}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -192,16 +185,12 @@ export function SetNewPasswordForm({
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder='Confirm new password'
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
validationMessage &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
className={cn('pr-10', validationMessage && 'border-red-500 focus:border-red-500')}
/>
<button
type='button'
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
>
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}

View File

@@ -5,14 +5,11 @@ import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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 { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
@@ -344,10 +341,10 @@ function SignupFormContent({
return (
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Create an account
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Create an account or log in
</p>
</div>
@@ -360,7 +357,7 @@ function SignupFormContent({
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
return hasOnlySSO
})() && (
<div className={`${inter.className} mt-8`}>
<div className='mt-8'>
<SSOLoginButton
callbackURL={redirectUrl || '/workspace'}
variant='primary'
@@ -371,7 +368,7 @@ function SignupFormContent({
{/* Email/Password Form - show unless explicitly disabled */}
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
<form onSubmit={onSubmit} className='mt-8 space-y-8'>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
@@ -388,10 +385,9 @@ function SignupFormContent({
value={name}
onChange={handleNameChange}
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showNameValidationError &&
nameErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
'border-red-500 focus:border-red-500'
)}
/>
{showNameValidationError && nameErrors.length > 0 && (
@@ -416,9 +412,8 @@ function SignupFormContent({
value={email}
onChange={handleEmailChange}
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
'border-red-500 focus:border-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
@@ -450,16 +445,16 @@ function SignupFormContent({
value={password}
onChange={handlePasswordChange}
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
'pr-10',
showValidationError &&
passwordErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
'border-red-500 focus:border-red-500'
)}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] transition hover:text-[#ECECEC]'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -496,12 +491,12 @@ function SignupFormContent({
const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection
return showDivider
})() && (
<div className={`${inter.className} relative my-6 font-light`}>
<div className='relative my-6 font-light'>
<div className='absolute inset-0 flex items-center'>
<div className='auth-divider w-full border-t' />
<div className='w-full border-[#2A2A2A] border-t' />
</div>
<div className='relative flex justify-center text-sm'>
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
<span className='bg-[#1C1C1C] px-4 font-[340] text-[#999]'>Or continue with</span>
</div>
</div>
)}
@@ -516,7 +511,6 @@ function SignupFormContent({
})() && (
<div
className={cn(
inter.className,
isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) ? 'mt-8' : undefined
)}
>
@@ -537,25 +531,23 @@ function SignupFormContent({
</div>
)}
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<div className='pt-6 text-center font-light text-[14px]'>
<span className='font-normal'>Already have an account? </span>
<Link
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
>
Sign in
</Link>
</div>
<div
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
>
<div className='absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[#999] text-[13px] leading-relaxed sm:px-8 md:px-[44px]'>
By creating an account, you agree to our{' '}
<Link
href='/terms'
target='_blank'
rel='noopener noreferrer'
className='auth-link underline-offset-4 transition hover:underline'
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
>
Terms of Service
</Link>{' '}
@@ -564,7 +556,7 @@ function SignupFormContent({
href='/privacy'
target='_blank'
rel='noopener noreferrer'
className='auth-link underline-offset-4 transition hover:underline'
className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline'
>
Privacy Policy
</Link>

View File

@@ -2,13 +2,10 @@
import { Suspense, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { useVerification } from '@/app/(auth)/verify/use-verification'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface VerifyContentProps {
hasEmailService: boolean
@@ -59,15 +56,13 @@ function VerificationForm({
setCountdown(30)
}
const buttonClass = useBrandedButtonClass()
return (
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
{isVerified
? 'Your email has been verified. Redirecting to dashboard...'
: !isEmailVerificationEnabled
@@ -81,9 +76,9 @@ function VerificationForm({
</div>
{!isVerified && isEmailVerificationEnabled && (
<div className={`${inter.className} mt-8 space-y-8`}>
<div className='mt-8 space-y-8'>
<div className='space-y-6'>
<p className='text-center text-muted-foreground text-sm'>
<p className='text-center text-[#999] text-sm'>
Enter the 6-digit code to verify your account.
{hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''}
</p>
@@ -96,61 +91,13 @@ function VerificationForm({
disabled={isLoading}
className={cn('gap-2', isInvalidOtp && 'otp-error')}
>
<InputOTPGroup className='[&>div]:!rounded-[10px] gap-2'>
<InputOTPSlot
index={0}
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={1}
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={2}
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={3}
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={4}
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPSlot
index={5}
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
/>
<InputOTPGroup>
<InputOTPSlot index={0} className={cn(isInvalidOtp && 'border-red-500')} />
<InputOTPSlot index={1} className={cn(isInvalidOtp && 'border-red-500')} />
<InputOTPSlot index={2} className={cn(isInvalidOtp && 'border-red-500')} />
<InputOTPSlot index={3} className={cn(isInvalidOtp && 'border-red-500')} />
<InputOTPSlot index={4} className={cn(isInvalidOtp && 'border-red-500')} />
<InputOTPSlot index={5} className={cn(isInvalidOtp && 'border-red-500')} />
</InputOTPGroup>
</InputOTP>
</div>
@@ -163,25 +110,27 @@ function VerificationForm({
)}
</div>
<Button
<BrandedButton
onClick={verifyCode}
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
disabled={!isOtpComplete || isLoading}
loading={isLoading}
loadingText='Verifying'
showArrow={false}
>
{isLoading ? 'Verifying...' : 'Verify Email'}
</Button>
Verify Email
</BrandedButton>
{hasEmailService && (
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
<p className='text-[#999] text-sm'>
Didn't receive a code?{' '}
{countdown > 0 ? (
<span>
Resend in <span className='font-medium text-foreground'>{countdown}s</span>
Resend in <span className='font-medium text-[#ECECEC]'>{countdown}s</span>
</span>
) : (
<button
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
onClick={handleResend}
disabled={isLoading || isResendDisabled}
>
@@ -202,7 +151,7 @@ function VerificationForm({
}
router.push('/signup')
}}
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
className='font-medium text-[#ECECEC] underline-offset-4 transition hover:text-white hover:underline'
>
Back to signup
</button>
@@ -217,8 +166,8 @@ function VerificationFormFallback() {
return (
<div className='text-center'>
<div className='animate-pulse'>
<div className='mx-auto mb-4 h-8 w-48 rounded bg-gray-200' />
<div className='mx-auto h-4 w-64 rounded bg-gray-200' />
<div className='mx-auto mb-4 h-8 w-48 rounded bg-[#2A2A2A]' />
<div className='mx-auto h-4 w-64 rounded bg-[#2A2A2A]' />
</div>
</div>
)

View File

@@ -25,7 +25,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
))}
</div>
)
@@ -222,34 +222,15 @@ export default function Collaboration() {
<style dangerouslySetInnerHTML={{ __html: CURSOR_KEYFRAMES }} />
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
rows={1}
gap={6}
/>
<div className='relative overflow-hidden'>
<Link
href='/studio/multiplayer'
target='_blank'
rel='noopener noreferrer'
className='absolute bottom-10 left-4 z-20 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:left-8 md:left-[80px]'
>
<div className='relative h-7 w-11 shrink-0'>
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
</div>
<div className='flex flex-col gap-[2px]'>
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px] uppercase leading-[100%] tracking-[0.08em]'>
Blog
</span>
<span className='font-[430] font-season text-[#F6F6F0] text-[14px] leading-[125%] tracking-[0.02em]'>
How we built realtime collaboration
</span>
</div>
</Link>
<div className='grid grid-cols-[auto_1fr]'>
<div className='flex flex-col items-start gap-3 px-4 pt-[100px] pb-8 sm:gap-4 sm:px-8 md:gap-[20px] md:px-[80px]'>
<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-[20px] md:px-[80px] md:pt-[100px]'>
<Badge
variant='blue'
size='md'
@@ -268,13 +249,14 @@ export default function Collaboration() {
collaboration
</h2>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
Grab your team. Build agents together <br /> in real-time inside your workspace.
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[15px] leading-[150%] tracking-[0.02em] md:text-[18px]'>
Grab your team. Build agents together <br className='hidden md:block' />
in real-time inside your workspace.
</p>
<Link
href='/signup'
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Build together
<span className='relative h-[10px] w-[10px] shrink-0'>
@@ -298,14 +280,14 @@ export default function Collaboration() {
</Link>
</div>
<figure className='pointer-events-none relative h-[600px] w-full'>
<div className='-left-[18%] absolute inset-y-0 min-w-full'>
<figure className='pointer-events-none relative h-[220px] w-full md:h-[600px]'>
<div className='md:-left-[18%] -top-[10%] absolute inset-y-0 left-[7%] min-w-full md:top-0'>
<Image
src='/landing/collaboration-visual.svg'
alt='Collaboration visual showing team workflows with real-time editing, shared cursors, and version control interface'
width={876}
height={480}
className='h-full w-auto min-w-[100vw] object-left'
className='h-full w-auto object-left md:min-w-[100vw]'
priority
/>
</div>
@@ -319,10 +301,29 @@ export default function Collaboration() {
</figcaption>
</figure>
</div>
<Link
href='/studio/multiplayer'
target='_blank'
rel='noopener noreferrer'
className='relative mx-4 mb-6 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:mx-8 md:absolute md:bottom-10 md:left-[80px] 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' />
</div>
<div className='flex flex-col gap-[2px]'>
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px] uppercase leading-[100%] tracking-[0.08em]'>
Blog
</span>
<span className='font-[430] font-season text-[#F6F6F0] text-[14px] leading-[125%] tracking-[0.02em]'>
How we built realtime collaboration
</span>
</div>
</Link>
</div>
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
rows={1}
gap={6}

View File

@@ -4,14 +4,484 @@
* SEO:
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
* - `<h2 id="enterprise-heading">` for the section title.
* - Compliance certs (SOC2, HIPAA) as visible `<strong>` text.
* - Compliance certs (SOC 2, HIPAA) as visible `<strong>` text.
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
*
* GEO:
* - Entity-rich: "Sim is SOC2 and HIPAA compliant" — not "We are compliant."
* - Entity-rich: "Sim is SOC 2 and HIPAA compliant" — not "We are compliant."
* - `<ul>` checklist of features (SSO, RBAC, audit logs, SLA, on-premise deployment)
* as an atomic answer block for "What enterprise features does Sim offer?".
*/
export default function Enterprise() {
return null
'use client'
import { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import Image from 'next/image'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { Lock } from '@/components/emcn/icons'
import { GithubIcon } from '@/components/icons'
/** Consistent color per actor — same pattern as Collaboration section cursors. */
const ACTOR_COLORS: Record<string, string> = {
'Sarah K.': '#2ABBF8',
'Sid G.': '#33C482',
'Theo L.': '#FA4EDF',
'Abhay K.': '#FFCC02',
'Danny S.': '#FF6B35',
}
/** Left accent bar opacity by recency — newest is brightest. */
const ACCENT_OPACITIES = [0.75, 0.45, 0.28, 0.15, 0.07] as const
/** Human-readable label per resource type. */
const RESOURCE_TYPE_LABEL: Record<string, string> = {
workflow: 'Workflow',
member: 'Member',
byok_key: 'BYOK Key',
api_key: 'API Key',
permission_group: 'Permission Group',
credential_set: 'Credential Set',
knowledge_base: 'Knowledge Base',
environment: 'Environment',
mcp_server: 'MCP Server',
file: 'File',
webhook: 'Webhook',
chat: 'Chat',
table: 'Table',
folder: 'Folder',
document: 'Document',
}
interface LogEntry {
id: number
actor: string
/** Matches the `description` field stored by recordAudit() */
description: string
resourceType: string
/** Unix ms timestamp of when this entry was "received" */
insertedAt: number
}
function formatTimeAgo(insertedAt: number): string {
const elapsed = Date.now() - insertedAt
if (elapsed < 8_000) return 'just now'
if (elapsed < 60_000) return `${Math.floor(elapsed / 1000)}s ago`
return `${Math.floor(elapsed / 60_000)}m ago`
}
/**
* Entry templates using real description strings from the actual recordAudit()
* calls across the codebase (e.g. `Added BYOK key for openai`,
* `Invited alex@acme.com to workspace as member`).
*/
const ENTRY_TEMPLATES: Omit<LogEntry, 'id' | 'insertedAt'>[] = [
{ actor: 'Sarah K.', description: 'Deployed workflow "Email Triage"', resourceType: 'workflow' },
{
actor: 'Sid G.',
description: 'Invited alex@acme.com to workspace as member',
resourceType: 'member',
},
{ actor: 'Theo L.', description: 'Added BYOK key for openai', resourceType: 'byok_key' },
{ actor: 'Sarah K.', description: 'Created workflow "Invoice Parser"', resourceType: 'workflow' },
{
actor: 'Abhay K.',
description: 'Created permission group "Engineering"',
resourceType: 'permission_group',
},
{ actor: 'Danny S.', description: 'Created API key "Production Key"', resourceType: 'api_key' },
{
actor: 'Theo L.',
description: 'Changed permissions for sam@acme.com to editor',
resourceType: 'member',
},
{ actor: 'Sarah K.', description: 'Uploaded file "Q3_Report.pdf"', resourceType: 'file' },
{
actor: 'Sid G.',
description: 'Created credential set "Prod Keys"',
resourceType: 'credential_set',
},
{
actor: 'Abhay K.',
description: 'Created knowledge base "Internal Docs"',
resourceType: 'knowledge_base',
},
{ actor: 'Danny S.', description: 'Updated environment variables', resourceType: 'environment' },
{
actor: 'Sarah K.',
description: 'Added tool "search_web" to MCP server',
resourceType: 'mcp_server',
},
{ actor: 'Sid G.', description: 'Created webhook "Stripe Payment"', resourceType: 'webhook' },
{ actor: 'Theo L.', description: 'Deployed chat "Support Assistant"', resourceType: 'chat' },
{ actor: 'Abhay K.', description: 'Created table "Lead Tracker"', resourceType: 'table' },
{ actor: 'Danny S.', description: 'Revoked API key "Staging Key"', resourceType: 'api_key' },
{
actor: 'Sarah K.',
description: 'Duplicated workflow "Data Enrichment"',
resourceType: 'workflow',
},
{
actor: 'Sid G.',
description: 'Removed member theo@acme.com from workspace',
resourceType: 'member',
},
{
actor: 'Theo L.',
description: 'Updated knowledge base "Product Docs"',
resourceType: 'knowledge_base',
},
{ actor: 'Abhay K.', description: 'Created folder "Finance Workflows"', resourceType: 'folder' },
{
actor: 'Danny S.',
description: 'Uploaded document "onboarding-guide.pdf"',
resourceType: 'document',
},
{
actor: 'Sarah K.',
description: 'Updated credential set "Prod Keys"',
resourceType: 'credential_set',
},
{
actor: 'Sid G.',
description: 'Added member abhay@acme.com to permission group "Engineering"',
resourceType: 'permission_group',
},
{ actor: 'Theo L.', description: 'Locked workflow "Customer Sync"', resourceType: 'workflow' },
]
const INITIAL_OFFSETS_MS = [0, 20_000, 75_000, 240_000, 540_000]
const MARQUEE_KEYFRAMES = `
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-25%); }
}
@media (prefers-reduced-motion: reduce) {
@keyframes marquee { 0%, 100% { transform: none; } }
}
`
const FEATURE_TAGS = [
'Access Control',
'Self-Hosting',
'Bring Your Own Key',
'Credential Sharing',
'Custom Limits',
'Admin API',
'White Labeling',
'Dedicated Support',
'99.99% Uptime SLA',
'Workflow Versioning',
'On-Premise',
'Organizations',
'Workspace Export',
'Audit Logs',
] as const
interface AuditRowProps {
entry: LogEntry
index: number
}
function AuditRow({ entry, index }: AuditRowProps) {
const color = ACTOR_COLORS[entry.actor] ?? '#F6F6F6'
const accentOpacity = ACCENT_OPACITIES[index] ?? 0.04
const timeAgo = formatTimeAgo(entry.insertedAt)
const resourceLabel = RESOURCE_TYPE_LABEL[entry.resourceType]
return (
<div className='group relative overflow-hidden border-[#2A2A2A] border-b bg-[#191919] transition-colors duration-150 last:border-b-0 hover:bg-[#212121]'>
{/* Left accent bar — brightness encodes recency */}
<div
aria-hidden='true'
className='absolute top-0 bottom-0 left-0 w-[2px] transition-opacity duration-150 group-hover:opacity-100'
style={{ backgroundColor: color, opacity: accentOpacity }}
/>
{/* Row content */}
<div className='flex min-w-0 items-center gap-3 py-[10px] pr-4 pl-5'>
{/* Actor avatar */}
<div
className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full'
style={{ backgroundColor: `${color}20` }}
>
<span className='font-[500] font-season text-[9px] leading-none' style={{ color }}>
{entry.actor[0]}
</span>
</div>
{/* Time */}
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
{timeAgo}
</span>
{/* Description — description hidden on mobile to avoid truncation */}
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
<span className='hidden sm:inline'>
<span className='text-[#F6F6F6]/40'> · </span>
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
</span>
</span>
{/* Resource type label — formatted name, neutral so it doesn't compete with actor colors */}
{resourceLabel && (
<span className='ml-auto shrink-0 rounded border border-[#2A2A2A] px-[7px] py-[3px] font-[430] font-season text-[#F6F6F6]/25 text-[10px] leading-none tracking-[0.04em]'>
{resourceLabel}
</span>
)}
</div>
</div>
)
}
function AuditLogPreview() {
const counterRef = useRef(ENTRY_TEMPLATES.length)
const templateIndexRef = useRef(5 % ENTRY_TEMPLATES.length)
const now = Date.now()
const [entries, setEntries] = useState<LogEntry[]>(() =>
ENTRY_TEMPLATES.slice(0, 5).map((t, i) => ({
...t,
id: i,
insertedAt: now - INITIAL_OFFSETS_MS[i],
}))
)
const [, tick] = useState(0)
useEffect(() => {
const addInterval = setInterval(() => {
const template = ENTRY_TEMPLATES[templateIndexRef.current]
templateIndexRef.current = (templateIndexRef.current + 1) % ENTRY_TEMPLATES.length
setEntries((prev) => [
{ ...template, id: counterRef.current++, insertedAt: Date.now() },
...prev.slice(0, 4),
])
}, 2600)
// Refresh time labels every 5s so "just now" ages to "Xs ago"
const tickInterval = setInterval(() => tick((n) => n + 1), 5_000)
return () => {
clearInterval(addInterval)
clearInterval(tickInterval)
}
}, [])
return (
<div className='mx-6 mt-6 overflow-hidden rounded-[8px] border border-[#2A2A2A] md:mx-8 md:mt-8'>
{/* Header */}
<div className='flex items-center justify-between border-[#2A2A2A] border-b bg-[#161616] px-4 py-[10px]'>
<div className='flex items-center gap-2'>
{/* Pulsing live indicator */}
<span className='relative flex h-[8px] w-[8px]'>
<span
className='absolute inline-flex h-full w-full animate-ping rounded-full opacity-50'
style={{ backgroundColor: '#33C482' }}
/>
<span
className='relative inline-flex h-[8px] w-[8px] rounded-full'
style={{ backgroundColor: '#33C482' }}
/>
</span>
<span className='font-[430] font-season text-[#F6F6F6]/40 text-[11px] uppercase tracking-[0.08em]'>
Audit Log
</span>
</div>
<div className='flex items-center gap-2'>
<span className='rounded border border-[#2A2A2A] px-[8px] py-[3px] font-[430] font-season text-[#F6F6F6]/20 text-[11px] tracking-[0.02em]'>
Export
</span>
<span className='rounded border border-[#2A2A2A] px-[8px] py-[3px] font-[430] font-season text-[#F6F6F6]/20 text-[11px] tracking-[0.02em]'>
Filter
</span>
</div>
</div>
{/* Log entries — new items push existing ones down */}
<div className='overflow-hidden'>
<AnimatePresence mode='popLayout' initial={false}>
{entries.map((entry, index) => (
<motion.div
key={entry.id}
layout
initial={{ y: -48, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
layout: {
type: 'spring',
stiffness: 380,
damping: 38,
mass: 0.8,
},
y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] },
opacity: { duration: 0.25 },
}}
>
<AuditRow entry={entry} index={index} />
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)
}
function TrustStrip() {
return (
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-[8px] border border-[#2A2A2A] sm:grid-cols-3 md:mx-8'>
{/* SOC 2 + HIPAA combined */}
<Link
href='https://trust.delve.co/sim-studio'
target='_blank'
rel='noopener noreferrer'
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
>
<Image
src='/footer/soc2.png'
alt='SOC 2 Type II'
width={22}
height={22}
className='shrink-0 object-contain'
/>
<div className='flex flex-col gap-[3px]'>
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
SOC 2 & HIPAA
</strong>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em] transition-colors group-hover:text-[#F6F6F6]/55'>
Type II · PHI protected
</span>
</div>
</Link>
{/* Open Source — center */}
<Link
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
>
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#FFCC02]/10'>
<GithubIcon width={11} height={11} className='text-[#FFCC02]/75' />
</div>
<div className='flex flex-col gap-[3px]'>
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
Open Source
</strong>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em] transition-colors group-hover:text-[#F6F6F6]/55'>
View on GitHub
</span>
</div>
</Link>
{/* SSO */}
<div className='flex items-center gap-3 px-4 py-[14px]'>
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#2ABBF8]/10'>
<Lock className='h-[14px] w-[14px] text-[#2ABBF8]/75' />
</div>
<div className='flex flex-col gap-[3px]'>
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
SSO & SCIM
</strong>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
Okta, Azure AD, Google
</span>
</div>
</div>
</div>
)
}
export default function Enterprise() {
return (
<section id='enterprise' aria-labelledby='enterprise-heading' className='bg-[#F6F6F6]'>
<div className='px-4 pt-[60px] pb-[40px] sm:px-8 sm:pt-[80px] sm:pb-0 md:px-[80px] md:pt-[100px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
<Badge
variant='blue'
size='md'
dot
className='bg-[#FFCC02]/10 font-season text-[#FFCC02] uppercase tracking-[0.02em]'
>
Enterprise
</Badge>
<h2
id='enterprise-heading'
className='max-w-[600px] font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
>
Enterprise features for
<br />
fast, scalable workflows
</h2>
</div>
<div className='mt-8 overflow-hidden rounded-[12px] bg-[#1C1C1C] sm:mt-10 md:mt-12'>
<AuditLogPreview />
<TrustStrip />
{/* Scrolling feature ticker */}
<div className='relative mt-6 overflow-hidden border-[#2A2A2A] border-t'>
<style dangerouslySetInnerHTML={{ __html: MARQUEE_KEYFRAMES }} />
{/* Fade edges */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-16'
style={{ background: 'linear-gradient(to right, #1C1C1C, transparent)' }}
/>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-16'
style={{ background: 'linear-gradient(to left, #1C1C1C, transparent)' }}
/>
{/* Duplicate tags for seamless loop */}
<div className='flex w-max' style={{ animation: 'marquee 30s linear infinite' }}>
{[...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS].map(
(tag, i) => (
<span
key={i}
className='whitespace-nowrap border-[#2A2A2A] border-r px-5 py-4 font-[430] font-season text-[#F6F6F6]/40 text-[13px] leading-none tracking-[0.02em]'
>
{tag}
</span>
)
)}
</div>
</div>
<div className='flex items-center justify-between border-[#2A2A2A] border-t px-6 py-5 md:px-8 md:py-6'>
<p className='font-[430] font-season text-[#F6F6F6]/40 text-[15px] leading-[150%] tracking-[0.02em]'>
Ready for growth?
</p>
<Link
href='/contact'
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-white bg-white px-[10px] 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'
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>
</div>
</div>
</section>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
'use client'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import Image from 'next/image'
import { Badge } from '@/components/emcn'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { FeaturesPreview } from '@/app/(home)/components/features/components/features-preview'
function hexToRgba(hex: string, alpha: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16)
@@ -13,8 +16,12 @@ function hexToRgba(hex: string, alpha: number): string {
const FEATURE_TABS = [
{
label: 'Integrations',
label: 'Mothership',
color: '#FA4EDF',
title: 'Your AI command center',
description:
'Direct your entire AI workforce from one place. Build agents, spin up workflows, query tables, and manage every resource across your workspace — in natural language.',
cta: 'Explore mothership',
segments: [
[0.3, 8],
[0.25, 10],
@@ -29,8 +36,12 @@ const FEATURE_TABS = [
],
},
{
label: 'Copilot',
label: 'Tables',
color: '#2ABBF8',
title: 'A database, built in',
description:
'Filter, sort, and edit data inline, then wire it directly into your workflows. Agents query, insert, and update rows on every run — no external database needed.',
cta: 'Explore tables',
segments: [
[0.25, 12],
[0.4, 10],
@@ -44,59 +55,34 @@ const FEATURE_TABS = [
],
},
{
label: 'Models',
color: '#00F701',
badgeColor: '#22C55E',
segments: [
[0.2, 6],
[0.35, 10],
[0.3, 8],
[0.5, 10],
[0.6, 8],
[0.75, 12],
[0.85, 10],
[1, 8],
[0.9, 12],
[1, 10],
[0.95, 6],
],
},
{
label: 'Deploy',
label: 'Files',
color: '#FFCC02',
badgeColor: '#EAB308',
segments: [
[0.3, 12],
[0.25, 8],
[0.4, 10],
[0.55, 10],
[0.7, 8],
[0.6, 10],
[0.85, 12],
[1, 10],
[0.9, 10],
[1, 10],
],
},
{
label: 'Logs',
color: '#FF6B35',
title: 'Upload, create, and share',
description:
'Create or upload documents, spreadsheets, and media that agents can read, write, and reference across workflows. One shared store your entire team and every agent can pull from.',
cta: 'Explore files',
segments: [
[0.25, 10],
[0.35, 8],
[0.3, 10],
[0.4, 8],
[0.35, 12],
[0.5, 10],
[0.65, 8],
[0.8, 12],
[0.9, 10],
[0.75, 10],
[0.9, 12],
[1, 10],
[0.85, 12],
[0.85, 10],
[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],
@@ -110,8 +96,48 @@ const FEATURE_TABS = [
[1, 10],
],
},
{
label: 'Logs',
hideOnMobile: true,
color: '#FF6B35',
title: 'Full visibility, every run',
description:
'Trace every execution block by block — inputs, outputs, cost, and duration. Filter by status or workflow, replay snapshots, and export reports to keep your team accountable.',
cta: 'Explore logs',
segments: [
[0.25, 10],
[0.35, 8],
[0.3, 10],
[0.5, 10],
[0.65, 8],
[0.8, 12],
[0.9, 10],
[1, 10],
[0.85, 12],
[1, 10],
],
},
]
const HEADING_TEXT = 'Everything you need to build, deploy, and manage AI agents. '
const HEADING_LETTERS = HEADING_TEXT.split('')
const LETTER_REVEAL_SPAN = 0.85
const LETTER_FADE_IN = 0.04
interface ScrollLetterProps {
scrollYProgress: MotionValue<number>
charIndex: number
children: string
}
function ScrollLetter({ scrollYProgress, charIndex, children }: ScrollLetterProps) {
const threshold = (charIndex / HEADING_LETTERS.length) * LETTER_REVEAL_SPAN
const opacity = useTransform(scrollYProgress, [threshold, threshold + LETTER_FADE_IN], [0.4, 1])
return <motion.span style={{ opacity }}>{children}</motion.span>
}
function DotGrid({
cols,
rows,
@@ -126,7 +152,7 @@ function DotGrid({
return (
<div
aria-hidden='true'
className={`shrink-0 bg-[#FDFDFD] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
className={`h-full shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
style={{
width: width ? `${width}px` : undefined,
display: 'grid',
@@ -136,20 +162,26 @@ function DotGrid({
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#DEDEDE]' />
<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)
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start 0.9', 'start 0.2'],
})
return (
<section
id='features'
aria-labelledby='features-heading'
className='relative overflow-hidden bg-[#F6F6F6] pb-[144px]'
className='relative overflow-hidden bg-[#F6F6F6]'
>
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
<Image
@@ -162,8 +194,11 @@ export default function Features() {
/>
</div>
<div className='relative z-10 pt-[100px]'>
<div className='flex flex-col items-start gap-[20px] px-[80px]'>
<div className='relative z-10 pt-[60px] lg:pt-[100px]'>
<div
ref={sectionRef}
className='flex flex-col items-start gap-[20px] px-[24px] lg:px-[80px]'
>
<Badge
variant='blue'
size='md'
@@ -177,51 +212,131 @@ export default function Features() {
),
}}
>
Features
Workspace
</Badge>
<h2
id='features-heading'
className='font-[430] font-season text-[#1C1C1C] text-[40px] leading-[100%] tracking-[-0.02em]'
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[28px] leading-[110%] tracking-[-0.02em] md:text-[40px]'
>
Power your AI workforce
{HEADING_LETTERS.map((char, i) => (
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
{char}
</ScrollLetter>
))}
<span className='text-[#1C1C1C]/40'>
Design powerful workflows, connect your data, and monitor every run all in one
platform.
</span>
</h2>
</div>
<div className='mt-[73px] flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
<DotGrid cols={10} rows={8} width={80} />
<div className='relative mt-[40px] pb-[40px] lg:mt-[73px] lg:pb-[80px]'>
<div
aria-hidden='true'
className='absolute top-0 bottom-0 left-[80px] z-20 hidden w-px bg-[#E9E9E9] lg:block'
/>
<div
aria-hidden='true'
className='absolute top-0 right-[80px] bottom-0 z-20 hidden w-px bg-[#E9E9E9] lg:block'
/>
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
{FEATURE_TABS.map((tab, index) => (
<button
key={tab.label}
type='button'
role='tab'
aria-selected={index === activeTab}
onClick={() => setActiveTab(index)}
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
>
{tab.label}
{index === activeTab && (
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
{tab.segments.map(([opacity, width], i) => (
<div
key={i}
className='h-full shrink-0'
style={{
width: `${width}%`,
backgroundColor: tab.color,
opacity,
}}
/>
))}
</div>
)}
</button>
))}
<div className='flex h-[68px] border border-[#E9E9E9] 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 role='tablist' aria-label='Feature categories' className='flex flex-1'>
{FEATURE_TABS.map((tab, index) => (
<button
key={tab.label}
type='button'
role='tab'
aria-selected={index === activeTab}
onClick={() => setActiveTab(index)}
className={`relative h-full flex-1 items-center justify-center whitespace-nowrap px-[12px] font-medium font-season text-[#212121] text-[12px] uppercase lg:px-0 lg:text-[14px]${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[#E9E9E9] 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
)}
{index === activeTab && (
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
{tab.segments.map(([opacity, width], i) => (
<div
key={i}
className='h-full shrink-0'
style={{
width: `${width}%`,
backgroundColor: tab.color,
opacity,
}}
/>
))}
</div>
)}
</button>
))}
</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>
<DotGrid cols={10} rows={8} width={80} borderLeft />
<div className='mt-[32px] flex flex-col gap-[24px] px-[24px] lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[120px]'>
<div className='flex flex-col items-start justify-between gap-[24px] pt-[20px] lg:h-[560px] lg:gap-0'>
<div className='flex flex-col items-start gap-[16px]'>
<h3 className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[120%] tracking-[-0.02em] lg:text-[28px]'>
{FEATURE_TABS[activeTab].title}
</h3>
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[16px] leading-[150%] tracking-[0.02em] lg:text-[18px]'>
{FEATURE_TABS[activeTab].description}
</p>
</div>
<Link
href='/signup'
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
>
{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>
<FeaturesPreview activeTab={activeTab} />
</div>
<div aria-hidden='true' className='mt-[60px] hidden h-px bg-[#E9E9E9] lg:block' />
</div>
</div>
</section>

View File

@@ -0,0 +1,100 @@
'use client'
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 { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks/use-animated-placeholder'
const MAX_HEIGHT = 120
const CTA_BUTTON =
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
export function FooterCTA() {
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 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 flex-col items-center px-4 pt-[120px] pb-[100px] sm:px-8 md:px-[80px]'>
<h2 className='text-center font-[430] font-season text-[#1C1C1C] text-[28px] 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-[#E5E5E5] bg-white px-[10px] py-[8px] shadow-sm'
onClick={() => textareaRef.current?.focus()}
>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={animatedPlaceholder}
rows={2}
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-[4px] py-[4px] font-body text-[#1C1C1C] text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#999] focus-visible:ring-0'
style={{ caretColor: '#1C1C1C', 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 ? '#C0C0C0' : '#1C1C1C',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={16} strokeWidth={2.25} color='#FFFFFF' />
</button>
</div>
</div>
</div>
<div className='mt-8 flex gap-[8px]'>
<a
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
className={`${CTA_BUTTON} border-[#D4D4D4] text-[#1C1C1C] transition-colors hover:bg-[#E8E8E8]`}
>
Docs
</a>
<Link
href='/signup'
className={`${CTA_BUTTON} gap-[8px] border-[#1C1C1C] bg-[#1C1C1C] text-white transition-colors hover:border-[#333] hover:bg-[#333]`}
>
Get started
</Link>
</div>
</div>
)
}

View File

@@ -1,18 +1,166 @@
/**
* Landing page footer — navigation, legal links, and entity reinforcement.
*
* SEO:
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
* - External links include `rel="noopener noreferrer"`.
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
*
* GEO:
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
*/
export default function Footer() {
return null
import Image from 'next/image'
import Link from 'next/link'
import { FooterCTA } from '@/app/(home)/components/footer/footer-cta'
const LINK_CLASS = 'text-[14px] text-[#999] transition-colors hover:text-[#ECECEC]'
interface FooterItem {
label: string
href: string
external?: 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: 'Status', href: 'https://status.sim.ai', external: true },
]
const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Changelog', href: '/changelog' },
]
const BLOCK_LINKS: FooterItem[] = [
{ label: 'Agent', href: 'https://docs.sim.ai/blocks/agent', external: true },
{ label: 'Router', href: 'https://docs.sim.ai/blocks/router', external: true },
{ label: 'Function', href: 'https://docs.sim.ai/blocks/function', external: true },
{ label: 'Condition', href: 'https://docs.sim.ai/blocks/condition', external: true },
{ label: 'API', href: 'https://docs.sim.ai/blocks/api', external: true },
{ label: 'Workflow', href: 'https://docs.sim.ai/blocks/workflow', external: true },
{ label: 'Parallel', href: 'https://docs.sim.ai/blocks/parallel', external: true },
{ label: 'Guardrails', href: 'https://docs.sim.ai/blocks/guardrails', external: true },
{ label: 'Evaluator', href: 'https://docs.sim.ai/blocks/evaluator', external: true },
{ label: 'Loop', href: 'https://docs.sim.ai/blocks/loop', external: true },
]
const INTEGRATION_LINKS: FooterItem[] = [
{ 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 },
{ label: 'Gmail', href: 'https://docs.sim.ai/tools/gmail', external: true },
{ label: 'HubSpot', href: 'https://docs.sim.ai/tools/hubspot', external: true },
{ label: 'Salesforce', href: 'https://docs.sim.ai/tools/salesforce', external: true },
{ label: 'Notion', href: 'https://docs.sim.ai/tools/notion', external: true },
{ label: 'Google Drive', href: 'https://docs.sim.ai/tools/google_drive', external: true },
{ label: 'Google Sheets', href: 'https://docs.sim.ai/tools/google_sheets', external: true },
{ label: 'Supabase', href: 'https://docs.sim.ai/tools/supabase', external: true },
{ label: 'Stripe', href: 'https://docs.sim.ai/tools/stripe', external: true },
{ label: 'Jira', href: 'https://docs.sim.ai/tools/jira', external: true },
{ label: 'Linear', href: 'https://docs.sim.ai/tools/linear', external: true },
{ label: 'Airtable', href: 'https://docs.sim.ai/tools/airtable', external: true },
{ label: 'Firecrawl', href: 'https://docs.sim.ai/tools/firecrawl', external: true },
{ label: 'Pinecone', href: 'https://docs.sim.ai/tools/pinecone', external: true },
{ label: 'Discord', href: 'https://docs.sim.ai/tools/discord', external: true },
{ label: 'Microsoft Teams', href: 'https://docs.sim.ai/tools/microsoft_teams', external: true },
{ label: 'Outlook', href: 'https://docs.sim.ai/tools/outlook', external: true },
{ label: 'Telegram', href: 'https://docs.sim.ai/tools/telegram', external: true },
]
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 },
]
const LEGAL_LINKS: FooterItem[] = [
{ label: 'Terms of Service', href: '/terms' },
{ label: 'Privacy Policy', href: '/privacy' },
]
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
return (
<div>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>{title}</h3>
<div className='flex flex-col gap-[10px]'>
{items.map(({ label, href, external }) =>
external ? (
<a
key={label}
href={href}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{label}
</a>
) : (
<Link key={label} href={href} className={LINK_CLASS}>
{label}
</Link>
)
)}
</div>
</div>
)
}
interface FooterProps {
hideCTA?: boolean
}
export default function Footer({ hideCTA }: FooterProps) {
return (
<footer
role='contentinfo'
className={`bg-[#F6F6F6] pb-[40px] font-[430] font-season text-[14px]${hideCTA ? ' pt-[40px]' : ''}`}
>
{!hideCTA && <FooterCTA />}
<div className='px-4 sm:px-8 md:px-[80px]'>
<div className='relative overflow-hidden rounded-lg bg-[#1C1C1C] px-6 pt-[40px] pb-[32px] sm:px-10 sm:pt-[48px] sm:pb-[40px]'>
<nav
aria-label='Footer navigation'
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-[24px] sm:col-span-1'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={85}
height={26}
className='h-[26.4px] w-auto'
/>
</Link>
</div>
<FooterColumn title='Product' items={PRODUCT_LINKS} />
<FooterColumn title='Resources' items={RESOURCES_LINKS} />
<FooterColumn title='Blocks' items={BLOCK_LINKS} />
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
<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

@@ -34,7 +34,7 @@ export default function Hero() {
<section
id='hero'
aria-labelledby='hero-heading'
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[71px]'
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[60px] pb-[12px] lg:pt-[100px]'
>
<p className='sr-only'>
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
@@ -53,7 +53,7 @@ export default function Hero() {
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
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>
@@ -61,25 +61,27 @@ export default function Hero() {
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
<h1
id='hero-heading'
className='font-[430] font-season text-[64px] text-white leading-[100%] tracking-[-0.02em]'
className='font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
>
Build Agents
Build AI Agents
</h1>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
Build and deploy agentic workflows
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[15px] leading-[125%] tracking-[0.02em] lg:text-[18px]'>
Sim is the AI Workspace for Agent Builders.
</p>
<div className='mt-[12px] flex items-center gap-[8px]'>
<Link
href='/login'
<a
href='https://form.typeform.com/to/jqCO12pF'
target='_blank'
rel='noopener noreferrer'
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
aria-label='Log in'
aria-label='Get a demo'
>
Log in
</Link>
Get a demo
</a>
<Link
href='/signup'
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
aria-label='Get started with Sim'
>
Get started
@@ -101,7 +103,7 @@ export default function Hero() {
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
</div>
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
<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]'

View File

@@ -0,0 +1,98 @@
'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 '@/app/workspace/[workspaceId]/home/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-[24px] pb-[2vh]'>
<h1
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
style={{ color: C.TEXT_PRIMARY }}
>
What should we get done?
</h1>
<div className='w-full max-w-[32rem]'>
<div
className='cursor-text rounded-[20px] border px-[10px] py-[8px]'
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-[4px] py-[4px] 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

@@ -8,6 +8,23 @@ 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.
@@ -18,7 +35,7 @@ import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
*/
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
const router = useRouter()
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
@@ -27,9 +44,8 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
const handleSubmit = useCallback(() => {
if (isEmpty) return
LandingPromptStorage.store(inputValue)
router.push('/signup')
}, [isEmpty, inputValue, router])
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -60,10 +76,10 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setCursorPos(null)}
>
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
</div>
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#33C482] px-[10px] 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>

View File

@@ -1,141 +1,204 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Database, Layout, Search, Settings } from 'lucide-react'
import { ChevronDown, Library } from '@/components/emcn'
import { ChevronDown, Home, Library } from '@/components/emcn'
import {
Calendar,
Database,
File,
HelpCircle,
Search,
Settings,
Table,
} from '@/components/emcn/icons'
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/**
* Props for the LandingPreviewSidebar component
*/
interface LandingPreviewSidebarProps {
workflows: PreviewWorkflow[]
activeWorkflowId: string
activeView: 'home' | 'workflow'
onSelectWorkflow: (id: string) => void
onSelectHome: () => void
}
/**
* Static footer navigation items matching the real sidebar
* Hardcoded dark-theme equivalents of the real sidebar CSS variables.
* The preview lives inside a `dark` wrapper but CSS variable cascade
* isn't guaranteed, so we pin the hex values directly.
*/
const FOOTER_NAV_ITEMS = [
{ id: 'logs', label: 'Logs', icon: Library },
{ id: 'templates', label: 'Templates', icon: Layout },
const C = {
SURFACE_1: '#1e1e1e',
SURFACE_2: '#252525',
SURFACE_ACTIVE: '#363636',
BORDER: '#2c2c2c',
TEXT_PRIMARY: '#e6e6e6',
TEXT_BODY: '#cdcdcd',
TEXT_ICON: '#939393',
BRAND: '#33C482',
} as const
const WORKSPACE_NAV = [
{ id: 'tables', label: 'Tables', icon: Table },
{ id: 'files', label: 'Files', icon: File },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
{ id: 'logs', label: 'Logs', icon: Library },
] as const
const FOOTER_NAV = [
{ id: 'help', label: 'Help', icon: HelpCircle },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const
function StaticNavItem({
icon: Icon,
label,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
label: string
}) {
return (
<div className='pointer-events-none mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px]'>
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
{label}
</span>
</div>
)
}
/**
* Lightweight static sidebar replicating the real workspace sidebar styling.
* Lightweight sidebar replicating the real workspace sidebar layout and sizing.
* Starts from the workspace header (no logo/collapse row).
* Only workflow items are interactive — everything else is pointer-events-none.
*
* Colors sourced from the dark theme CSS variables:
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
*/
export function LandingPreviewSidebar({
workflows,
activeWorkflowId,
activeView,
onSelectWorkflow,
onSelectHome,
}: LandingPreviewSidebarProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const handleToggle = useCallback(() => {
setIsDropdownOpen((prev) => !prev)
}, [])
useEffect(() => {
if (!isDropdownOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isDropdownOpen])
const isHomeActive = activeView === 'home'
return (
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
{/* Header */}
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
<div className='flex items-center justify-between'>
<button
type='button'
onClick={handleToggle}
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
<div
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-[12px]'
style={{ backgroundColor: C.SURFACE_1 }}
>
{/* Workspace Header */}
<div className='flex-shrink-0 px-[10px]'>
<div
className='pointer-events-none flex h-[32px] w-full items-center gap-[8px] rounded-[8px] border pr-[8px] pl-[5px]'
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE_2 }}
>
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
<path
d='M1 9C1 4.58 4.58 1 9 1'
stroke='#1e1e1e'
strokeWidth='1.8'
strokeLinecap='round'
/>
</svg>
</div>
<span
className='min-w-0 flex-1 truncate text-left font-medium text-[13px]'
style={{ color: C.TEXT_PRIMARY }}
>
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
<ChevronDown
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
<div className='pointer-events-none flex flex-shrink-0 items-center'>
<Search className='h-[14px] w-[14px] text-[#787878]' />
Superark
</span>
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
</div>
</div>
{/* Top Navigation: Home (interactive), Search (static) */}
<div className='mt-[10px] flex flex-shrink-0 flex-col gap-[2px] px-[8px]'>
<button
type='button'
onClick={onSelectHome}
className='mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
onMouseEnter={(e) => {
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
}}
onMouseLeave={(e) => {
if (!isHomeActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<Home className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
Home
</span>
</button>
<StaticNavItem icon={Search} label='Search' />
</div>
{/* Workspace */}
<div className='mt-[14px] flex flex-shrink-0 flex-col'>
<div className='px-[16px] pb-[6px]'>
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
Workspace
</div>
</div>
<div className='flex flex-col gap-[2px] px-[8px]'>
{WORKSPACE_NAV.map((item) => (
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
))}
</div>
</div>
{/* Workspace switcher dropdown */}
{isDropdownOpen && (
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
<div
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
role='menuitem'
onClick={() => setIsDropdownOpen(false)}
>
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
{/* Scrollable Tasks + Workflows */}
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-[14px]'>
{/* Workflows */}
<div className='flex flex-col'>
<div className='px-[16px]'>
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
Workflows
</div>
</div>
)}
</div>
{/* Workflow items */}
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
{workflows.map((workflow) => {
const isActive = workflow.id === activeWorkflowId
return (
<button
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
}`}
>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
style={{ backgroundColor: workflow.color }}
/>
<div className='min-w-0 flex-1'>
<div
className={`min-w-0 truncate text-left font-medium ${
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
}`}
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
{workflows.map((workflow) => {
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
return (
<button
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className='group mx-[2px] flex h-[28px] w-full items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
>
{workflow.name}
</div>
</div>
</button>
)
})}
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<div
className='min-w-0 flex-1 truncate text-left text-[13px]'
style={{ color: C.TEXT_BODY, fontWeight: 450 }}
>
{workflow.name}
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Footer navigation — static */}
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
{FOOTER_NAV_ITEMS.map((item) => {
const Icon = item.icon
return (
<div
key={item.id}
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
</div>
)
})}
{/* Footer */}
<div className='flex flex-shrink-0 flex-col gap-[2px] px-[8px] pt-[9px] pb-[8px]'>
{FOOTER_NAV.map((item) => (
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
))}
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { memo } from 'react'
import { motion } from 'framer-motion'
import { Database } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { Blimp } from '@/components/emcn'
import {
AgentIcon,
AnthropicIcon,
@@ -13,6 +14,7 @@ import {
GmailIcon,
GoogleCalendarIcon,
GoogleSheetsIcon,
HubspotIcon,
JiraIcon,
LinearIcon,
LinkedInIcon,
@@ -21,6 +23,7 @@ import {
OpenAIIcon,
RedditIcon,
ReductoIcon,
SalesforceIcon,
ScheduleIcon,
SlackIcon,
StartIcon,
@@ -56,13 +59,16 @@ const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
google_calendar: GoogleCalendarIcon,
gmail: GmailIcon,
google_sheets: GoogleSheetsIcon,
hubspot: HubspotIcon,
linear: LinearIcon,
firecrawl: FirecrawlIcon,
reddit: RedditIcon,
notion: NotionIcon,
reducto: ReductoIcon,
salesforce: SalesforceIcon,
textract: TextractIcon,
linkedin: LinkedInIcon,
mothership: Blimp,
}
/** Model prefix → provider icon for the "Model" row in agent blocks. */

View File

@@ -91,11 +91,11 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
}
/**
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
* Self-healing CRM workflow — Schedule -> Mothership
*/
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
id: 'wf-content-pipeline',
name: 'Content Pipeline',
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
id: 'wf-self-healing-crm',
name: 'Self-healing CRM',
color: '#33C482',
blocks: [
{
@@ -111,23 +111,20 @@ const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
hideTargetHandle: true,
},
{
id: 'agent-2',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'grok-4' },
{ title: 'System Prompt', value: 'Repurpose trending...' },
],
id: 'mothership-1',
name: 'CRM Agent',
type: 'mothership',
bgColor: '#33C482',
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
tools: [
{ name: 'X', type: 'x', bgColor: '#000000' },
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
{ name: 'HubSpot', type: 'hubspot', bgColor: '#FF7A59' },
{ name: 'Salesforce', type: 'salesforce', bgColor: '#E0E0E0' },
],
position: { x: 420, y: 180 },
hideSourceHandle: true,
},
],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
}
/**
@@ -154,7 +151,7 @@ const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
}
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
CONTENT_PIPELINE_WORKFLOW,
SELF_HEALING_CRM_WORKFLOW,
IT_SERVICE_WORKFLOW,
NEW_AGENT_WORKFLOW,
]

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { motion, type Variants } from 'framer-motion'
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
@@ -56,6 +57,7 @@ const panelVariants: Variants = {
* load — workflow switches render instantly.
*/
export function LandingPreview() {
const [activeView, setActiveView] = useState<'home' | 'workflow'>('workflow')
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
const isInitialMount = useRef(true)
@@ -63,12 +65,23 @@ export function LandingPreview() {
isInitialMount.current = false
}, [])
const handleSelectWorkflow = useCallback((id: string) => {
setActiveWorkflowId(id)
setActiveView('workflow')
}, [])
const handleSelectHome = useCallback(() => {
setActiveView('home')
}, [])
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
const isWorkflowView = activeView === 'workflow'
return (
<motion.div
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1e1e1e] antialiased'
initial='hidden'
animate='visible'
variants={containerVariants}
@@ -77,15 +90,34 @@ export function LandingPreview() {
<LandingPreviewSidebar
workflows={PREVIEW_WORKFLOWS}
activeWorkflowId={activeWorkflowId}
onSelectWorkflow={setActiveWorkflowId}
activeView={activeView}
onSelectWorkflow={handleSelectWorkflow}
onSelectHome={handleSelectHome}
/>
</motion.div>
<div className='relative flex-1 overflow-hidden'>
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
<div className='flex min-w-0 flex-1 flex-col py-[8px] pr-[8px] pl-[8px] lg:pl-0'>
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[#1b1b1b]'>
<div
className={
isWorkflowView
? 'relative min-w-0 flex-1 overflow-hidden'
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
}
>
{isWorkflowView ? (
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
) : (
<LandingPreviewHome />
)}
</div>
<motion.div
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
variants={panelVariants}
>
<LandingPreviewPanel />
</motion.div>
</div>
</div>
<motion.div className='hidden lg:flex' variants={panelVariants}>
<LandingPreviewPanel />
</motion.div>
</motion.div>
)
}

View File

@@ -0,0 +1,101 @@
import Link from 'next/link'
import { cn } from '@/lib/core/utils/cn'
const FEATURED_POST = {
title: 'Build with Sim for Enterprise',
slug: 'enterprise',
image: '/blog/thumbnails/enterprise.webp',
} as const
const POSTS = [
{ title: 'Introducing Sim v0.5', slug: 'v0-5', image: '/blog/thumbnails/v0-5.webp' },
{ title: '$7M Series A', slug: 'series-a', image: '/blog/thumbnails/series-a.webp' },
{
title: 'Realtime Collaboration',
slug: 'multiplayer',
image: '/blog/thumbnails/multiplayer.webp',
},
{ title: 'Inside the Executor', slug: 'executor', image: '/blog/thumbnails/executor.webp' },
{ title: 'Inside Sim Copilot', slug: 'copilot', image: '/blog/thumbnails/copilot.webp' },
] as const
function BlogCard({
slug,
image,
title,
imageHeight,
titleSize = '12px',
className,
}: {
slug: string
image: string
title: string
imageHeight: string
titleSize?: string
className?: string
}) {
return (
<Link
href={`/blog/${slug}`}
className={cn(
'group/card flex flex-col overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]',
className
)}
prefetch={false}
>
<div className='w-full overflow-hidden bg-[#141414]' style={{ height: imageHeight }}>
<img
src={image}
alt={title}
decoding='async'
className='h-full w-full object-cover transition-transform duration-200 group-hover/card:scale-[1.02]'
/>
</div>
<div className='flex-shrink-0 px-[10px] py-[6px]'>
<span
className='font-[430] font-season text-[#cdcdcd] leading-[140%]'
style={{ fontSize: titleSize }}
>
{title}
</span>
</div>
</Link>
)
}
export function BlogDropdown() {
return (
<div className='w-[560px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
<div className='grid grid-cols-3 gap-[8px]'>
<BlogCard
slug={FEATURED_POST.slug}
image={FEATURED_POST.image}
title={FEATURED_POST.title}
imageHeight='190px'
titleSize='13px'
className='col-span-2 row-span-2'
/>
{POSTS.slice(0, 2).map((post) => (
<BlogCard
key={post.slug}
slug={post.slug}
image={post.image}
title={post.title}
imageHeight='72px'
/>
))}
{POSTS.slice(2).map((post) => (
<BlogCard
key={post.slug}
slug={post.slug}
image={post.image}
title={post.title}
imageHeight='72px'
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,92 @@
import { AgentIcon, GithubOutlineIcon, McpIcon } from '@/components/icons'
const PREVIEW_CARDS = [
{
title: 'Introduction',
href: 'https://docs.sim.ai',
image: '/landing/docs-getting-started.svg',
},
{
title: 'Getting Started',
href: 'https://docs.sim.ai/getting-started',
image: '/landing/docs-intro.svg',
},
] as const
const RESOURCE_CARDS = [
{
title: 'Agent',
description: 'Build AI agents',
href: 'https://docs.sim.ai/blocks/agent',
icon: AgentIcon,
},
{
title: 'MCP',
description: 'Connect tools',
href: 'https://docs.sim.ai/mcp',
icon: McpIcon,
},
{
title: 'Self-hosting',
description: 'Host on your infra',
href: 'https://docs.sim.ai/self-hosting',
icon: GithubOutlineIcon,
},
] as const
export function DocsDropdown() {
return (
<div className='w-[480px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
<div className='grid grid-cols-2 gap-[10px]'>
{PREVIEW_CARDS.map((card) => (
<a
key={card.title}
href={card.href}
target='_blank'
rel='noopener noreferrer'
className='group/card overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]'
>
<div className='h-[120px] w-full overflow-hidden bg-[#141414]'>
<img
src={card.image}
alt={card.title}
decoding='async'
className='h-full w-full scale-[1.04] object-cover transition-transform duration-200 group-hover/card:scale-[1.06]'
/>
</div>
<div className='px-[10px] py-[8px]'>
<span className='font-[430] font-season text-[#cdcdcd] text-[13px]'>
{card.title}
</span>
</div>
</a>
))}
</div>
<div className='mt-[8px] grid grid-cols-3 gap-[8px]'>
{RESOURCE_CARDS.map((card) => {
const Icon = card.icon
return (
<a
key={card.title}
href={card.href}
target='_blank'
rel='noopener noreferrer'
className='flex flex-col gap-[4px] rounded-[5px] border border-[#2A2A2A] px-[10px] py-[8px] transition-colors hover:border-[#3D3D3D] hover:bg-[#232323]'
>
<div className='flex items-center gap-[6px]'>
<Icon className='h-[13px] w-[13px] flex-shrink-0 text-[#939393]' />
<span className='font-[430] font-season text-[#cdcdcd] text-[12px]'>
{card.title}
</span>
</div>
<span className='font-season text-[#939393] text-[11px] leading-[130%]'>
{card.description}
</span>
</a>
)
})}
</div>
</div>
)
}

View File

@@ -1,97 +1,385 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { ChevronDown } from '@/components/emcn'
import { GithubOutlineIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { BlogDropdown } 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'
import { getBrandConfig } from '@/ee/whitelabeling'
type DropdownId = 'docs' | 'blog' | null
interface NavLink {
label: string
href: string
external?: boolean
icon?: 'chevron'
dropdown?: 'docs' | 'blog'
}
const NAV_LINKS: NavLink[] = [
{ label: 'Docs', href: '/docs', icon: 'chevron' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' },
{ 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 },
]
/** Logo and nav edge: horizontal padding (px) for left/right symmetry. */
const LOGO_CELL = 'flex items-center px-[20px]'
/** Links: even spacing between items. */
const LOGO_CELL = 'flex items-center pl-[20px] lg:pl-[80px] pr-[20px]'
const LINK_CELL = 'flex items-center px-[14px]'
export default function Navbar() {
interface NavbarProps {
logoOnly?: boolean
}
export default function Navbar({ logoOnly = false }: NavbarProps) {
const brand = getBrandConfig()
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)
const openDropdown = useCallback((id: DropdownId) => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
setActiveDropdown(id)
}, [])
const scheduleClose = useCallback(() => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
closeTimerRef.current = setTimeout(() => {
setActiveDropdown(null)
closeTimerRef.current = null
}, 100)
}, [])
useEffect(() => {
return () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
}
}, [])
useEffect(() => {
document.body.style.overflow = mobileMenuOpen ? 'hidden' : ''
return () => {
document.body.style.overflow = ''
}
}, [mobileMenuOpen])
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)')
const handler = () => {
if (mq.matches) setMobileMenuOpen(false)
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const anyHighlighted = activeDropdown !== null || hoveredLink !== null
return (
<nav
aria-label='Primary navigation'
className='flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
className='relative flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
itemScope
itemType='https://schema.org/SiteNavigationElement'
>
{/* Logo */}
<Link href='/' className={LOGO_CELL} aria-label='Sim home' itemProp='url'>
<Link href='/' className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
<span itemProp='name' className='sr-only'>
Sim
{brand.name}
</span>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={71}
height={22}
className='h-[22px] w-auto'
priority
/>
{brand.logoUrl ? (
<Image
src={brand.logoUrl}
alt={`${brand.name} Logo`}
width={71}
height={22}
className='h-[22px] w-auto object-contain'
priority
unoptimized
/>
) : (
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={71}
height={22}
className='h-[22px] w-auto'
priority
/>
)}
</Link>
{/* Links */}
<ul className='mt-[0.75px] flex'>
{NAV_LINKS.map(({ label, href, external, icon }) => (
<li key={label} className='flex'>
{external ? (
<a href={href} target='_blank' rel='noopener noreferrer' className={LINK_CELL}>
{label}
</a>
) : (
<Link
href={href}
className={icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL}
aria-label={label}
>
{label}
{icon === 'chevron' && (
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
)}
</Link>
{!logoOnly && (
<>
<ul className='mt-[0.75px] hidden lg:flex'>
{NAV_LINKS.map(({ label, href, external, icon, dropdown }) => {
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-[8px]` : LINK_CELL,
'transition-colors duration-200',
isDimmed && 'text-[#F6F6F6]/60'
)
const chevron = icon === 'chevron' && <NavChevron open={isActive} />
if (hasDropdown) {
return (
<li
key={label}
className='relative flex'
onMouseEnter={() => openDropdown(dropdown)}
onMouseLeave={scheduleClose}
>
<button
type='button'
className={cn(linkClass, 'h-full cursor-pointer')}
aria-expanded={isActive}
aria-haspopup='true'
>
{label}
{chevron}
</button>
<div
className={cn(
'-mt-[2px] 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 />}
</div>
</li>
)
}
return (
<li
key={label}
className='flex'
onMouseEnter={() => setHoveredLink(label)}
onMouseLeave={() => setHoveredLink(null)}
>
{external ? (
<a href={href} target='_blank' rel='noopener noreferrer' className={linkClass}>
{label}
{chevron}
</a>
) : (
<Link href={href} className={linkClass} aria-label={label}>
{label}
{chevron}
</Link>
)}
</li>
)
})}
<li
className={cn(
'flex transition-opacity duration-200',
anyHighlighted && hoveredLink !== 'github' && 'opacity-60'
)}
onMouseEnter={() => setHoveredLink('github')}
onMouseLeave={() => setHoveredLink(null)}
>
<GitHubStars />
</li>
</ul>
<div className='hidden flex-1 lg:block' />
<div className='hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex'>
<Link
href='/login'
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
<div className='flex flex-1 items-center justify-end pr-[20px] lg:hidden'>
<button
type='button'
className='flex h-[32px] w-[32px] items-center justify-center rounded-[5px] transition-colors hover:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen((prev) => !prev)}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
>
<MobileMenuIcon open={mobileMenuOpen} />
</button>
</div>
<div
className={cn(
'fixed inset-x-0 top-[52px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[#1C1C1C] font-[430] font-season text-[14px] transition-all duration-200 lg:hidden',
mobileMenuOpen ? 'visible opacity-100' : 'invisible opacity-0'
)}
</li>
))}
<li className='flex'>
<GitHubStars />
</li>
</ul>
>
<ul className='flex flex-col'>
{NAV_LINKS.map(({ label, href, external }) => (
<li key={label} className='border-[#2A2A2A] border-b'>
{external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
<ExternalArrowIcon />
</a>
) : (
<Link
href={href}
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
</Link>
)}
</li>
))}
<li className='border-[#2A2A2A] border-b'>
<a
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-[8px] px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
<GithubOutlineIcon className='h-[14px] w-[14px]' />
GitHub
</a>
</li>
</ul>
<div className='flex-1' />
{/* CTAs */}
<div className='flex items-center gap-[8px] px-[20px]'>
<Link
href='/login'
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
<div className='mt-auto flex flex-col gap-[10px] p-[20px]'>
<Link
href='/login'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
</div>
</>
)}
</nav>
)
}
interface NavChevronProps {
open: boolean
}
/**
* Animated chevron matching the exact geometry of the emcn ChevronDown SVG.
* Each arm rotates around its midpoint so the center vertex travels up/down
* while the outer endpoints adjust — producing a Stripe-style morph.
*/
function NavChevron({ open }: NavChevronProps) {
return (
<svg width='9' height='6' viewBox='0 0 10 6' fill='none' className='mt-[1.5px] flex-shrink-0'>
<line
x1='1'
y1='1'
x2='5'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
style={{
transformOrigin: '3px 3px',
transform: open ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 250ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
<line
x1='5'
y1='5'
x2='9'
y2='1'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
style={{
transformOrigin: '7px 3px',
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 250ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</svg>
)
}
function MobileMenuIcon({ open }: { open: boolean }) {
if (open) {
return (
<svg width='14' height='14' viewBox='0 0 14 14' fill='none'>
<path
d='M1 1L13 13M13 1L1 13'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
/>
</svg>
)
}
return (
<svg width='16' height='12' viewBox='0 0 16 12' fill='none'>
<path
d='M0 1H16M0 6H16M0 11H16'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
/>
</svg>
)
}
function ExternalArrowIcon() {
return (
<svg width='12' height='12' viewBox='0 0 12 12' fill='none' className='text-[#666]'>
<path
d='M3.5 2.5H9.5V8.5M9 3L3 9'
stroke='currentColor'
strokeWidth='1.2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}

View File

@@ -22,9 +22,10 @@ const PRICING_TIERS: PricingTier[] = [
features: [
'1,000 credits (trial)',
'5GB file storage',
'3 tables · 1,000 rows each',
'5 min execution limit',
'Limited log retention',
'CLI/SDK Access',
'7-day log retention',
'CLI/SDK/MCP Access',
],
cta: { label: 'Get started', href: '/signup' },
},
@@ -36,11 +37,12 @@ const PRICING_TIERS: PricingTier[] = [
billingPeriod: 'per month',
color: '#00F701',
features: [
'6,000 credits/mo',
'+50 daily refresh credits',
'150 runs/min (sync)',
'50 min sync execution limit',
'6,000 credits/mo · +50/day',
'50GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 150 runs/min',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
cta: { label: 'Get started', href: '/signup' },
},
@@ -52,11 +54,12 @@ const PRICING_TIERS: PricingTier[] = [
billingPeriod: 'per month',
color: '#FA4EDF',
features: [
'25,000 credits/mo',
'+200 daily refresh credits',
'300 runs/min (sync)',
'50 min sync execution limit',
'25,000 credits/mo · +200/day',
'500GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 300 runs/min',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
cta: { label: 'Get started', href: '/signup' },
},
@@ -66,7 +69,15 @@ const PRICING_TIERS: PricingTier[] = [
description: 'For organizations needing security and scale',
price: 'Custom',
color: '#FFCC02',
features: ['Custom infra limits', 'SSO', 'SOC2', 'Self hosting', 'Dedicated support'],
features: [
'Custom credits & infra limits',
'Custom file storage',
'10,000 tables · 1M rows each',
'Custom execution limits',
'Unlimited log retention',
'SSO & SCIM · SOC2 & HIPAA',
'Self hosting · Dedicated support',
],
cta: { label: 'Book a demo', href: '/contact' },
},
]
@@ -114,16 +125,16 @@ function PricingCard({ tier }: PricingCardProps) {
</p>
<div className='mt-4'>
{isEnterprise ? (
<a
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}
</a>
</Link>
) : isPro ? (
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
>
{tier.cta.label}
</Link>
@@ -174,7 +185,7 @@ function PricingCard({ tier }: PricingCardProps) {
export default function Pricing() {
return (
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
<div className='px-4 pt-[60px] pb-[40px] sm:px-8 sm:pt-[80px] sm:pb-0 md:px-[80px] md:pt-[100px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
<Badge
variant='blue'
@@ -193,7 +204,7 @@ export default function Pricing() {
</h2>
</div>
<div className='mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
<div className='mt-8 grid grid-cols-1 gap-4 sm:mt-10 sm:grid-cols-2 md:mt-12 lg:grid-cols-4'>
{PRICING_TIERS.map((tier) => (
<PricingCard key={tier.id} tier={tier} />
))}

View File

@@ -74,6 +74,10 @@ export default function StructuredData() {
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
breadcrumb: { '@id': 'https://sim.ai/#breadcrumb' },
inLanguage: 'en-US',
speakable: {
'@type': 'SpeakableSpecification',
cssSelector: ['#hero-heading', '[id="hero"] p'],
},
potentialAction: [{ '@type': 'ReadAction', target: ['https://sim.ai'] }],
},
{

View File

@@ -1,8 +1,8 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import { AnimatePresence, type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Badge, ChevronDown } from '@/components/emcn'
@@ -337,7 +337,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
))}
</div>
)
@@ -349,8 +349,17 @@ export default function Templates() {
const sectionRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [isPreparingTemplate, setIsPreparingTemplate] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const router = useRouter()
useEffect(() => {
const mq = window.matchMedia('(max-width: 1023px)')
setIsMobile(mq.matches)
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start 0.9', 'start 0.2'],
@@ -415,8 +424,8 @@ export default function Templates() {
<div className='bg-[#1C1C1C]'>
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={160}
rows={1}
gap={6}
/>
@@ -440,7 +449,7 @@ export default function Templates() {
</svg>
</div>
<div className='px-[80px] pt-[100px]'>
<div className='px-[20px] pt-[60px] lg:px-[80px] lg:pt-[100px]'>
<div className='flex flex-col items-start gap-[20px]'>
<Badge
variant='blue'
@@ -457,84 +466,132 @@ export default function Templates() {
<h2
id='templates-heading'
className='font-[430] font-season text-[40px] text-white leading-[100%] tracking-[-0.02em]'
className='font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
>
Ship your agent in minutes
</h2>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
Pre-built templates for every use casepick one, swap <br />
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[15px] leading-[150%] tracking-[0.02em] lg:text-[18px]'>
Pre-built templates for every use casepick one, swap{' '}
<br className='hidden lg:inline' />
models and tools to fit your stack, and deploy.
</p>
</div>
</div>
<div className='mt-[73px] flex border-[#2A2A2A] border-y'>
<DotGrid
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-r p-[6px]'
cols={6}
rows={55}
gap={6}
/>
<div className='mt-[40px] flex border-[#2A2A2A] border-y lg:mt-[73px]'>
<div className='shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid
className='h-full w-[24px] overflow-hidden border-[#2A2A2A] border-r p-[4px]'
cols={2}
rows={55}
gap={4}
/>
</div>
<div className='hidden h-full lg:block'>
<DotGrid
className='h-full w-[80px] overflow-hidden border-[#2A2A2A] border-r p-[6px]'
cols={8}
rows={55}
gap={6}
/>
</div>
</div>
<div className='flex min-w-0 flex-1'>
<div className='flex min-w-0 flex-1 flex-col lg:flex-row'>
<div
role='tablist'
aria-label='Workflow templates'
className='flex w-[300px] shrink-0 flex-col border-[#2A2A2A] border-r'
className='flex w-full shrink-0 flex-col border-[#2A2A2A] lg:w-[300px] lg:border-r'
>
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
const isActive = index === activeIndex
return (
<button
key={workflow.id}
id={`template-tab-${index}`}
type='button'
role='tab'
aria-selected={isActive}
aria-controls={TEMPLATES_PANEL_ID}
onClick={() => setActiveIndex(index)}
className={cn(
'relative text-left',
isActive
? 'z-10'
: 'flex items-center px-[12px] py-[10px] shadow-[inset_0_-1px_0_0_#2A2A2A] last:shadow-none hover:bg-[#232323]/50'
)}
>
{isActive ? (
(() => {
const depth = DEPTH_CONFIGS[workflow.id]
return (
<>
<div
className='absolute top-[-8px] bottom-0 left-0 w-2'
style={{
clipPath: LEFT_WALL_CLIP,
backgroundColor: hexToRgba(depth.color, 0.63),
}}
/>
<div
className='absolute right-[-8px] bottom-0 left-2 h-2'
style={buildBottomWallStyle(depth)}
/>
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
{workflow.name}
</span>
<ChevronDown
className='-rotate-90 h-[11px] w-[11px] shrink-0'
style={{ color: depth.color }}
<div key={workflow.id}>
<button
id={`template-tab-${index}`}
type='button'
role='tab'
aria-selected={isActive}
aria-controls={TEMPLATES_PANEL_ID}
onClick={() => setActiveIndex(index)}
className={cn(
'relative w-full text-left',
isActive
? 'z-10'
: cn(
'flex items-center px-[12px] py-[10px] hover:bg-[#232323]/50',
index < TEMPLATE_WORKFLOWS.length - 1 &&
'shadow-[inset_0_-1px_0_0_#2A2A2A]'
)
)}
>
{isActive ? (
(() => {
const depth = DEPTH_CONFIGS[workflow.id]
return (
<>
<div
className='absolute top-[-8px] bottom-0 left-0 w-2'
style={{
clipPath: LEFT_WALL_CLIP,
backgroundColor: hexToRgba(depth.color, 0.63),
}}
/>
</div>
</>
)
})()
) : (
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
{workflow.name}
</span>
)}
</button>
<div
className='absolute right-[-8px] bottom-0 left-2 h-2'
style={buildBottomWallStyle(depth)}
/>
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
{workflow.name}
</span>
<ChevronDown
className='-rotate-90 h-[11px] w-[11px] shrink-0'
style={{ color: depth.color }}
/>
</div>
</>
)
})()
) : (
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
{workflow.name}
</span>
)}
</button>
<AnimatePresence>
{isActive && isMobile && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className='overflow-hidden'
>
<div className='aspect-[16/10] w-full border-[#2A2A2A] border-y bg-[#1b1b1b]'>
<LandingPreviewWorkflow
workflow={workflow}
animate
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
/>
</div>
<div className='p-[12px]'>
<button
type='button'
onClick={handleUseTemplate}
disabled={isPreparingTemplate}
className='inline-flex h-[32px] w-full cursor-pointer items-center justify-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] font-[430] font-season text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
>
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
@@ -557,7 +614,7 @@ export default function Templates() {
type='button'
onClick={handleUseTemplate}
disabled={isPreparingTemplate}
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
<span className='relative h-[10px] w-[10px] shrink-0'>
@@ -582,12 +639,24 @@ export default function Templates() {
</div>
</div>
<DotGrid
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-l p-[6px]'
cols={6}
rows={55}
gap={6}
/>
<div className='shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid
className='h-full w-[24px] overflow-hidden border-[#2A2A2A] border-l p-[4px]'
cols={2}
rows={55}
gap={4}
/>
</div>
<div className='hidden h-full lg:block'>
<DotGrid
className='h-full w-[80px] overflow-hidden border-[#2A2A2A] border-l p-[6px]'
cols={8}
rows={55}
gap={6}
/>
</div>
</div>
</div>
</div>
</div>

View File

@@ -28,8 +28,8 @@ import {
* for immediate availability to AI crawlers.
* - Section `id` attributes serve as fragment anchors for precise AI citations.
* - Content ordering prioritizes answer-first patterns: definition (Hero) ->
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration, Testimonials) ->
* pricing (Pricing) -> enterprise (Enterprise).
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration) ->
* enterprise (Enterprise) -> pricing (Pricing) -> testimonials (Testimonials).
*/
export default async function Landing() {
return (
@@ -43,8 +43,8 @@ export default async function Landing() {
<Templates />
<Features />
<Collaboration />
<Pricing />
<Enterprise />
<Pricing />
<Testimonials />
</main>
<Footer />

View File

@@ -9,8 +9,8 @@ export function BackLink() {
return (
<Link
href='/studio'
className='group flex items-center gap-1 text-gray-600 text-sm hover:text-gray-900'
href='/blog'
className='group flex items-center gap-1 text-[#999] text-sm hover:text-[#ECECEC]'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
@@ -21,7 +21,7 @@ export function BackLink() {
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
)}
</span>
Back to Sim Studio
Back to Blog
</Link>
)
}

View File

@@ -6,9 +6,8 @@ import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
import { BackLink } from '@/app/(landing)/blog/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/blog/[slug]/share-button'
export async function generateStaticParams() {
const posts = await getAllPostMeta()
@@ -36,11 +35,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
const related = await getRelatedPosts(slug, 3)
return (
<article
className={`${soehne.className} w-full`}
itemScope
itemType='https://schema.org/BlogPosting'
>
<article className='w-full' itemScope itemType='https://schema.org/BlogPosting'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
@@ -71,7 +66,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
</div>
<div className='flex flex-1 flex-col justify-between'>
<h1
className='font-medium text-[36px] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
className='font-[500] text-[#ECECEC] text-[36px] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
itemProp='headline'
>
{post.title}
@@ -90,7 +85,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
className='text-[#999] text-[14px] leading-[1.5] hover:text-[#ECECEC] sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
@@ -100,15 +95,15 @@ export default async function Page({ params }: { params: Promise<{ slug: string
</div>
))}
</div>
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
<ShareButton url={`${getBaseUrl()}/blog/${slug}`} title={post.title} />
</div>
</div>
</div>
<hr className='mt-8 border-gray-200 border-t sm:mt-12' />
<hr className='mt-8 border-[#2A2A2A] border-t sm:mt-12' />
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
<div className='flex flex-shrink-0 items-center gap-4'>
<time
className='block text-[14px] text-gray-600 leading-[1.5] sm:text-[16px]'
className='block text-[#999] text-[14px] leading-[1.5] sm:text-[16px]'
dateTime={post.date}
itemProp='datePublished'
>
@@ -121,7 +116,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
<meta itemProp='dateModified' content={post.updated ?? post.date} />
</div>
<div className='flex-1'>
<p className='m-0 block translate-y-[-4px] font-[400] text-[18px] leading-[1.5] sm:text-[20px] md:text-[26px]'>
<p className='m-0 block translate-y-[-4px] font-[400] text-[#999] text-[18px] leading-[1.5] sm:text-[20px] md:text-[26px]'>
{post.description}
</p>
</div>
@@ -129,18 +124,18 @@ export default async function Page({ params }: { params: Promise<{ slug: string
</header>
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12' itemProp='articleBody'>
<div className='prose prose-lg max-w-none'>
<div className='prose prose-lg prose-invert max-w-none prose-blockquote:border-[#3d3d3d] prose-hr:border-[#2A2A2A] prose-a:text-[#ECECEC] prose-blockquote:text-[#999] prose-code:text-[#ECECEC] prose-headings:text-[#ECECEC] prose-li:text-[#999] prose-p:text-[#999] prose-strong:text-[#ECECEC]'>
<Article />
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
</div>
</div>
{related.length > 0 && (
<div className='mx-auto max-w-[900px] px-6 pb-24 sm:px-8 md:px-12'>
<h2 className='mb-4 font-medium text-[24px]'>Related posts</h2>
<h2 className='mb-4 font-[500] text-[#ECECEC] text-[24px]'>Related posts</h2>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
{related.map((p) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-gray-200'>
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
<Image
src={p.ogImage}
alt={p.title}
@@ -152,14 +147,14 @@ export default async function Page({ params }: { params: Promise<{ slug: string
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-gray-600 text-xs'>
<div className='mb-1 text-[#999] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='font-medium text-sm leading-tight'>{p.title}</div>
<div className='font-[500] text-[#ECECEC] text-sm leading-tight'>{p.title}</div>
</div>
</div>
</Link>

View File

@@ -2,7 +2,12 @@
import { useState } from 'react'
import { Share2 } from 'lucide-react'
import { Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
interface ShareButtonProps {
url: string
@@ -10,56 +15,46 @@ interface ShareButtonProps {
}
export function ShareButton({ url, title }: ShareButtonProps) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => {
setCopied(false)
setOpen(false)
}, 1000)
setTimeout(() => setCopied(false), 1500)
} catch {
setOpen(false)
/* clipboard unavailable */
}
}
const handleShareTwitter = () => {
const tweetUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
window.open(tweetUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
const handleShareLinkedIn = () => {
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
return (
<Popover
open={open}
onOpenChange={setOpen}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className='flex items-center gap-1.5 text-gray-600 text-sm hover:text-gray-900'
className='flex items-center gap-1.5 text-[#999] text-sm hover:text-[#ECECEC]'
aria-label='Share this post'
>
<Share2 className='h-4 w-4' />
<span>Share</span>
</button>
</PopoverTrigger>
<PopoverContent align='end' minWidth={140}>
<PopoverItem onClick={handleCopyLink}>{copied ? 'Copied!' : 'Copy link'}</PopoverItem>
<PopoverItem onClick={handleShareTwitter}>Share on X</PopoverItem>
<PopoverItem onClick={handleShareLinkedIn}>Share on LinkedIn</PopoverItem>
</PopoverContent>
</Popover>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={handleCopyLink}>
{copied ? 'Copied!' : 'Copy link'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareTwitter}>Share on X</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareLinkedIn}>Share on LinkedIn</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
export const revalidate = 3600
@@ -23,8 +22,8 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
const author = posts[0]?.author
if (!author) {
return (
<main className={`${soehne.className} mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12`}>
<h1 className='font-medium text-[32px]'>Author not found</h1>
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='font-[500] text-[#ECECEC] text-[32px]'>Author not found</h1>
</main>
)
}
@@ -32,12 +31,12 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
'@context': 'https://schema.org',
'@type': 'Person',
name: author.name,
url: `https://sim.ai/studio/authors/${author.id}`,
url: `https://sim.ai/blog/authors/${author.id}`,
sameAs: author.url ? [author.url] : [],
image: author.avatarUrl,
}
return (
<main className={`${soehne.className} mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12`}>
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
@@ -53,12 +52,12 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
unoptimized
/>
) : null}
<h1 className='font-medium text-[32px] leading-tight'>{author.name}</h1>
<h1 className='font-[500] text-[#ECECEC] text-[32px] leading-tight'>{author.name}</h1>
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{posts.map((p) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-gray-200'>
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
<Image
src={p.ogImage}
alt={p.title}
@@ -68,14 +67,14 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-gray-600 text-xs'>
<div className='mb-1 text-[#999] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='font-medium text-sm leading-tight'>{p.title}</div>
<div className='font-[500] text-[#ECECEC] text-sm leading-tight'>{p.title}</div>
</div>
</div>
</Link>

View File

@@ -1,4 +1,5 @@
import { Footer, Nav } from '@/app/(landing)/components'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default function StudioLayout({ children }: { children: React.ReactNode }) {
const orgJsonLd = {
@@ -23,7 +24,7 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
}
return (
<div className='flex min-h-screen flex-col'>
<div className='flex min-h-screen flex-col bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
@@ -32,9 +33,11 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
/>
<Nav hideAuthButtons={false} variant='landing' />
<header>
<Navbar />
</header>
<main className='relative flex-1'>{children}</main>
<Footer fullWidth={true} />
<Footer />
</div>
)
}

View File

@@ -1,17 +1,16 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { PostGrid } from '@/app/(landing)/studio/post-grid'
import { PostGrid } from '@/app/(landing)/blog/post-grid'
export const metadata: Metadata = {
title: 'Studio',
title: 'Blog',
description: 'Announcements, insights, and guides from the Sim team.',
}
export const revalidate = 3600
export default async function StudioIndex({
export default async function BlogIndex({
searchParams,
}: {
searchParams: Promise<{ page?: string; tag?: string }>
@@ -37,30 +36,32 @@ export default async function StudioIndex({
const posts = sorted.slice(start, start + perPage)
// Tag filter chips are intentionally disabled for now.
// const tags = await getAllTags()
const studioJsonLd = {
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'Sim Studio',
url: 'https://sim.ai/studio',
name: 'Sim Blog',
url: 'https://sim.ai/blog',
description: 'Announcements, insights, and guides for building AI agent workflows.',
}
return (
<main className={`${soehne.className} mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12`}>
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(studioJsonLd) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogJsonLd) }}
/>
<h1 className='mb-3 font-medium text-[40px] leading-tight sm:text-[56px]'>Sim Studio</h1>
<p className='mb-10 text-[18px] text-gray-700'>
<h1 className='mb-3 font-[500] text-[#ECECEC] text-[40px] leading-tight sm:text-[56px]'>
Blog
</h1>
<p className='mb-10 text-[#999] text-[18px]'>
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='/studio' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
<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={`/studio?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'}`}>
<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>
))}
@@ -73,19 +74,19 @@ export default async function StudioIndex({
<div className='mt-10 flex items-center justify-center gap-3'>
{pageNum > 1 && (
<Link
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded border px-3 py-1 text-sm'
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
>
Previous
</Link>
)}
<span className='text-gray-600 text-sm'>
<span className='text-[#999] text-sm'>
Page {pageNum} of {totalPages}
</span>
{pageNum < totalPages && (
<Link
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded border px-3 py-1 text-sm'
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
>
Next
</Link>

View File

@@ -26,8 +26,8 @@ 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={`/studio/${p.slug}`} className='group flex flex-col'>
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
<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-[#2A2A2A] transition-colors duration-300 hover:border-[#3d3d3d]'>
{/* Image container with fixed aspect ratio to prevent layout shift */}
<div className='relative aspect-video w-full overflow-hidden'>
<Image
@@ -42,29 +42,29 @@ export function PostGrid({ posts }: { posts: Post[] }) {
/>
</div>
<div className='flex flex-1 flex-col p-4'>
<div className='mb-2 text-gray-600 text-xs'>
<div className='mb-2 text-[#999] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
<h3 className='mb-1 font-[500] text-[#ECECEC] text-lg leading-tight'>{p.title}</h3>
<p className='mb-3 line-clamp-3 flex-1 text-[#999] 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-white'>
<Avatar key={idx} className='size-4 border border-[#1C1C1C]'>
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
<AvatarFallback className='border border-[#1C1C1C] bg-[#2A2A2A] text-[#999] text-[10px]'>
{author?.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
))}
</div>
<span className='text-gray-600 text-xs'>
<span className='text-[#999] text-xs'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 2)
.map((a) => a?.name)

View File

@@ -11,7 +11,7 @@ export async function GET() {
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Sim Studio</title>
<title>Sim Blog</title>
<link>${site}</link>
<description>Announcements, insights, and guides for AI agent workflows.</description>
${items

View File

@@ -10,16 +10,19 @@ export default async function TagsIndex() {
const tags = await getAllTags()
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='mb-6 font-medium text-[32px] leading-tight'>Browse by tag</h1>
<h1 className='mb-6 font-[500] text-[#ECECEC] text-[32px] leading-tight'>Browse by tag</h1>
<div className='flex flex-wrap gap-3'>
<Link href='/studio' className='rounded-full border border-gray-300 px-3 py-1 text-sm'>
<Link
href='/blog'
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
>
All
</Link>
{tags.map((t) => (
<Link
key={t.tag}
href={`/studio?tag=${encodeURIComponent(t.tag)}`}
className='rounded-full border border-gray-300 px-3 py-1 text-sm'
href={`/blog?tag=${encodeURIComponent(t.tag)}`}
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
>
{t.tag} ({t.count})
</Link>

View File

@@ -1,5 +1,4 @@
import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter'
import {
ComplianceBadges,
Logo,
@@ -14,7 +13,7 @@ interface FooterProps {
export default function Footer({ fullWidth = false }: FooterProps) {
return (
<footer className={`${inter.className} relative w-full overflow-hidden bg-white`}>
<footer className='relative w-full overflow-hidden bg-white'>
<div
className={
fullWidth
@@ -58,10 +57,10 @@ export default function Footer({ fullWidth = false }: FooterProps) {
Enterprise
</Link>
<Link
href='/studio'
href='/blog'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Sim Studio
Blog
</Link>
<Link
href='/changelog'

View File

@@ -23,7 +23,6 @@ import {
SupabaseIcon,
} from '@/components/icons'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import {
CARD_WIDTH,
IconButton,
@@ -364,7 +363,7 @@ export default function Hero() {
return (
<section
id='hero'
className={`${soehne.className} flex w-full flex-col items-center justify-center pt-[36px] sm:pt-[80px]`}
className='flex w-full flex-col items-center justify-center pt-[36px] sm:pt-[80px]'
aria-labelledby='hero-heading'
>
<h1

View File

@@ -1,5 +1,4 @@
import * as Icons from '@/components/icons'
import { inter } from '@/app/_styles/fonts/inter/inter'
const modelProviderIcons = [
{ icon: Icons.OpenAIIcon, label: 'OpenAI' },
@@ -122,7 +121,7 @@ export default function Integrations() {
return (
<section
id='integrations'
className={`${inter.className} flex flex-col pt-[40px] pb-[27px] sm:pt-[24px]`}
className='flex flex-col pt-[40px] pb-[27px] sm:pt-[24px]'
aria-labelledby='integrations-heading'
>
<h2

View File

@@ -17,7 +17,6 @@ import {
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { ENTERPRISE_PLAN_FEATURES } from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
const logger = createLogger('LandingPricing')
@@ -117,7 +116,6 @@ function PricingCard({
return (
<div
className={cn(
`${inter.className}`,
'relative flex h-full flex-col justify-between bg-[#FEFEFE]',
tier.featured ? 'p-0' : 'px-0 py-0',
'sm:px-5 sm:pt-4 sm:pb-4',

View File

@@ -1,5 +1,3 @@
import { inter } from '@/app/_styles/fonts/inter/inter'
interface LandingTemplatePreviewProps {
previewImage: string
avatarImage: string
@@ -37,14 +35,8 @@ export default function LandingTemplatePreview({
{/* Title and Author Info */}
<div className='min-w-0 flex-1'>
<h4
className={`${inter.className} truncate font-medium text-foreground text-sm leading-none`}
>
{title}
</h4>
<p
className={`${inter.className} mt-1 flex items-center gap-2 text-muted-foreground text-xs`}
>
<h4 className='truncate font-medium text-foreground text-sm leading-none'>{title}</h4>
<p className='mt-1 flex items-center gap-2 text-muted-foreground text-xs'>
<span>{authorName}</span>
<span>{usageCount.toLocaleString()} copies</span>
</p>

View File

@@ -1,4 +1,3 @@
import { inter } from '@/app/_styles/fonts/inter/inter'
import LandingTemplatePreview from '@/app/(landing)/components/landing-templates/components/landing-template-preview'
const templates = [
@@ -80,7 +79,7 @@ export default function LandingTemplates() {
return (
<section
id='templates'
className={`${inter.className} flex flex-col px-4 pt-[40px] sm:px-[50px] sm:pt-[34px]`}
className='flex flex-col px-4 pt-[40px] sm:px-[50px] sm:pt-[34px]'
aria-labelledby='templates-heading'
>
<h2

View File

@@ -1,36 +1,29 @@
'use client'
import { isHosted } from '@/lib/core/config/feature-flags'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import Footer from '@/app/(landing)/components/footer/footer'
import Nav from '@/app/(landing)/components/nav/nav'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
interface LegalLayoutProps {
title: string
children: React.ReactNode
navVariant?: 'landing' | 'auth' | 'legal'
}
export default function LegalLayout({ title, children, navVariant = 'legal' }: LegalLayoutProps) {
export default function LegalLayout({ title, children }: LegalLayoutProps) {
return (
<main className={`${soehne.className} min-h-screen bg-white text-gray-900`}>
{/* Header - Nav handles all conditional logic */}
<Nav variant={navVariant} />
<main className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
<header>
<Navbar />
</header>
{/* Content */}
<div className='px-12 pt-[40px] pb-[40px]'>
<h1 className='mb-12 text-center font-bold text-4xl text-gray-900 md:text-5xl'>{title}</h1>
<div className='prose prose-gray mx-auto prose-h2:mt-12 prose-h3:mt-8 prose-h2:mb-6 prose-h3:mb-4 space-y-8 text-gray-700'>
<div className='mx-auto max-w-[800px] px-6 pt-[60px] pb-[80px] sm:px-12'>
<h1 className='mb-12 text-center font-[500] text-4xl text-[#ECECEC] md:text-5xl'>
{title}
</h1>
<div className='space-y-8 text-[#999] text-[15px] leading-[1.7] [&_h2]:mt-12 [&_h2]:mb-6 [&_h2]:text-[#ECECEC] [&_h3]:mt-8 [&_h3]:mb-4 [&_h3]:text-[#ECECEC] [&_li]:text-[#999] [&_strong]:text-[#ECECEC]'>
{children}
</div>
</div>
{/* Footer - Only for hosted instances */}
{isHosted && (
<div className='relative z-20'>
<Footer fullWidth={true} />
</div>
)}
{isHosted && <Footer hideCTA />}
</main>
)
}

View File

@@ -8,7 +8,6 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { GithubIcon } from '@/components/icons'
import { isHosted } from '@/lib/core/config/feature-flags'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandConfig } from '@/ee/whitelabeling'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
@@ -118,7 +117,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
return (
<nav
aria-label='Primary navigation'
className={`${soehne.className} flex w-full items-center justify-between px-4 ${
className={`flex w-full items-center justify-between px-4 ${
variant === 'auth' ? 'pt-[20px] sm:pt-[16.5px]' : 'pt-[12px] sm:pt-[8.5px]'
} pb-[21px] sm:px-8 md:px-[44px]`}
itemScope

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react'
import Image from 'next/image'
import { inter } from '@/app/_styles/fonts/inter/inter'
interface Testimonial {
text: string
@@ -123,7 +122,7 @@ export default function Testimonials() {
return (
<section
id='testimonials'
className={`flex hidden h-[150px] items-center sm:block ${inter.variable}`}
className='flex hidden h-[150px] items-center sm:block'
aria-label='Social proof testimonials'
>
<div className='relative mx-auto h-full w-full max-w-[1289px] pl-[2px]'>
@@ -180,9 +179,7 @@ export default function Testimonials() {
</div>
{/* Tweet content below with padding */}
<p
className={`${inter.className} mt-2 line-clamp-4 font-[380] text-[#0A0A0A] text-[13px] leading-[1.3] transition-colors duration-300 group-hover:text-white`}
>
<p className='mt-2 line-clamp-4 font-[380] text-[#0A0A0A] text-[13px] leading-[1.3] transition-colors duration-300 group-hover:text-white'>
{tweet.text}
</p>
</div>

View File

@@ -582,7 +582,7 @@ export default function PrivacyPolicy() {
Please note that we may ask you to verify your identity before responding to such
requests.
</p>
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
<p className='mb-4 border-[#3d3d3d] border-l-4 bg-[#2A2A2A] p-3 text-[#ECECEC]'>
You have the right to complain to a Data Protection Authority about our collection and use
of your Personal Information. For more information, please contact your local data
protection authority in the European Economic Area (EEA).
@@ -604,10 +604,7 @@ export default function PrivacyPolicy() {
sharing practices (such as analytics or advertising services) may be considered a "sale"
or "share" under CCPA/CPRA. You have the right to opt-out of such data sharing. To
exercise this right, contact us at{' '}
<Link
href='mailto:privacy@sim.ai'
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
>
<Link href='mailto:privacy@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
privacy@sim.ai
</Link>
.
@@ -693,10 +690,7 @@ export default function PrivacyPolicy() {
Sim interacts with are not covered by this policy and should be reported directly to the
solution vendor in accordance with their disclosure policy (if any). Before beginning your
inquiry, email us at{' '}
<Link
href='mailto:security@sim.ai'
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
>
<Link href='mailto:security@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
security@sim.ai
</Link>{' '}
if you're unsure whether a system or endpoint is in scope.
@@ -715,10 +709,7 @@ export default function PrivacyPolicy() {
<h3 className='mb-2 font-medium text-xl'>Reporting a vulnerability</h3>
<p className='mb-4'>
To report any security flaws, send an email to{' '}
<Link
href='mailto:security@sim.ai'
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
>
<Link href='mailto:security@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
security@sim.ai
</Link>
. The next business day, we'll acknowledge receipt of your vulnerability report and keep
@@ -762,7 +753,7 @@ export default function PrivacyPolicy() {
Email:{' '}
<Link
href='mailto:privacy@sim.ai'
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
className='text-[#ECECEC] underline hover:text-white'
>
privacy@sim.ai
</Link>

View File

@@ -289,7 +289,7 @@ export default function TermsOfService() {
Agreement. The arbitration will be conducted by JAMS, an established alternative dispute
resolution provider.
</p>
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
<p className='mb-4 border-[#3d3d3d] border-l-4 bg-[#2A2A2A] p-3 text-[#ECECEC]'>
YOU AND COMPANY AGREE THAT EACH OF US MAY BRING CLAIMS AGAINST THE OTHER ONLY ON AN
INDIVIDUAL BASIS AND NOT ON A CLASS, REPRESENTATIVE, OR COLLECTIVE BASIS. ONLY INDIVIDUAL
RELIEF IS AVAILABLE, AND DISPUTES OF MORE THAN ONE CUSTOMER OR USER CANNOT BE ARBITRATED
@@ -298,10 +298,7 @@ export default function TermsOfService() {
<p className='mb-4'>
You have the right to opt out of the provisions of this Arbitration Agreement by sending a
timely written notice of your decision to opt out to:{' '}
<Link
href='mailto:legal@sim.ai'
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
>
<Link href='mailto:legal@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
legal@sim.ai{' '}
</Link>
within 30 days after first becoming subject to this Arbitration Agreement.
@@ -350,7 +347,7 @@ export default function TermsOfService() {
Our Copyright Agent can be reached at:{' '}
<Link
href='mailto:copyright@sim.ai'
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
className='text-[#ECECEC] underline hover:text-white'
>
copyright@sim.ai
</Link>
@@ -361,10 +358,7 @@ export default function TermsOfService() {
<h2 className='mb-4 font-semibold text-2xl'>18. Contact Us</h2>
<p>
If you have any questions about these Terms, please contact us at:{' '}
<Link
href='mailto:legal@sim.ai'
className='text-[var(--brand-primary-hex)] underline hover:text-[var(--brand-primary-hover-hex)]'
>
<Link href='mailto:legal@sim.ai' className='text-[#ECECEC] underline hover:text-white'>
legal@sim.ai
</Link>
</p>

View File

@@ -13,6 +13,7 @@ export type AppSession = {
emailVerified?: boolean
name?: string | null
image?: string | null
role?: string
createdAt?: Date
updatedAt?: Date
} | null

View File

@@ -20,7 +20,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/verify') ||
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat') ||
pathname.startsWith('/studio') ||
pathname.startsWith('/blog') ||
pathname.startsWith('/resume') ||
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')

View File

@@ -17,6 +17,18 @@
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
}
.workspace-root {
letter-spacing: 0.02em;
}
.workspace-root code,
.workspace-root kbd,
.workspace-root samp,
.workspace-root pre,
.workspace-root .font-mono {
letter-spacing: normal;
}
.sidebar-container {
width: var(--sidebar-width);
transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
@@ -33,14 +45,42 @@
opacity: 0;
}
html[data-sidebar-collapsed] .sidebar-container span,
html[data-sidebar-collapsed] .sidebar-container .text-small {
opacity: 0;
}
.sidebar-container .sidebar-collapse-hide {
transition: opacity 60ms ease;
}
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
opacity: 0;
}
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
display: none;
}
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
width: 0;
opacity: 0;
}
@keyframes sidebar-collapse-guard {
from {
pointer-events: none;
}
to {
pointer-events: auto;
}
}
.sidebar-container[data-collapsed] {
animation: sidebar-collapse-guard 250ms step-end;
}
.sidebar-container.is-resizing {
transition: none;
}
@@ -145,7 +185,7 @@
--brand-400: #8e4cfb;
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #32bd7e;
--brand-tertiary-2: #33c482;
--selection: #1a5cf6;
--warning: #ea580c;
@@ -267,7 +307,7 @@
--brand-400: #8e4cfb;
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #32bd7e;
--brand-tertiary-2: #33c482;
--selection: #4b83f7;
--warning: #ff6600;
@@ -550,42 +590,6 @@ input[type="search"]::-ms-clear {
background-color: hsl(var(--input-background));
}
.auth-card {
background-color: var(--white) !important;
border-color: var(--border-muted) !important;
}
.dark .auth-card {
background-color: var(--surface-1) !important;
border-color: var(--border-muted) !important;
}
.auth-text-primary {
color: var(--text-inverse) !important;
}
.auth-text-secondary {
color: var(--text-secondary) !important;
}
.auth-text-muted {
color: var(--text-muted) !important;
}
.auth-divider {
border-color: var(--border-muted) !important;
}
.auth-card-shadow {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.05),
0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
}
.auth-link {
color: var(--text-muted) !important;
}
.transition-ring {
transition-property: box-shadow, transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -790,6 +794,59 @@ input[type="search"]::-ms-clear {
}
}
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
}
@keyframes notification-countdown {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 34.56;
}
}
@keyframes notification-exit {
from {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
to {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97);
}
}
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes toast-exit {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
}
/* WandPromptBar status indicator */
@keyframes smoke-pulse {
0%,

View File

@@ -1,187 +0,0 @@
/**
* POST /api/attribution
*
* Automatic UTM-based referral attribution.
*
* Reads the `sim_utm` cookie (set by proxy on auth pages), matches a campaign
* by UTM specificity, and atomically inserts an attribution record + applies
* bonus credits.
*
* Idempotent — the unique constraint on `userId` prevents double-attribution.
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
const logger = createLogger('AttributionAPI')
const COOKIE_NAME = 'sim_utm'
const UtmCookieSchema = z.object({
utm_source: z.string().optional(),
utm_medium: z.string().optional(),
utm_campaign: z.string().optional(),
utm_content: z.string().optional(),
referrer_url: z.string().optional(),
landing_page: z.string().optional(),
created_at: z.string().optional(),
})
/**
* Finds the most specific active campaign matching the given UTM params.
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
*/
async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) {
const campaigns = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.isActive, true))
let bestMatch: (typeof campaigns)[number] | null = null
let bestScore = -1
for (const campaign of campaigns) {
let score = 0
let mismatch = false
const fields = [
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
] as const
for (const { campaignVal, utmVal } of fields) {
if (campaignVal === null) continue
if (campaignVal === utmVal) {
score++
} else {
mismatch = true
break
}
}
if (!mismatch && score > 0) {
if (
score > bestScore ||
(score === bestScore &&
bestMatch &&
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
) {
bestScore = score
bestMatch = campaign
}
}
}
return bestMatch
}
export async function POST() {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const cookieStore = await cookies()
const utmCookie = cookieStore.get(COOKIE_NAME)
if (!utmCookie?.value) {
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
}
let utmData: z.infer<typeof UtmCookieSchema>
try {
let decoded: string
try {
decoded = decodeURIComponent(utmCookie.value)
} catch {
decoded = utmCookie.value
}
utmData = UtmCookieSchema.parse(JSON.parse(decoded))
} catch {
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
}
const matchedCampaign = await findMatchingCampaign(utmData)
if (!matchedCampaign) {
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' })
}
const bonusAmount = Number(matchedCampaign.bonusCreditAmount)
let attributed = false
await db.transaction(async (tx) => {
const [existingStats] = await tx
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
if (!existingStats) {
await tx.insert(userStats).values({
id: nanoid(),
userId: session.user.id,
})
}
const result = await tx
.insert(referralAttribution)
.values({
id: nanoid(),
userId: session.user.id,
campaignId: matchedCampaign.id,
utmSource: utmData.utm_source || null,
utmMedium: utmData.utm_medium || null,
utmCampaign: utmData.utm_campaign || null,
utmContent: utmData.utm_content || null,
referrerUrl: utmData.referrer_url || null,
landingPage: utmData.landing_page || null,
bonusCreditAmount: bonusAmount.toString(),
})
.onConflictDoNothing({ target: referralAttribution.userId })
.returning({ id: referralAttribution.id })
if (result.length > 0) {
await applyBonusCredits(session.user.id, bonusAmount, tx)
attributed = true
}
})
if (attributed) {
logger.info('Referral attribution created and bonus credits applied', {
userId: session.user.id,
campaignId: matchedCampaign.id,
campaignName: matchedCampaign.name,
utmSource: utmData.utm_source,
utmCampaign: utmData.utm_campaign,
utmContent: utmData.utm_content,
bonusAmount,
})
} else {
logger.info('User already attributed, skipping', { userId: session.user.id })
}
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({
attributed,
bonusAmount: attributed ? bonusAmount : undefined,
reason: attributed ? undefined : 'already_attributed',
})
} catch (error) {
logger.error('Attribution error', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -14,6 +14,7 @@ import {
} from '@/lib/copilot/chat-streaming'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -301,6 +302,7 @@ export async function POST(req: NextRequest) {
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
promptForToolApproval: true,
},
})
@@ -314,6 +316,7 @@ export async function POST(req: NextRequest) {
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
promptForToolApproval: true,
})
const responseData = {
@@ -454,6 +457,30 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
status: string
} | null = null
if (chat.conversationId) {
try {
const [meta, events] = await Promise.all([
getStreamMeta(chat.conversationId),
readStreamEvents(chat.conversationId, 0),
])
streamSnapshot = {
events: events || [],
status: meta?.status || 'unknown',
}
} catch (err) {
logger.warn('Failed to read stream snapshot for chat', {
chatId,
conversationId: chat.conversationId,
error: err instanceof Error ? err.message : String(err),
})
}
}
const transformedChat = {
id: chat.id,
title: chat.title,
@@ -466,6 +493,7 @@ export async function GET(req: NextRequest) {
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
...(streamSnapshot ? { streamSnapshot } : {}),
}
logger.info(`Retrieved chat ${chatId}`)

View File

@@ -171,7 +171,7 @@ export async function GET(request: NextRequest) {
([category, templates]) => `
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
<ul style="list-style: none; padding: 0; margin: 0;">
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
</ul>
`
)

View File

@@ -120,8 +120,8 @@ export async function verifyFileAccess(
return true
}
// 1. Workspace files: Check database first (most reliable for both local and cloud)
if (inferredContext === 'workspace') {
// 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
if (inferredContext === 'workspace' || inferredContext === 'mothership') {
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal)
}

View File

@@ -4,6 +4,7 @@ import { sanitizeFileName } from '@/executor/constants'
import '@/lib/uploads/core/setup.server'
import { getSession } from '@/lib/auth'
import type { StorageContext } from '@/lib/uploads/config'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
import {
SUPPORTED_AUDIO_EXTENSIONS,
@@ -46,9 +47,10 @@ export async function POST(request: NextRequest) {
const formData = await request.formData()
const files = formData.getAll('file') as File[]
const rawFiles = formData.getAll('file')
const files = rawFiles.filter((f): f is File => f instanceof File)
if (!files || files.length === 0) {
if (files.length === 0) {
throw new InvalidRequestError('No files provided')
}
@@ -73,7 +75,7 @@ export async function POST(request: NextRequest) {
const uploadResults = []
for (const file of files) {
const originalName = file.name
const originalName = file.name || 'untitled'
if (!validateFileExtension(originalName)) {
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
@@ -231,6 +233,53 @@ export async function POST(request: NextRequest) {
}
}
// Handle mothership context (chat-scoped uploads to workspace S3)
if (context === 'mothership') {
if (!workspaceId) {
throw new InvalidRequestError('Mothership context requires workspaceId parameter')
}
logger.info(`Uploading mothership file: ${originalName}`)
const storageKey = generateWorkspaceFileKey(workspaceId, originalName)
const metadata: Record<string, string> = {
originalName: originalName,
uploadedAt: new Date().toISOString(),
purpose: 'mothership',
userId: session.user.id,
workspaceId,
}
const fileInfo = await storageService.uploadFile({
file: buffer,
fileName: storageKey,
contentType: file.type || 'application/octet-stream',
context: 'mothership',
preserveKey: true,
customKey: storageKey,
metadata,
})
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path
uploadResults.push({
fileName: originalName,
presignedUrl: '',
fileInfo: {
path: finalPath,
key: fileInfo.key,
name: originalName,
size: buffer.length,
type: file.type || 'application/octet-stream',
},
directUploadSupported: false,
})
logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`)
continue
}
// Handle copilot, chat, profile-pictures contexts
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
if (context === 'copilot') {

View File

@@ -0,0 +1,248 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { document } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
createDocumentRecords,
deleteDocument,
getProcessingConfig,
processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('DocumentUpsertAPI')
const UpsertDocumentSchema = z.object({
documentId: z.string().optional(),
filename: z.string().min(1, 'Filename is required'),
fileUrl: z.string().min(1, 'File URL is required'),
fileSize: z.number().min(1, 'File size must be greater than 0'),
mimeType: z.string().min(1, 'MIME type is required'),
documentTagsData: z.string().optional(),
processingOptions: z.object({
chunkSize: z.number().min(100).max(4000),
minCharactersPerChunk: z.number().min(1).max(2000),
recipe: z.string(),
lang: z.string(),
chunkOverlap: z.number().min(0).max(500),
}),
workflowId: z.string().optional(),
})
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
const body = await req.json()
logger.info(`[${requestId}] Knowledge base document upsert request`, {
knowledgeBaseId,
hasDocumentId: !!body.documentId,
filename: body.filename,
})
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const validatedData = UpsertDocumentSchema.parse(body)
if (validatedData.workflowId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: validatedData.workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status }
)
}
}
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted to upsert document in unauthorized knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let existingDocumentId: string | null = null
let isUpdate = false
if (validatedData.documentId) {
const existingDoc = await db
.select({ id: document.id })
.from(document)
.where(
and(
eq(document.id, validatedData.documentId),
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.deletedAt)
)
)
.limit(1)
if (existingDoc.length > 0) {
existingDocumentId = existingDoc[0].id
}
} else {
const docsByFilename = await db
.select({ id: document.id })
.from(document)
.where(
and(
eq(document.filename, validatedData.filename),
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.deletedAt)
)
)
.limit(1)
if (docsByFilename.length > 0) {
existingDocumentId = docsByFilename[0].id
}
}
if (existingDocumentId) {
isUpdate = true
logger.info(
`[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old`
)
}
const createdDocuments = await createDocumentRecords(
[
{
filename: validatedData.filename,
fileUrl: validatedData.fileUrl,
fileSize: validatedData.fileSize,
mimeType: validatedData.mimeType,
...(validatedData.documentTagsData && {
documentTagsData: validatedData.documentTagsData,
}),
},
],
knowledgeBaseId,
requestId
)
const firstDocument = createdDocuments[0]
if (!firstDocument) {
logger.error(`[${requestId}] createDocumentRecords returned empty array unexpectedly`)
return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 })
}
if (existingDocumentId) {
try {
await deleteDocument(existingDocumentId, requestId)
} catch (deleteError) {
logger.error(
`[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`,
deleteError
)
await deleteDocument(firstDocument.documentId, requestId).catch(() => {})
return NextResponse.json({ error: 'Failed to replace existing document' }, { status: 500 })
}
}
processDocumentsWithQueue(
createdDocuments,
knowledgeBaseId,
validatedData.processingOptions,
requestId
).catch((error: unknown) => {
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
})
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.knowledgeBaseDocumentsUploaded({
knowledgeBaseId,
documentsCount: 1,
uploadType: 'single',
chunkSize: validatedData.processingOptions.chunkSize,
recipe: validatedData.processingOptions.recipe,
})
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: validatedData.filename,
description: isUpdate
? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`
: `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`,
metadata: {
fileName: validatedData.filename,
previousDocumentId: existingDocumentId,
isUpdate,
},
request: req,
})
return NextResponse.json({
success: true,
data: {
documentsCreated: [
{
documentId: firstDocument.documentId,
filename: firstDocument.filename,
status: 'pending',
},
],
isUpdate,
previousDocumentId: existingDocumentId,
processingMethod: 'background',
processingConfig: {
maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments,
batchSize: getProcessingConfig().batchSize,
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid upsert request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error upserting document`, error)
const errorMessage = error instanceof Error ? error.message : 'Failed to upsert document'
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found'
return NextResponse.json(
{ error: errorMessage },
{ status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 }
)
}
}

View File

@@ -19,6 +19,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
import {
executeToolServerSide,
@@ -28,6 +29,10 @@ import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/de
import { env } from '@/lib/core/config/env'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
authorizeWorkflowByWorkspacePermission,
resolveWorkflowIdForUser,
} from '@/lib/workflows/utils'
const logger = createLogger('CopilotMcpAPI')
const mcpRateLimiter = new RateLimiter()
@@ -660,12 +665,110 @@ async function handleDirectToolCall(
}
}
/**
* Build mode uses the main chat orchestrator with the 'fast' command instead of
* the subagent endpoint. In Go, 'build' is not a registered subagent — it's a mode
* (ModeFast) on the main chat processor that bypasses subagent orchestration and
* executes all tools directly.
*/
async function handleBuildToolCall(
args: Record<string, unknown>,
userId: string,
abortSignal?: AbortSignal
): Promise<CallToolResult> {
try {
const requestText = (args.request as string) || JSON.stringify(args)
const workflowId = args.workflowId as string | undefined
const resolved = workflowId
? await (async () => {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
return authorization.allowed ? { workflowId } : null
})()
: await resolveWorkflowIdForUser(userId)
if (!resolved?.workflowId) {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: false,
error: 'workflowId is required for build. Call create_workflow first.',
},
null,
2
),
},
],
isError: true,
}
}
const chatId = randomUUID()
const requestPayload = {
message: requestText,
workflowId: resolved.workflowId,
userId,
model: DEFAULT_COPILOT_MODEL,
mode: 'agent',
commands: ['fast'],
messageId: randomUUID(),
chatId,
}
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workflowId: resolved.workflowId,
chatId,
goRoute: '/api/mcp',
autoExecuteTools: true,
timeout: 300000,
interactive: false,
abortSignal,
})
const responseData = {
success: result.success,
content: result.content,
toolCalls: result.toolCalls,
error: result.error,
}
return {
content: [{ type: 'text', text: JSON.stringify(responseData, null, 2) }],
isError: !result.success,
}
} catch (error) {
logger.error('Build tool call failed', { error })
return {
content: [
{
type: 'text',
text: `Build failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
}
}
}
async function handleSubagentToolCall(
toolDef: (typeof SUBAGENT_TOOL_DEFS)[number],
args: Record<string, unknown>,
userId: string,
abortSignal?: AbortSignal
): Promise<CallToolResult> {
if (toolDef.agentId === 'build') {
return handleBuildToolCall(args, userId, abortSignal)
}
try {
const requestText =
(args.request as string) ||

View File

@@ -7,7 +7,11 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/chat-streaming'
import {
createSSEStream,
SSE_RESPONSE_HEADERS,
waitForPendingChatStream,
} from '@/lib/copilot/chat-streaming'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
@@ -31,6 +35,8 @@ const FileAttachmentSchema = z.object({
const ResourceAttachmentSchema = z.object({
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
id: z.string().min(1),
title: z.string().optional(),
active: z.boolean().optional(),
})
const MothershipMessageSchema = z.object({
@@ -124,9 +130,19 @@ export async function POST(req: NextRequest) {
if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0) {
const results = await Promise.allSettled(
resourceAttachments.map((r) =>
resolveActiveResourceContext(r.type, r.id, workspaceId, authenticatedUserId)
)
resourceAttachments.map(async (r) => {
const ctx = await resolveActiveResourceContext(
r.type,
r.id,
workspaceId,
authenticatedUserId
)
if (!ctx) return null
return {
...ctx,
tag: r.active ? '@active_tab' : '@open_tab',
}
})
)
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
@@ -232,6 +248,10 @@ export async function POST(req: NextRequest) {
{ selectedModel: '' }
)
if (actualChatId) {
await waitForPendingChatStream(actualChatId)
}
const stream = createSSEStream({
requestPayload,
userId: authenticatedUserId,
@@ -249,7 +269,8 @@ export async function POST(req: NextRequest) {
chatId: actualChatId,
goRoute: '/api/mothership',
autoExecuteTools: true,
interactive: false,
interactive: true,
promptForToolApproval: false,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
@@ -258,6 +279,7 @@ export async function POST(req: NextRequest) {
role: 'assistant' as const,
content: result.content,
timestamp: new Date().toISOString(),
...(result.requestId ? { requestId: result.requestId } : {}),
}
if (result.toolCalls.length > 0) {
assistantMessage.toolCalls = result.toolCalls

View File

@@ -1,171 +0,0 @@
/**
* POST /api/referral-code/redeem
*
* Redeem a referral/promo code to receive bonus credits.
*
* Body:
* - code: string — The referral code to redeem
*
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
*
* Constraints:
* - Enterprise users cannot redeem codes
* - One redemption per user, ever (unique constraint on userId)
* - One redemption per organization for team users (partial unique on organizationId)
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
const logger = createLogger('ReferralCodeRedemption')
const RedeemCodeSchema = z.object({
code: z.string().min(1, 'Code is required'),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { code } = RedeemCodeSchema.parse(body)
const subscription = await getHighestPrioritySubscription(session.user.id)
if (isEnterprise(subscription?.plan)) {
return NextResponse.json({
redeemed: false,
error: 'Enterprise accounts cannot redeem referral codes',
})
}
const isTeamSub = isTeam(subscription?.plan)
const orgId = isTeamSub ? subscription!.referenceId : null
const normalizedCode = code.trim().toUpperCase()
const [campaign] = await db
.select()
.from(referralCampaigns)
.where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true)))
.limit(1)
if (!campaign) {
logger.info('Invalid code redemption attempt', {
userId: session.user.id,
code: normalizedCode,
})
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
}
const [existingUserAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
.where(eq(referralAttribution.userId, session.user.id))
.limit(1)
if (existingUserAttribution) {
return NextResponse.json({
redeemed: false,
error: 'You have already redeemed a code',
})
}
if (orgId) {
const [existingOrgAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
.where(eq(referralAttribution.organizationId, orgId))
.limit(1)
if (existingOrgAttribution) {
return NextResponse.json({
redeemed: false,
error: 'A code has already been redeemed for your organization',
})
}
}
const bonusAmount = Number(campaign.bonusCreditAmount)
let redeemed = false
await db.transaction(async (tx) => {
const [existingStats] = await tx
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
if (!existingStats) {
await tx.insert(userStats).values({
id: nanoid(),
userId: session.user.id,
})
}
const result = await tx
.insert(referralAttribution)
.values({
id: nanoid(),
userId: session.user.id,
organizationId: orgId,
campaignId: campaign.id,
utmSource: null,
utmMedium: null,
utmCampaign: null,
utmContent: null,
referrerUrl: null,
landingPage: null,
bonusCreditAmount: bonusAmount.toString(),
})
.onConflictDoNothing()
.returning({ id: referralAttribution.id })
if (result.length > 0) {
await applyBonusCredits(session.user.id, bonusAmount, tx)
redeemed = true
}
})
if (redeemed) {
logger.info('Referral code redeemed', {
userId: session.user.id,
organizationId: orgId,
code: normalizedCode,
campaignId: campaign.id,
campaignName: campaign.name,
bonusAmount,
})
}
if (!redeemed) {
return NextResponse.json({
redeemed: false,
error: 'You have already redeemed a code',
})
}
return NextResponse.json({
redeemed: true,
bonusAmount,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Referral code redemption error', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,276 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
batchInsertRows,
createTable,
deleteTable,
getWorkspaceTableLimits,
type TableSchema,
} from '@/lib/table'
import type { ColumnDefinition, RowData } from '@/lib/table/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { normalizeColumn } from '@/app/api/table/utils'
const logger = createLogger('TableImportCSV')
const MAX_CSV_FILE_SIZE = 50 * 1024 * 1024
const MAX_BATCH_SIZE = 1000
const SCHEMA_SAMPLE_SIZE = 100
type ColumnType = 'string' | 'number' | 'boolean' | 'date'
async function parseCsvBuffer(
buffer: Buffer,
delimiter = ','
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
const { parse } = await import('csv-parse/sync')
const parsed = parse(buffer.toString('utf-8'), {
columns: true,
skip_empty_lines: true,
trim: true,
relax_column_count: true,
relax_quotes: true,
skip_records_with_error: true,
cast: false,
delimiter,
}) as Record<string, unknown>[]
if (parsed.length === 0) {
throw new Error('CSV file has no data rows')
}
const headers = Object.keys(parsed[0])
if (headers.length === 0) {
throw new Error('CSV file has no headers')
}
return { headers, rows: parsed }
}
function inferColumnType(values: unknown[]): ColumnType {
const nonEmpty = values.filter((v) => v !== null && v !== undefined && v !== '')
if (nonEmpty.length === 0) return 'string'
const allNumber = nonEmpty.every((v) => {
const n = Number(v)
return !Number.isNaN(n) && String(v).trim() !== ''
})
if (allNumber) return 'number'
const allBoolean = nonEmpty.every((v) => {
const s = String(v).toLowerCase()
return s === 'true' || s === 'false'
})
if (allBoolean) return 'boolean'
const isoDatePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
const allDate = nonEmpty.every((v) => {
const s = String(v)
return isoDatePattern.test(s) && !Number.isNaN(Date.parse(s))
})
if (allDate) return 'date'
return 'string'
}
function inferSchema(headers: string[], rows: Record<string, unknown>[]): ColumnDefinition[] {
const sample = rows.slice(0, SCHEMA_SAMPLE_SIZE)
const seen = new Set<string>()
return headers.map((name) => {
let colName = sanitizeName(name)
let suffix = 2
while (seen.has(colName.toLowerCase())) {
colName = `${sanitizeName(name)}_${suffix}`
suffix++
}
seen.add(colName.toLowerCase())
return {
name: colName,
type: inferColumnType(sample.map((r) => r[name])),
}
})
}
/**
* Strips non-alphanumeric characters (except underscore), collapses runs of
* underscores, and ensures the name starts with a letter or underscore.
* Used for both table names and column names to satisfy NAME_PATTERN.
*/
function sanitizeName(raw: string, fallbackPrefix = 'col'): string {
let name = raw
.trim()
.replace(/[^a-zA-Z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
if (!name || /^\d/.test(name)) {
name = `${fallbackPrefix}_${name}`
}
return name
}
function coerceValue(value: unknown, colType: ColumnType): string | number | boolean | null {
if (value === null || value === undefined || value === '') return null
switch (colType) {
case 'number': {
const n = Number(value)
return Number.isNaN(n) ? null : n
}
case 'boolean': {
const s = String(value).toLowerCase()
if (s === 'true') return true
if (s === 'false') return false
return null
}
case 'date': {
const d = new Date(String(value))
return Number.isNaN(d.getTime()) ? String(value) : d.toISOString()
}
default:
return String(value)
}
}
function coerceRows(
rows: Record<string, unknown>[],
columns: ColumnDefinition[],
headerToColumn: Map<string, string>
): RowData[] {
const colTypeMap = new Map(columns.map((c) => [c.name, c.type as ColumnType]))
return rows.map((row) => {
const coerced: RowData = {}
for (const [header, value] of Object.entries(row)) {
const colName = headerToColumn.get(header)
if (colName) {
coerced[colName] = coerceValue(value, colTypeMap.get(colName) ?? 'string')
}
}
return coerced
})
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get('file')
const workspaceId = formData.get('workspaceId') as string | null
if (!file || !(file instanceof File)) {
return NextResponse.json({ error: 'CSV file is required' }, { status: 400 })
}
if (file.size > MAX_CSV_FILE_SIZE) {
return NextResponse.json(
{ error: `File exceeds maximum allowed size of ${MAX_CSV_FILE_SIZE / (1024 * 1024)} MB` },
{ status: 400 }
)
}
if (!workspaceId) {
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
}
const permission = await getUserEntityPermissions(authResult.userId, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext !== 'csv' && ext !== 'tsv') {
return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const delimiter = ext === 'tsv' ? '\t' : ','
const { headers, rows } = await parseCsvBuffer(buffer, delimiter)
const columns = inferSchema(headers, rows)
const headerToColumn = new Map(headers.map((h, i) => [h, columns[i].name]))
const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table')
const planLimits = await getWorkspaceTableLimits(workspaceId)
const normalizedSchema: TableSchema = {
columns: columns.map(normalizeColumn),
}
const table = await createTable(
{
name: tableName,
description: `Imported from ${file.name}`,
schema: normalizedSchema,
workspaceId,
userId: authResult.userId,
maxRows: planLimits.maxRowsPerTable,
maxTables: planLimits.maxTables,
},
requestId
)
try {
const coerced = coerceRows(rows, columns, headerToColumn)
let inserted = 0
for (let i = 0; i < coerced.length; i += MAX_BATCH_SIZE) {
const batch = coerced.slice(i, i + MAX_BATCH_SIZE)
const batchRequestId = crypto.randomUUID().slice(0, 8)
const result = await batchInsertRows(
{ tableId: table.id, rows: batch, workspaceId, userId: authResult.userId },
table,
batchRequestId
)
inserted += result.length
}
logger.info(`[${requestId}] CSV imported`, {
tableId: table.id,
fileName: file.name,
columns: columns.length,
rows: inserted,
})
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: normalizedSchema,
rowCount: inserted,
},
},
})
} catch (insertError) {
await deleteTable(table.id, requestId).catch(() => {})
throw insertError
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error(`[${requestId}] CSV import failed:`, error)
const isClientError =
message.includes('maximum table limit') ||
message.includes('CSV file has no') ||
message.includes('Invalid table name') ||
message.includes('Invalid schema') ||
message.includes('already exists')
return NextResponse.json(
{ error: isClientError ? message : 'Failed to import CSV' },
{ status: isClientError ? 400 : 500 }
)
}
}

View File

@@ -1,42 +0,0 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('SuperUserAPI')
export const revalidate = 0
// GET /api/user/super-user - Check if current user is a super user (database status)
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized super user status check attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (currentUser.length === 0) {
logger.warn(`[${requestId}] User not found: ${session.user.id}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({
isSuperUser: currentUser[0].isSuperUser,
})
} catch (error) {
logger.error(`[${requestId}] Error checking super user status`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -72,7 +72,7 @@ export async function GET() {
emailPreferences: userSettings.emailPreferences ?? {},
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
superUserModeEnabled: userSettings.superUserModeEnabled ?? false,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
showActionBar: userSettings.showActionBar ?? true,

View File

@@ -103,7 +103,6 @@ export type {
AdminOrganization,
AdminOrganizationBillingSummary,
AdminOrganizationDetail,
AdminReferralCampaign,
AdminSeatAnalytics,
AdminSingleResponse,
AdminSubscription,
@@ -118,7 +117,6 @@ export type {
AdminWorkspaceMember,
DbMember,
DbOrganization,
DbReferralCampaign,
DbSubscription,
DbUser,
DbUserStats,
@@ -147,7 +145,6 @@ export {
parseWorkflowVariables,
toAdminFolder,
toAdminOrganization,
toAdminReferralCampaign,
toAdminSubscription,
toAdminUser,
toAdminWorkflow,

View File

@@ -1,142 +0,0 @@
/**
* GET /api/v1/admin/referral-campaigns/:id
*
* Get a single referral campaign by ID.
*
* PATCH /api/v1/admin/referral-campaigns/:id
*
* Update campaign fields. All fields are optional.
*
* Body:
* - name: string (non-empty) - Campaign name
* - bonusCreditAmount: number (> 0) - Bonus credits in dollars
* - isActive: boolean - Enable/disable the campaign
* - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code
* - utmSource: string | null - UTM source match (null = wildcard)
* - utmMedium: string | null - UTM medium match (null = wildcard)
* - utmCampaign: string | null - UTM campaign match (null = wildcard)
* - utmContent: string | null - UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
import { referralCampaigns } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminReferralCampaign } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminReferralCampaignDetailAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
try {
const { id: campaignId } = await context.params
const [campaign] = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.id, campaignId))
.limit(1)
if (!campaign) {
return notFoundResponse('Campaign')
}
logger.info(`Admin API: Retrieved referral campaign ${campaignId}`)
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to get referral campaign', { error })
return internalErrorResponse('Failed to get referral campaign')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
try {
const { id: campaignId } = await context.params
const body = await request.json()
const [existing] = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.id, campaignId))
.limit(1)
if (!existing) {
return notFoundResponse('Campaign')
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (body.name !== undefined) {
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
return badRequestResponse('name must be a non-empty string')
}
updateData.name = body.name.trim()
}
if (body.bonusCreditAmount !== undefined) {
if (
typeof body.bonusCreditAmount !== 'number' ||
!Number.isFinite(body.bonusCreditAmount) ||
body.bonusCreditAmount <= 0
) {
return badRequestResponse('bonusCreditAmount must be a positive number')
}
updateData.bonusCreditAmount = body.bonusCreditAmount.toString()
}
if (body.isActive !== undefined) {
if (typeof body.isActive !== 'boolean') {
return badRequestResponse('isActive must be a boolean')
}
updateData.isActive = body.isActive
}
if (body.code !== undefined) {
if (body.code !== null) {
if (typeof body.code !== 'string') {
return badRequestResponse('code must be a string or null')
}
if (body.code.trim().length < 6) {
return badRequestResponse('code must be at least 6 characters')
}
}
updateData.code = body.code ? body.code.trim().toUpperCase() : null
}
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
if (body[field] !== undefined) {
if (body[field] !== null && typeof body[field] !== 'string') {
return badRequestResponse(`${field} must be a string or null`)
}
updateData[field] = body[field] || null
}
}
const [updated] = await db
.update(referralCampaigns)
.set(updateData)
.where(eq(referralCampaigns.id, campaignId))
.returning()
logger.info(`Admin API: Updated referral campaign ${campaignId}`, {
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
})
return singleResponse(toAdminReferralCampaign(updated, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to update referral campaign', { error })
return internalErrorResponse('Failed to update referral campaign')
}
})

View File

@@ -1,104 +1,160 @@
/**
* GET /api/v1/admin/referral-campaigns
*
* List referral campaigns with optional filtering and pagination.
* List Stripe promotion codes with cursor-based pagination.
*
* Query Parameters:
* - active: string (optional) - Filter by active status ('true' or 'false')
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
* - limit: number (default: 50, max: 100)
* - starting_after: string (cursor — Stripe promotion code ID)
* - active: 'true' | 'false' (optional filter)
*
* POST /api/v1/admin/referral-campaigns
*
* Create a new referral campaign.
* Create a Stripe coupon and an associated promotion code.
*
* Body:
* - name: string (required) - Campaign name
* - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars
* - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code
* - utmSource: string | null (optional) - UTM source match (null = wildcard)
* - utmMedium: string | null (optional) - UTM medium match (null = wildcard)
* - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard)
* - utmContent: string | null (optional) - UTM content match (null = wildcard)
* - name: string (required) — Display name for the coupon
* - percentOff: number (required, 1100) — Percentage discount
* - code: string | null (optional, min 6 chars, auto-uppercased) — Desired code
* - duration: 'once' | 'repeating' | 'forever' (default: 'once')
* - durationInMonths: number (required when duration is 'repeating')
* - maxRedemptions: number (optional) — Total redemption cap
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
*/
import { db } from '@sim/db'
import { referralCampaigns } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq, type SQL } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { NextResponse } from 'next/server'
import type Stripe from 'stripe'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminReferralCampaign,
createPaginationMeta,
parsePaginationParams,
toAdminReferralCampaign,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminReferralCampaignsAPI')
const logger = createLogger('AdminPromoCodes')
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
type Duration = (typeof VALID_DURATIONS)[number]
interface PromoCodeResponse {
id: string
code: string
couponId: string
name: string
percentOff: number
duration: string
durationInMonths: number | null
maxRedemptions: number | null
expiresAt: string | null
active: boolean
timesRedeemed: number
createdAt: string
}
function formatPromoCode(promo: {
id: string
code: string
coupon: {
id: string
name: string | null
percent_off: number | null
duration: string
duration_in_months: number | null
}
max_redemptions: number | null
expires_at: number | null
active: boolean
times_redeemed: number
created: number
}): PromoCodeResponse {
return {
id: promo.id,
code: promo.code,
couponId: promo.coupon.id,
name: promo.coupon.name ?? '',
percentOff: promo.coupon.percent_off ?? 0,
duration: promo.coupon.duration,
durationInMonths: promo.coupon.duration_in_months,
maxRedemptions: promo.max_redemptions,
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
active: promo.active,
timesRedeemed: promo.times_redeemed,
createdAt: new Date(promo.created * 1000).toISOString(),
}
}
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const activeFilter = url.searchParams.get('active')
try {
const conditions: SQL<unknown>[] = []
if (activeFilter === 'true') {
conditions.push(eq(referralCampaigns.isActive, true))
} else if (activeFilter === 'false') {
conditions.push(eq(referralCampaigns.isActive, false))
}
const stripe = requireStripeClient()
const url = new URL(request.url)
const whereClause = conditions.length > 0 ? conditions[0] : undefined
const baseUrl = getBaseUrl()
const limitParam = url.searchParams.get('limit')
let limit = limitParam ? Number.parseInt(limitParam, 10) : 50
if (Number.isNaN(limit) || limit < 1) limit = 50
if (limit > 100) limit = 100
const [countResult, campaigns] = await Promise.all([
db.select({ total: count() }).from(referralCampaigns).where(whereClause),
db
.select()
.from(referralCampaigns)
.where(whereClause)
.orderBy(referralCampaigns.createdAt)
.limit(limit)
.offset(offset),
])
const startingAfter = url.searchParams.get('starting_after') || undefined
const activeFilter = url.searchParams.get('active')
const total = countResult[0].total
const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl))
const pagination = createPaginationMeta(total, limit, offset)
const listParams: Record<string, unknown> = { limit }
if (startingAfter) listParams.starting_after = startingAfter
if (activeFilter === 'true') listParams.active = true
else if (activeFilter === 'false') listParams.active = false
logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`)
const promoCodes = await stripe.promotionCodes.list(listParams)
return listResponse(data, pagination)
const data = promoCodes.data.map(formatPromoCode)
logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`)
return NextResponse.json({
data,
hasMore: promoCodes.has_more,
...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}),
})
} catch (error) {
logger.error('Admin API: Failed to list referral campaigns', { error })
return internalErrorResponse('Failed to list referral campaigns')
logger.error('Admin API: Failed to list promotion codes', { error })
return internalErrorResponse('Failed to list promotion codes')
}
})
export const POST = withAdminAuth(async (request) => {
try {
const stripe = requireStripeClient()
const body = await request.json()
const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body
if (!name || typeof name !== 'string') {
return badRequestResponse('name is required and must be a string')
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return badRequestResponse('name is required and must be a non-empty string')
}
if (
typeof bonusCreditAmount !== 'number' ||
!Number.isFinite(bonusCreditAmount) ||
bonusCreditAmount <= 0
typeof percentOff !== 'number' ||
!Number.isFinite(percentOff) ||
percentOff < 1 ||
percentOff > 100
) {
return badRequestResponse('bonusCreditAmount must be a positive number')
return badRequestResponse('percentOff must be a number between 1 and 100')
}
const effectiveDuration: Duration = duration ?? 'once'
if (!VALID_DURATIONS.includes(effectiveDuration)) {
return badRequestResponse(`duration must be one of: ${VALID_DURATIONS.join(', ')}`)
}
if (effectiveDuration === 'repeating') {
if (
typeof durationInMonths !== 'number' ||
!Number.isInteger(durationInMonths) ||
durationInMonths < 1
) {
return badRequestResponse(
'durationInMonths is required and must be a positive integer when duration is "repeating"'
)
}
}
if (code !== undefined && code !== null) {
@@ -110,31 +166,77 @@ export const POST = withAdminAuth(async (request) => {
}
}
const id = nanoid()
if (maxRedemptions !== undefined && maxRedemptions !== null) {
if (
typeof maxRedemptions !== 'number' ||
!Number.isInteger(maxRedemptions) ||
maxRedemptions < 1
) {
return badRequestResponse('maxRedemptions must be a positive integer')
}
}
const [campaign] = await db
.insert(referralCampaigns)
.values({
id,
name,
code: code ? code.trim().toUpperCase() : null,
utmSource: utmSource || null,
utmMedium: utmMedium || null,
utmCampaign: utmCampaign || null,
utmContent: utmContent || null,
bonusCreditAmount: bonusCreditAmount.toString(),
})
.returning()
if (expiresAt !== undefined && expiresAt !== null) {
const parsed = new Date(expiresAt)
if (Number.isNaN(parsed.getTime())) {
return badRequestResponse('expiresAt must be a valid ISO 8601 date string')
}
if (parsed.getTime() <= Date.now()) {
return badRequestResponse('expiresAt must be in the future')
}
}
logger.info(`Admin API: Created referral campaign ${id}`, {
name,
code: campaign.code,
bonusCreditAmount,
const coupon = await stripe.coupons.create({
name: name.trim(),
percent_off: percentOff,
duration: effectiveDuration,
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
})
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
let promoCode
try {
const promoParams: Stripe.PromotionCodeCreateParams = {
coupon: coupon.id,
...(code ? { code: code.trim().toUpperCase() } : {}),
...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}),
...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}),
}
promoCode = await stripe.promotionCodes.create(promoParams)
} catch (promoError) {
try {
await stripe.coupons.del(coupon.id)
} catch (cleanupError) {
logger.error(
'Admin API: Failed to clean up orphaned coupon after promo code creation failed',
{
couponId: coupon.id,
cleanupError,
}
)
}
throw promoError
}
logger.info('Admin API: Created Stripe promotion code', {
promoCodeId: promoCode.id,
code: promoCode.code,
couponId: coupon.id,
percentOff,
duration: effectiveDuration,
})
return singleResponse(formatPromoCode(promoCode))
} catch (error) {
logger.error('Admin API: Failed to create referral campaign', { error })
return internalErrorResponse('Failed to create referral campaign')
if (
error instanceof Error &&
'type' in error &&
(error as { type: string }).type === 'StripeInvalidRequestError'
) {
logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message })
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to create promotion code', { error })
return internalErrorResponse('Failed to create promotion code')
}
})

View File

@@ -9,7 +9,6 @@ import type {
auditLog,
member,
organization,
referralCampaigns,
subscription,
user,
userStats,
@@ -33,7 +32,6 @@ export type DbOrganization = InferSelectModel<typeof organization>
export type DbSubscription = InferSelectModel<typeof subscription>
export type DbMember = InferSelectModel<typeof member>
export type DbUserStats = InferSelectModel<typeof userStats>
export type DbReferralCampaign = InferSelectModel<typeof referralCampaigns>
// =============================================================================
// Pagination
@@ -650,52 +648,6 @@ export interface AdminUndeployResult {
isDeployed: boolean
}
// =============================================================================
// Referral Campaign Types
// =============================================================================
export interface AdminReferralCampaign {
id: string
name: string
code: string | null
utmSource: string | null
utmMedium: string | null
utmCampaign: string | null
utmContent: string | null
bonusCreditAmount: string
isActive: boolean
signupUrl: string | null
createdAt: string
updatedAt: string
}
export function toAdminReferralCampaign(
dbCampaign: DbReferralCampaign,
baseUrl: string
): AdminReferralCampaign {
const utmParams = new URLSearchParams()
if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource)
if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium)
if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign)
if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent)
const query = utmParams.toString()
return {
id: dbCampaign.id,
name: dbCampaign.name,
code: dbCampaign.code,
utmSource: dbCampaign.utmSource,
utmMedium: dbCampaign.utmMedium,
utmCampaign: dbCampaign.utmCampaign,
utmContent: dbCampaign.utmContent,
bonusCreditAmount: dbCampaign.bonusCreditAmount,
isActive: dbCampaign.isActive,
signupUrl: query ? `${baseUrl}/signup?${query}` : null,
createdAt: dbCampaign.createdAt.toISOString(),
updatedAt: dbCampaign.updatedAt.toISOString(),
}
}
// =============================================================================
// Audit Log Types
// =============================================================================

View File

@@ -267,11 +267,24 @@ async function createRejectedTask(
* Format: "username@domain.com" or "Display Name <username@domain.com>"
*/
function extractSenderEmail(from: string): string {
const match = from.match(/<([^>]+)>/)
return (match?.[1] || from).toLowerCase().trim()
const openBracket = from.indexOf('<')
const closeBracket = from.indexOf('>', openBracket + 1)
if (openBracket !== -1 && closeBracket !== -1) {
return from
.substring(openBracket + 1, closeBracket)
.toLowerCase()
.trim()
}
return from.toLowerCase().trim()
}
function extractDisplayName(from: string): string | null {
const match = from.match(/^(.+?)\s*</)
return match?.[1]?.trim().replace(/^"|"$/g, '') || null
const openBracket = from.indexOf('<')
if (openBracket <= 0) return null
const name = from.substring(0, openBracket).trim()
if (!name) return null
if (name.startsWith('"') && name.endsWith('"')) {
return name.slice(1, -1) || null
}
return name
}

View File

@@ -1,6 +1,6 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -22,8 +22,11 @@ import {
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeployAPI')
@@ -55,43 +58,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
}
let needsRedeployment = false
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)
if (active?.state) {
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (normalizedData) {
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || {},
}
const { hasWorkflowChanged } = await import('@/lib/workflows/comparison')
needsRedeployment = hasWorkflowChanged(
currentState as WorkflowState,
active.state as WorkflowState
)
}
}
const needsRedeployment = await checkNeedsRedeployment(id)
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)

View File

@@ -20,6 +20,10 @@ import {
} from '@/lib/execution/call-chain'
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
import { processInputFileFields } from '@/lib/execution/files'
import {
registerManualExecutionAborter,
unregisterManualExecutionAborter,
} from '@/lib/execution/manual-cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import {
@@ -845,6 +849,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const encoder = new TextEncoder()
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
let isStreamClosed = false
let isManualAbortRegistered = false
const eventWriter = createExecutionEventWriter(executionId)
setExecutionMeta(executionId, {
@@ -857,6 +862,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
async start(controller) {
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
registerManualExecutionAborter(executionId, timeoutController.abort)
isManualAbortRegistered = true
const sendEvent = (event: ExecutionEvent) => {
if (!isStreamClosed) {
try {
@@ -1224,6 +1232,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
finalMetaStatus = 'error'
} finally {
if (isManualAbortRegistered) {
unregisterManualExecutionAborter(executionId)
isManualAbortRegistered = false
}
try {
await eventWriter.close()
} catch (closeError) {

View File

@@ -0,0 +1,148 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCheckHybridAuth = vi.fn()
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockMarkExecutionCancelled = vi.fn()
const mockAbortManualExecution = vi.fn()
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
}))
vi.mock('@/lib/execution/cancellation', () => ({
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
}))
vi.mock('@/lib/execution/manual-cancellation', () => ({
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
}))
vi.mock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: (params: unknown) =>
mockAuthorizeWorkflowByWorkspacePermission(params),
}))
import { POST } from './route'
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
mockAbortManualExecution.mockReturnValue(false)
})
it('returns success when cancellation was durably recorded', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: true,
reason: 'recorded',
})
const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: true,
locallyAborted: false,
reason: 'recorded',
})
})
it('returns unsuccessful response when Redis is unavailable', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: false,
reason: 'redis_unavailable',
})
})
it('returns unsuccessful response when Redis persistence fails', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_write_failed',
})
const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: false,
locallyAborted: false,
reason: 'redis_write_failed',
})
})
it('returns success when local fallback aborts execution without Redis durability', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
mockAbortManualExecution.mockReturnValue(true)
const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: true,
reason: 'redis_unavailable',
})
})
})

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
const logger = createLogger('CancelExecutionAPI')
@@ -45,20 +46,27 @@ export async function POST(
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
const marked = await markExecutionCancelled(executionId)
const cancellation = await markExecutionCancelled(executionId)
const locallyAborted = abortManualExecution(executionId)
if (marked) {
if (cancellation.durablyRecorded) {
logger.info('Execution marked as cancelled in Redis', { executionId })
} else if (locallyAborted) {
logger.info('Execution cancelled via local in-process fallback', { executionId })
} else {
logger.info('Redis not available, cancellation will rely on connection close', {
logger.warn('Execution cancellation was not durably recorded', {
executionId,
reason: cancellation.reason,
})
}
return NextResponse.json({
success: true,
success: cancellation.durablyRecorded || locallyAborted,
executionId,
redisAvailable: marked,
redisAvailable: cancellation.reason !== 'redis_unavailable',
durablyRecorded: cancellation.durablyRecorded,
locallyAborted,
reason: cancellation.reason,
})
} catch (error: any) {
logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message })

View File

@@ -1,13 +1,12 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowStatusAPI')
@@ -23,54 +22,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(validation.error.message, validation.error.status)
}
let needsRedeployment = false
if (validation.workflow.isDeployed) {
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (!normalizedData) {
return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,
deployedAt: validation.workflow.deployedAt,
isPublished: validation.workflow.isPublished,
needsRedeployment: false,
})
}
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || {},
lastSaved: Date.now(),
}
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)
if (active?.state) {
needsRedeployment = hasWorkflowChanged(
currentState as WorkflowState,
active.state as WorkflowState
)
}
}
const needsRedeployment = validation.workflow.isDeployed
? await checkNeedsRedeployment(id)
: false
return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,

View File

@@ -1,6 +1,11 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowUtils')
@@ -18,6 +23,50 @@ export function createSuccessResponse(data: any) {
return NextResponse.json(data)
}
/**
* Checks whether a deployed workflow has changes that require redeployment.
* Compares the current persisted state (from normalized tables) against the
* active deployment version state.
*
* This is the single source of truth for redeployment detection — used by
* both the /deploy and /status endpoints to ensure consistent results.
*/
export async function checkNeedsRedeployment(workflowId: string): Promise<boolean> {
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)
if (!active?.state) return false
const [normalizedData, [workflowRecord]] = await Promise.all([
loadWorkflowFromNormalizedTables(workflowId),
db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1),
])
if (!normalizedData) return false
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || {},
}
return hasWorkflowChanged(currentState as WorkflowState, active.state as WorkflowState)
}
/**
* Verifies user's workspace permissions using the permissions table
* @param userId User ID to check

View File

@@ -87,32 +87,33 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
const formData = await request.formData()
const file = formData.get('file') as File
const rawFile = formData.get('file')
if (!file) {
if (!rawFile || !(rawFile instanceof File)) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file size (100MB limit)
const fileName = rawFile.name || 'untitled'
const maxSize = 100 * 1024 * 1024
if (file.size > maxSize) {
if (rawFile.size > maxSize) {
return NextResponse.json(
{ error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` },
{ error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)` },
{ status: 400 }
)
}
const buffer = Buffer.from(await file.arrayBuffer())
const buffer = Buffer.from(await rawFile.arrayBuffer())
const userFile = await uploadWorkspaceFile(
workspaceId,
session.user.id,
buffer,
file.name,
file.type || 'application/octet-stream'
fileName,
rawFile.type || 'application/octet-stream'
)
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
recordAudit({
workspaceId,
@@ -122,8 +123,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.FILE,
resourceId: userFile.id,
resourceName: file.name,
description: `Uploaded file "${file.name}"`,
resourceName: fileName,
description: `Uploaded file "${fileName}"`,
request,
})

View File

@@ -1,27 +0,0 @@
'use client'
import Link from 'next/link'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface BrandedLinkProps {
href: string
children: React.ReactNode
className?: string
target?: string
rel?: string
}
export function BrandedLink({ href, children, className = '', target, rel }: BrandedLinkProps) {
const buttonClass = useBrandedButtonClass()
return (
<Link
href={href}
target={target}
rel={rel}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all ${className}`}
>
{children}
</Link>
)
}

View File

@@ -1,8 +1,5 @@
import { BookOpen, Github, Rss } from 'lucide-react'
import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedLink } from '@/app/changelog/components/branded-link'
import ChangelogList from '@/app/changelog/components/timeline-list'
export interface ChangelogEntry {
@@ -47,44 +44,38 @@ export default async function ChangelogContent() {
}
return (
<div className='min-h-screen bg-background'>
<div className='min-h-screen'>
<div className='relative grid md:grid-cols-2'>
{/* Left intro panel */}
<div className='relative top-0 overflow-hidden border-border border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
<div className='absolute inset-0 bg-grid-pattern opacity-[0.03] dark:opacity-[0.06]' />
<div className='absolute inset-0 bg-gradient-to-tr from-background via-transparent to-background/60' />
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
<div className='relative mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
<h1
className={`${soehne.className} mt-6 font-semibold text-4xl tracking-tight sm:text-5xl`}
>
Changelog
</h1>
<p className={`${inter.className} mt-4 text-muted-foreground text-sm`}>
<h1 className='mt-6 font-[500] text-4xl tracking-tight sm:text-5xl'>Changelog</h1>
<p className='mt-4 text-[#999] text-sm'>
Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All
changes are documented here with detailed release notes.
</p>
<hr className='mt-6 border-border' />
<hr className='mt-6 border-[#2A2A2A]' />
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
<BrandedLink
<Link
href='https://github.com/simstudioai/sim/releases'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-2 rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] py-[5px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
<Github className='h-4 w-4' />
View on GitHub
</BrandedLink>
</Link>
<Link
href='https://docs.sim.ai'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
className='inline-flex items-center gap-2 rounded-[5px] border border-[#3d3d3d] px-[9px] py-[5px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
>
<BookOpen className='h-4 w-4' />
Documentation
</Link>
<Link
href='/changelog.xml'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
className='inline-flex items-center gap-2 rounded-[5px] border border-[#3d3d3d] px-[9px] py-[5px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
>
<Rss className='h-4 w-4' />
RSS Feed

View File

@@ -3,8 +3,6 @@
import React from 'react'
import ReactMarkdown from 'react-markdown'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
type Props = { initialEntries: ChangelogEntry[] }
@@ -100,7 +98,7 @@ export default function ChangelogList({ initialEntries }: Props) {
<div key={entry.tag}>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-2'>
<div className={`${soehne.className} font-semibold text-[18px] tracking-tight`}>
<div className='font-[500] text-[#ECECEC] text-[18px] tracking-tight'>
{entry.tag}
</div>
{entry.contributors && entry.contributors.length > 0 && (
@@ -115,7 +113,7 @@ export default function ChangelogList({ initialEntries }: Props) {
title={`@${contributor}`}
className='block'
>
<Avatar className='size-6 ring-2 ring-background'>
<Avatar className='size-6 ring-2 ring-[#1C1C1C]'>
<AvatarImage
src={`https://avatars.githubusercontent.com/${contributor}`}
alt={`@${contributor}`}
@@ -126,14 +124,14 @@ export default function ChangelogList({ initialEntries }: Props) {
</a>
))}
{entry.contributors.length > 5 && (
<div className='relative flex size-6 items-center justify-center rounded-full bg-muted text-[10px] text-foreground ring-2 ring-background hover:z-10'>
<div className='relative flex size-6 items-center justify-center rounded-full bg-[#2A2A2A] text-[#ECECEC] text-[10px] ring-2 ring-[#1C1C1C] hover:z-10'>
+{entry.contributors.length - 5}
</div>
)}
</div>
)}
</div>
<div className={`${inter.className} text-muted-foreground text-xs`}>
<div className='text-[#999] text-xs'>
{new Date(entry.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
@@ -142,15 +140,13 @@ export default function ChangelogList({ initialEntries }: Props) {
</div>
</div>
<div
className={`${inter.className} prose prose-sm dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-brand-primary prose-headings:text-foreground prose-p:text-muted-foreground prose-a:no-underline hover:prose-a:underline`}
>
<div className='max-w-none'>
<ReactMarkdown
components={{
h2: ({ children, ...props }) =>
isContributorsLabel(children) ? null : (
<h3
className={`${soehne.className} mt-5 mb-2 font-medium text-[13px] text-foreground tracking-tight`}
className='mt-5 mb-2 font-[500] text-[#ECECEC] text-[13px] tracking-tight'
{...props}
>
{children}
@@ -159,7 +155,7 @@ export default function ChangelogList({ initialEntries }: Props) {
h3: ({ children, ...props }) =>
isContributorsLabel(children) ? null : (
<h4
className={`${soehne.className} mt-4 mb-1 font-medium text-[13px] text-foreground tracking-tight`}
className='mt-4 mb-1 font-[500] text-[#ECECEC] text-[13px] tracking-tight'
{...props}
>
{children}
@@ -174,28 +170,25 @@ export default function ChangelogList({ initialEntries }: Props) {
const text = String(children)
if (/^\s*contributors\s*:?\s*$/i.test(text)) return null
return (
<li className='text-[13px] text-muted-foreground leading-relaxed' {...props}>
<li className='text-[#999] text-[13px] leading-relaxed' {...props}>
{children}
</li>
)
},
p: ({ children, ...props }) =>
/^\s*contributors\s*:?\s*$/i.test(String(children)) ? null : (
<p
className='mb-3 text-[13px] text-muted-foreground leading-relaxed'
{...props}
>
<p className='mb-3 text-[#999] text-[13px] leading-relaxed' {...props}>
{children}
</p>
),
strong: ({ children, ...props }) => (
<strong className='font-medium text-foreground' {...props}>
<strong className='font-[500] text-[#ECECEC]' {...props}>
{children}
</strong>
),
code: ({ children, ...props }) => (
<code
className='rounded bg-muted px-1 py-0.5 font-mono text-foreground text-xs'
className='rounded bg-[#2A2A2A] px-1 py-0.5 font-mono text-[#ECECEC] text-xs'
{...props}
>
{children}
@@ -224,7 +217,7 @@ export default function ChangelogList({ initialEntries }: Props) {
type='button'
onClick={loadMore}
disabled={loading}
className='rounded-md border border-border px-3 py-1.5 text-[13px] hover:bg-muted disabled:opacity-60'
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1.5 text-[#ECECEC] text-[13px] transition-colors hover:bg-[#2A2A2A] disabled:opacity-60'
>
{loading ? 'Loading…' : 'Show more'}
</button>

View File

@@ -1,11 +1,17 @@
import Nav from '@/app/(landing)/components/nav/nav'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default function ChangelogLayout({ children }: { children: React.ReactNode }) {
return (
<div className='relative min-h-screen text-foreground'>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
<Nav />
<div
className={`${martianMono.variable} relative min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
>
<header>
<Navbar />
</header>
{children}
<Footer hideCTA />
</div>
)
}

View File

@@ -2,17 +2,13 @@
import { type KeyboardEvent, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Input } from '@/components/emcn'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import { Label } from '@/components/ui/label'
import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Nav from '@/app/(landing)/components/nav/nav'
import Navbar from '@/app/(home)/components/navbar/navbar'
const logger = createLogger('EmailAuth')
@@ -185,26 +181,26 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
}
return (
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
<Nav hideAuthButtons={true} variant='auth' />
<AuthBackground className='dark font-[430] font-season'>
<main className='relative flex min-h-screen flex-col text-[#ECECEC]'>
<header className='shrink-0 bg-[#1C1C1C]'>
<Navbar logoOnly />
</header>
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
>
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[380] text-[#999] text-[16px]'>
{showOtpVerification
? `A verification code has been sent to ${email}`
: 'This chat requires email verification'}
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px]`}>
<div className='mt-8 w-full max-w-[410px]'>
{!showOtpVerification ? (
<form
onSubmit={(e) => {
@@ -229,10 +225,9 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
onChange={handleEmailChange}
onKeyDown={handleEmailKeyDown}
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
'border-red-500 focus:border-red-500'
)}
autoFocus
/>
@@ -251,7 +246,7 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
</form>
) : (
<div className='space-y-6'>
<p className='text-center text-muted-foreground text-sm'>
<p className='text-center text-[#999] text-sm'>
Enter the 6-digit code to verify your account. If you don't see it in your
inbox, check your spam folder.
</p>
@@ -269,18 +264,12 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
disabled={isVerifyingOtp}
className={cn('gap-2', authError && 'otp-error')}
>
<InputOTPGroup className='[&>div]:!rounded-[10px] gap-2'>
<InputOTPGroup>
{[0, 1, 2, 3, 4, 5].map((index) => (
<InputOTPSlot
key={index}
index={index}
className={cn(
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
'border-gray-300 hover:border-gray-400',
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
authError &&
'border-red-500 focus:border-red-500 focus:ring-red-100'
)}
className={cn(authError && 'border-red-500')}
/>
))}
</InputOTPGroup>

View File

@@ -3,15 +3,12 @@
import { type KeyboardEvent, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Nav from '@/app/(landing)/components/nav/nav'
import Navbar from '@/app/(home)/components/navbar/navbar'
const logger = createLogger('PasswordAuth')
@@ -82,19 +79,19 @@ export default function PasswordAuth({ identifier, onAuthSuccess }: PasswordAuth
}
return (
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
<Nav hideAuthButtons={true} variant='auth' />
<AuthBackground className='dark font-[430] font-season'>
<main className='relative flex min-h-screen flex-col text-[#ECECEC]'>
<header className='shrink-0 bg-[#1C1C1C]'>
<Navbar logoOnly />
</header>
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
>
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
Password Required
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<p className='font-[380] text-[#999] text-[16px]'>
This chat is password-protected
</p>
</div>
@@ -104,7 +101,7 @@ export default function PasswordAuth({ identifier, onAuthSuccess }: PasswordAuth
e.preventDefault()
handleAuthenticate()
}}
className={`${inter.className} mt-8 w-full max-w-[410px] space-y-6`}
className='mt-8 w-full max-w-[410px] space-y-6'
>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
@@ -124,17 +121,17 @@ export default function PasswordAuth({ identifier, onAuthSuccess }: PasswordAuth
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
'pr-10',
showValidationError &&
passwordErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
'border-red-500 focus:border-red-500'
)}
autoFocus
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
className='-translate-y-1/2 absolute top-1/2 right-3 text-[#999] hover:text-[#ECECEC]'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}

View File

@@ -3,7 +3,6 @@
import Image from 'next/image'
import Link from 'next/link'
import { GithubIcon } from '@/components/icons'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { useBrandConfig } from '@/ee/whitelabeling'
interface ChatHeaderProps {
@@ -41,7 +40,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
className='h-6 w-6 rounded-md object-cover'
/>
)}
<h2 className={`${inter.className} font-medium text-[18px] text-foreground`}>
<h2 className='font-medium text-[18px] text-foreground'>
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
</h2>
</div>
@@ -57,9 +56,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
aria-label={`GitHub repository - ${starCount} stars`}
>
<GithubIcon className='h-[16px] w-[16px]' aria-hidden='true' />
<span className={`${inter.className}`} aria-live='polite'>
{starCount}
</span>
<span aria-live='polite'>{starCount}</span>
</a>
{/* Only show Sim logo if no custom branding is set */}

View File

@@ -1,6 +1,6 @@
'use client'
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from '@/components/emcn'
export function ChatLoadingState() {
return (

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