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
This commit is contained in:
Justin Blumencranz
2026-08-24 15:06:02 -07:00
committed by GitHub
parent 3ce99530f8
commit 49e2bb5da7
7 changed files with 181 additions and 19 deletions
@@ -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('<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())
})
})
@@ -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
@@ -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())
})
})
@@ -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) => {
@@ -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()
@@ -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$/, ''))
@@ -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', () => {