From 27a41d4e33ad05e39ca1d92d6ad3cbb37d9e0dc3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 19 Mar 2026 10:39:43 -0700 Subject: [PATCH 01/11] fix(open-resource): open resource tool to open existing files (#3670) * fix(open-resource): open resource tool to open existing files * fix loading state * address comment * remove title --- .../resource-content/resource-content.tsx | 4 +- .../orchestrator/tool-executor/index.ts | 51 ++++++++++++++--- .../tool-executor/integration-tools.ts | 5 +- .../orchestrator/tool-executor/param-types.ts | 12 ++++ .../tools/server/knowledge/knowledge-base.ts | 9 +-- .../copilot/tools/server/table/user-table.ts | 11 +--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 9 +-- .../workspace/workspace-file-manager.ts | 55 +++++++++++++++++++ 8 files changed, 123 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 6ba4f70e76e..461f02f7dd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -344,10 +344,10 @@ interface EmbeddedFileProps { } function EmbeddedFile({ workspaceId, fileId, previewMode }: EmbeddedFileProps) { - const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId) + const { data: files = [], isLoading, isFetching } = useWorkspaceFiles(workspaceId) const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId]) - if (isLoading) return LOADING_SKELETON + if (isLoading || (isFetching && !file)) return LOADING_SKELETON if (!file) { return ( diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index fac22162fd9..3617a0321e2 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -68,6 +68,8 @@ import type { ListWorkspaceMcpServersParams, MoveFolderParams, MoveWorkflowParams, + OpenResourceParams, + OpenResourceType, RenameFolderParams, RenameWorkflowParams, RunBlockParams, @@ -77,6 +79,7 @@ import type { SetGlobalWorkflowVariablesParams, UpdateWorkflowParams, UpdateWorkspaceMcpServerParams, + ValidOpenResourceParams, } from './param-types' import { PLATFORM_ACTIONS_CONTENT } from './platform-actions' import { executeVfsGlob, executeVfsGrep, executeVfsList, executeVfsRead } from './vfs-tools' @@ -105,6 +108,36 @@ import { } from './workflow-tools' const logger = createLogger('CopilotToolExecutor') +const VALID_OPEN_RESOURCE_TYPES = new Set([ + 'workflow', + 'table', + 'knowledgebase', + 'file', +]) + +function validateOpenResourceParams( + params: OpenResourceParams +): { success: true; params: ValidOpenResourceParams } | { success: false; error: string } { + if (!params.type) { + return { success: false, error: 'type is required' } + } + + if (!VALID_OPEN_RESOURCE_TYPES.has(params.type)) { + return { success: false, error: `Invalid resource type: ${params.type}` } + } + + if (!params.id) { + return { success: false, error: `${params.type} resources require \`id\`` } + } + + return { + success: true, + params: { + type: params.type, + id: params.id, + }, + } +} type ManageCustomToolOperation = 'add' | 'edit' | 'delete' | 'list' @@ -996,16 +1029,16 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record< list: (p, c) => executeVfsList(p, c), // Resource visibility - open_resource: async (p) => { - const resourceType = p.type as string | undefined - const resourceId = p.id as string | undefined - if (!resourceType || !resourceId) { - return { success: false, error: 'type and id are required' } - } - const validTypes = new Set(['workflow', 'table', 'knowledgebase', 'file']) - if (!validTypes.has(resourceType)) { - return { success: false, error: `Invalid resource type: ${resourceType}` } + open_resource: async (p: OpenResourceParams) => { + const validated = validateOpenResourceParams(p) + if (!validated.success) { + return { success: false, error: validated.error } } + + const params = validated.params + const resourceType = params.type + const resourceId = params.id + return { success: true, output: { message: `Opened ${resourceType} ${resourceId} for the user` }, diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts index 01c15ed3ddc..1e87acd851f 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts @@ -15,6 +15,7 @@ import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { getTableById, queryRows } from '@/lib/table/service' import { downloadWorkspaceFile, + findWorkspaceFileRecord, listWorkspaceFiles, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' @@ -178,9 +179,7 @@ export async function executeIntegrationToolDirect( logger.warn('Skipping non-text sandbox input file', { fileName, ext }) continue } - const record = allFiles.find( - (f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC') - ) + const record = findWorkspaceFileRecord(allFiles, filePath) if (!record) { logger.warn('Sandbox input file not found', { fileName }) continue diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/param-types.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/param-types.ts index 0fd03c11b3e..660e62d67b0 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/param-types.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/param-types.ts @@ -202,3 +202,15 @@ export interface UpdateWorkspaceMcpServerParams { export interface DeleteWorkspaceMcpServerParams { serverId: string } + +export type OpenResourceType = 'workflow' | 'table' | 'knowledgebase' | 'file' + +export interface OpenResourceParams { + type?: OpenResourceType + id?: string +} + +export interface ValidOpenResourceParams { + type: OpenResourceType + id: string +} diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index dcaf085f133..842754d724c 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -28,7 +28,7 @@ import { updateTagDefinition, } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' const logger = createLogger('KnowledgeBaseServerTool') @@ -235,13 +235,8 @@ export const knowledgeBaseServerTool: BaseServerTool f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC') - ) + const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, args.filePath) if (!fileRecord) { return { diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index f308881c320..ec64397ea0c 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -26,7 +26,7 @@ import { import type { ColumnDefinition, RowData, TableDefinition } from '@/lib/table/types' import { downloadWorkspaceFile, - listWorkspaceFiles, + resolveWorkspaceFileReference, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('UserTableServerTool') @@ -40,15 +40,10 @@ async function resolveWorkspaceFile( filePath: string, workspaceId: string ): Promise<{ buffer: Buffer; name: string; type: string }> { - const match = filePath.match(/^files\/(.+)$/) - const fileName = match ? match[1] : filePath - const files = await listWorkspaceFiles(workspaceId) - const record = files.find( - (f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC') - ) + const record = await resolveWorkspaceFileReference(workspaceId, filePath) if (!record) { throw new Error( - `File not found: "${fileName}". Use glob("files/*/meta.json") to list available files.` + `File not found: "${filePath}". Use glob("files/*/meta.json") to list available files.` ) } const buffer = await downloadWorkspaceFile(record) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 85a34bfe40e..122cff4a5bc 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -56,7 +56,10 @@ import { import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { getKnowledgeBases } from '@/lib/knowledge/service' import { listTables } from '@/lib/table/service' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + findWorkspaceFileRecord, + listWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -397,9 +400,7 @@ export class WorkspaceVFS { try { const files = await listWorkspaceFiles(this._workspaceId) - const record = files.find( - (f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC') - ) + const record = findWorkspaceFileRecord(files, fileName) if (!record) return null return readFileRecord(record) } catch (err) { diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 4d18638d3ff..25d7639ab89 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -328,6 +328,61 @@ export async function listWorkspaceFiles( } } +/** + * Normalize a workspace file reference to its display name. + * Supports raw names and VFS-style paths like `files/name`, `files/name/content`, + * and `files/name/meta.json`. + */ +export function normalizeWorkspaceFileReference(fileReference: string): string { + const trimmed = fileReference.trim().replace(/^\/+/, '') + + if (trimmed.startsWith('files/')) { + const withoutPrefix = trimmed.slice('files/'.length) + if (withoutPrefix.endsWith('/meta.json')) { + return withoutPrefix.slice(0, -'/meta.json'.length) + } + if (withoutPrefix.endsWith('/content')) { + return withoutPrefix.slice(0, -'/content'.length) + } + return withoutPrefix + } + + return trimmed +} + +/** + * Find a workspace file record in an existing list from either its id or a VFS/name reference. + */ +export function findWorkspaceFileRecord( + files: WorkspaceFileRecord[], + fileReference: string +): WorkspaceFileRecord | null { + const exactIdMatch = files.find((file) => file.id === fileReference) + if (exactIdMatch) { + return exactIdMatch + } + + const normalizedReference = normalizeWorkspaceFileReference(fileReference) + return ( + files.find( + (file) => + file.name === normalizedReference || + file.name.normalize('NFC') === normalizedReference.normalize('NFC') + ) ?? null + ) +} + +/** + * Resolve a workspace file record from either its id or a VFS/name reference. + */ +export async function resolveWorkspaceFileReference( + workspaceId: string, + fileReference: string +): Promise { + const files = await listWorkspaceFiles(workspaceId) + return findWorkspaceFileRecord(files, fileReference) +} + /** * Get a specific workspace file */ From 25789855af5dfb1ef733c78eccb66b625c5728ed Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 19 Mar 2026 11:44:38 -0700 Subject: [PATCH 02/11] fix(tool): Fix custom tools spreading out string output (#3676) * fix(tool): Fix issue with custom tools spreading out string output * Fix lint * Avoid any transformation on custom tool outputs --------- Co-authored-by: Theodore Li --- apps/sim/executor/utils/output-filter.ts | 3 + apps/sim/tools/index.test.ts | 180 +++++++++++++++++++++++ apps/sim/tools/index.ts | 11 +- 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/apps/sim/executor/utils/output-filter.ts b/apps/sim/executor/utils/output-filter.ts index be28f48a59a..5da00faba53 100644 --- a/apps/sim/executor/utils/output-filter.ts +++ b/apps/sim/executor/utils/output-filter.ts @@ -24,6 +24,9 @@ export function filterOutputForLog( additionalHiddenKeys?: string[] } ): NormalizedBlockOutput { + if (typeof output !== 'object' || output === null || Array.isArray(output)) { + return output as NormalizedBlockOutput + } const blockConfig = blockType ? getBlock(blockType) : undefined const filtered: NormalizedBlockOutput = {} const additionalHiddenKeys = options?.additionalHiddenKeys ?? [] diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 18fdbd1fb2a..c20681b5d27 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -1830,6 +1830,186 @@ describe('Rate Limiting and Retry Logic', () => { }) }) +describe('stripInternalFields Safety', () => { + let cleanupEnvVars: () => void + + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + }) + + afterEach(() => { + vi.resetAllMocks() + cleanupEnvVars() + }) + + it('should preserve string output from tools without character-indexing', async () => { + const stringOutput = '{"type":"button","phone":"917899658001"}' + + const mockTool = { + id: 'test_string_output', + name: 'Test String Output', + description: 'A tool that returns a string as output', + version: '1.0.0', + params: {}, + request: { + url: '/api/test/string-output', + method: 'POST' as const, + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: stringOutput, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_string_output = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ success: true }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + const result = await executeTool('test_string_output', {}, true) + + expect(result.success).toBe(true) + expect(result.output).toBe(stringOutput) + expect(typeof result.output).toBe('string') + + Object.assign(tools, originalTools) + }) + + it('should preserve array output from tools', async () => { + const arrayOutput = [{ id: 1 }, { id: 2 }] + + const mockTool = { + id: 'test_array_output', + name: 'Test Array Output', + description: 'A tool that returns an array as output', + version: '1.0.0', + params: {}, + request: { + url: '/api/test/array-output', + method: 'POST' as const, + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: arrayOutput, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_array_output = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ success: true }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + const result = await executeTool('test_array_output', {}, true) + + expect(result.success).toBe(true) + expect(Array.isArray(result.output)).toBe(true) + expect(result.output).toEqual(arrayOutput) + + Object.assign(tools, originalTools) + }) + + it('should still strip __-prefixed fields from object output', async () => { + const mockTool = { + id: 'test_strip_internal', + name: 'Test Strip Internal', + description: 'A tool with __internal fields in output', + version: '1.0.0', + params: {}, + request: { + url: '/api/test/strip-internal', + method: 'POST' as const, + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'ok', __costDollars: 0.05, _id: 'keep-this' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_strip_internal = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ success: true }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + const result = await executeTool('test_strip_internal', {}, true) + + expect(result.success).toBe(true) + expect(result.output.result).toBe('ok') + expect(result.output.__costDollars).toBeUndefined() + expect(result.output._id).toBe('keep-this') + + Object.assign(tools, originalTools) + }) + + it('should preserve __-prefixed fields in custom tool output', async () => { + const mockTool = { + id: 'custom_test-preserve-dunder', + name: 'Custom Preserve Dunder', + description: 'A custom tool whose output has __ fields', + version: '1.0.0', + params: {}, + request: { + url: '/api/function/execute', + method: 'POST' as const, + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'ok', __metadata: { source: 'user' }, __tag: 'important' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any)['custom_test-preserve-dunder'] = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ success: true }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + const result = await executeTool('custom_test-preserve-dunder', {}, true) + + expect(result.success).toBe(true) + expect(result.output.result).toBe('ok') + expect(result.output.__metadata).toEqual({ source: 'user' }) + expect(result.output.__tag).toBe('important') + + Object.assign(tools, originalTools) + }) +}) + describe('Cost Field Handling', () => { let cleanupEnvVars: () => void diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 60f710ed08e..0dbf1753f6b 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -363,6 +363,9 @@ async function reportCustomDimensionUsage( * fields like `_id`. */ function stripInternalFields(output: Record): Record { + if (typeof output !== 'object' || output === null || Array.isArray(output)) { + return output + } const result: Record = {} for (const [key, value] of Object.entries(output)) { if (!key.startsWith('__')) { @@ -825,7 +828,9 @@ export async function executeTool( ) } - const strippedOutput = stripInternalFields(finalResult.output || {}) + const strippedOutput = isCustomTool(normalizedToolId) + ? finalResult.output + : stripInternalFields(finalResult.output ?? {}) return { ...finalResult, @@ -880,7 +885,9 @@ export async function executeTool( ) } - const strippedOutput = stripInternalFields(finalResult.output || {}) + const strippedOutput = isCustomTool(normalizedToolId) + ? finalResult.output + : stripInternalFields(finalResult.output ?? {}) return { ...finalResult, From 507954c2d5b03962dbee8d5ba399d18896326703 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 19 Mar 2026 11:48:51 -0700 Subject: [PATCH 03/11] fix(home): stop sidebar collapsing when artifact opens (#3677) --- apps/sim/app/workspace/[workspaceId]/home/home.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 1f9a3e73ee9..90adeda0480 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -16,7 +16,6 @@ import { persistImportedWorkflow } from '@/lib/workflows/operations/import-expor import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks' import type { ChatContext } from '@/stores/panel' -import { useSidebarStore } from '@/stores/sidebar/store' import { MessageContent, MothershipView, @@ -167,8 +166,6 @@ export function Home({ chatId }: HomeProps = {}) { const handleResourceEvent = useCallback(() => { if (isResourceCollapsedRef.current) { - const { isCollapsed, toggleCollapsed } = useSidebarStore.getState() - if (!isCollapsed) toggleCollapsed() setIsResourceCollapsed(false) startAnimatingIn() } From ce3d2d5e955fa82b4750f5896c986e381ccb6f10 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 19 Mar 2026 12:14:16 -0700 Subject: [PATCH 04/11] fix(oauth): fall back to configured scopes when DB scope is empty (#3678) Providers like Box don't return a scope field in their token response, leaving the account.scope column empty. The credentials API now falls back to the provider's configured scopes when the stored scope is empty, preventing false "Additional permissions required" banners. Co-authored-by: Claude Opus 4.6 --- apps/sim/app/api/auth/oauth/credentials/route.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 6b096803b91..eab12f41f86 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -38,7 +39,13 @@ function toCredentialResponse( scope: string | null ) { const storedScope = scope?.trim() - const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : [] + // Some providers (e.g. Box) don't return scopes in their token response, + // so the DB column stays empty. Fall back to the configured scopes for + // the provider so the credential-selector doesn't show a false + // "Additional permissions required" banner. + const scopes = storedScope + ? storedScope.split(/[\s,]+/).filter(Boolean) + : getCanonicalScopesForProvider(providerId) const [_, featureType = 'default'] = providerId.split('-') return { From c3c22e467404e5edacb5ca8f98122a614102ae0e Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 19 Mar 2026 12:57:10 -0700 Subject: [PATCH 05/11] improvement(react): replace unnecessary useEffect patterns with better React primitives (#3675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(react): replace unnecessary useEffect patterns with better React primitives * fix(react): revert unsafe render-time side effects to useEffect * fix(react): restore useEffect for modals, scroll, and env sync - Modals (create-workspace, rename-document, edit-knowledge-base): restore useEffect watching `open` prop for form reset on programmatic open, since Radix onOpenChange doesn't fire for parent-driven prop changes - Popover: add useEffect watching `open` for programmatic close reset - Chat scroll: restore useEffect watching `isStreamingResponse` so the 1s suppression timer starts when streaming begins, not before the fetch - Credentials manager: revert render-time pattern to useEffect for initial sync from cached React Query data (useRef captures initial value, making the !== check always false on mount) * fix(react): restore useEffect for help/invite modals, combobox index reset - Help modal: restore useEffect watching `open` for form reset on programmatic open (same Radix onOpenChange pattern as other modals) - Invite modal: restore useEffect watching `open` to clear error on programmatic open - Combobox: restore useEffect to reset highlightedIndex when filtered options shrink (prevents stale index from reappearing when options grow) - Remove no-op handleOpenChange wrappers in rename-document and edit-knowledge-base modals (now pure pass-throughs after useEffect fix) * fix(context-menu): use requestAnimationFrame for ColorGrid focus, remove no-op wrapper in create-workspace-modal - ColorGrid: replaced setTimeout with requestAnimationFrame for initial focus to wait for submenu paint completion - create-workspace-modal: removed handleOpenChange pass-through wrapper, use onOpenChange directly * fix(files): restore filesRef pattern to prevent preview mode reset on refetch The useEffect that sets previewMode should only run when selectedFileId changes, not when files array reference changes from React Query refetch. Restores the filesRef pattern to read latest files without triggering the effect — prevents overriding user's manual mode selection. * fix(add-documents-modal, combobox): restore useEffect for modal reset, fix combobox dep array - add-documents-modal: handleOpenChange(true) is dead code in Radix controlled mode — restored useEffect watching open for reset-on-open - combobox: depend on filteredOptions array (not .length) so highlight resets when items change even with same count --- apps/sim/app/(auth)/login/login-form.tsx | 43 ++++++---------- .../reset-password/reset-password-content.tsx | 17 +++---- apps/sim/app/(auth)/signup/signup-form.tsx | 45 ++++++----------- apps/sim/app/chat/components/input/input.tsx | 21 +++----- .../voice-interface/voice-interface.tsx | 50 ++++++++++--------- .../add-documents-modal.tsx | 30 +++++++---- .../credentials/credentials-manager.tsx | 13 +++-- .../components/help-modal/help-modal.tsx | 26 ++++++---- .../components/context-menu/context-menu.tsx | 35 +++++++------ .../create-workspace-modal.tsx | 30 +++++------ .../components/permissions-table.tsx | 19 ++++--- .../w/components/sidebar/sidebar.tsx | 31 +++++++++--- .../emcn/components/combobox/combobox.tsx | 34 ++++++------- .../components/date-picker/date-picker.tsx | 9 ++-- .../emcn/components/popover/popover.tsx | 41 +++++++++------ .../components/time-picker/time-picker.tsx | 6 ++- 16 files changed, 231 insertions(+), 219 deletions(-) diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 85e924fd328..f6e842a602f 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' @@ -99,15 +99,21 @@ export default function LoginPage({ const router = useRouter() const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) - const [_mounted, setMounted] = useState(false) const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) const buttonClass = useBrandedButtonClass() - const [callbackUrl, setCallbackUrl] = useState('/workspace') - const [isInviteFlow, setIsInviteFlow] = useState(false) + const callbackUrlParam = searchParams?.get('callbackUrl') + const invalidCallbackRef = useRef(false) + if (callbackUrlParam && !validateCallbackUrl(callbackUrlParam) && !invalidCallbackRef.current) { + invalidCallbackRef.current = true + logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam }) + } + const callbackUrl = + callbackUrlParam && validateCallbackUrl(callbackUrlParam) ? callbackUrlParam : '/workspace' + const isInviteFlow = searchParams?.get('invite_flow') === 'true' const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [forgotPasswordEmail, setForgotPasswordEmail] = useState('') @@ -120,30 +126,11 @@ export default function LoginPage({ const [email, setEmail] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [resetSuccessMessage, setResetSuccessMessage] = useState(null) - - useEffect(() => { - setMounted(true) - - if (searchParams) { - const callback = searchParams.get('callbackUrl') - if (callback) { - if (validateCallbackUrl(callback)) { - setCallbackUrl(callback) - } else { - logger.warn('Invalid callback URL detected and blocked:', { url: callback }) - } - } - - const inviteFlow = searchParams.get('invite_flow') === 'true' - setIsInviteFlow(inviteFlow) - - const resetSuccess = searchParams.get('resetSuccess') === 'true' - if (resetSuccess) { - setResetSuccessMessage('Password reset successful. Please sign in with your new password.') - } - } - }, [searchParams]) + const [resetSuccessMessage, setResetSuccessMessage] = useState(() => + searchParams?.get('resetSuccess') === 'true' + ? 'Password reset successful. Please sign in with your new password.' + : null + ) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { diff --git a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx index 9127c6e0b42..a48eedc5f8a 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useEffect, useState } from 'react' +import { Suspense, useState } from 'react' import { createLogger } from '@sim/logger' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' @@ -22,14 +22,9 @@ function ResetPasswordContent() { text: '', }) - useEffect(() => { - if (!token) { - setStatusMessage({ - type: 'error', - text: 'Invalid or missing reset token. Please request a new password reset link.', - }) - } - }, [token]) + const tokenError = !token + ? 'Invalid or missing reset token. Please request a new password reset link.' + : null const handleResetPassword = async (password: string) => { try { @@ -87,8 +82,8 @@ function ResetPasswordContent() { token={token} onSubmit={handleResetPassword} isSubmitting={isSubmitting} - statusType={statusMessage.type} - statusMessage={statusMessage.text} + statusType={tokenError ? 'error' : statusMessage.type} + statusMessage={tokenError ?? statusMessage.text} /> diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index b04ad8af4c9..0a8138053a1 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useEffect, useState } from 'react' +import { Suspense, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' @@ -82,49 +82,32 @@ function SignupFormContent({ const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const [isLoading, setIsLoading] = useState(false) - const [, setMounted] = useState(false) const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) - const [email, setEmail] = useState('') + const [email, setEmail] = useState(() => searchParams.get('email') ?? '') const [emailError, setEmailError] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [redirectUrl, setRedirectUrl] = useState('') - const [isInviteFlow, setIsInviteFlow] = useState(false) const buttonClass = useBrandedButtonClass() + const redirectUrl = useMemo( + () => searchParams.get('redirect') || searchParams.get('callbackUrl') || '', + [searchParams] + ) + const isInviteFlow = useMemo( + () => + searchParams.get('invite_flow') === 'true' || + redirectUrl.startsWith('/invite/') || + redirectUrl.startsWith('/credential-account/'), + [searchParams, redirectUrl] + ) + const [name, setName] = useState('') const [nameErrors, setNameErrors] = useState([]) const [showNameValidationError, setShowNameValidationError] = useState(false) - useEffect(() => { - setMounted(true) - const emailParam = searchParams.get('email') - if (emailParam) { - setEmail(emailParam) - } - - // Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl) - const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl') - if (redirectParam) { - setRedirectUrl(redirectParam) - - if ( - redirectParam.startsWith('/invite/') || - redirectParam.startsWith('/credential-account/') - ) { - setIsInviteFlow(true) - } - } - - const inviteFlowParam = searchParams.get('invite_flow') - if (inviteFlowParam === 'true') { - setIsInviteFlow(true) - } - }, [searchParams]) - const validatePassword = (passwordValue: string): string[] => { const errors: string[] = [] diff --git a/apps/sim/app/chat/components/input/input.tsx b/apps/sim/app/chat/components/input/input.tsx index 5c9bfea95be..25402cf475b 100644 --- a/apps/sim/app/chat/components/input/input.tsx +++ b/apps/sim/app/chat/components/input/input.tsx @@ -71,11 +71,6 @@ export const ChatInput: React.FC<{ } } - // Adjust height on input change - useEffect(() => { - adjustTextareaHeight() - }, [inputValue]) - // Close the input when clicking outside (only when empty) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -94,17 +89,14 @@ export const ChatInput: React.FC<{ return () => document.removeEventListener('mousedown', handleClickOutside) }, [inputValue]) - // Handle focus and initial height when activated - useEffect(() => { - if (isActive && textareaRef.current) { - textareaRef.current.focus() - adjustTextareaHeight() // Adjust height when becoming active - } - }, [isActive]) - const handleActivate = () => { setIsActive(true) - // Focus is now handled by the useEffect above + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.focus() + adjustTextareaHeight() + } + }) } // Handle file selection @@ -186,6 +178,7 @@ export const ChatInput: React.FC<{ const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value) + adjustTextareaHeight() } // Handle voice start with smooth transition to voice-first mode diff --git a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx index fd7f291c31a..9c9cc265395 100644 --- a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx @@ -78,9 +78,10 @@ export function VoiceInterface({ const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle') const isCallEndedRef = useRef(false) - useEffect(() => { - currentStateRef.current = state - }, [state]) + const updateState = useCallback((next: 'idle' | 'listening' | 'agent_speaking') => { + setState(next) + currentStateRef.current = next + }, []) const recognitionRef = useRef(null) const mediaStreamRef = useRef(null) @@ -97,9 +98,10 @@ export function VoiceInterface({ (window as WindowWithSpeech).webkitSpeechRecognition ) - useEffect(() => { - isMutedRef.current = isMuted - }, [isMuted]) + const updateIsMuted = useCallback((next: boolean) => { + setIsMuted(next) + isMutedRef.current = next + }, []) const setResponseTimeout = useCallback(() => { if (responseTimeoutRef.current) { @@ -108,7 +110,7 @@ export function VoiceInterface({ responseTimeoutRef.current = setTimeout(() => { if (currentStateRef.current === 'listening') { - setState('idle') + updateState('idle') } }, 5000) }, []) @@ -123,10 +125,10 @@ export function VoiceInterface({ useEffect(() => { if (isPlayingAudio && state !== 'agent_speaking') { clearResponseTimeout() - setState('agent_speaking') + updateState('agent_speaking') setCurrentTranscript('') - setIsMuted(true) + updateIsMuted(true) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { track.enabled = false @@ -141,17 +143,17 @@ export function VoiceInterface({ } } } else if (!isPlayingAudio && state === 'agent_speaking') { - setState('idle') + updateState('idle') setCurrentTranscript('') - setIsMuted(false) + updateIsMuted(false) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { track.enabled = true }) } } - }, [isPlayingAudio, state, clearResponseTimeout]) + }, [isPlayingAudio, state, clearResponseTimeout, updateState, updateIsMuted]) const setupAudio = useCallback(async () => { try { @@ -310,7 +312,7 @@ export function VoiceInterface({ return } - setState('listening') + updateState('listening') setCurrentTranscript('') if (recognitionRef.current) { @@ -320,10 +322,10 @@ export function VoiceInterface({ logger.error('Error starting recognition:', error) } } - }, [isInitialized, isMuted, state]) + }, [isInitialized, isMuted, state, updateState]) const stopListening = useCallback(() => { - setState('idle') + updateState('idle') setCurrentTranscript('') if (recognitionRef.current) { @@ -333,15 +335,15 @@ export function VoiceInterface({ // Ignore } } - }, []) + }, [updateState]) const handleInterrupt = useCallback(() => { if (state === 'agent_speaking') { onInterrupt?.() - setState('listening') + updateState('listening') setCurrentTranscript('') - setIsMuted(false) + updateIsMuted(false) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { track.enabled = true @@ -356,14 +358,14 @@ export function VoiceInterface({ } } } - }, [state, onInterrupt]) + }, [state, onInterrupt, updateState, updateIsMuted]) const handleCallEnd = useCallback(() => { isCallEndedRef.current = true - setState('idle') + updateState('idle') setCurrentTranscript('') - setIsMuted(false) + updateIsMuted(false) if (recognitionRef.current) { try { @@ -376,7 +378,7 @@ export function VoiceInterface({ clearResponseTimeout() onInterrupt?.() onCallEnd?.() - }, [onCallEnd, onInterrupt, clearResponseTimeout]) + }, [onCallEnd, onInterrupt, clearResponseTimeout, updateState, updateIsMuted]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -397,7 +399,7 @@ export function VoiceInterface({ } const newMutedState = !isMuted - setIsMuted(newMutedState) + updateIsMuted(newMutedState) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { @@ -410,7 +412,7 @@ export function VoiceInterface({ } else if (state === 'idle') { startListening() } - }, [isMuted, state, handleInterrupt, stopListening, startListening]) + }, [isMuted, state, handleInterrupt, stopListening, startListening, updateIsMuted]) useEffect(() => { if (isSupported) { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx index 56463b60f7b..61b6f258fb6 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { Loader2, RotateCcw, X } from 'lucide-react' import { useParams } from 'next/navigation' @@ -75,15 +75,25 @@ export function AddDocumentsModal({ } }, [open, clearError]) + /** Handles close with upload guard */ + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!newOpen) { + if (isUploading) return + setFiles([]) + setFileError(null) + clearError() + setIsDragging(false) + setDragCounter(0) + setRetryingIndexes(new Set()) + } + onOpenChange(newOpen) + }, + [isUploading, clearError, onOpenChange] + ) + const handleClose = () => { - if (isUploading) return - setFiles([]) - setFileError(null) - clearError() - setIsDragging(false) - setDragCounter(0) - setRetryingIndexes(new Set()) - onOpenChange(false) + handleOpenChange(false) } const processFiles = async (fileList: FileList | File[]) => { @@ -220,7 +230,7 @@ export function AddDocumentsModal({ } return ( - + New Documents diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index 15ff5a19079..ee553726c17 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -494,13 +494,12 @@ export function CredentialsManager() { }, [variables]) useEffect(() => { - if (workspaceEnvData) { - if (hasSavedRef.current) { - hasSavedRef.current = false - } else { - setWorkspaceVars(workspaceEnvData?.workspace || {}) - initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {} - } + if (!workspaceEnvData) return + if (hasSavedRef.current) { + hasSavedRef.current = false + } else { + setWorkspaceVars(workspaceEnvData.workspace || {}) + initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {} } }, [workspaceEnvData]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx index a51cef4be24..c017e6162e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx @@ -89,21 +89,25 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM }) /** - * Reset all state when modal opens/closes + * Reset all form and UI state to prepare for a fresh modal session */ + const resetModalState = useCallback(() => { + setSubmitStatus(null) + setImages([]) + setIsDragging(false) + setIsProcessing(false) + reset({ + subject: '', + message: '', + type: DEFAULT_REQUEST_TYPE, + }) + }, [reset]) + useEffect(() => { if (open) { - setSubmitStatus(null) - setImages([]) - setIsDragging(false) - setIsProcessing(false) - reset({ - subject: '', - message: '', - type: DEFAULT_REQUEST_TYPE, - }) + resetModalState() } - }, [open, reset]) + }, [open, resetModalState]) /** * Fix z-index for popover/dropdown when inside modal diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index a6b8d5df91b..ae179d5d79f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, DropdownMenu, @@ -39,29 +39,27 @@ function ColorGrid({ hexInput, setHexInput, onColorChange, - isOpen, + buttonRefs, }: { hexInput: string setHexInput: (color: string) => void onColorChange?: (color: string) => void - isOpen: boolean + buttonRefs: RefObject<(HTMLButtonElement | null)[]> }) { const [focusedIndex, setFocusedIndex] = useState(-1) const gridRef = useRef(null) - const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) useEffect(() => { - if (isOpen && gridRef.current) { - const selectedIndex = WORKFLOW_COLORS.findIndex( - ({ color }) => color.toLowerCase() === hexInput.toLowerCase() - ) - const initialIndex = selectedIndex >= 0 ? selectedIndex : 0 - setFocusedIndex(initialIndex) - setTimeout(() => { - buttonRefs.current[initialIndex]?.focus() - }, 50) - } - }, [isOpen, hexInput]) + const selectedIndex = WORKFLOW_COLORS.findIndex( + ({ color }) => color.toLowerCase() === hexInput.toLowerCase() + ) + const idx = selectedIndex >= 0 ? selectedIndex : 0 + setFocusedIndex(idx) + requestAnimationFrame(() => { + buttonRefs.current[idx]?.focus() + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const handleKeyDown = useCallback( (e: React.KeyboardEvent, index: number) => { @@ -176,10 +174,10 @@ function ColorPickerSubmenu({ handleHexFocus: (e: React.FocusEvent) => void disabled?: boolean }) { - const [isSubOpen, setIsSubOpen] = useState(false) + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) return ( - + Change color @@ -190,7 +188,7 @@ function ColorPickerSubmenu({ hexInput={hexInput} setHexInput={setHexInput} onColorChange={onColorChange} - isOpen={isSubOpen} + buttonRefs={buttonRefs} />
e.preventDefault()} > {showOpenInNewTab && onOpenInNewTab && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx index 1f775d13b3e..5193680eb89 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button, Input, @@ -33,29 +33,31 @@ export function CreateWorkspaceModal({ useEffect(() => { if (open) { setName('') - requestAnimationFrame(() => inputRef.current?.focus()) } }, [open]) - const handleSubmit = useCallback(async () => { + const handleSubmit = async () => { const trimmed = name.trim() if (!trimmed || isCreating) return await onConfirm(trimmed) - }, [name, isCreating, onConfirm]) + } - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - void handleSubmit() - } - }, - [handleSubmit] - ) + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + void handleSubmit() + } + } return ( - + { + e.preventDefault() + inputRef.current?.focus() + }} + > Create Workspace { const { data: session } = useSession() const userPerms = useUserPermissionsContext() - const [hasLoadedOnce, setHasLoadedOnce] = useState(false) + const hasLoadedOnceRef = useRef(false) - useEffect(() => { - if (!permissionsLoading && !userPerms.isLoading && !isPendingInvitationsLoading) { - setHasLoadedOnce(true) - } - }, [permissionsLoading, userPerms.isLoading, isPendingInvitationsLoading]) + if ( + !hasLoadedOnceRef.current && + !permissionsLoading && + !userPerms.isLoading && + !isPendingInvitationsLoading + ) { + hasLoadedOnceRef.current = true + } + + const hasLoadedOnce = hasLoadedOnceRef.current const existingUsers: UserPermissions[] = useMemo( () => diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 1dc90641221..ae32f81e323 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -591,10 +591,15 @@ export const Sidebar = memo(function Sidebar() { id: 'settings', label: 'Settings', icon: Settings, - onClick: () => navigateToSettings(), + onClick: () => { + if (!isCollapsed) { + setSidebarWidth(SIDEBAR_WIDTH.MIN) + } + navigateToSettings() + }, }, ], - [workspaceId, navigateToSettings] + [workspaceId, navigateToSettings, isCollapsed, setSidebarWidth] ) const { data: fetchedTasks = [], isLoading: tasksLoading } = useTasks(workspaceId) @@ -636,6 +641,16 @@ export const Sidebar = memo(function Sidebar() { setIsTaskDeleteModalOpen(true) }, [tasks]) + const navigateToPage = useCallback( + (path: string) => { + if (!isCollapsed) { + setSidebarWidth(SIDEBAR_WIDTH.MIN) + } + router.push(path) + }, + [isCollapsed, setSidebarWidth, router] + ) + const handleConfirmDeleteTasks = useCallback(() => { const { taskIds: taskIdsToDelete } = contextMenuSelectionRef.current if (taskIdsToDelete.length === 0) return @@ -648,7 +663,7 @@ export const Sidebar = memo(function Sidebar() { const onDeleteSuccess = () => { useFolderStore.getState().clearTaskSelection() if (isViewingDeletedTask) { - router.push(`/workspace/${workspaceId}/home`) + navigateToPage(`/workspace/${workspaceId}/home`) } } @@ -658,7 +673,7 @@ export const Sidebar = memo(function Sidebar() { deleteTasksMutation.mutate(taskIdsToDelete, { onSuccess: onDeleteSuccess }) } setIsTaskDeleteModalOpen(false) - }, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, router]) + }, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, navigateToPage]) const [visibleTaskCount, setVisibleTaskCount] = useState(5) const [renamingTaskId, setRenamingTaskId] = useState(null) @@ -910,7 +925,7 @@ export const Sidebar = memo(function Sidebar() { try { const pathWorkspaceId = resolveWorkspaceIdFromPath() if (pathWorkspaceId) { - router.push(`/workspace/${pathWorkspaceId}/templates`) + navigateToPage(`/workspace/${pathWorkspaceId}/templates`) logger.info('Navigated to templates', { workspaceId: pathWorkspaceId }) } else { logger.warn('No workspace ID found, cannot navigate to templates') @@ -926,7 +941,7 @@ export const Sidebar = memo(function Sidebar() { try { const pathWorkspaceId = resolveWorkspaceIdFromPath() if (pathWorkspaceId) { - router.push(`/workspace/${pathWorkspaceId}/logs`) + navigateToPage(`/workspace/${pathWorkspaceId}/logs`) logger.info('Navigated to logs', { workspaceId: pathWorkspaceId }) } else { logger.warn('No workspace ID found, cannot navigate to logs') @@ -1113,7 +1128,7 @@ export const Sidebar = memo(function Sidebar() { @@ -1131,7 +1146,7 @@ export const Sidebar = memo(function Sidebar() { } hover={tasksHover} - onClick={() => router.push(`/workspace/${workspaceId}/home`)} + onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)} ariaLabel='Tasks' className='mt-[6px]' > diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index 4b922ae8111..e9cbeebd855 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -462,13 +462,25 @@ const Combobox = memo( [disabled, editable, inputRef] ) + const effectiveHighlightedIndex = + highlightedIndex >= 0 && highlightedIndex < filteredOptions.length ? highlightedIndex : -1 + + /** + * Reset highlighted index when filtered options change and index is out of bounds + */ + useEffect(() => { + if (highlightedIndex >= 0 && highlightedIndex >= filteredOptions.length) { + setHighlightedIndex(-1) + } + }, [filteredOptions, highlightedIndex]) + /** * Scroll highlighted option into view */ useEffect(() => { - if (highlightedIndex >= 0 && dropdownRef.current) { + if (effectiveHighlightedIndex >= 0 && dropdownRef.current) { const highlightedElement = dropdownRef.current.querySelector( - `[data-option-index="${highlightedIndex}"]` + `[data-option-index="${effectiveHighlightedIndex}"]` ) if (highlightedElement) { highlightedElement.scrollIntoView({ @@ -477,19 +489,7 @@ const Combobox = memo( }) } } - }, [highlightedIndex]) - - /** - * Adjust highlighted index when filtered options change - */ - useEffect(() => { - setHighlightedIndex((prev) => { - if (prev >= 0 && prev < filteredOptions.length) { - return prev - } - return -1 - }) - }, [filteredOptions]) + }, [effectiveHighlightedIndex]) const SelectedIcon = selectedOption?.icon @@ -713,7 +713,7 @@ const Combobox = memo( const globalIndex = filteredOptions.findIndex( (o) => o.value === option.value ) - const isHighlighted = globalIndex === highlightedIndex + const isHighlighted = globalIndex === effectiveHighlightedIndex const OptionIcon = option.icon return ( @@ -789,7 +789,7 @@ const Combobox = memo( const isSelected = multiSelect ? multiSelectValues?.includes(option.value) : effectiveSelectedValue === option.value - const isHighlighted = index === highlightedIndex + const isHighlighted = index === effectiveHighlightedIndex const OptionIcon = option.icon return ( diff --git a/apps/sim/components/emcn/components/date-picker/date-picker.tsx b/apps/sim/components/emcn/components/date-picker/date-picker.tsx index 67fa14d273e..0a597dec581 100644 --- a/apps/sim/components/emcn/components/date-picker/date-picker.tsx +++ b/apps/sim/components/emcn/components/date-picker/date-picker.tsx @@ -559,12 +559,15 @@ const DatePicker = React.forwardRef((props, ref } }, [open, isRangeMode, initialStart, initialEnd]) - React.useEffect(() => { - if (!isRangeMode && selectedDate) { + const singleValueKey = !isRangeMode && selectedDate ? selectedDate.getTime() : undefined + const [prevSingleValueKey, setPrevSingleValueKey] = React.useState(singleValueKey) + if (singleValueKey !== prevSingleValueKey) { + setPrevSingleValueKey(singleValueKey) + if (selectedDate) { setViewMonth(selectedDate.getMonth()) setViewYear(selectedDate.getFullYear()) } - }, [isRangeMode, selectedDate]) + } /** * Handles selection of a specific day in single mode. diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 561f041a6cf..8702c41a5ac 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -226,6 +226,7 @@ const Popover: React.FC = ({ size = 'md', colorScheme = 'default', open, + onOpenChange, ...props }) => { const [currentFolder, setCurrentFolder] = React.useState(null) @@ -251,21 +252,33 @@ const Popover: React.FC = ({ } }, []) + /** Resets all navigation state to initial values */ + const resetState = React.useCallback(() => { + setCurrentFolder(null) + setFolderTitle(null) + setOnFolderSelect(null) + setSearchQuery('') + setLastHoveredItem(null) + setIsKeyboardNav(false) + setSelectedIndex(-1) + registeredItemsRef.current = [] + }, []) + React.useEffect(() => { - if (open === false) { - setCurrentFolder(null) - setFolderTitle(null) - setOnFolderSelect(null) - setSearchQuery('') - setLastHoveredItem(null) - setIsKeyboardNav(false) - setSelectedIndex(-1) - registeredItemsRef.current = [] - } else { - // Reset hover state when opening to prevent stale submenu from previous menu - setLastHoveredItem(null) + if (!open) { + resetState() } - }, [open]) + }, [open, resetState]) + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + setLastHoveredItem(null) + } + onOpenChange?.(nextOpen) + }, + [onOpenChange] + ) const openFolder = React.useCallback( (id: string, title: string, onLoad?: () => void | Promise, onSelect?: () => void) => { @@ -336,7 +349,7 @@ const Popover: React.FC = ({ return ( - + {children} diff --git a/apps/sim/components/emcn/components/time-picker/time-picker.tsx b/apps/sim/components/emcn/components/time-picker/time-picker.tsx index 1bd45418b15..4bc776b347a 100644 --- a/apps/sim/components/emcn/components/time-picker/time-picker.tsx +++ b/apps/sim/components/emcn/components/time-picker/time-picker.tsx @@ -135,13 +135,15 @@ const TimePicker = React.forwardRef( const [hour, setHour] = React.useState(parsed.hour) const [minute, setMinute] = React.useState(parsed.minute) const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsed.ampm) + const [prevValue, setPrevValue] = React.useState(value) - React.useEffect(() => { + if (value !== prevValue) { + setPrevValue(value) const newParsed = parseTime(value || '') setHour(newParsed.hour) setMinute(newParsed.minute) setAmpm(newParsed.ampm) - }, [value]) + } React.useEffect(() => { if (open) { From 17bdc80eb98560c179782a537355a9089b5aa2eb Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 19 Mar 2026 13:02:03 -0700 Subject: [PATCH 06/11] improvement(platform): added more email validation utils, added integrations page, improved enterprise section, update docs generation script (#3667) * improvement(platform): added more email validation utils, added integrations page, improved enterprise section, update docs generation script * remove unused route * restore hardcoded ff * updated * chore: install soap package types for workday integration * fix(integrations): strip version suffix for template matching, add MX DNS cache * change ff * remove extraneous comments * fix(email): cache timeout results in MX check to prevent repeated 5s waits --- .../collaboration/collaboration.tsx | 2 +- .../components/enterprise/enterprise.tsx | 316 +- .../app/(home)/components/footer/footer.tsx | 7 +- .../navbar/components/blog-dropdown.tsx | 52 +- .../app/(home)/components/navbar/navbar.tsx | 12 +- apps/sim/app/(home)/landing.tsx | 10 +- .../[slug]/components/integration-faq.tsx | 54 + .../components/template-card-button.tsx | 28 + .../(landing)/integrations/[slug]/page.tsx | 761 ++ .../components/integration-card.tsx | 55 + .../components/integration-grid.tsx | 71 + .../components/integration-icon.tsx | 54 + .../integrations/data/icon-mapping.ts | 339 + .../integrations/data/integrations.json | 10718 ++++++++++++++++ .../integrations/data/popular-workflows.ts | 117 + .../app/(landing)/integrations/data/types.ts | 37 + .../app/(landing)/integrations/data/utils.ts | 15 + .../sim/app/(landing)/integrations/layout.tsx | 43 + apps/sim/app/(landing)/integrations/page.tsx | 165 + .../components/template-prompts/consts.ts | 84 + apps/sim/lib/auth/auth.ts | 25 +- .../lib/messaging/email/validation.test.ts | 152 +- apps/sim/lib/messaging/email/validation.ts | 141 +- apps/sim/package.json | 1 + bun.lock | 4 + scripts/generate-docs.ts | 376 + 26 files changed, 13490 insertions(+), 149 deletions(-) create mode 100644 apps/sim/app/(landing)/integrations/[slug]/components/integration-faq.tsx create mode 100644 apps/sim/app/(landing)/integrations/[slug]/components/template-card-button.tsx create mode 100644 apps/sim/app/(landing)/integrations/[slug]/page.tsx create mode 100644 apps/sim/app/(landing)/integrations/components/integration-card.tsx create mode 100644 apps/sim/app/(landing)/integrations/components/integration-grid.tsx create mode 100644 apps/sim/app/(landing)/integrations/components/integration-icon.tsx create mode 100644 apps/sim/app/(landing)/integrations/data/icon-mapping.ts create mode 100644 apps/sim/app/(landing)/integrations/data/integrations.json create mode 100644 apps/sim/app/(landing)/integrations/data/popular-workflows.ts create mode 100644 apps/sim/app/(landing)/integrations/data/types.ts create mode 100644 apps/sim/app/(landing)/integrations/data/utils.ts create mode 100644 apps/sim/app/(landing)/integrations/layout.tsx create mode 100644 apps/sim/app/(landing)/integrations/page.tsx diff --git a/apps/sim/app/(home)/components/collaboration/collaboration.tsx b/apps/sim/app/(home)/components/collaboration/collaboration.tsx index 383779ac835..e9e760a8525 100644 --- a/apps/sim/app/(home)/components/collaboration/collaboration.tsx +++ b/apps/sim/app/(home)/components/collaboration/collaboration.tsx @@ -303,7 +303,7 @@ export default function Collaboration() {
= { @@ -32,7 +33,7 @@ const ACTOR_COLORS: Record = { } /** Left accent bar opacity by recency — newest is brightest. */ -const ACCENT_OPACITIES = [0.75, 0.45, 0.28, 0.15, 0.07] as const +const ACCENT_OPACITIES = [0.75, 0.5, 0.35, 0.22, 0.12, 0.05] as const /** Human-readable label per resource type. */ const RESOURCE_TYPE_LABEL: Record = { @@ -150,7 +151,7 @@ const ENTRY_TEMPLATES: Omit[] = [ { actor: 'Theo L.', description: 'Locked workflow "Customer Sync"', resourceType: 'workflow' }, ] -const INITIAL_OFFSETS_MS = [0, 20_000, 75_000, 240_000, 540_000] +const INITIAL_OFFSETS_MS = [0, 20_000, 75_000, 180_000, 360_000, 600_000] const MARQUEE_KEYFRAMES = ` @keyframes marquee { @@ -191,7 +192,7 @@ function AuditRow({ entry, index }: AuditRowProps) { const resourceLabel = RESOURCE_TYPE_LABEL[entry.resourceType] return ( -
+
{/* Left accent bar — brightness encodes recency */}
) @@ -238,11 +231,11 @@ function AuditRow({ entry, index }: AuditRowProps) { function AuditLogPreview() { const counterRef = useRef(ENTRY_TEMPLATES.length) - const templateIndexRef = useRef(5 % ENTRY_TEMPLATES.length) + const templateIndexRef = useRef(6 % ENTRY_TEMPLATES.length) const now = Date.now() const [entries, setEntries] = useState(() => - ENTRY_TEMPLATES.slice(0, 5).map((t, i) => ({ + ENTRY_TEMPLATES.slice(0, 6).map((t, i) => ({ ...t, id: i, insertedAt: now - INITIAL_OFFSETS_MS[i], @@ -257,7 +250,7 @@ function AuditLogPreview() { setEntries((prev) => [ { ...template, id: counterRef.current++, insertedAt: Date.now() }, - ...prev.slice(0, 4), + ...prev.slice(0, 5), ]) }, 2600) @@ -271,60 +264,217 @@ function AuditLogPreview() { }, []) return ( -
- {/* Header */} -
-
- {/* Pulsing live indicator */} - - - - - - Audit Log - -
-
- - Export - - - Filter - -
+
+ + {entries.map((entry, index) => ( + + + + ))} + +
+ ) +} + +const CHECK_PATH = + 'M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z' + +interface PermissionFeature { + name: string + key: string + defaultEnabled: boolean + providerId?: string +} + +interface PermissionCategory { + label: string + color: string + features: PermissionFeature[] +} + +const PERMISSION_CATEGORIES: PermissionCategory[] = [ + { + label: 'Providers', + color: '#FA4EDF', + features: [ + { key: 'openai', name: 'OpenAI', defaultEnabled: true, providerId: 'openai' }, + { key: 'anthropic', name: 'Anthropic', defaultEnabled: true, providerId: 'anthropic' }, + { key: 'google', name: 'Google', defaultEnabled: false, providerId: 'google' }, + { key: 'xai', name: 'xAI', defaultEnabled: true, providerId: 'xai' }, + ], + }, + { + label: 'Workspace', + color: '#2ABBF8', + features: [ + { key: 'knowledge-base', name: 'Knowledge Base', defaultEnabled: true }, + { key: 'tables', name: 'Tables', defaultEnabled: true }, + { key: 'copilot', name: 'Copilot', defaultEnabled: false }, + { key: 'environment', name: 'Environment', defaultEnabled: false }, + ], + }, + { + label: 'Tools', + color: '#33C482', + features: [ + { key: 'mcp-tools', name: 'MCP Tools', defaultEnabled: true }, + { key: 'custom-tools', name: 'Custom Tools', defaultEnabled: false }, + { key: 'skills', name: 'Skills', defaultEnabled: true }, + { key: 'invitations', name: 'Invitations', defaultEnabled: true }, + ], + }, +] + +const INITIAL_ACCESS_STATE = Object.fromEntries( + PERMISSION_CATEGORIES.flatMap((category) => + category.features.map((feature) => [feature.key, feature.defaultEnabled]) + ) +) + +function CheckboxIcon({ checked, color }: { checked: boolean; color: string }) { + return ( +
+ ) +} + +function ProviderPreviewIcon({ providerId }: { providerId?: string }) { + if (!providerId) return null + + const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon + if (!ProviderIcon) return null + + return ( +
+ +
+ ) +} + +function AccessControlPanel() { + const ref = useRef(null) + const isInView = useInView(ref, { once: true, margin: '-40px' }) + const [accessState, setAccessState] = useState>(INITIAL_ACCESS_STATE) + + const allFeatures = PERMISSION_CATEGORIES.flatMap((c) => c.features) + + return ( +
+
+ {PERMISSION_CATEGORIES.map((category, catIdx) => { + const offsetBefore = PERMISSION_CATEGORIES.slice(0, catIdx).reduce( + (sum, c) => sum + c.features.length, + 0 + ) + + return ( +
0 ? 'mt-4' : ''}> + + {category.label} + +
+ {category.features.map((feature, featIdx) => { + const enabled = accessState[feature.key] + + return ( + + setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] })) + } + whileTap={{ scale: 0.98 }} + > + + + + {feature.name} + + + ) + })} +
+
+ ) + })}
- {/* Log entries — new items push existing ones down */} -
- - {entries.map((entry, index) => ( - - - - ))} - + {/* Desktop — categorized grid */} +
+ {PERMISSION_CATEGORIES.map((category, catIdx) => ( +
0 ? 'mt-4' : ''}> + + {category.label} + +
+ {category.features.map((feature, featIdx) => { + const enabled = accessState[feature.key] + const currentIndex = + PERMISSION_CATEGORIES.slice(0, catIdx).reduce( + (sum, c) => sum + c.features.length, + 0 + ) + featIdx + + return ( + + setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] })) + } + whileTap={{ scale: 0.98 }} + > + + + + {feature.name} + + + ) + })} +
+
+ ))}
) @@ -420,7 +570,37 @@ export default function Enterprise() {
- +
+ {/* Audit Trail */} +
+
+

+ Audit Trail +

+

+ Every action is captured with full actor attribution. +

+
+ +
+
+ + {/* Access Control */} +
+
+

+ Access Control +

+

+ Restrict providers, surfaces, and tools per group. +

+
+
+ +
+
+
+ {/* Scrolling feature ticker */} diff --git a/apps/sim/app/(home)/components/footer/footer.tsx b/apps/sim/app/(home)/components/footer/footer.tsx index adde492497f..2e62fb42ff6 100644 --- a/apps/sim/app/(home)/components/footer/footer.tsx +++ b/apps/sim/app/(home)/components/footer/footer.tsx @@ -11,15 +11,19 @@ interface FooterItem { } const PRODUCT_LINKS: FooterItem[] = [ - { label: 'Pricing', href: '#pricing' }, + { label: 'Pricing', href: '/#pricing' }, { label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true }, { label: 'Self Hosting', href: 'https://docs.sim.ai/self-hosting', external: true }, { label: 'MCP', href: 'https://docs.sim.ai/mcp', external: true }, + { label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true }, + { label: 'Tables', href: 'https://docs.sim.ai/tables', external: true }, + { label: 'API', href: 'https://docs.sim.ai/api-reference/getting-started', external: true }, { label: 'Status', href: 'https://status.sim.ai', external: true }, ] const RESOURCES_LINKS: FooterItem[] = [ { label: 'Blog', href: '/blog' }, + { label: 'Templates', href: '/templates' }, { label: 'Docs', href: 'https://docs.sim.ai', external: true }, { label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true }, { label: 'Changelog', href: '/changelog' }, @@ -39,6 +43,7 @@ const BLOCK_LINKS: FooterItem[] = [ ] const INTEGRATION_LINKS: FooterItem[] = [ + { label: 'All Integrations →', href: '/integrations' }, { 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 }, diff --git a/apps/sim/app/(home)/components/navbar/components/blog-dropdown.tsx b/apps/sim/app/(home)/components/navbar/components/blog-dropdown.tsx index ed7f1fabbb3..068a1c3a2f6 100644 --- a/apps/sim/app/(home)/components/navbar/components/blog-dropdown.tsx +++ b/apps/sim/app/(home)/components/navbar/components/blog-dropdown.tsx @@ -1,23 +1,11 @@ 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 +export interface NavBlogPost { + slug: string + title: string + ogImage: string +} function BlogCard({ slug, @@ -63,34 +51,32 @@ function BlogCard({ ) } -export function BlogDropdown() { +interface BlogDropdownProps { + posts: NavBlogPost[] +} + +export function BlogDropdown({ posts }: BlogDropdownProps) { + const [featured, ...rest] = posts + + if (!featured) return null + return (
- {POSTS.slice(0, 2).map((post) => ( - - ))} - - {POSTS.slice(2).map((post) => ( + {rest.map((post) => ( diff --git a/apps/sim/app/(home)/components/navbar/navbar.tsx b/apps/sim/app/(home)/components/navbar/navbar.tsx index 17337b6e73f..35cea4f68a7 100644 --- a/apps/sim/app/(home)/components/navbar/navbar.tsx +++ b/apps/sim/app/(home)/components/navbar/navbar.tsx @@ -5,7 +5,10 @@ import Image from 'next/image' import Link from 'next/link' import { GithubOutlineIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' -import { BlogDropdown } from '@/app/(home)/components/navbar/components/blog-dropdown' +import { + BlogDropdown, + type NavBlogPost, +} from '@/app/(home)/components/navbar/components/blog-dropdown' import { DocsDropdown } from '@/app/(home)/components/navbar/components/docs-dropdown' import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars' import { getBrandConfig } from '@/ee/whitelabeling' @@ -23,7 +26,7 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: 'Docs', href: 'https://docs.sim.ai', external: true, icon: 'chevron', dropdown: 'docs' }, { label: 'Blog', href: '/blog', icon: 'chevron', dropdown: 'blog' }, - { label: 'Pricing', href: '#pricing' }, + { label: 'Pricing', href: '/#pricing' }, { label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true }, ] @@ -32,9 +35,10 @@ const LINK_CELL = 'flex items-center px-[14px]' interface NavbarProps { logoOnly?: boolean + blogPosts?: NavBlogPost[] } -export default function Navbar({ logoOnly = false }: NavbarProps) { +export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) { const brand = getBrandConfig() const [activeDropdown, setActiveDropdown] = useState(null) const [hoveredLink, setHoveredLink] = useState(null) @@ -161,7 +165,7 @@ export default function Navbar({ logoOnly = false }: NavbarProps) { }} > {dropdown === 'docs' && } - {dropdown === 'blog' && } + {dropdown === 'blog' && }
) diff --git a/apps/sim/app/(home)/landing.tsx b/apps/sim/app/(home)/landing.tsx index 46552691b2e..dc676efa4fe 100644 --- a/apps/sim/app/(home)/landing.tsx +++ b/apps/sim/app/(home)/landing.tsx @@ -1,3 +1,4 @@ +import { getAllPostMeta } from '@/lib/blog/registry' import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono' import { season } from '@/app/_styles/fonts/season/season' import { @@ -32,11 +33,18 @@ import { * enterprise (Enterprise) -> pricing (Pricing) -> testimonials (Testimonials). */ export default async function Landing() { + const allPosts = await getAllPostMeta() + const featuredPost = allPosts.find((p) => p.featured) ?? allPosts[0] + const recentPosts = allPosts.filter((p) => p !== featuredPost).slice(0, 4) + const blogPosts = [featuredPost, ...recentPosts] + .filter(Boolean) + .map((p) => ({ slug: p.slug, title: p.title, ogImage: p.ogImage })) + return (
- +
diff --git a/apps/sim/app/(landing)/integrations/[slug]/components/integration-faq.tsx b/apps/sim/app/(landing)/integrations/[slug]/components/integration-faq.tsx new file mode 100644 index 00000000000..c829d33fb03 --- /dev/null +++ b/apps/sim/app/(landing)/integrations/[slug]/components/integration-faq.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useState } from 'react' +import { ChevronDown } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { FAQItem } from '@/app/(landing)/integrations/data/types' + +interface IntegrationFAQProps { + faqs: FAQItem[] +} + +export function IntegrationFAQ({ faqs }: IntegrationFAQProps) { + const [openIndex, setOpenIndex] = useState(0) + + return ( +
+ {faqs.map(({ question, answer }, index) => { + const isOpen = openIndex === index + return ( +
+ + + {isOpen && ( +
+

{answer}

+
+ )} +
+ ) + })} +
+ ) +} diff --git a/apps/sim/app/(landing)/integrations/[slug]/components/template-card-button.tsx b/apps/sim/app/(landing)/integrations/[slug]/components/template-card-button.tsx new file mode 100644 index 00000000000..a42a3728b62 --- /dev/null +++ b/apps/sim/app/(landing)/integrations/[slug]/components/template-card-button.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' + +interface TemplateCardButtonProps { + prompt: string + children: React.ReactNode +} + +export function TemplateCardButton({ prompt, children }: TemplateCardButtonProps) { + const router = useRouter() + + function handleClick() { + LandingPromptStorage.store(prompt) + router.push('/signup') + } + + return ( + + ) +} diff --git a/apps/sim/app/(landing)/integrations/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/[slug]/page.tsx new file mode 100644 index 00000000000..693e0fee68d --- /dev/null +++ b/apps/sim/app/(landing)/integrations/[slug]/page.tsx @@ -0,0 +1,761 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { TEMPLATES } from '@/app/workspace/[workspaceId]/home/components/template-prompts/consts' +import { IntegrationIcon } from '../components/integration-icon' +import { blockTypeToIconMap } from '../data/icon-mapping' +import integrations from '../data/integrations.json' +import { POPULAR_WORKFLOWS } from '../data/popular-workflows' +import type { AuthType, FAQItem, Integration } from '../data/types' +import { IntegrationFAQ } from './components/integration-faq' +import { TemplateCardButton } from './components/template-card-button' + +const allIntegrations = integrations as Integration[] +const INTEGRATION_COUNT = allIntegrations.length + +/** Fast O(1) lookups — avoids repeated linear scans inside render loops. */ +const byName = new Map(allIntegrations.map((i) => [i.name, i])) +const bySlug = new Map(allIntegrations.map((i) => [i.slug, i])) +const byType = new Map(allIntegrations.map((i) => [i.type, i])) + +/** Returns workflow pairs that feature the given integration on either side. */ +function getPairsFor(name: string) { + return POPULAR_WORKFLOWS.filter((p) => p.from === name || p.to === name) +} + +/** + * Returns up to `limit` related integration slugs. + * + * Scoring: + * +100 — integration appears as a workflow pair partner (explicit editorial signal) + * +N — N operation names shared with the current integration (semantic similarity) + * + * This means genuine partners always rank first; operation-similar integrations + * (e.g. Slack → Teams → Discord for "Send Message") fill the rest organically. + */ +function getRelatedSlugs( + name: string, + slug: string, + operations: Integration['operations'], + limit = 6 +): string[] { + const partners = new Set(getPairsFor(name).map((p) => (p.from === name ? p.to : p.from))) + const currentOps = new Set(operations.map((o) => o.name.toLowerCase())) + + return allIntegrations + .filter((i) => i.slug !== slug) + .map((i) => ({ + slug: i.slug, + score: + (partners.has(i.name) ? 100 : 0) + + i.operations.filter((o) => currentOps.has(o.name.toLowerCase())).length, + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ slug: s }) => s) +} + +const AUTH_STEP: Record = { + oauth: 'Authenticate with one-click OAuth — no credentials to copy-paste.', + 'api-key': 'Add your API key to authenticate — find it in your account settings.', + none: 'Authenticate your account to connect.', +} + +/** + * Generates targeted FAQs from integration metadata. + * Questions mirror real search queries to drive FAQPage rich snippets. + */ +function buildFAQs(integration: Integration): FAQItem[] { + const { name, description, operations, triggers, authType } = integration + const topOps = operations.slice(0, 5) + const topOpNames = topOps.map((o) => o.name) + const pairs = getPairsFor(name) + const authStep = AUTH_STEP[authType] + + const faqs: FAQItem[] = [ + { + question: `What is Sim's ${name} integration?`, + answer: `Sim's ${name} integration lets you build AI-powered workflows that automate tasks in ${name} without writing code. ${description} You can connect ${name} to hundreds of other services in the same workflow — from CRMs and spreadsheets to messaging tools and databases.`, + }, + { + question: `What can I automate with ${name} in Sim?`, + answer: + topOpNames.length > 0 + ? `With Sim you can: ${topOpNames.join('; ')}${operations.length > 5 ? `; and ${operations.length - 5} more tools` : ''}. Each action runs inside an AI agent block, so you can combine ${name} with LLM reasoning, conditional logic, and data from any other connected service.` + : `Sim lets you automate ${name} workflows by connecting it to an AI agent that can read from it, write to it, and chain it together with other services — all driven by natural-language instructions instead of rigid rules.`, + }, + { + question: `How do I connect ${name} to Sim?`, + answer: `Getting started takes under five minutes: (1) Create a free account at sim.ai. (2) Open a new workflow. (3) Drag a ${name} block onto the canvas. (4) ${authStep} (5) Choose the tool you want to use, wire it to the inputs you need, and click Run. Your automation is live.`, + }, + ...(topOpNames.length >= 2 + ? [ + { + question: `How do I ${topOpNames[0].toLowerCase()} with ${name} in Sim?`, + answer: `Add a ${name} block to your workflow and select "${topOpNames[0]}" as the tool. Fill in the required fields — you can reference outputs from earlier steps, such as text generated by an AI agent or data fetched from another integration. No code is required.`, + }, + ] + : []), + ...(pairs.length > 0 + ? [ + { + question: `Can I connect ${name} to ${pairs[0].from === name ? pairs[0].to : pairs[0].from} with Sim?`, + answer: `Yes. ${pairs[0].description} In Sim, you set this up by adding both a ${name} block and a ${pairs[0].from === name ? pairs[0].to : pairs[0].from} block to the same workflow and connecting them through an AI agent that orchestrates the logic between them.`, + }, + ] + : []), + ...(triggers.length > 0 + ? [ + { + question: `Can ${name} trigger a Sim workflow automatically?`, + answer: `Yes. ${name} supports ${triggers.length} webhook trigger${triggers.length === 1 ? '' : 's'} that can instantly start a Sim workflow: ${triggers.map((t) => t.name).join(', ')}. No polling needed — the workflow fires the moment the event occurs in ${name}.`, + }, + ] + : []), + { + question: `What ${name} tools does Sim support?`, + answer: + operations.length > 0 + ? `Sim supports ${operations.length} ${name} tool${operations.length === 1 ? '' : 's'}: ${operations.map((o) => o.name).join(', ')}.` + : `Sim supports core ${name} tools for reading and writing data, triggering actions, and integrating with your other services. See the full list in the Sim documentation.`, + }, + { + question: `Is the ${name} integration free to use?`, + answer: `Yes — Sim's free plan includes access to the ${name} integration and every other integration in the library. No credit card is needed to get started. Visit sim.ai to create your account.`, + }, + ] + + return faqs +} + +export async function generateStaticParams() { + return allIntegrations.map((i) => ({ slug: i.slug })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }> +}): Promise { + const { slug } = await params + const integration = bySlug.get(slug) + if (!integration) return {} + + const { name, description, operations } = integration + const opSample = operations + .slice(0, 3) + .map((o) => o.name) + .join(', ') + const metaDesc = `Automate ${name} with AI-powered workflows on Sim. ${description.slice(0, 100).trimEnd()}. Free to start.` + + return { + title: `${name} Integration`, + description: metaDesc, + keywords: [ + `${name} automation`, + `${name} integration`, + `automate ${name}`, + `connect ${name}`, + `${name} workflow`, + `${name} AI automation`, + ...(opSample ? [`${name} ${opSample}`] : []), + 'workflow automation', + 'no-code automation', + 'AI agent workflow', + ], + openGraph: { + title: `${name} Integration — AI Workflow Automation | Sim`, + description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${description.slice(0, 100).trimEnd()}.`, + url: `https://sim.ai/integrations/${slug}`, + type: 'website', + images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image', + title: `${name} Integration | Sim`, + description: `Automate ${name} with AI-powered workflows. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`, + }, + alternates: { canonical: `https://sim.ai/integrations/${slug}` }, + } +} + +export default async function IntegrationPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const integration = bySlug.get(slug) + if (!integration) notFound() + + const { name, description, longDescription, bgColor, docsUrl, operations, triggers, authType } = + integration + + const IconComponent = blockTypeToIconMap[integration.type] + const faqs = buildFAQs(integration) + const relatedSlugs = getRelatedSlugs(name, slug, operations) + const relatedIntegrations = relatedSlugs + .map((s) => bySlug.get(s)) + .filter((i): i is Integration => i !== undefined) + const featuredPairs = getPairsFor(name) + const baseType = integration.type.replace(/_v\d+$/, '') + const matchingTemplates = TEMPLATES.filter( + (t) => + t.integrationBlockTypes.includes(integration.type) || + t.integrationBlockTypes.includes(baseType) + ) + + const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' }, + { + '@type': 'ListItem', + position: 2, + name: 'Integrations', + item: 'https://sim.ai/integrations', + }, + { '@type': 'ListItem', position: 3, name, item: `https://sim.ai/integrations/${slug}` }, + ], + } + + const softwareAppJsonLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: `${name} Integration`, + description, + url: `https://sim.ai/integrations/${slug}`, + applicationCategory: 'BusinessApplication', + operatingSystem: 'Web', + featureList: operations.map((o) => o.name), + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + } + + const howToJsonLd = { + '@context': 'https://schema.org', + '@type': 'HowTo', + name: `How to automate ${name} with Sim`, + description: `Step-by-step guide to connecting ${name} to AI-powered workflows in Sim.`, + step: [ + { + '@type': 'HowToStep', + position: 1, + name: 'Create a free Sim account', + text: 'Sign up at sim.ai — no credit card required.', + }, + { + '@type': 'HowToStep', + position: 2, + name: `Add a ${name} block`, + text: `Open a workflow, drag a ${name} block onto the canvas, and authenticate with your ${name} credentials.`, + }, + { + '@type': 'HowToStep', + position: 3, + name: 'Configure and run', + text: `Choose the operation you want, connect it to an AI agent, and run your workflow. Automate anything in ${name} without code.`, + }, + ], + } + + const faqJsonLd = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: faqs.map(({ question, answer }) => ({ + '@type': 'Question', + name: question, + acceptedAnswer: { '@type': 'Answer', text: answer }, + })), + } + + return ( + <> +