diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx index d2231304ddf..065448ac4e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx @@ -1,10 +1,12 @@ /** - * @vitest-environment node + * @vitest-environment jsdom */ -import type { ReactNode, SVGProps } from 'react' +import { act, type ReactNode, type SVGProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { getBlockByToolName } from '@/blocks/registry' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay' +import { getBlock, getBlockByToolName } from '@/blocks/registry' import { ToolCallItem } from './tool-call-item' vi.mock('@/components/ui', () => ({ @@ -12,6 +14,11 @@ vi.mock('@/components/ui', () => ({ })) describe('ToolCallItem', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + }) + it.each(['executing', 'success', 'error', 'cancelled'] as const)( 'renders the %s tool row without an icon', (status) => { @@ -115,4 +122,34 @@ describe('ToolCallItem', () => { expect(markup).toContain(' { + vi.mocked(getBlock).mockReturnValue(undefined) + const container = document.createElement('div') + const root: Root = createRoot(container) + + act(() => { + root.render( + + ) + }) + expect(container.querySelector('[data-testid="custom-block-icon"]')).toBeNull() + + vi.mocked(getBlock).mockReturnValue({ + type: 'custom_block_invoice_parser', + name: 'Invoice Parser', + icon: (props: SVGProps) => , + } as ReturnType) + act(() => notifyBlockOverlayChanged()) + + expect(container.querySelector('[data-testid="custom-block-icon"]')).not.toBeNull() + act(() => root.unmount()) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index f9fb73b92e3..908990eea5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -13,6 +13,7 @@ import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display' import { BrandIcon } from '@/blocks/brand-icon' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getBlockByToolName } from '@/blocks/registry' import type { ToolCallData, ToolCallStatus } from '../../../../types' import { resolveToolDisplayState } from '../../utils' @@ -122,11 +123,12 @@ export function ToolCallItem({ toolCallId, startedAt, }: ToolCallItemProps) { - const readBlock = useMemo(() => { - if (toolName !== ReadTool.id) return undefined - const path = params?.path - return typeof path === 'string' ? getReadTargetBlock(path) : undefined - }, [toolName, params]) + useCustomBlockOverlayVersion() + const readPath = params?.path + const readBlock = + toolName === ReadTool.id && typeof readPath === 'string' + ? getReadTargetBlock(readPath) + : undefined // Like read's VFS-target resolution above, the gateway uses its exact // discovered toolId only as a deterministic registry lookup. This renders diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-overlay.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-overlay.test.tsx new file mode 100644 index 00000000000..4228bb4093b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-overlay.test.tsx @@ -0,0 +1,84 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetBlock } = vi.hoisted(() => ({ + mockGetBlock: vi.fn(), +})) + +vi.mock('@/blocks/registry', () => ({ + getBlock: mockGetBlock, + getBlockByToolName: vi.fn(), + getLatestBlock: vi.fn(), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: vi.fn(() => ({ data: null, isPending: false })), +})) + +interface MockAgentGroupItem { + type: string + data?: { id: string; displayTitle: string } +} + +vi.mock('./components', () => ({ + AgentGroup: ({ items }: { items: MockAgentGroupItem[] }) => ( +
+ {items.map((item) => item.data && {item.data.displayTitle})} +
+ ), + ChatContent: () => null, + CircleStop: () => null, + Options: () => null, + PendingTagIndicator: () => null, +})) + +import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' +import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay' +import { MessageContent } from './message-content' + +describe('MessageContent custom-block hydration', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + }) + + it('refreshes a read title when the custom-block registry hydrates after mount', () => { + mockGetBlock.mockReturnValue(undefined) + const blocks: ContentBlock[] = [ + { + type: 'tool_call', + toolCall: { + id: 'read-custom-block', + name: 'read', + status: 'success', + params: { + path: 'organization/custom-blocks/custom_block_invoice_parser.json', + }, + }, + timestamp: 1, + }, + ] + const container = document.createElement('div') + const root: Root = createRoot(container) + + act(() => { + root.render() + }) + expect(container.textContent).toContain('Read Custom block invoice parser') + + mockGetBlock.mockReturnValue({ + type: 'custom_block_invoice_parser', + name: 'Invoice Parser', + icon: () => null, + }) + act(() => notifyBlockOverlayChanged()) + + expect(container.textContent).toContain('Read Invoice Parser') + expect(container.textContent).not.toContain('Read Custom block invoice parser') + act(() => root.unmount()) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 5a13215a565..394fc9180e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -22,6 +22,7 @@ import { } from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import type { ContentBlock, OptionItem, ToolCallData } from '../../types' import { SUBAGENT_LABELS } from '../../types' import type { AgentGroupItem } from './components' @@ -851,7 +852,11 @@ function MessageContentInner({ actions, }: MessageContentProps) { const { onWorkspaceResourceSelect } = useChatSurface() - const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks]) + const blockOverlayVersion = useCustomBlockOverlayVersion() + const parsed = useMemo( + () => (blocks.length > 0 ? parseBlocks(blocks) : []), + [blocks, blockOverlayVersion] + ) const [trailingRevealing, setTrailingRevealing] = useState(false) const handleTrailingRevealChange = useCallback((revealing: boolean) => { diff --git a/apps/sim/lib/copilot/tools/client/read-block.test.ts b/apps/sim/lib/copilot/tools/client/read-block.test.ts index a3da77c0db5..66bfbee7704 100644 --- a/apps/sim/lib/copilot/tools/client/read-block.test.ts +++ b/apps/sim/lib/copilot/tools/client/read-block.test.ts @@ -5,9 +5,18 @@ import { describe, expect, it, vi } from 'vitest' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' const gmailBlock = { type: 'gmail_v2', name: 'Gmail', icon: () => null } +const customBlock = { + type: 'custom_block_invoice_parser', + name: 'Invoice Parser', + icon: () => null, +} vi.mock('@/blocks/registry', () => ({ - getBlock: vi.fn((type: string) => (type === 'gmail_v2' ? gmailBlock : undefined)), + getBlock: vi.fn((type: string) => { + if (type === 'gmail_v2') return gmailBlock + if (type === 'custom_block_invoice_parser') return customBlock + return undefined + }), getLatestBlock: vi.fn((baseType: string) => (baseType === 'gmail' ? gmailBlock : undefined)), })) @@ -21,6 +30,12 @@ describe('getReadTargetBlock', () => { expect(getReadTargetBlock('components/integrations/gmail')?.name).toBe('Gmail') }) + it('resolves an organization custom-block read to its block', () => { + expect( + getReadTargetBlock('organization/custom-blocks/custom_block_invoice_parser.json')?.name + ).toBe('Invoice Parser') + }) + it('returns undefined for unknown blocks and non-component paths', () => { expect(getReadTargetBlock('components/blocks/unknown_block.json')).toBeUndefined() expect(getReadTargetBlock('workflows/My Workflow/meta.json')).toBeUndefined() diff --git a/apps/sim/lib/copilot/tools/client/read-block.ts b/apps/sim/lib/copilot/tools/client/read-block.ts index c4faf4e8f8e..4aa3fb8167c 100644 --- a/apps/sim/lib/copilot/tools/client/read-block.ts +++ b/apps/sim/lib/copilot/tools/client/read-block.ts @@ -2,16 +2,20 @@ import { getBlock, getLatestBlock } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' /** - * Resolves the block a copilot `read` call targets when the path is a - * component schema — `components/blocks/{type}.json` or - * `components/integrations/{service}/{operation}.json` — so tool rows can show - * the block's display name and brand icon instead of the raw type id - * (e.g. "Gmail" instead of `gmail_v2`). Returns undefined for every other - * path, leaving the generic read-target labeling untouched. + * Resolves the block a copilot `read` call targets when the path references a + * component schema or organization custom block, so tool rows can show the + * block's display name and brand icon instead of its raw type id. Returns + * undefined for every other path, leaving generic read-target labeling + * untouched. */ export function getReadTargetBlock(path: string | undefined): BlockConfig | undefined { if (!path) return undefined const segments = path.trim().split('/').filter(Boolean) + + if (segments[0] === 'organization' && segments[1] === 'custom-blocks' && segments.length === 3) { + return getBlock(segments[2].replace(/\.json$/, '')) + } + if (segments[0] !== 'components' || segments.length < 3) return undefined if (segments[1] === 'blocks' && segments.length === 3) { return getBlock(segments[2].replace(/\.json$/, '')) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index f5040b30022..3cf17f8d0a3 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -8,9 +8,18 @@ import { resolveToolDisplay } from './store-utils' import { ClientToolCallState } from './tool-call-state' const gmailBlock = { type: 'gmail_v2', name: 'Gmail', icon: () => null } +const customBlock = { + type: 'custom_block_invoice_parser', + name: 'Invoice Parser', + icon: () => null, +} vi.mock('@/blocks/registry', () => ({ - getBlock: vi.fn((type: string) => (type === 'gmail_v2' ? gmailBlock : undefined)), + getBlock: vi.fn((type: string) => { + if (type === 'gmail_v2') return gmailBlock + if (type === 'custom_block_invoice_parser') return customBlock + return undefined + }), getLatestBlock: vi.fn((baseType: string) => (baseType === 'gmail' ? gmailBlock : undefined)), })) @@ -180,7 +189,7 @@ describe('resolveToolDisplay', () => { ).toBe('Read style details for deck.pptx') }) - it('shows the block display name for block and integration schema reads', () => { + it('shows the block display name for block, integration, and custom-block reads', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'components/blocks/gmail_v2.json', @@ -198,6 +207,12 @@ describe('resolveToolDisplay', () => { path: 'components/blocks/unknown_block.json', })?.text ).toBe('Read Unknown block') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'organization/custom-blocks/custom_block_invoice_parser.json', + })?.text + ).toBe('Read Invoice Parser') }) it('humanizes internal VFS resource identifiers', () => {