From 92bd7de4e9a1956142d58568237624a7a8a5ac1c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 10 Jun 2026 15:31:51 +0200 Subject: [PATCH 1/4] feat: add React component labels for agents Expose session-local component labels in React agent summaries and allow label-based node lookup. --- .../skills/rozenite-agent/domains/react.md | 10 +- .../middleware/src/agent/local-domains.ts | 66 ++++-- .../runtime/react/__tests__/store.test.ts | 221 ++++++++++++++++++ .../src/agent/runtime/react/store.ts | 126 ++++++++-- .../src/agent/runtime/react/types.ts | 10 +- 5 files changed, 395 insertions(+), 38 deletions(-) create mode 100644 packages/middleware/src/agent/runtime/react/__tests__/store.test.ts diff --git a/packages/cli/skills/rozenite-agent/domains/react.md b/packages/cli/skills/rozenite-agent/domains/react.md index 842d7da4..74d2ba5f 100644 --- a/packages/cli/skills/rozenite-agent/domains/react.md +++ b/packages/cli/skills/rozenite-agent/domains/react.md @@ -3,11 +3,11 @@ Search and traverse the React component tree, read props, state, and hooks for a ## Tools - `searchNodes` -> `{"query":""}` | `{"query":"","cursor":""}` | `{"query":"","limit":20}` -- `getNode` -> `{"nodeId":123}` -- `getChildren` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":""}` | `{"nodeId":123,"limit":20}` -- `getProps` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":""}` | `{"nodeId":123,"limit":20}` -- `getState` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":""}` | `{"nodeId":123,"limit":20}` -- `getHooks` -> `{"nodeId":123}` | `{"nodeId":123,"path":[0,"subHooks",1]}` | `{"nodeId":123,"limit":20}` +- `getNode` -> `{"nodeId":123}` | `{"id":"@c2"}` +- `getChildren` -> `{"nodeId":123}` | `{"id":"@c2","cursor":""}` | `{"id":"@c2","limit":20}` +- `getProps` -> `{"nodeId":123}` | `{"id":"@c2","cursor":""}` | `{"id":"@c2","limit":20}` +- `getState` -> `{"nodeId":123}` | `{"id":"@c2","cursor":""}` | `{"id":"@c2","limit":20}` +- `getHooks` -> `{"nodeId":123}` | `{"id":"@c2","path":[0,"subHooks",1]}` | `{"id":"@c2","limit":20}` - `startProfiling` -> `{}` | `{"shouldRestart":true}` - `isProfilingStarted` -> `{}` - `stopProfiling` -> `{}` | `{"waitForDataMs":3000}` | `{"slowRenderThresholdMs":16}` diff --git a/packages/middleware/src/agent/local-domains.ts b/packages/middleware/src/agent/local-domains.ts index afc60e59..42db9ff9 100644 --- a/packages/middleware/src/agent/local-domains.ts +++ b/packages/middleware/src/agent/local-domains.ts @@ -647,30 +647,45 @@ export const createReactDomainService = (deps: { payload: unknown; }) => void; }): LocalAgentToolService => { + const nodeIdentifierSchema = { + oneOf: [{ type: 'integer' }, { type: 'string' }], + }; + const nodeIdentifierRequirement = { + anyOf: [{ required: ['id'] }, { required: ['nodeId'] }], + }; const tools: AgentTool[] = [ { name: 'getNode', - description: 'Get a single React node summary by ID.', + description: 'Get a single React node summary by node ID or label.', inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'React DevTools node ID or component label.', + }, nodeId: { - type: 'integer', - description: 'React DevTools node ID.', + ...nodeIdentifierSchema, + description: 'React DevTools node ID or component label.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { name: 'getChildren', - description: "Get a node's direct children with cursor-based pagination.", + description: + "Get a node's direct children by node ID or label with cursor-based pagination.", inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'Parent node ID or component label.', + }, nodeId: { - type: 'integer', - description: 'Parent node ID.', + ...nodeIdentifierSchema, + description: 'Parent node ID or component label.', }, limit: { type: 'integer', @@ -681,7 +696,7 @@ export const createReactDomainService = (deps: { description: 'Opaque cursor returned by the previous page.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { @@ -691,9 +706,13 @@ export const createReactDomainService = (deps: { inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'Node ID or component label to read props for.', + }, nodeId: { - type: 'integer', - description: 'Node ID to read props for.', + ...nodeIdentifierSchema, + description: 'Node ID or component label to read props for.', }, limit: { type: 'integer', @@ -704,7 +723,7 @@ export const createReactDomainService = (deps: { description: 'Opaque cursor returned by the previous page.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { @@ -714,9 +733,13 @@ export const createReactDomainService = (deps: { inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'Node ID or component label to read state for.', + }, nodeId: { - type: 'integer', - description: 'Node ID to read state for.', + ...nodeIdentifierSchema, + description: 'Node ID or component label to read state for.', }, limit: { type: 'integer', @@ -727,7 +750,7 @@ export const createReactDomainService = (deps: { description: 'Opaque cursor returned by the previous page.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { @@ -737,9 +760,13 @@ export const createReactDomainService = (deps: { inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'Node ID or component label to read hooks for.', + }, nodeId: { - type: 'integer', - description: 'Node ID to read hooks for.', + ...nodeIdentifierSchema, + description: 'Node ID or component label to read hooks for.', }, path: { type: 'array', @@ -757,7 +784,7 @@ export const createReactDomainService = (deps: { description: 'Opaque cursor returned by the previous page.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { @@ -771,8 +798,9 @@ export const createReactDomainService = (deps: { description: 'Search query. Required and non-empty.', }, rootId: { - type: 'integer', - description: 'Optional root node ID to scope search to a subtree.', + ...nodeIdentifierSchema, + description: + 'Optional root node ID or component label to scope search to a subtree.', }, match: { type: 'string', diff --git a/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts b/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts new file mode 100644 index 00000000..b4345678 --- /dev/null +++ b/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; +import { createReactTreeStore } from '../store.js'; + +const DEVICE_ID = 'device-1'; + +const waitFor = async (predicate: () => boolean): Promise => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + + expect(predicate()).toBe(true); +}; + +const createStoreWithBridgeStub = ( + sent: Array<{ event: string; payload: unknown }>, +) => { + return createReactTreeStore({ + createBridge: async (options) => ({ + ingest: () => null, + send: (event, payload) => { + sent.push({ event, payload }); + options?.sendMessage?.({ event, payload }); + }, + startProfiling: () => undefined, + stopProfiling: () => undefined, + reloadAndProfile: () => undefined, + getProfilingStatus: () => ({ + supportsProfiling: true, + supportsReloadAndProfile: false, + isProfilingStarted: false, + isProcessingData: false, + hasProfilingData: false, + rootsWithData: 0, + rootsCount: 0, + }), + getProfilingDataSnapshot: () => null, + getCommitData: () => { + throw new Error('No commit data'); + }, + }), + }); +}; + +const createStoreWithTree = () => { + const store = createReactTreeStore(); + store.registerDevice(DEVICE_ID); + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [2, 3], + }, + { + nodeId: 2, + displayName: 'App', + elementType: 'function', + parentId: 1, + childIds: [4], + }, + { + nodeId: 3, + displayName: 'Sidebar', + elementType: 'function', + parentId: 1, + childIds: [], + }, + { + nodeId: 4, + displayName: 'Button', + elementType: 'host', + parentId: 2, + childIds: [], + }, + ], + }); + + return store; +}; + +describe('React tree store labels', () => { + it('adds deterministic labels to node summaries', () => { + const store = createStoreWithTree(); + + const result = store.searchNodes(DEVICE_ID, { query: 'o', limit: 10 }); + + expect(result.items).toEqual([ + expect.objectContaining({ + nodeId: 1, + label: '@c1', + displayName: 'Root', + }), + expect.objectContaining({ + nodeId: 4, + label: '@c3', + parentLabel: '@c2', + displayName: 'Button', + }), + ]); + expect(store.getNode(DEVICE_ID, { id: '@c4' })).toMatchObject({ + nodeId: 3, + label: '@c4', + parentLabel: '@c1', + displayName: 'Sidebar', + }); + }); + + it('resolves labels for node lookup', () => { + const store = createStoreWithTree(); + + expect(store.getNode(DEVICE_ID, { id: '@c2' })).toMatchObject({ + nodeId: 2, + label: '@c2', + displayName: 'App', + parentLabel: '@c1', + }); + }); + + it('keeps numeric nodeId lookup working', () => { + const store = createStoreWithTree(); + + expect(store.getNode(DEVICE_ID, { nodeId: 2 })).toMatchObject({ + nodeId: 2, + label: '@c2', + displayName: 'App', + }); + }); + + it('resolves labels for children and inspectable requests', async () => { + const sent: Array<{ event: string; payload: unknown }> = []; + const store = createStoreWithBridgeStub(sent); + store.registerDevice(DEVICE_ID, { + sendMessage: () => undefined, + }); + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [2], + }, + { + nodeId: 2, + displayName: 'App', + elementType: 'function', + parentId: 1, + rendererId: 7, + childIds: [], + }, + ], + }); + + expect(store.getChildren(DEVICE_ID, { id: '@c1' }).items).toEqual([ + expect.objectContaining({ + nodeId: 2, + label: '@c2', + parentLabel: '@c1', + }), + ]); + + const propsPromise = store.getProps(DEVICE_ID, { id: '@c2' }); + await waitFor(() => sent.length > 0); + + expect(sent.at(-1)).toEqual({ + event: 'inspectElement', + payload: { + forceFullData: true, + id: 2, + path: null, + rendererID: 7, + requestID: 1, + }, + }); + + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'full-data', + value: { + props: { title: 'Hello' }, + }, + }, + }); + + await expect(propsPromise).resolves.toMatchObject({ + items: [{ name: 'title', value: 'Hello' }], + }); + }); + + it('rejects stale labels after a tree sync', () => { + const store = createStoreWithTree(); + expect(store.getNode(DEVICE_ID, { id: '@c4' }).nodeId).toBe(3); + + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [], + }, + ], + }); + + expect(() => store.getNode(DEVICE_ID, { id: '@c4' })).toThrow( + 'Component label "@c4" no longer exists in the current React tree.', + ); + }); +}); diff --git a/packages/middleware/src/agent/runtime/react/store.ts b/packages/middleware/src/agent/runtime/react/store.ts index 15fee1ec..b84ce9d9 100644 --- a/packages/middleware/src/agent/runtime/react/store.ts +++ b/packages/middleware/src/agent/runtime/react/store.ts @@ -68,6 +68,8 @@ type ReactChangeDescription = { type DeviceReactTreeState = { rootIds: number[]; nodesById: Map; + labelByNodeId: Map; + nodeIdByLabel: Map; inspectedById: Map; bridge: ReactDevToolsBridge | null; bridgePromise: Promise | null; @@ -192,6 +194,7 @@ const delay = async (ms: number): Promise => { const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { return { nodeId: node.nodeId, + label: '@c?', displayName: node.displayName, elementType: node.elementType, ...(node.key !== undefined ? { key: node.key } : {}), @@ -200,12 +203,49 @@ const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { }; }; -const getNodeId = (value: unknown): number => { - if (!Number.isInteger(value)) { - throw new Error('"nodeId" must be an integer'); +const ensureNodeSummaryForState = ( + state: DeviceReactTreeState, + node: ReactNodeRecord, +): ReactNodeSummary => { + const parentLabel = + node.parentId !== undefined ? state.labelByNodeId.get(node.parentId) : undefined; + + return { + ...ensureNodeSummary(node), + label: state.labelByNodeId.get(node.nodeId) ?? '@c?', + ...(parentLabel !== undefined ? { parentLabel } : {}), + }; +}; + +const resolveNodeId = ( + state: DeviceReactTreeState, + value: unknown, + fieldName: string, +): number => { + if (Number.isInteger(value)) { + return Number(value); } - return Number(value); + if (typeof value === 'string') { + const trimmed = value.trim(); + if (/^@c\d+$/.test(trimmed)) { + const nodeId = state.nodeIdByLabel.get(trimmed); + if (nodeId === undefined) { + throw new Error( + `Component label "${trimmed}" no longer exists in the current React tree.`, + ); + } + + return nodeId; + } + + const parsed = Number(trimmed); + if (Number.isInteger(parsed)) { + return parsed; + } + } + + throw new Error(`"${fieldName}" must be an integer or component label like "@c12"`); }; const ensureNodeExists = ( @@ -222,6 +262,29 @@ const ensureNodeExists = ( return node; }; +const getRequestedNodeId = ( + state: DeviceReactTreeState, + request: Record, +): number => { + return resolveNodeId( + state, + request.id !== undefined ? request.id : request.nodeId, + request.id !== undefined ? 'id' : 'nodeId', + ); +}; + +const getOptionalNodeId = ( + state: DeviceReactTreeState, + value: unknown, + fieldName: string, +): number | undefined => { + if (value === undefined) { + return undefined; + } + + return resolveNodeId(state, value, fieldName); +}; + const createSerializableSnapshot = ( value: unknown, depth = 3, @@ -485,6 +548,38 @@ const toReactNodeRecord = (node: ReactTreeNodeInput): ReactNodeRecord => { }; }; +const rebuildLabels = (state: DeviceReactTreeState): void => { + state.labelByNodeId.clear(); + state.nodeIdByLabel.clear(); + + const visited = new Set(); + let nextLabelIndex = 1; + + const walk = (nodeId: number): void => { + if (visited.has(nodeId) || !state.nodesById.has(nodeId)) { + return; + } + + visited.add(nodeId); + const label = `@c${nextLabelIndex++}`; + state.labelByNodeId.set(nodeId, label); + state.nodeIdByLabel.set(label, nodeId); + + const node = state.nodesById.get(nodeId); + if (!node) { + return; + } + + for (const childId of node.childIds) { + walk(childId); + } + }; + + for (const rootId of state.rootIds) { + walk(rootId); + } +}; + export const createReactTreeStore = (options?: { createBridge?: (options?: { sendMessage?: (message: { event: string; payload: unknown }) => void; @@ -502,6 +597,8 @@ export const createReactTreeStore = (options?: { const created: DeviceReactTreeState = { rootIds: [], nodesById: new Map(), + labelByNodeId: new Map(), + nodeIdByLabel: new Map(), inspectedById: new Map(), bridge: null, bridgePromise: null, @@ -553,6 +650,7 @@ export const createReactTreeStore = (options?: { state.rootIds = rootIds.filter((rootId) => nodesById.has(rootId)); state.nodesById = nodesById; + rebuildLabels(state); state.inspectedById.clear(); }; @@ -874,9 +972,7 @@ export const createReactTreeStore = (options?: { } const query = rawQuery.trim().toLowerCase(); - const rootId = Number.isInteger(request.rootId) - ? Number(request.rootId) - : undefined; + const rootId = getOptionalNodeId(state, request.rootId, 'rootId'); const match = normalizeMatch(request.match); const limit = normalizeLimit(request.limit); @@ -920,7 +1016,9 @@ export const createReactTreeStore = (options?: { const safeOffset = Math.max(0, Math.min(offset, matched.length)); const end = Math.min(safeOffset + limit, matched.length); - const items = matched.slice(safeOffset, end).map(ensureNodeSummary); + const items = matched + .slice(safeOffset, end) + .map((node) => ensureNodeSummaryForState(state, node)); const hasMore = end < matched.length; const nextCursor = hasMore ? encodeCursor({ @@ -945,9 +1043,9 @@ export const createReactTreeStore = (options?: { const getNode = (deviceId: string, rawRequest: unknown): ReactNodeSummary => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); const node = ensureNodeExists(state, nodeId); - return ensureNodeSummary(node); + return ensureNodeSummaryForState(state, node); }; const getChildren = ( @@ -956,7 +1054,7 @@ export const createReactTreeStore = (options?: { ): ReactGetChildrenResult => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); const node = ensureNodeExists(state, nodeId); const limit = normalizeLimit(request.limit); const filtersHash = hashFilters({ nodeId }); @@ -984,7 +1082,9 @@ export const createReactTreeStore = (options?: { .filter((child): child is ReactNodeRecord => Boolean(child)); const safeOffset = Math.max(0, Math.min(offset, children.length)); const end = Math.min(safeOffset + limit, children.length); - const items = children.slice(safeOffset, end).map(ensureNodeSummary); + const items = children + .slice(safeOffset, end) + .map((child) => ensureNodeSummaryForState(state, child)); const hasMore = end < children.length; const nextCursor = hasMore ? encodeCursor({ @@ -1065,7 +1165,7 @@ export const createReactTreeStore = (options?: { ): Promise => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); ensureNodeExists(state, nodeId); const path = kind === 'hooks' ? normalizePath(request.path) : []; diff --git a/packages/middleware/src/agent/runtime/react/types.ts b/packages/middleware/src/agent/runtime/react/types.ts index 109288ed..60c2149d 100644 --- a/packages/middleware/src/agent/runtime/react/types.ts +++ b/packages/middleware/src/agent/runtime/react/types.ts @@ -1,13 +1,21 @@ export interface ReactNodeSummary { nodeId: number; + label: string; displayName: string; elementType: string; key?: string; childCount: number; parentId?: number; + parentLabel?: string; } -export interface ReactNodeRecord extends ReactNodeSummary { +export interface ReactNodeRecord { + nodeId: number; + displayName: string; + elementType: string; + key?: string; + childCount: number; + parentId?: number; childIds: number[]; rendererId?: number; } From eb0995e9210ca298b29214ce29e2f81f4b190847 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 12 Jun 2026 15:35:51 +0200 Subject: [PATCH 2/4] fix: resolve React component labels in CLI agent Accept stable component labels in the CLI React runtime store. Keep numeric nodeId support and cover the live command path. --- .../runtime/react/__tests__/store.test.ts | 169 ++++++++++++++++++ .../src/commands/agent/runtime/react/store.ts | 123 +++++++++++-- .../src/commands/agent/runtime/react/types.ts | 15 +- 3 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/commands/agent/runtime/react/__tests__/store.test.ts diff --git a/packages/cli/src/commands/agent/runtime/react/__tests__/store.test.ts b/packages/cli/src/commands/agent/runtime/react/__tests__/store.test.ts new file mode 100644 index 00000000..179f7bd7 --- /dev/null +++ b/packages/cli/src/commands/agent/runtime/react/__tests__/store.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { createReactTreeStore } from '../store.js'; + +const DEVICE_ID = 'device-1'; + +const waitFor = async (predicate: () => boolean): Promise => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + + expect(predicate()).toBe(true); +}; + +const createStoreWithBridgeStub = ( + sent: Array<{ event: string; payload: unknown }>, +) => { + return createReactTreeStore({ + createBridge: async (options) => ({ + ingest: () => null, + send: (event, payload) => { + sent.push({ event, payload }); + options?.sendMessage?.({ event, payload }); + }, + startProfiling: () => undefined, + stopProfiling: () => undefined, + reloadAndProfile: () => undefined, + getProfilingStatus: () => ({ + supportsProfiling: true, + supportsReloadAndProfile: false, + isProfilingStarted: false, + isProcessingData: false, + hasProfilingData: false, + rootsWithData: 0, + rootsCount: 0, + }), + getProfilingDataSnapshot: () => null, + getCommitData: () => { + throw new Error('No commit data'); + }, + }), + }); +}; + +const createStoreWithTree = () => { + const store = createReactTreeStore(); + store.registerDevice(DEVICE_ID); + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [2, 3], + }, + { + nodeId: 2, + displayName: 'App', + elementType: 'function', + parentId: 1, + childIds: [4], + }, + { + nodeId: 3, + displayName: 'Sidebar', + elementType: 'function', + parentId: 1, + childIds: [], + }, + { + nodeId: 4, + displayName: 'Button', + elementType: 'host', + parentId: 2, + childIds: [], + }, + ], + }); + + return store; +}; + +describe('CLI React tree store labels', () => { + it('resolves labels for node lookup while keeping numeric nodeId lookup working', () => { + const store = createStoreWithTree(); + + expect(store.getNode(DEVICE_ID, { id: '@c2' })).toMatchObject({ + nodeId: 2, + label: '@c2', + displayName: 'App', + parentLabel: '@c1', + }); + expect(store.getNode(DEVICE_ID, { nodeId: 2 })).toMatchObject({ + nodeId: 2, + label: '@c2', + displayName: 'App', + }); + }); + + it('resolves labels for children and inspectable requests', async () => { + const sent: Array<{ event: string; payload: unknown }> = []; + const store = createStoreWithBridgeStub(sent); + store.registerDevice(DEVICE_ID, { + sendMessage: () => undefined, + }); + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [2], + }, + { + nodeId: 2, + displayName: 'App', + elementType: 'function', + parentId: 1, + rendererId: 7, + childIds: [], + }, + ], + }); + + expect(store.getChildren(DEVICE_ID, { id: '@c1', limit: 3 }).items).toEqual([ + expect.objectContaining({ + nodeId: 2, + label: '@c2', + parentLabel: '@c1', + }), + ]); + + const propsPromise = store.getProps(DEVICE_ID, { id: '@c2' }); + await waitFor(() => sent.length > 0); + + expect(sent.at(-1)).toEqual({ + event: 'inspectElement', + payload: { + forceFullData: true, + id: 2, + path: null, + rendererID: 7, + requestID: 1, + }, + }); + + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'full-data', + value: { + props: { title: 'Hello' }, + }, + }, + }); + + await expect(propsPromise).resolves.toMatchObject({ + items: [{ name: 'title', value: 'Hello' }], + }); + }); +}); diff --git a/packages/cli/src/commands/agent/runtime/react/store.ts b/packages/cli/src/commands/agent/runtime/react/store.ts index 064f762f..0b6cfed2 100644 --- a/packages/cli/src/commands/agent/runtime/react/store.ts +++ b/packages/cli/src/commands/agent/runtime/react/store.ts @@ -65,6 +65,8 @@ type ReactChangeDescription = { type DeviceReactTreeState = { rootIds: number[]; nodesById: Map; + labelByNodeId: Map; + nodeIdByLabel: Map; inspectedById: Map; bridge: ReactDevToolsBridge | null; bridgePromise: Promise | null; @@ -180,6 +182,7 @@ const delay = async (ms: number): Promise => { const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { return { nodeId: node.nodeId, + label: '@c?', displayName: node.displayName, elementType: node.elementType, ...(node.key !== undefined ? { key: node.key } : {}), @@ -188,12 +191,48 @@ const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { }; }; -const getNodeId = (value: unknown): number => { - if (!Number.isInteger(value)) { - throw new Error('"nodeId" must be an integer'); +const ensureNodeSummaryForState = ( + state: DeviceReactTreeState, + node: ReactNodeRecord, +): ReactNodeSummary => { + const parentLabel = node.parentId !== undefined + ? state.labelByNodeId.get(node.parentId) + : undefined; + + return { + ...ensureNodeSummary(node), + label: state.labelByNodeId.get(node.nodeId) ?? '@c?', + ...(parentLabel !== undefined ? { parentLabel } : {}), + }; +}; + +const resolveNodeId = ( + state: DeviceReactTreeState, + value: unknown, + fieldName: string, +): number => { + if (Number.isInteger(value)) { + return Number(value); } - return Number(value); + if (typeof value === 'string') { + const trimmed = value.trim(); + if (/^@c\d+$/.test(trimmed)) { + const nodeId = state.nodeIdByLabel.get(trimmed); + if (nodeId === undefined) { + throw new Error(`Component label "${trimmed}" no longer exists in the current React tree.`); + } + + return nodeId; + } + + const parsed = Number(trimmed); + if (Number.isInteger(parsed)) { + return parsed; + } + } + + throw new Error(`"${fieldName}" must be an integer or component label like "@c12"`); }; const ensureNodeExists = (state: DeviceReactTreeState, nodeId: number): ReactNodeRecord => { @@ -205,6 +244,29 @@ const ensureNodeExists = (state: DeviceReactTreeState, nodeId: number): ReactNod return node; }; +const getRequestedNodeId = ( + state: DeviceReactTreeState, + request: Record, +): number => { + return resolveNodeId( + state, + request.id !== undefined ? request.id : request.nodeId, + request.id !== undefined ? 'id' : 'nodeId', + ); +}; + +const getOptionalNodeId = ( + state: DeviceReactTreeState, + value: unknown, + fieldName: string, +): number | undefined => { + if (value === undefined) { + return undefined; + } + + return resolveNodeId(state, value, fieldName); +}; + const createSerializableSnapshot = ( value: unknown, depth = 3, @@ -439,6 +501,38 @@ const toReactNodeRecord = (node: ReactTreeNodeInput): ReactNodeRecord => { }; }; +const rebuildLabels = (state: DeviceReactTreeState): void => { + state.labelByNodeId.clear(); + state.nodeIdByLabel.clear(); + + const visited = new Set(); + let nextLabelIndex = 1; + + const walk = (nodeId: number): void => { + if (visited.has(nodeId) || !state.nodesById.has(nodeId)) { + return; + } + + visited.add(nodeId); + const label = `@c${nextLabelIndex++}`; + state.labelByNodeId.set(nodeId, label); + state.nodeIdByLabel.set(label, nodeId); + + const node = state.nodesById.get(nodeId); + if (!node) { + return; + } + + for (const childId of node.childIds) { + walk(childId); + } + }; + + for (const rootId of state.rootIds) { + walk(rootId); + } +}; + export const createReactTreeStore = ( options?: { createBridge?: (options?: { @@ -458,6 +552,8 @@ export const createReactTreeStore = ( const created: DeviceReactTreeState = { rootIds: [], nodesById: new Map(), + labelByNodeId: new Map(), + nodeIdByLabel: new Map(), inspectedById: new Map(), bridge: null, bridgePromise: null, @@ -511,6 +607,7 @@ export const createReactTreeStore = ( state.rootIds = rootIds.filter((rootId) => nodesById.has(rootId)); state.nodesById = nodesById; + rebuildLabels(state); // Tree-sync snapshots replace current topology; invalidate stale inspected snapshots. state.inspectedById.clear(); }; @@ -794,7 +891,7 @@ export const createReactTreeStore = ( } const query = rawQuery.trim().toLowerCase(); - const rootId = Number.isInteger(request.rootId) ? Number(request.rootId) : undefined; + const rootId = getOptionalNodeId(state, request.rootId, 'rootId'); const match = normalizeMatch(request.match); const limit = normalizeLimit(request.limit); @@ -833,7 +930,9 @@ export const createReactTreeStore = ( const safeOffset = Math.max(0, Math.min(offset, matched.length)); const end = Math.min(safeOffset + limit, matched.length); - const items = matched.slice(safeOffset, end).map(ensureNodeSummary); + const items = matched + .slice(safeOffset, end) + .map((node) => ensureNodeSummaryForState(state, node)); const hasMore = end < matched.length; const nextCursor = hasMore ? encodeCursor({ @@ -858,15 +957,15 @@ export const createReactTreeStore = ( const getNode = (deviceId: string, rawRequest: unknown): ReactNodeSummary => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); const node = ensureNodeExists(state, nodeId); - return ensureNodeSummary(node); + return ensureNodeSummaryForState(state, node); }; const getChildren = (deviceId: string, rawRequest: unknown): ReactGetChildrenResult => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); const node = ensureNodeExists(state, nodeId); const limit = normalizeLimit(request.limit); const filtersHash = hashFilters({ nodeId }); @@ -889,7 +988,9 @@ export const createReactTreeStore = ( .filter((child): child is ReactNodeRecord => Boolean(child)); const safeOffset = Math.max(0, Math.min(offset, children.length)); const end = Math.min(safeOffset + limit, children.length); - const items = children.slice(safeOffset, end).map(ensureNodeSummary); + const items = children + .slice(safeOffset, end) + .map((child) => ensureNodeSummaryForState(state, child)); const hasMore = end < children.length; const nextCursor = hasMore ? encodeCursor({ @@ -968,7 +1069,7 @@ export const createReactTreeStore = ( ): Promise => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); ensureNodeExists(state, nodeId); const path = kind === 'hooks' ? normalizePath(request.path) : []; diff --git a/packages/cli/src/commands/agent/runtime/react/types.ts b/packages/cli/src/commands/agent/runtime/react/types.ts index 40069575..16ccb32f 100644 --- a/packages/cli/src/commands/agent/runtime/react/types.ts +++ b/packages/cli/src/commands/agent/runtime/react/types.ts @@ -1,13 +1,21 @@ export interface ReactNodeSummary { nodeId: number; + label: string; displayName: string; elementType: string; key?: string; childCount: number; parentId?: number; + parentLabel?: string; } -export interface ReactNodeRecord extends ReactNodeSummary { +export interface ReactNodeRecord { + nodeId: number; + displayName: string; + elementType: string; + key?: string; + childCount: number; + parentId?: number; childIds: number[]; rendererId?: number; } @@ -40,7 +48,7 @@ export interface ReactDevToolsBridgeMessage { export interface ReactSearchNodesRequest { query?: string; - rootId?: number; + rootId?: number | string; match?: 'name' | 'name-or-key'; limit?: number; cursor?: string; @@ -57,10 +65,12 @@ export interface ReactSearchNodesResult { export interface ReactGetNodeRequest { nodeId?: number; + id?: string | number; } export interface ReactGetChildrenRequest { nodeId?: number; + id?: string | number; limit?: number; cursor?: string; } @@ -81,6 +91,7 @@ export interface ReactInspectableEntry { export interface ReactGetInspectableRequest { nodeId?: number; + id?: string | number; limit?: number; cursor?: string; } From cc8638ae8102f28f634e02e256fed09e4f775ee8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 12 Jun 2026 15:50:11 +0200 Subject: [PATCH 3/4] chore: add changeset for label-based lookups Document label-based React node lookup support. --- .changeset/label-based-react-node-lookups.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/label-based-react-node-lookups.md diff --git a/.changeset/label-based-react-node-lookups.md b/.changeset/label-based-react-node-lookups.md new file mode 100644 index 00000000..733ac32f --- /dev/null +++ b/.changeset/label-based-react-node-lookups.md @@ -0,0 +1,6 @@ +--- +'@rozenite/middleware': patch +'rozenite': patch +--- + +Add stable React component labels so nodes can be looked up by labels like `@c2` across the React agent tools. From 450458126c4ee742ca4d8a37e4e1bd0921044aed Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 12 Jun 2026 16:19:42 +0200 Subject: [PATCH 4/4] fix: restore react agent label lookup Reinstate label maps and state-aware summaries after merging main. --- .../src/agent/runtime/react/store.ts | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/middleware/src/agent/runtime/react/store.ts b/packages/middleware/src/agent/runtime/react/store.ts index d2d24884..d909e063 100644 --- a/packages/middleware/src/agent/runtime/react/store.ts +++ b/packages/middleware/src/agent/runtime/react/store.ts @@ -71,6 +71,8 @@ type ReactChangeDescription = { type DeviceReactTreeState = { rootIds: number[]; nodesById: Map; + labelByNodeId: Map; + nodeIdByLabel: Map; inspectedById: Map; bridge: ReactDevToolsBridge | null; bridgePromise: Promise | null; @@ -208,6 +210,7 @@ const delay = async (ms: number): Promise => { const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { return { nodeId: node.nodeId, + label: '@c?', displayName: node.displayName, elementType: node.elementType, ...(node.key !== undefined ? { key: node.key } : {}), @@ -313,18 +316,6 @@ const getRequestedNodeId = ( ); }; -const getOptionalNodeId = ( - state: DeviceReactTreeState, - value: unknown, - fieldName: string, -): number | undefined => { - if (value === undefined) { - return undefined; - } - - return resolveNodeId(state, value, fieldName); -}; - const createSerializableSnapshot = ( value: unknown, depth = 3, @@ -605,6 +596,8 @@ export const createReactTreeStore = (options?: { const created: DeviceReactTreeState = { rootIds: [], nodesById: new Map(), + labelByNodeId: new Map(), + nodeIdByLabel: new Map(), inspectedById: new Map(), bridge: null, bridgePromise: null, @@ -656,6 +649,34 @@ export const createReactTreeStore = (options?: { state.rootIds = rootIds.filter((rootId) => nodesById.has(rootId)); state.nodesById = nodesById; + state.labelByNodeId.clear(); + state.nodeIdByLabel.clear(); + + const visited = new Set(); + let nextLabelIndex = 1; + const assignLabels = (nodeId: number): void => { + if (visited.has(nodeId)) { + return; + } + const node = nodesById.get(nodeId); + if (!node) { + return; + } + + visited.add(nodeId); + const label = `@c${nextLabelIndex++}`; + state.labelByNodeId.set(nodeId, label); + state.nodeIdByLabel.set(label, nodeId); + + for (const childId of node.childIds) { + assignLabels(childId); + } + }; + + for (const rootId of state.rootIds) { + assignLabels(rootId); + } + state.inspectedById.clear(); }; @@ -1023,7 +1044,9 @@ export const createReactTreeStore = (options?: { const safeOffset = Math.max(0, Math.min(offset, matched.length)); const end = Math.min(safeOffset + limit, matched.length); - const items = matched.slice(safeOffset, end).map(ensureNodeSummary); + const items = matched + .slice(safeOffset, end) + .map((node) => ensureNodeSummaryForState(state, node)); const hasMore = end < matched.length; const nextCursor = hasMore ? encodeCursor({ @@ -1187,7 +1210,9 @@ export const createReactTreeStore = (options?: { .filter((child): child is ReactNodeRecord => Boolean(child)); const safeOffset = Math.max(0, Math.min(offset, children.length)); const end = Math.min(safeOffset + limit, children.length); - const items = children.slice(safeOffset, end).map(ensureNodeSummary); + const items = children + .slice(safeOffset, end) + .map((child) => ensureNodeSummaryForState(state, child)); const hasMore = end < children.length; const nextCursor = hasMore ? encodeCursor({