From 3462583aca560928a634edc21b4c040630f35e2c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 28 Sep 2026 19:39:05 -0700 Subject: [PATCH 1/2] fix(ui): clean up stale workflow state and provider policy handling --- .../components/chat/chat.test.tsx | 125 ++++++ .../w/[workflowId]/components/chat/chat.tsx | 32 +- .../components/user-input/constants.ts | 225 ----------- .../components/user-input/hooks/index.ts | 1 - .../user-input/hooks/use-mention-data.ts | 365 ------------------ .../user-input/hooks/use-mention-menu.ts | 3 +- .../copilot/components/user-input/utils.ts | 30 -- .../components/block/block.test.tsx | 87 +++++ .../components/block/block.tsx | 7 + .../components/group-detail.test.tsx | 161 ++++++++ .../components/group-detail.tsx | 115 +++--- apps/sim/hooks/queries/allowed-providers.ts | 18 +- .../src/mocks/terminal-console.mock.ts | 8 + scripts/check-application-graph.test.ts | 12 +- 14 files changed, 474 insertions(+), 715 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.test.tsx create mode 100644 apps/sim/ee/access-control/components/group-detail.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx new file mode 100644 index 00000000000..4e1fea54a41 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx @@ -0,0 +1,125 @@ +/** @vitest-environment jsdom */ + +import { act } from 'react' +import { authClientMock } from '@sim/testing/mocks/auth-client.mock' +import { providersModelsMock } from '@sim/testing/mocks/providers-models.mock' +import { providersUtilsMock } from '@sim/testing/mocks/providers-utils.mock' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat' +import { useChatStore } from '@/stores/chat/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +vi.mock('@/lib/auth/auth-client', () => authClientMock) +vi.mock('@/providers/models', () => providersModelsMock) +vi.mock('@/providers/utils', () => providersUtilsMock) +vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution', () => ({ + useWorkflowExecution: () => ({ handleRunWorkflow: vi.fn(), handleCancelExecution: vi.fn() }), + isChatWorkflowRunResult: () => false, + WorkflowAttachmentUploadError: class extends Error {}, +})) +vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components', () => ({ + ChatMessage: () => null, + OutputSelect: () => null, +})) +vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float', () => ({ + useFloatDrag: () => ({}), + useFloatBoundarySync: () => {}, + useFloatResize: () => ({}), +})) + +let root: Root +let container: HTMLDivElement + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: vi.fn() }) + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + useChatStore.setState({ ...useChatStore.getInitialState(), isChatOpen: true }) + useWorkflowRegistry.setState({ activeWorkflowId: 'workflow-a' }) + useChatStore.getState().addMessage({ workflowId: 'workflow-a', type: 'user', content: 'first' }) + useChatStore.getState().addMessage({ workflowId: 'workflow-a', type: 'user', content: 'second' }) + useChatStore.getState().addMessage({ + workflowId: 'workflow-a', + type: 'workflow', + content: 'partial', + isStreaming: true, + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root.render()) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + useChatStore.setState(useChatStore.getInitialState()) + useWorkflowRegistry.setState({ activeWorkflowId: null }) + Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo') + vi.useRealTimers() +}) + +function input() { + const element = container.querySelector( + 'input[placeholder="Type a message..."]' + ) + if (!element) throw new Error('Chat composer missing') + return element +} + +function press(key: string) { + act(() => { + input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + }) +} + +describe('floating chat prompt history', () => { + it('keeps the history cursor while assistant output streams and finalizes', () => { + press('ArrowUp') + expect(input().value).toBe('second') + const responseId = useChatStore.getState().messages[2].id + act(() => useChatStore.getState().setMessageContent(responseId, 'next chunk')) + press('ArrowUp') + expect(input().value).toBe('first') + act(() => useChatStore.getState().finalizeMessageStream(responseId)) + press('ArrowDown') + expect(input().value).toBe('second') + press('ArrowDown') + expect(input().value).toBe('') + }) + + it('resets navigation when the workflow changes even with identical prompt history', () => { + act(() => { + useChatStore + .getState() + .addMessage({ workflowId: 'workflow-b', type: 'user', content: 'first' }) + useChatStore + .getState() + .addMessage({ workflowId: 'workflow-b', type: 'user', content: 'second' }) + }) + press('ArrowUp') + expect(input().value).toBe('second') + act(() => useWorkflowRegistry.setState({ activeWorkflowId: 'workflow-b' })) + press('ArrowUp') + expect(input().value).toBe('second') + act(() => + useChatStore + .getState() + .addMessage({ workflowId: 'workflow-b', type: 'user', content: 'newest' }) + ) + press('ArrowUp') + expect(input().value).toBe('newest') + act(() => useChatStore.getState().clearChat('workflow-b')) + press('ArrowDown') + expect(input().value).toBe('newest') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 2dff953adcd..8dbf0230432 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -267,6 +267,17 @@ export function Chat() { })) ) + const promptHistory = useChatStore( + useShallow((state) => + !activeWorkflowId + ? [] + : state.messages + .filter((message) => message.workflowId === activeWorkflowId && message.type === 'user') + .map((message) => message.content) + .filter((content): content is string => typeof content === 'string') + ) + ) + const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated) const entries = useWorkflowConsoleEntries( hasConsoleHydrated && typeof activeWorkflowId === 'string' ? activeWorkflowId : undefined @@ -277,7 +288,6 @@ export function Chat() { const { addToQueue } = useOperationQueue() const [chatMessage, setChatMessage] = useState('') - const [promptHistory, setPromptHistory] = useState([]) const [historyIndex, setHistoryIndex] = useState(-1) const [moreMenuOpen, setMoreMenuOpen] = useState(false) @@ -428,23 +438,9 @@ export function Chat() { } ) - const userMessages = useMemo(() => { - return workflowMessages - .filter((msg) => msg.type === 'user') - .map((msg) => msg.content) - .filter((content): content is string => typeof content === 'string') - }, [workflowMessages]) - useEffect(() => { - if (!activeWorkflowId) { - setPromptHistory([]) - setHistoryIndex(-1) - return - } - - setPromptHistory(userMessages) setHistoryIndex(-1) - }, [activeWorkflowId, userMessages]) + }, [activeWorkflowId, promptHistory]) /** * Auto-scroll to bottom when messages load and chat is open @@ -724,9 +720,6 @@ export function Chat() { } const messageAttachments = toChatMessageAttachments(result.uploadedAttachments) - if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) { - setPromptHistory((prev) => [...prev, sentMessage]) - } setHistoryIndex(-1) const messageContent = @@ -759,7 +752,6 @@ export function Chat() { chatFiles, activeWorkflowId, isExecuting, - promptHistory, getConversationId, addMessage, handleRunWorkflow, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts deleted file mode 100644 index 85acddb8a22..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { ChatContext } from '@/stores/panel' - -/** - * Mention folder types - */ -export type MentionFolderId = - | 'chats' - | 'workflows' - | 'knowledge' - | 'blocks' - | 'workflow-blocks' - | 'logs' - | 'integrations' - -/** - * Menu item category types for mention menu (includes folders + docs item) - */ -export type MentionCategory = MentionFolderId | 'docs' - -/** - * Configuration interface for folder types - */ -export interface FolderConfig { - /** Display title in menu */ - title: string - /** Data source key in useMentionData return */ - dataKey: string - /** Loading state key in useMentionData return */ - loadingKey: string - /** Ensure loaded function key in useMentionData return (optional - some folders auto-load) */ - ensureLoadedKey?: string - /** Extract label from an item */ - getLabel: (item: TItem) => string - /** Extract unique ID from an item */ - getId: (item: TItem) => string - /** Empty state message */ - emptyMessage: string - /** No match message (when filtering) */ - noMatchMessage: string - /** Filter function for matching query */ - filterFn: (item: TItem, query: string) => boolean - /** Build the ChatContext object from an item */ - buildContext: (item: TItem, workflowId?: string | null) => ChatContext - /** Whether to use insertAtCursor fallback when replaceActiveMentionWith fails */ - useInsertFallback?: boolean -} - -/** - * Configuration for all folder types in the mention menu - */ -export const FOLDER_CONFIGS: Record = { - chats: { - title: 'Chats', - dataKey: 'pastChats', - loadingKey: 'isLoadingPastChats', - ensureLoadedKey: 'ensurePastChatsLoaded', - getLabel: (item) => item.title || 'New Chat', - getId: (item) => item.id, - emptyMessage: 'No past chats', - noMatchMessage: 'No matching chats', - filterFn: (item, q) => (item.title || 'New Chat').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'past_chat', - chatId: item.id, - label: item.title || 'New Chat', - }), - useInsertFallback: false, - }, - workflows: { - title: 'All workflows', - dataKey: 'workflows', - loadingKey: 'isLoadingWorkflows', - getLabel: (item) => item.name || 'Untitled Workflow', - getId: (item) => item.id, - emptyMessage: 'No workflows', - noMatchMessage: 'No matching workflows', - filterFn: (item, q) => (item.name || 'Untitled Workflow').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'workflow', - workflowId: item.id, - label: item.name || 'Untitled Workflow', - }), - useInsertFallback: true, - }, - knowledge: { - title: 'Knowledge Bases', - dataKey: 'knowledgeBases', - loadingKey: 'isLoadingKnowledge', - ensureLoadedKey: 'ensureKnowledgeLoaded', - getLabel: (item) => item.name || 'Untitled', - getId: (item) => item.id, - emptyMessage: 'No knowledge bases', - noMatchMessage: 'No matching knowledge bases', - filterFn: (item, q) => (item.name || 'Untitled').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'knowledge', - knowledgeId: item.id, - label: item.name || 'Untitled', - }), - useInsertFallback: false, - }, - blocks: { - title: 'Blocks', - dataKey: 'blocksList', - loadingKey: 'isLoadingBlocks', - ensureLoadedKey: 'ensureBlocksLoaded', - getLabel: (item) => item.name || item.id, - getId: (item) => item.id, - emptyMessage: 'No blocks found', - noMatchMessage: 'No matching blocks', - filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'blocks', - blockIds: [item.id], - label: item.name || item.id, - }), - useInsertFallback: false, - }, - 'workflow-blocks': { - title: 'Workflow Blocks', - dataKey: 'workflowBlocks', - loadingKey: 'isLoadingWorkflowBlocks', - // No ensureLoadedKey - workflow blocks auto-sync from store - getLabel: (item) => item.name || item.id, - getId: (item) => item.id, - emptyMessage: 'No blocks in this workflow', - noMatchMessage: 'No matching blocks', - filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), - buildContext: (item, workflowId) => ({ - kind: 'workflow_block', - workflowId: workflowId || '', - blockId: item.id, - label: item.name || item.id, - }), - useInsertFallback: true, - }, - logs: { - title: 'Logs', - dataKey: 'logsList', - loadingKey: 'isLoadingLogs', - ensureLoadedKey: 'ensureLogsLoaded', - getLabel: (item) => item.workflowName, - getId: (item) => item.id, - emptyMessage: 'No executions found', - noMatchMessage: 'No matching executions', - filterFn: (item, q) => - [item.workflowName, item.trigger || ''].join(' ').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'logs', - executionId: item.executionId || item.id, - label: item.workflowName, - }), - useInsertFallback: false, - }, - integrations: { - title: 'Integrations', - dataKey: 'integrations', - loadingKey: 'isLoadingIntegrations', - getLabel: (item) => item.name, - getId: (item) => item.blockType, - emptyMessage: 'No integrations', - noMatchMessage: 'No matching integrations', - filterFn: (item, q) => item.name.toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'integration', - blockType: item.blockType, - label: item.name, - }), - useInsertFallback: true, - }, -} - -/** - * Order of folders in the mention menu - */ -export const FOLDER_ORDER: MentionFolderId[] = [ - 'chats', - 'workflows', - 'knowledge', - 'blocks', - 'workflow-blocks', - 'integrations', - 'logs', -] - -/** - * Docs item configuration (special case - not a folder) - */ -export const DOCS_CONFIG = { - getLabel: () => 'Docs', - buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }), -} as const - -/** - * Total number of items in root menu (folders + docs) - */ -export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1 - -/** - * Slash command configuration - */ -export interface SlashCommand { - id: string - label: string -} - -export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [ - { id: 'fast', label: 'Fast' }, - { id: 'research', label: 'Research' }, - { id: 'actions', label: 'Actions' }, -] as const - -export const WEB_COMMANDS: readonly SlashCommand[] = [ - { id: 'search', label: 'Search' }, - { id: 'read', label: 'Read' }, - { id: 'scrape', label: 'Scrape' }, - { id: 'crawl', label: 'Crawl' }, -] as const - -export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] - -/** - * Scroll tolerance for mention menu positioning (in pixels) - */ -export const SCROLL_TOLERANCE = 8 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts index 4bf89ce3a0f..f17c6d0102e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts @@ -1,6 +1,5 @@ export { useContextManagement } from './use-context-management' export { useFileAttachments } from './use-file-attachments' export { useIntegrationAutoMention } from './use-integration-auto-mention' -export { useMentionData } from './use-mention-data' export { useMentionMenu } from './use-mention-menu' export { useMentionTokens } from './use-mention-tokens' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts deleted file mode 100644 index 77c6765d44c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ /dev/null @@ -1,365 +0,0 @@ -'use client' - -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { useShallow } from 'zustand/react/shallow' -import { requestJson } from '@/lib/api/client/request' -import { listCopilotChatsContract } from '@/lib/api/contracts/copilot' -import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge/base' -import { listLogsContract } from '@/lib/api/contracts/logs' -import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' -import { type IntegrationDescriptor, listIntegrations } from '@/blocks/integration-matcher' -import { useWorkflows } from '@/hooks/queries/workflows' -import { usePermissionConfig } from '@/hooks/use-permission-config' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -const logger = createLogger('useMentionData') - -/** - * Represents a past chat for mention suggestions - */ -export interface PastChat { - id: string - title: string | null - workflowId: string | null - updatedAt?: string -} - -/** - * Represents a workflow for mention suggestions - */ -export interface WorkflowItem { - id: string - name: string - color?: string -} - -/** - * Represents a knowledge base for mention suggestions - */ -export interface KnowledgeItem { - id: string - name: string -} - -/** - * Represents a block for mention suggestions - */ -export interface BlockItem { - id: string - name: string - iconComponent?: any - bgColor?: string -} - -/** - * Represents a workflow block for mention suggestions - */ -export interface WorkflowBlockItem { - id: string - name: string - type: string - iconComponent?: any - bgColor?: string -} - -/** - * Represents a log/execution for mention suggestions - */ -export interface LogItem { - id: string - executionId?: string - level: string - trigger: string | null - createdAt: string - workflowName: string -} - -interface UseMentionDataProps { - workflowId: string | null - workspaceId: string -} - -/** - * Return type for useMentionData hook - */ -export interface MentionDataReturn { - // Data arrays - pastChats: PastChat[] - workflows: WorkflowItem[] - knowledgeBases: KnowledgeItem[] - blocksList: BlockItem[] - workflowBlocks: WorkflowBlockItem[] - logsList: LogItem[] - integrations: readonly IntegrationDescriptor[] - - // Loading states - isLoadingPastChats: boolean - isLoadingWorkflows: boolean - isLoadingKnowledge: boolean - isLoadingBlocks: boolean - isLoadingWorkflowBlocks: boolean - isLoadingLogs: boolean - isLoadingIntegrations: boolean - - // Ensure loaded functions - ensurePastChatsLoaded: () => Promise - ensureKnowledgeLoaded: () => Promise - ensureBlocksLoaded: () => Promise - ensureLogsLoaded: () => Promise -} - -/** - * Custom hook to fetch and manage data for mention suggestions - * Loads data from APIs for chats, workflows, knowledge bases, blocks, and logs - * - * @param props - Configuration including workflow and workspace IDs - * @returns Mention data state and loading operations - */ -export function useMentionData(props: UseMentionDataProps): MentionDataReturn { - const { workflowId, workspaceId } = props - - const { config, isBlockAllowed } = usePermissionConfig() - - const [pastChats, setPastChats] = useState([]) - const [isLoadingPastChats, setIsLoadingPastChats] = useState(false) - - const [knowledgeBases, setKnowledgeBases] = useState([]) - const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false) - - const [blocksList, setBlocksList] = useState([]) - const [isLoadingBlocks, setIsLoadingBlocks] = useState(false) - - // Reset on permission changes and on block-overlay bumps (custom-block or - // block-visibility hydrate) so late preview reveals refresh the folder. - const blockOverlayVersion = useCustomBlockOverlayVersion() - useEffect(() => { - setBlocksList([]) - }, [config.allowedIntegrations, blockOverlayVersion]) - - const [logsList, setLogsList] = useState([]) - const [isLoadingLogs, setIsLoadingLogs] = useState(false) - - const [workflowBlocks, setWorkflowBlocks] = useState([]) - const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false) - - // Integrations are derived synchronously from the block registry via the - // shared auto-mention matcher singleton — no fetch, no loading state. The - // accessor returns a stable cached reference so no memoization is needed. - const integrations = listIntegrations() - - const blockKeys = useWorkflowStore( - useShallow(useCallback((state) => Object.keys(state.blocks), [])) - ) - - const { data: registryWorkflowList = [] } = useWorkflows(workspaceId) - const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isLoadingWorkflows = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' - - const workflows: WorkflowItem[] = registryWorkflowList - .filter((w) => w.workspaceId === workspaceId) - .sort((a, b) => { - const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return dateB - dateA - }) - .map((w) => ({ - id: w.id, - name: w.name || 'Untitled Workflow', - })) - - /** - * Resets past chats when workflow changes - */ - useEffect(() => { - setPastChats([]) - setIsLoadingPastChats(false) - }, [workflowId]) - - /** - * Syncs workflow blocks from store - * Only re-runs when blocks are added/removed (not on position updates) - */ - useEffect(() => { - const syncWorkflowBlocks = async () => { - if (!workflowId || blockKeys.length === 0) { - setWorkflowBlocks([]) - return - } - - try { - // Fetch current blocks from store - const workflowStoreBlocks = useWorkflowStore.getState().blocks - - const { getBlockRegistry } = await import('@/blocks/registry') - const blockRegistry = getBlockRegistry() - const mapped = Object.values(workflowStoreBlocks).map((b: any) => { - const reg = (blockRegistry as any)[b.type] - return { - id: b.id, - name: b.name || b.id, - type: b.type, - iconComponent: reg?.icon, - bgColor: reg?.bgColor || '#6B7280', - } - }) - setWorkflowBlocks(mapped) - logger.debug('Synced workflow blocks for mention menu', { - count: mapped.length, - }) - } catch (error) { - logger.debug('Failed to sync workflow blocks:', error) - } - } - - syncWorkflowBlocks() - }, [blockKeys, workflowId]) - - /** - * Ensures past chats are loaded - */ - const ensurePastChatsLoaded = useCallback(async () => { - if (isLoadingPastChats || pastChats.length > 0) return - try { - setIsLoadingPastChats(true) - const data = await requestJson(listCopilotChatsContract, {}) - const items = data.chats - - const currentWorkflowChats = items.filter((c) => c.workflowId === workflowId) - - setPastChats( - currentWorkflowChats.map((c) => ({ - id: c.id, - title: c.title ?? null, - workflowId: c.workflowId ?? null, - updatedAt: c.updatedAt ?? undefined, - })) - ) - } catch { - } finally { - setIsLoadingPastChats(false) - } - }, [isLoadingPastChats, pastChats.length, workflowId]) - - /** - * Ensures knowledge bases are loaded - */ - const ensureKnowledgeLoaded = useCallback(async () => { - if (isLoadingKnowledge || knowledgeBases.length > 0) return - try { - setIsLoadingKnowledge(true) - const result = await requestJson(listKnowledgeBasesContract, { - query: { workspaceId, includeCounts: false }, - }) - const items = result.data - const sorted = [...items].sort((a, b) => { - const ta = new Date(a.updatedAt || a.createdAt || 0).getTime() - const tb = new Date(b.updatedAt || b.createdAt || 0).getTime() - return tb - ta - }) - setKnowledgeBases(sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' }))) - } catch { - } finally { - setIsLoadingKnowledge(false) - } - }, [isLoadingKnowledge, knowledgeBases.length, workspaceId]) - - /** - * Ensures blocks are loaded - */ - const ensureBlocksLoaded = useCallback(async () => { - if (isLoadingBlocks || blocksList.length > 0) return - try { - setIsLoadingBlocks(true) - const { getAllBlocks } = await import('@/blocks') - const all = getAllBlocks() - const regularBlocks = all - .filter( - (b: any) => - b.type !== 'starter' && - !b.hideFromToolbar && - b.category === 'blocks' && - isBlockAllowed(b.type) - ) - .map((b: any) => ({ - id: b.type, - name: b.name || b.type, - iconComponent: b.icon, - bgColor: b.bgColor, - })) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - - const toolBlocks = all - .filter( - (b: any) => - b.type !== 'starter' && - !b.hideFromToolbar && - b.category === 'tools' && - isBlockAllowed(b.type) - ) - .map((b: any) => ({ - id: b.type, - name: b.name || b.type, - iconComponent: b.icon, - bgColor: b.bgColor, - })) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - - setBlocksList([...regularBlocks, ...toolBlocks]) - } catch { - } finally { - setIsLoadingBlocks(false) - } - }, [isLoadingBlocks, blocksList.length, isBlockAllowed]) - - /** - * Ensures logs are loaded - */ - const ensureLogsLoaded = useCallback(async () => { - if (isLoadingLogs || logsList.length > 0) return - try { - setIsLoadingLogs(true) - const data = await requestJson(listLogsContract, { - query: { workspaceId, limit: 50 }, - }) - const items = data.data - const mapped = items.map((l) => ({ - id: l.id, - executionId: l.executionId || l.id, - level: l.level, - trigger: l.trigger || null, - createdAt: l.createdAt, - workflowName: l.workflow?.name ?? 'Untitled Workflow', - })) - setLogsList(mapped) - } catch { - } finally { - setIsLoadingLogs(false) - } - }, [isLoadingLogs, logsList.length, workspaceId]) - - return { - // State - pastChats, - isLoadingPastChats, - workflows, - isLoadingWorkflows, - knowledgeBases, - isLoadingKnowledge, - blocksList, - isLoadingBlocks, - logsList, - isLoadingLogs, - workflowBlocks, - isLoadingWorkflowBlocks, - integrations, - isLoadingIntegrations: false, - - // Operations - ensurePastChatsLoaded, - ensureKnowledgeLoaded, - ensureBlocksLoaded, - ensureLogsLoaded, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts index ac69a0d6e58..ff77b9faace 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' import type { ChatContext } from '@/stores/panel' +const SCROLL_TOLERANCE = 8 + interface UseMentionMenuProps { /** Current message text */ message: string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 7c6b3313ac7..4c1d0c82d16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -1,9 +1,4 @@ import { escapeRegExp } from '@sim/utils/string' -import { - FOLDER_CONFIGS, - type MentionFolderId, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' -import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data' import type { ChatContext } from '@/stores/panel' /** @@ -114,31 +109,6 @@ export function computeMentionHighlightRanges( return ranges } -/** - * Gets the data array for a folder ID from mentionData. - * Uses FOLDER_CONFIGS as the source of truth for key mapping. - * Returns any[] since item types vary by folder and are used with dynamic config.filterFn - */ -export function getFolderData(mentionData: MentionDataReturn, folderId: MentionFolderId): any[] { - const config = FOLDER_CONFIGS[folderId] - return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || [] -} - -/** - * Gets the ensure loaded function for a folder ID from mentionData. - * Uses FOLDER_CONFIGS as the source of truth for key mapping. - */ -export function getFolderEnsureLoaded( - mentionData: MentionDataReturn, - folderId: MentionFolderId -): (() => Promise) | undefined { - const config = FOLDER_CONFIGS[folderId] - if (!config.ensureLoadedKey) return undefined - return mentionData[config.ensureLoadedKey as keyof MentionDataReturn] as - | (() => Promise) - | undefined -} - /** * Extract specific ChatContext types for type-safe narrowing */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.test.tsx new file mode 100644 index 00000000000..85d07e2b6f6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.test.tsx @@ -0,0 +1,87 @@ +/** @vitest-environment jsdom */ + +import { act, type ComponentProps } from 'react' +import { ReactFlowProvider } from '@xyflow/react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block' +import { getBlock } from '@/blocks' +import { WorkflowBlock } from '@/blocks/blocks/workflow' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +vi.mock('@/triggers/registry', () => ({ TRIGGER_REGISTRY: {} })) + +let root: Root +let container: HTMLDivElement + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.mocked(getBlock).mockReturnValue(WorkflowBlock) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.mocked(getBlock).mockReset() +}) + +function workflow(name: string): WorkflowMetadata { + return { id: 'child', name, createdAt: new Date(0), lastModified: new Date(0), sortOrder: 0 } +} + +function render(data: ComponentProps['data']) { + act(() => + root.render( + + + + ) + ) +} + +describe('preview workflow selection updates', () => { + it('resolves the selected workflow when labels become ready without a block edit', () => { + const data = { + type: 'workflow', + name: 'Run child', + workflowMap: { child: workflow('Billing') }, + workflowLabelsReady: false, + subBlockValues: { workflowId: { value: 'child' } }, + } + render(data) + expect(container.textContent).not.toContain('Billing') + render({ ...data, workflowLabelsReady: true }) + expect(container.textContent).toContain('Billing') + }) + + it('refreshes the selected workflow name without a block edit', () => { + const data = { + type: 'workflow', + name: 'Run child', + workflowMap: { child: workflow('Billing') }, + workflowLabelsReady: true, + subBlockValues: { workflowId: { value: 'child' } }, + } + render(data) + expect(container.textContent).toContain('Billing') + render({ ...data, workflowMap: { child: workflow('Invoices') } }) + expect(container.textContent).toContain('Invoices') + expect(container.textContent).not.toContain('Billing') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 04dfea0021a..db9692d5760 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -715,6 +715,13 @@ function shouldSkipPreviewBlockRender( /** Skip subBlockValues comparison in lightweight mode */ if (nextProps.data.lightweight) return true + if ( + prevProps.data.workflowMap !== nextProps.data.workflowMap || + prevProps.data.workflowLabelsReady !== nextProps.data.workflowLabelsReady + ) { + return false + } + const prevValues = prevProps.data.subBlockValues const nextValues = nextProps.data.subBlockValues diff --git a/apps/sim/ee/access-control/components/group-detail.test.tsx b/apps/sim/ee/access-control/components/group-detail.test.tsx new file mode 100644 index 00000000000..3ca858c2a35 --- /dev/null +++ b/apps/sim/ee/access-control/components/group-detail.test.tsx @@ -0,0 +1,161 @@ +/** @vitest-environment jsdom */ + +import { act, type ReactNode } from 'react' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { jsonResponse } from '@sim/testing/helpers/http' +import { authClientMock } from '@sim/testing/mocks/auth-client.mock' +import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' +import { providersModelsMock } from '@sim/testing/mocks/providers-models.mock' +import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PermissionGroup } from '@/lib/api/contracts/permission-groups' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GroupDetail } from '@/ee/access-control/components/group-detail' +import { organizationKeys } from '@/hooks/queries/utils/organization-keys' +import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' + +vi.mock('@/lib/auth/auth-client', () => authClientMock) +vi.mock('next/navigation', () => nextNavigationMock) +vi.mock('@/providers/models', () => providersModelsMock) +vi.mock('@/providers/utils', () => providersUtilsMock) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children: ReactNode }) => <>{children}, +})) + +const group: PermissionGroup = { + id: 'group-1', + name: 'Engineering', + description: null, + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG }, + createdBy: 'user-1', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + creatorName: null, + creatorEmail: null, + memberCount: 0, + isDefault: false, + workspaces: [], +} + +let root: Root +let container: HTMLDivElement +let client: QueryClient + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + providersUtilsMockFns.mockGetAllProviderIds.mockReturnValue(['openai', 'anthropic']) + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(permissionGroupKeys.members('org-1', 'group-1'), []) + client.setQueryData(organizationKeys.roster('org-1'), { + members: [], + pendingInvitations: [], + workspaces: [], + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + client.clear() + container.remove() + providersUtilsMockFns.mockGetAllProviderIds.mockReset() + vi.useRealTimers() +}) + +function render() { + act(() => + root.render( + + + {}} + onDeleted={() => {}} + /> + + + ) + ) +} + +function expectNoProviderControls() { + expect(container.querySelector('[id^="provider-"]')).toBeNull() + expect(container.querySelector('input[placeholder="Search providers..."]')).toBeNull() + expect( + [...container.querySelectorAll('button')].some((button) => + /^(De)?select All$/i.test(button.textContent ?? '') + ) + ).toBe(false) +} + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) +} + +describe('provider permission policy availability', () => { + it('withholds provider edits until the server policy is known', () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => createDeferred().promise) + ) + render() + expectNoProviderControls() + }) + + it.each([ + { status: 503, body: { error: 'Policy temporarily unavailable' }, blacklist: ['openai'] }, + { status: 401, body: { error: 'Session expired' }, blacklist: [] }, + { status: 200, body: { blacklistedProviders: 'invalid' }, blacklist: ['openai'] }, + ])( + 'withholds edits after a $status response and recovers through retry', + async ({ status, body, blacklist }) => { + const retry = createDeferred() + let requests = 0 + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + if (url !== '/api/settings/allowed-providers') + throw new Error(`Unexpected request: ${url}`) + requests++ + return requests === 1 ? Promise.resolve(jsonResponse(body, status)) : retry.promise + }) + ) + render() + await settle() + expect(container.querySelector('[role="alert"]')).not.toBeNull() + expectNoProviderControls() + const retryButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'Try again' + ) + expect(retryButton).toBeDefined() + act(() => retryButton!.click()) + await settle() + expect(requests).toBe(2) + expectNoProviderControls() + await act(async () => { + retry.resolve(jsonResponse({ blacklistedProviders: blacklist })) + }) + await settle() + expect(container.querySelector('[role="alert"]')).toBeNull() + const anthropic = container.querySelector('#provider-anthropic') + expect(anthropic?.getAttribute('aria-checked')).toBe('true') + expect(Boolean(container.querySelector('#provider-openai'))).toBe( + !blacklist.includes('openai') + ) + act(() => anthropic!.click()) + expect(anthropic?.getAttribute('aria-checked')).toBe('false') + } + ) +}) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 52230d0ba2e..16e819069bd 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -50,7 +50,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { MemberRow } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -775,7 +778,7 @@ export function GroupDetail({ viewingGroup.id ) const { data: roster } = useOrganizationRoster(organizationId) - const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: true }) + const blacklistedProviders = useBlacklistedProviders() // Recompute when custom (deploy-as-block) blocks or the viewer's block // visibility hydrate into the overlay. @@ -815,11 +818,10 @@ export function GroupDetail({ const visibleBlocks = useMemo(() => allBlocks.filter((b) => !b.hideFromToolbar), [allBlocks]) const allProviderIds = useMemo(() => { - const allIds = getAllProviderIds() - const blacklist = blacklistedProvidersData?.blacklistedProviders ?? [] - if (blacklist.length === 0) return allIds - return allIds.filter((id) => !blacklist.includes(id.toLowerCase())) - }, [blacklistedProvidersData]) + if (!blacklistedProviders.isSuccess) return [] + const blacklist = blacklistedProviders.data.blacklistedProviders + return getAllProviderIds().filter((id) => !blacklist.includes(id.toLowerCase())) + }, [blacklistedProviders.data, blacklistedProviders.isSuccess]) /** Maps every tool id to ALL block types that expose it (some tools are shared across blocks). */ const toolBlockTypes = useMemo(() => { @@ -1616,50 +1618,63 @@ export function GroupDetail({ )} - {configTab === 'providers' && ( -
-
- setSearchTerm(e.target.value)} - className='min-w-0 flex-1' - /> - void setStatusFilter(next)} - /> - setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)} - disabled={filteredProviders.length === 0} - > - {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'} - -
- {filteredProviders.length === 0 ? ( - - No providers match your filters. - - ) : ( -
- {filteredProviders.map((providerId) => ( - toggleProvider(providerId)} - deniedCount={deniedCountByProvider[providerId] ?? 0} - workspaceId={workspaceId} - isAllowed={isModelAllowed} - onToggle={toggleModel} - onSetDenied={setModelsDenied} - /> - ))} + {configTab === 'providers' && + (blacklistedProviders.isError ? ( + void blacklistedProviders.refetch()} + variant='inline' + /> + ) : !blacklistedProviders.isSuccess ? ( + Loading providers + ) : ( +
+
+ setSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> + void setStatusFilter(next)} + /> + + setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed) + } + disabled={filteredProviders.length === 0} + > + {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'} +
- )} -
- )} + {filteredProviders.length === 0 ? ( + + No providers match your filters. + + ) : ( +
+ {filteredProviders.map((providerId) => ( + toggleProvider(providerId)} + deniedCount={deniedCountByProvider[providerId] ?? 0} + workspaceId={workspaceId} + isAllowed={isModelAllowed} + onToggle={toggleModel} + onSetDenied={setModelsDenied} + /> + ))} +
+ )} +
+ ))} {configTab === 'blocks' && (
diff --git a/apps/sim/hooks/queries/allowed-providers.ts b/apps/sim/hooks/queries/allowed-providers.ts index f49de382073..c9461ce2a26 100644 --- a/apps/sim/hooks/queries/allowed-providers.ts +++ b/apps/sim/hooks/queries/allowed-providers.ts @@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' -import type { ContractJsonResponse } from '@/lib/api/contracts' import { getAllowedProvidersContract } from '@/lib/api/contracts' /** @@ -15,26 +14,13 @@ export const allowedProvidersKeys = { export const BLACKLISTED_PROVIDERS_STALE_TIME = 5 * 60 * 1000 -type BlacklistedProvidersResponse = ContractJsonResponse - -async function fetchBlacklistedProviders( - signal: AbortSignal -): Promise { - try { - return await requestJson(getAllowedProvidersContract, { signal }) - } catch { - return { blacklistedProviders: [] } - } -} - /** * Hook to fetch the list of blacklisted provider IDs from the server. */ -export function useBlacklistedProviders({ enabled = true }: { enabled?: boolean } = {}) { +export function useBlacklistedProviders() { return useQuery({ queryKey: allowedProvidersKeys.blacklisted(), - queryFn: ({ signal }) => fetchBlacklistedProviders(signal), + queryFn: ({ signal }) => requestJson(getAllowedProvidersContract, { signal }), staleTime: BLACKLISTED_PROVIDERS_STALE_TIME, - enabled, }) } diff --git a/packages/testing/src/mocks/terminal-console.mock.ts b/packages/testing/src/mocks/terminal-console.mock.ts index 16fa06a87ca..921dcc0f959 100644 --- a/packages/testing/src/mocks/terminal-console.mock.ts +++ b/packages/testing/src/mocks/terminal-console.mock.ts @@ -12,10 +12,16 @@ interface ConsoleEntryLike { [key: string]: unknown } +const EMPTY_ENTRIES: ConsoleEntryLike[] = [] + const entriesByWorkflow: Record = {} const mockGetWorkflowEntries = vi.fn((workflowId: string) => entriesByWorkflow[workflowId] ?? []) +const mockUseWorkflowConsoleEntries = vi.fn((workflowId?: string) => + workflowId ? (entriesByWorkflow[workflowId] ?? EMPTY_ENTRIES) : EMPTY_ENTRIES +) + const mockAddConsole = vi.fn((entry: ConsoleEntryLike) => { const stored = { ...entry, id: entry.id ?? `mock-${generateRandomString(16)}` } if (!entriesByWorkflow[entry.workflowId]) entriesByWorkflow[entry.workflowId] = [] @@ -93,6 +99,7 @@ const mockUseTerminalConsoleStore = Object.assign( */ export const terminalConsoleMockFns = { mockGetWorkflowEntries, + mockUseWorkflowConsoleEntries, mockAddConsole, mockUpdateConsole, mockCancelRunningEntries, @@ -120,6 +127,7 @@ export const terminalConsoleMockFns = { */ export const terminalConsoleMock = { useTerminalConsoleStore: mockUseTerminalConsoleStore, + useWorkflowConsoleEntries: mockUseWorkflowConsoleEntries, consolePersistence: mockConsolePersistence, loadExecutionPointer: mockLoadExecutionPointer, saveExecutionPointer: mockSaveExecutionPointer, diff --git a/scripts/check-application-graph.test.ts b/scripts/check-application-graph.test.ts index f0e6dc0ab56..7a434f24c98 100644 --- a/scripts/check-application-graph.test.ts +++ b/scripts/check-application-graph.test.ts @@ -51,22 +51,20 @@ describe('a deferred edge into a forbidden tree', () => { */ it('is reported when a root defers the load of a forbidden module', () => { /** - * Walked from a module that defers the block registry — `const - * { getBlockRegistry } = await import('@/blocks/registry')`. A root's own + * Walked from a live chat payload module that defers `@/tools/params`. A root's own * deferred edges are checked before its static imports, so the reported chain * is that single deferred hop, whatever else the root reaches. */ - const root = - 'app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts' + const root = 'lib/mothership/chat/payload.ts' const violations = findViolations({ root, - forbidden: { 'blocks/': FORBIDDEN_PREFIXES['blocks/'] }, + forbidden: { 'tools/': FORBIDDEN_PREFIXES['tools/'] }, }) expect(violations).toHaveLength(1) - expect(violations[0].forbidden).toBe('blocks/registry.ts') + expect(violations[0].forbidden).toBe('tools/params.ts') expect(violations[0].reason).toContain('deferred') - expect(violations[0].path).toEqual([root, 'blocks/registry.ts']) + expect(violations[0].path).toEqual([root, 'tools/params.ts']) }) /** From 805e22255b0da0e6cfcbd2adc9c05f16937fe8ea Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 28 Sep 2026 19:52:42 -0700 Subject: [PATCH 2/2] chore(tests): address cleanup review findings --- .../components/block/block.test.tsx | 87 ------------------- .../src/mocks/terminal-console.mock.ts | 1 + 2 files changed, 1 insertion(+), 87 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.test.tsx deleted file mode 100644 index 85d07e2b6f6..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** @vitest-environment jsdom */ - -import { act, type ComponentProps } from 'react' -import { ReactFlowProvider } from '@xyflow/react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block' -import { getBlock } from '@/blocks' -import { WorkflowBlock } from '@/blocks/blocks/workflow' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' - -vi.mock('@/triggers/registry', () => ({ TRIGGER_REGISTRY: {} })) - -let root: Root -let container: HTMLDivElement - -beforeEach(() => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - vi.mocked(getBlock).mockReturnValue(WorkflowBlock) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => root.unmount()) - container.remove() - vi.mocked(getBlock).mockReset() -}) - -function workflow(name: string): WorkflowMetadata { - return { id: 'child', name, createdAt: new Date(0), lastModified: new Date(0), sortOrder: 0 } -} - -function render(data: ComponentProps['data']) { - act(() => - root.render( - - - - ) - ) -} - -describe('preview workflow selection updates', () => { - it('resolves the selected workflow when labels become ready without a block edit', () => { - const data = { - type: 'workflow', - name: 'Run child', - workflowMap: { child: workflow('Billing') }, - workflowLabelsReady: false, - subBlockValues: { workflowId: { value: 'child' } }, - } - render(data) - expect(container.textContent).not.toContain('Billing') - render({ ...data, workflowLabelsReady: true }) - expect(container.textContent).toContain('Billing') - }) - - it('refreshes the selected workflow name without a block edit', () => { - const data = { - type: 'workflow', - name: 'Run child', - workflowMap: { child: workflow('Billing') }, - workflowLabelsReady: true, - subBlockValues: { workflowId: { value: 'child' } }, - } - render(data) - expect(container.textContent).toContain('Billing') - render({ ...data, workflowMap: { child: workflow('Invoices') } }) - expect(container.textContent).toContain('Invoices') - expect(container.textContent).not.toContain('Billing') - }) -}) diff --git a/packages/testing/src/mocks/terminal-console.mock.ts b/packages/testing/src/mocks/terminal-console.mock.ts index 921dcc0f959..cb31cb840b9 100644 --- a/packages/testing/src/mocks/terminal-console.mock.ts +++ b/packages/testing/src/mocks/terminal-console.mock.ts @@ -60,6 +60,7 @@ const mockWaitForConsoleHydration = vi.fn(async () => undefined) export function resetTerminalConsoleMock(): void { for (const key of Object.keys(entriesByWorkflow)) delete entriesByWorkflow[key] mockGetWorkflowEntries.mockClear() + mockUseWorkflowConsoleEntries.mockClear() mockAddConsole.mockClear() mockUpdateConsole.mockClear() mockCancelRunningEntries.mockClear()