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
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
/**
* @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', () => ({
ShimmerText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}))

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) => {
Expand Down Expand Up @@ -115,4 +122,34 @@ describe('ToolCallItem', () => {
expect(markup).toContain('<svg')
expect(markup).toContain('Read recent emails')
})

it('refreshes the read icon when custom blocks hydrate after mount', () => {
vi.mocked(getBlock).mockReturnValue(undefined)
const container = document.createElement('div')
const root: Root = createRoot(container)

act(() => {
root.render(
<ToolCallItem
toolName='read'
displayTitle='Read Custom block invoice parser'
status='success'
params={{
path: 'organization/custom-blocks/custom_block_invoice_parser.json',
}}
/>
)
})
expect(container.querySelector('[data-testid="custom-block-icon"]')).toBeNull()

vi.mocked(getBlock).mockReturnValue({
type: 'custom_block_invoice_parser',
name: 'Invoice Parser',
icon: (props: SVGProps<SVGSVGElement>) => <svg {...props} data-testid='custom-block-icon' />,
} as ReturnType<typeof getBlock>)
act(() => notifyBlockOverlayChanged())

expect(container.querySelector('[data-testid="custom-block-icon"]')).not.toBeNull()
act(() => root.unmount())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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[] }) => (
<div>
{items.map((item) => item.data && <span key={item.data.id}>{item.data.displayTitle}</span>)}
</div>
),
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(<MessageContent blocks={blocks} fallbackContent='' isStreaming={false} />)
})
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())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down
17 changes: 16 additions & 1 deletion apps/sim/lib/copilot/tools/client/read-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}))

Expand All @@ -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()
Expand Down
16 changes: 10 additions & 6 deletions apps/sim/lib/copilot/tools/client/read-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/, ''))
Comment thread
j15z marked this conversation as resolved.
}

if (segments[0] !== 'components' || segments.length < 3) return undefined
if (segments[1] === 'blocks' && segments.length === 3) {
return getBlock(segments[2].replace(/\.json$/, ''))
Expand Down
19 changes: 17 additions & 2 deletions apps/sim/lib/copilot/tools/client/store-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}))

Expand Down Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down
Loading