Skip to content

Commit 49e2bb5

Browse files
authored
fix(copilot): show custom block names in read tool rows (#7044)
* fix(copilot): show custom block names in read tool rows * fix(copilot): refresh custom block metadata after hydration
1 parent 3ce9953 commit 49e2bb5

7 files changed

Lines changed: 181 additions & 19 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
/**
2-
* @vitest-environment node
2+
* @vitest-environment jsdom
33
*/
4-
import type { ReactNode, SVGProps } from 'react'
4+
import { act, type ReactNode, type SVGProps } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
56
import { renderToStaticMarkup } from 'react-dom/server'
6-
import { describe, expect, it, vi } from 'vitest'
7-
import { getBlockByToolName } from '@/blocks/registry'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay'
9+
import { getBlock, getBlockByToolName } from '@/blocks/registry'
810
import { ToolCallItem } from './tool-call-item'
911

1012
vi.mock('@/components/ui', () => ({
1113
ShimmerText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
1214
}))
1315

1416
describe('ToolCallItem', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks()
19+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
20+
})
21+
1522
it.each(['executing', 'success', 'error', 'cancelled'] as const)(
1623
'renders the %s tool row without an icon',
1724
(status) => {
@@ -115,4 +122,34 @@ describe('ToolCallItem', () => {
115122
expect(markup).toContain('<svg')
116123
expect(markup).toContain('Read recent emails')
117124
})
125+
126+
it('refreshes the read icon when custom blocks hydrate after mount', () => {
127+
vi.mocked(getBlock).mockReturnValue(undefined)
128+
const container = document.createElement('div')
129+
const root: Root = createRoot(container)
130+
131+
act(() => {
132+
root.render(
133+
<ToolCallItem
134+
toolName='read'
135+
displayTitle='Read Custom block invoice parser'
136+
status='success'
137+
params={{
138+
path: 'organization/custom-blocks/custom_block_invoice_parser.json',
139+
}}
140+
/>
141+
)
142+
})
143+
expect(container.querySelector('[data-testid="custom-block-icon"]')).toBeNull()
144+
145+
vi.mocked(getBlock).mockReturnValue({
146+
type: 'custom_block_invoice_parser',
147+
name: 'Invoice Parser',
148+
icon: (props: SVGProps<SVGSVGElement>) => <svg {...props} data-testid='custom-block-icon' />,
149+
} as ReturnType<typeof getBlock>)
150+
act(() => notifyBlockOverlayChanged())
151+
152+
expect(container.querySelector('[data-testid="custom-block-icon"]')).not.toBeNull()
153+
act(() => root.unmount())
154+
})
118155
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired
1313
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
1414
import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display'
1515
import { BrandIcon } from '@/blocks/brand-icon'
16+
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
1617
import { getBlockByToolName } from '@/blocks/registry'
1718
import type { ToolCallData, ToolCallStatus } from '../../../../types'
1819
import { resolveToolDisplayState } from '../../utils'
@@ -122,11 +123,12 @@ export function ToolCallItem({
122123
toolCallId,
123124
startedAt,
124125
}: ToolCallItemProps) {
125-
const readBlock = useMemo(() => {
126-
if (toolName !== ReadTool.id) return undefined
127-
const path = params?.path
128-
return typeof path === 'string' ? getReadTargetBlock(path) : undefined
129-
}, [toolName, params])
126+
useCustomBlockOverlayVersion()
127+
const readPath = params?.path
128+
const readBlock =
129+
toolName === ReadTool.id && typeof readPath === 'string'
130+
? getReadTargetBlock(readPath)
131+
: undefined
130132

131133
// Like read's VFS-target resolution above, the gateway uses its exact
132134
// discovered toolId only as a deterministic registry lookup. This renders
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockGetBlock } = vi.hoisted(() => ({
9+
mockGetBlock: vi.fn(),
10+
}))
11+
12+
vi.mock('@/blocks/registry', () => ({
13+
getBlock: mockGetBlock,
14+
getBlockByToolName: vi.fn(),
15+
getLatestBlock: vi.fn(),
16+
}))
17+
18+
vi.mock('@/lib/auth/auth-client', () => ({
19+
useSession: vi.fn(() => ({ data: null, isPending: false })),
20+
}))
21+
22+
interface MockAgentGroupItem {
23+
type: string
24+
data?: { id: string; displayTitle: string }
25+
}
26+
27+
vi.mock('./components', () => ({
28+
AgentGroup: ({ items }: { items: MockAgentGroupItem[] }) => (
29+
<div>
30+
{items.map((item) => item.data && <span key={item.data.id}>{item.data.displayTitle}</span>)}
31+
</div>
32+
),
33+
ChatContent: () => null,
34+
CircleStop: () => null,
35+
Options: () => null,
36+
PendingTagIndicator: () => null,
37+
}))
38+
39+
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
40+
import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay'
41+
import { MessageContent } from './message-content'
42+
43+
describe('MessageContent custom-block hydration', () => {
44+
beforeEach(() => {
45+
vi.clearAllMocks()
46+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
47+
})
48+
49+
it('refreshes a read title when the custom-block registry hydrates after mount', () => {
50+
mockGetBlock.mockReturnValue(undefined)
51+
const blocks: ContentBlock[] = [
52+
{
53+
type: 'tool_call',
54+
toolCall: {
55+
id: 'read-custom-block',
56+
name: 'read',
57+
status: 'success',
58+
params: {
59+
path: 'organization/custom-blocks/custom_block_invoice_parser.json',
60+
},
61+
},
62+
timestamp: 1,
63+
},
64+
]
65+
const container = document.createElement('div')
66+
const root: Root = createRoot(container)
67+
68+
act(() => {
69+
root.render(<MessageContent blocks={blocks} fallbackContent='' isStreaming={false} />)
70+
})
71+
expect(container.textContent).toContain('Read Custom block invoice parser')
72+
73+
mockGetBlock.mockReturnValue({
74+
type: 'custom_block_invoice_parser',
75+
name: 'Invoice Parser',
76+
icon: () => null,
77+
})
78+
act(() => notifyBlockOverlayChanged())
79+
80+
expect(container.textContent).toContain('Read Invoice Parser')
81+
expect(container.textContent).not.toContain('Read Custom block invoice parser')
82+
act(() => root.unmount())
83+
})
84+
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '@/lib/copilot/tools/tool-display'
2323
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
2424
import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
25+
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
2526
import type { ContentBlock, OptionItem, ToolCallData } from '../../types'
2627
import { SUBAGENT_LABELS } from '../../types'
2728
import type { AgentGroupItem } from './components'
@@ -851,7 +852,11 @@ function MessageContentInner({
851852
actions,
852853
}: MessageContentProps) {
853854
const { onWorkspaceResourceSelect } = useChatSurface()
854-
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])
855+
const blockOverlayVersion = useCustomBlockOverlayVersion()
856+
const parsed = useMemo(
857+
() => (blocks.length > 0 ? parseBlocks(blocks) : []),
858+
[blocks, blockOverlayVersion]
859+
)
855860

856861
const [trailingRevealing, setTrailingRevealing] = useState(false)
857862
const handleTrailingRevealChange = useCallback((revealing: boolean) => {

apps/sim/lib/copilot/tools/client/read-block.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ import { describe, expect, it, vi } from 'vitest'
55
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
66

77
const gmailBlock = { type: 'gmail_v2', name: 'Gmail', icon: () => null }
8+
const customBlock = {
9+
type: 'custom_block_invoice_parser',
10+
name: 'Invoice Parser',
11+
icon: () => null,
12+
}
813

914
vi.mock('@/blocks/registry', () => ({
10-
getBlock: vi.fn((type: string) => (type === 'gmail_v2' ? gmailBlock : undefined)),
15+
getBlock: vi.fn((type: string) => {
16+
if (type === 'gmail_v2') return gmailBlock
17+
if (type === 'custom_block_invoice_parser') return customBlock
18+
return undefined
19+
}),
1120
getLatestBlock: vi.fn((baseType: string) => (baseType === 'gmail' ? gmailBlock : undefined)),
1221
}))
1322

@@ -21,6 +30,12 @@ describe('getReadTargetBlock', () => {
2130
expect(getReadTargetBlock('components/integrations/gmail')?.name).toBe('Gmail')
2231
})
2332

33+
it('resolves an organization custom-block read to its block', () => {
34+
expect(
35+
getReadTargetBlock('organization/custom-blocks/custom_block_invoice_parser.json')?.name
36+
).toBe('Invoice Parser')
37+
})
38+
2439
it('returns undefined for unknown blocks and non-component paths', () => {
2540
expect(getReadTargetBlock('components/blocks/unknown_block.json')).toBeUndefined()
2641
expect(getReadTargetBlock('workflows/My Workflow/meta.json')).toBeUndefined()

apps/sim/lib/copilot/tools/client/read-block.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ import { getBlock, getLatestBlock } from '@/blocks/registry'
22
import type { BlockConfig } from '@/blocks/types'
33

44
/**
5-
* Resolves the block a copilot `read` call targets when the path is a
6-
* component schema — `components/blocks/{type}.json` or
7-
* `components/integrations/{service}/{operation}.json` — so tool rows can show
8-
* the block's display name and brand icon instead of the raw type id
9-
* (e.g. "Gmail" instead of `gmail_v2`). Returns undefined for every other
10-
* path, leaving the generic read-target labeling untouched.
5+
* Resolves the block a copilot `read` call targets when the path references a
6+
* component schema or organization custom block, so tool rows can show the
7+
* block's display name and brand icon instead of its raw type id. Returns
8+
* undefined for every other path, leaving generic read-target labeling
9+
* untouched.
1110
*/
1211
export function getReadTargetBlock(path: string | undefined): BlockConfig | undefined {
1312
if (!path) return undefined
1413
const segments = path.trim().split('/').filter(Boolean)
14+
15+
if (segments[0] === 'organization' && segments[1] === 'custom-blocks' && segments.length === 3) {
16+
return getBlock(segments[2].replace(/\.json$/, ''))
17+
}
18+
1519
if (segments[0] !== 'components' || segments.length < 3) return undefined
1620
if (segments[1] === 'blocks' && segments.length === 3) {
1721
return getBlock(segments[2].replace(/\.json$/, ''))

apps/sim/lib/copilot/tools/client/store-utils.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,18 @@ import { resolveToolDisplay } from './store-utils'
88
import { ClientToolCallState } from './tool-call-state'
99

1010
const gmailBlock = { type: 'gmail_v2', name: 'Gmail', icon: () => null }
11+
const customBlock = {
12+
type: 'custom_block_invoice_parser',
13+
name: 'Invoice Parser',
14+
icon: () => null,
15+
}
1116

1217
vi.mock('@/blocks/registry', () => ({
13-
getBlock: vi.fn((type: string) => (type === 'gmail_v2' ? gmailBlock : undefined)),
18+
getBlock: vi.fn((type: string) => {
19+
if (type === 'gmail_v2') return gmailBlock
20+
if (type === 'custom_block_invoice_parser') return customBlock
21+
return undefined
22+
}),
1423
getLatestBlock: vi.fn((baseType: string) => (baseType === 'gmail' ? gmailBlock : undefined)),
1524
}))
1625

@@ -180,7 +189,7 @@ describe('resolveToolDisplay', () => {
180189
).toBe('Read style details for deck.pptx')
181190
})
182191

183-
it('shows the block display name for block and integration schema reads', () => {
192+
it('shows the block display name for block, integration, and custom-block reads', () => {
184193
expect(
185194
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
186195
path: 'components/blocks/gmail_v2.json',
@@ -198,6 +207,12 @@ describe('resolveToolDisplay', () => {
198207
path: 'components/blocks/unknown_block.json',
199208
})?.text
200209
).toBe('Read Unknown block')
210+
211+
expect(
212+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
213+
path: 'organization/custom-blocks/custom_block_invoice_parser.json',
214+
})?.text
215+
).toBe('Read Invoice Parser')
201216
})
202217

203218
it('humanizes internal VFS resource identifiers', () => {

0 commit comments

Comments
 (0)