Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/sim/app/account/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
parseSettingsPathSection,
} from '@/components/settings/navigation'
import { getSession } from '@/lib/auth'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { isPlatformAdmin } from '@/lib/permissions/super-user'

interface AccountSettingsSectionPageProps {
Expand Down Expand Up @@ -46,6 +46,7 @@ export default async function AccountSettingsSectionPage({
})
if (!parsed) notFound()
if (parsed === 'billing' && !isBillingEnabled) redirect(getAccountSettingsHref('general'))
if (parsed === 'copilot' && !isHosted) redirect(getAccountSettingsHref('general'))
if (parsed === 'admin' || parsed === 'mothership') {
const isSuperUser = await isPlatformAdmin(session.user.id)
if (!isSuperUser) notFound()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,13 @@ export function ToolCallItem({
return (
<div className='flex items-center gap-[6px] pl-6'>
{BlockIcon && (
<BlockIcon className='size-[14px] flex-shrink-0' style={getBareIconStyle(BlockIcon)} />
// Size via inline style: a custom block's image icon carries a trailing
// `size-full` that defeats size *classes* (it fills tiled surfaces), so a
// class-only size renders the uploaded icon at natural size here.
<BlockIcon
className='size-[14px] flex-shrink-0'
style={{ width: 14, height: 14, ...getBareIconStyle(BlockIcon) }}
/>
)}
{isExecuting ? (
<ShimmerText className='text-[13px] [--shimmer-rest:var(--text-secondary)]'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function SearchModal({
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const currentWorkflowId = params.workflowId as string | undefined
const inputRef = useRef<HTMLInputElement>(null)
const [mounted, setMounted] = useState(false)
const { navigateToSettings } = useSettingsNavigation()
Expand Down Expand Up @@ -581,23 +582,25 @@ export function SearchModal({
*/
const filteredBlocks = useMemo(() => {
if (!isOnWorkflowPage) return []
// A custom block is hidden on its own source workflow's canvas — placing it
// there recurses (same exclusion as the toolbar).
return filterAndCap(
blocks,
blocks.filter((b) => !b.sourceWorkflowId || b.sourceWorkflowId !== currentWorkflowId),
(b) => b.name,
deferredSearch,
(b) => b.searchValue
)
}, [isOnWorkflowPage, blocks, deferredSearch])
}, [isOnWorkflowPage, blocks, deferredSearch, currentWorkflowId])

const filteredTools = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(
tools,
tools.filter((t) => !t.sourceWorkflowId || t.sourceWorkflowId !== currentWorkflowId),
(t) => t.name,
deferredSearch,
(t) => t.searchValue
)
}, [isOnWorkflowPage, tools, deferredSearch])
}, [isOnWorkflowPage, tools, deferredSearch, currentWorkflowId])

const filteredTriggers = useMemo(() => {
if (!isOnWorkflowPage) return []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChipConfirmModal, chipVariants, cn } from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
Expand Down Expand Up @@ -120,6 +121,15 @@ export function SettingsSidebar({
}

if (item.selfHostedOverride && !isHosted) {
/**
* Org-plane sections route through the organization gate in
* `settings/[section]/page.tsx` (host organization + org-admin viewer),
* which 404s other viewers — mirror it here so the item never links to
* a dead page.
*/
if (ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id) && !isOrgAdminOrOwner) {
return false
}
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { memo, useEffect, useRef, useState } from 'react'
import { memo, type ReactElement, useEffect, useRef, useState } from 'react'
import {
ChevronDown,
Chip,
Expand All @@ -14,6 +14,7 @@ import {
Plus,
Send,
Skeleton,
Tooltip,
} from '@sim/emcn'
import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
Expand All @@ -33,6 +34,27 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'

const logger = createLogger('WorkspaceHeader')

interface DisabledReasonTooltipProps {
reason: string | null
children: ReactElement
}

/**
* Wraps a menu item in a tooltip explaining why the action is unavailable.
* Renders the child as-is when there is no reason to show.
*/
function DisabledReasonTooltip({ reason, children }: DisabledReasonTooltipProps) {
if (!reason) return children
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Content>
<p>{reason}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}

interface WorkspaceHeaderProps {
/** The active workspace object */
activeWorkspace?: { name: string } | null
Expand Down Expand Up @@ -548,62 +570,67 @@ function WorkspaceHeaderImpl({
<DropdownMenuSeparator className='mx-0' />

<div className='flex flex-col gap-0.5'>
<DisabledReasonTooltip reason={createWorkspaceDisabledReason}>
<Chip
leftIcon={Plus}
onClick={(e) => {
e.stopPropagation()
if (!canCreateWorkspace) return
setIsWorkspaceMenuOpen(false)
setIsCreateModalOpen(true)
}}
disabled={isCreatingWorkspace}
aria-disabled={!canCreateWorkspace || undefined}
fullWidth
flush
className={cn(
'select-none',
!canCreateWorkspace &&
'cursor-not-allowed opacity-60 hover-hover:bg-transparent'
)}
>
New workspace
</Chip>
</DisabledReasonTooltip>
</div>

<DropdownMenuSeparator className='mx-0' />
<DisabledReasonTooltip reason={inviteDisabledReason}>
<Chip
leftIcon={Plus}
onClick={(e) => {
e.stopPropagation()
leftIcon={Send}
onClick={() => {
setIsWorkspaceMenuOpen(false)
if (!canCreateWorkspace) {
if (isInvitationsDisabled) {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
return
}
setIsCreateModalOpen(true)
setIsInviteModalOpen(true)
}}
disabled={isCreatingWorkspace}
title={createWorkspaceDisabledReason ?? undefined}
fullWidth
flush
className='w-full select-none disabled:pointer-events-none disabled:opacity-50'
className='select-none'
>
New workspace
Invite teammates
</Chip>
</div>

<DropdownMenuSeparator className='mx-0' />
<Chip
leftIcon={Send}
onClick={() => {
setIsWorkspaceMenuOpen(false)
if (isInvitationsDisabled) {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
return
}
setIsInviteModalOpen(true)
}}
title={inviteDisabledReason ?? undefined}
fullWidth
flush
className='w-full select-none'
>
Invite teammates
</Chip>
<Chip
leftIcon={ManageWorkspace}
onClick={() => {
setIsWorkspaceMenuOpen(false)
if (isInvitationsDisabled) {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
return
}
navigateToSettings({ section: 'teammates' })
}}
title={inviteDisabledReason ?? undefined}
fullWidth
flush
className='w-full select-none'
>
Manage workspace
</Chip>
</DisabledReasonTooltip>
<DisabledReasonTooltip reason={inviteDisabledReason}>
<Chip
leftIcon={ManageWorkspace}
onClick={() => {
setIsWorkspaceMenuOpen(false)
if (isInvitationsDisabled) {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
return
}
navigateToSettings({ section: 'teammates' })
}}
fullWidth
flush
className='select-none'
>
Manage workspace
</Chip>
</DisabledReasonTooltip>
</>
)}
</DropdownMenuContent>
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/blocks/custom/build-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CUSTOM_BLOCK_TILE_COLOR,
type CustomBlockRow,
isCustomBlockType,
isReservedOutputName,
} from '@/blocks/custom/build-config'
import type { BlockIcon } from '@/blocks/types'

Expand All @@ -33,6 +34,18 @@ describe('isCustomBlockType', () => {
})
})

describe('isReservedOutputName', () => {
it('rejects the system output fields case-insensitively', () => {
expect(isReservedOutputName('cost')).toBe(true)
expect(isReservedOutputName('Cost')).toBe(true)
expect(isReservedOutputName(' success ')).toBe(true)
expect(isReservedOutputName('error')).toBe(true)
expect(isReservedOutputName('result')).toBe(false)
expect(isReservedOutputName('cost_2')).toBe(false)
expect(isReservedOutputName('summary')).toBe(false)
})
})

describe('buildCustomBlockConfig', () => {
const fields: WorkflowInputField[] = [
{ name: 'title', type: 'string' },
Expand All @@ -47,6 +60,7 @@ describe('buildCustomBlockConfig', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
expect(config.type).toBe('custom_block_abc123')
expect(config.name).toBe('Invoice Parser')
expect(config.sourceWorkflowId).toBe('wf-1')
expect(config.category).toBe('tools')
expect(config.bgColor).toBe(CUSTOM_BLOCK_TILE_COLOR)
expect(config.hideFromToolbar).toBeUndefined()
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/blocks/custom/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ export const RESERVED_PARAMS = new Set([
'advancedMode',
])

/**
* Output names the block projects itself (`success`/`error` from `buildOutputs`,
* `cost` from the executor's billing aggregation). A user-named exposed output
* must never shadow these — an output literally named `cost` would clobber the
* billed cost. `result` is deliberately NOT reserved: it only exists as a system
* field when no outputs are curated, which cannot co-occur with a named output.
*/
export const RESERVED_OUTPUT_NAMES = new Set(['success', 'error', 'cost'])

/** Whether an exposed-output name collides with a system output field. */
export function isReservedOutputName(name: string): boolean {
return RESERVED_OUTPUT_NAMES.has(name.trim().toLowerCase())
}

/** Map a Start input field type to the editor sub-block type used to collect it. */
function subBlockTypeForField(fieldType: string): SubBlockType {
switch (fieldType) {
Expand Down Expand Up @@ -122,6 +136,7 @@ export function buildCustomBlockConfig(
type: row.type,
name: row.name,
description: row.description,
sourceWorkflowId: row.workflowId,
category: 'tools',
longDescription:
'A published workflow packaged as a reusable, self-contained block. Fill its input ' +
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,12 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
}
}
hideFromToolbar?: boolean
/**
* For published custom blocks only: the bound source workflow's id. Discovery
* surfaces use it to hide a workflow's own block on that workflow's canvas
* (placing it would recurse).
*/
sourceWorkflowId?: string
/**
* Marks an unreleased block. Preview blocks are hidden from every discovery
* surface (toolbar, search, mentions, copilot/VFS, docs) in every environment —
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ function getProviderFromStore(model: string): string | null {
return null
}

/**
* Whether an Ollama instance is available. `isOllamaConfigured` reads the
* server-only `OLLAMA_URL` env var, which is always undefined in the browser —
* there the providers store (populated from the server's model list, which is
* non-empty only when Ollama is configured) is the signal.
*/
function isOllamaAvailable(): boolean {
if (isOllamaConfigured) return true
return useProvidersStore.getState().providers.ollama.models.length > 0
}

function buildModelVisibilityCondition(model: string, shouldShow: boolean) {
if (!model) {
return { field: 'model', value: '__no_model_selected__' }
Expand Down Expand Up @@ -197,7 +208,7 @@ function shouldRequireApiKeyForModel(model: string): boolean {
return false
if (storeProvider) return true

if (isOllamaConfigured) {
if (isOllamaAvailable()) {
if (normalizedModel.includes('/')) return true
if (normalizedModel in getBaseModelProviders()) return true
return false
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/components/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getOrganizationSettingsHref,
getWorkspaceSettingsHref,
isOrganizationSettingsSectionAvailable,
ORGANIZATION_PLANE_UNIFIED_SECTIONS,
ORGANIZATION_SETTINGS_ITEMS,
ORGANIZATION_SETTINGS_PATH_ALIASES,
parseSettingsPathSection,
Expand Down Expand Up @@ -109,6 +110,19 @@ describe('settings navigation boundaries', () => {
expect([...workspaceIds].sort()).toEqual(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).sort())
})

it('derives the organization-plane unified sections from the registry', () => {
expect([...ORGANIZATION_PLANE_UNIFIED_SECTIONS].sort()).toEqual([
'access-control',
'audit-logs',
'billing',
'data-drains',
'data-retention',
'organization',
'sso',
'whitelabeling',
])
})

it('shares labels, icons, and docs links across projections', () => {
const unifiedSso = buildUnifiedSettingsNavigation().find(({ id }) => id === 'sso')
const organizationSso = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'sso')
Expand Down
Loading
Loading