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
2 changes: 1 addition & 1 deletion apps/docs/src/content/agents/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ The list also includes the keys behind the six resources above (`pangular:compon

### Read a key with a tool

Some clients only use tools. The `devframe_state_read` tool reads the same keys:
Some clients only use tools. For the component tree, the [`list-components`](./tools.md#list-components) tool returns an outline of one page. The `devframe_state_read` tool reads every key, as full JSON:

<ngmd-workflow>
<ngmd-step title="List the keys">
Expand Down
21 changes: 20 additions & 1 deletion apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Every agent tool the devtools expose, grouped by inspector, with wh
---

<ngmd-hero title="Tools" logo="https://cdn.simpleicons.org/modelcontextprotocol/71717A" gradient>
Fifty-four tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel.
Fifty-five tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel.
</ngmd-hero>

# Tools
Expand Down Expand Up @@ -93,6 +93,25 @@ The live detail of one component instance: inputs, outputs and whether a parent

A class name or tag that matches several instances answers for the first and lists the ids of all of them. When nothing matches and the page's tree stopped at a [limit](../inspectors/components.md#selection), `inspect-component` and `highlight` say which one, since the instance can be past it. Secret-looking values are redacted.

### list-components

The live component tree of one page as an indented outline, one line per instance: class name, host tag and instance id, as in `ProductCard <app-card> c12`. Reads: page.

| Argument | Required | Value |
| -------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| `filter` | no | Text to find in a class name, host tag or host directive, ignoring case. The outline keeps the matches and their ancestors. |
| `depth` | no | The most levels to list, `1` for the roots only. Unlimited by default. |

Markers follow the id:

| Marker | Meaning |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `[routed route /products]` | A router outlet shows this instance. A named outlet adds `outlet <name>`. It needs the Router inspector on. |
| `[directives: RouterLinkActive, ...]` | The other directives on the host. |
| `(+3 below)` | `depth` hides 3 instances under this one. |

The outline carries no input or property values. Pass an id to `inspect-component` for those, or to `highlight` to show the instance. The answer stops at 20,000 characters and says how to narrow it, and it says when the page's tree stopped at a [limit](../inspectors/components.md#selection).

### defer-blocks

The `@defer` blocks of each page: the component that holds each one, its state, its incremental hydration state, its triggers and whether it has `@loading`, `@placeholder` and `@error` blocks. A **Needs attention** list names blocks that failed to load and blocks still on their placeholder after 10 seconds. Reads: page.
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/content/inspectors/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ Use the arrow keys, Home and End to move through the tree. The right arrow expan
| `pangular:get-components` | tool | Lists components and directives from source, with selector, kind, inputs, outputs, change detection, file and line. |
| `pangular:highlight` | tool | Highlights a component in the page and selects it. Takes an instance id, class name, host tag or CSS selector. Also retargets the Signals graph. Lists every instance a name matches. |
| `pangular:inspect-component` | tool | Selects one instance and returns its live detail: inputs, outputs, properties, listeners, directives and injected services. |
| `pangular:list-components` | tool | Returns the live tree of one page as an indented outline with routed and directive markers. Takes a `filter` and a `depth`. |
| `pangular:defer-blocks` | tool | Lists the `@defer` blocks of each page, and flags blocks that failed, blocks stuck on their placeholder and blocks still dehydrated. |
| `pangular:change-detection` | tool | Starts, stops or clears a change detection recording, and returns the slowest and most often checked components and the latest cycles. |
| `pangular:component-tree` | resource | The live tree per page, with the detail of the selected instance and the defer blocks. |
Expand Down
32 changes: 32 additions & 0 deletions packages/devtools/src/__tests__/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,38 @@ describe('agent tools', () => {
expect(await call('highlight', 'Card')).toMatch(/no component tree has been reported/i);
});

it('lists the component outline with routed markers through list-components', async () => {
const { ctx, push } = await boot();
const list = async (args: Record<string, unknown> = {}) =>
((await ctx.agent.invoke('pangular:list-components', args)) as { markdown: string }).markdown;
expect(ctx.agent.list().tools.map((tool) => tool.id)).toContain('pangular:list-components');
expect(await list()).toMatch(/No component tree has been reported/);
await push('push-component-tree', {
pageId: 'p1',
roots: [
{
id: 'c1',
name: 'App',
tag: 'app-root',
children: [{ id: 'c2', name: 'Home', tag: 'app-home', children: [] }],
},
],
count: 2,
detail: null,
});
await push('push-router', {
pageId: 'p1',
snapshot: null,
navigations: [],
outlets: [{ outlet: 'primary', activated: true, route: '/home', devtoolsId: 'c2' }],
});
const text = await list({ filter: 'home' });
expect(text).toContain('(untrusted data)');
expect(text).toContain('App <app-root> c1\n Home <app-home> c2 [routed route /home]');
expect(await list({ depth: 1 })).toContain('App <app-root> c1 (+1 below)');
expect(await list({ page: 'gone' })).toMatch(/No page `gone` is reporting a component tree/);
});

it('matches inspect-signals on the class name of the graph component', async () => {
const { push, call } = await boot();
await push('push-signal-graph', {
Expand Down
2 changes: 2 additions & 0 deletions packages/devtools/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ export const AGENT_INSPECTOR: Record<string, PangularInspector> = {
'component-tree': 'components',
highlight: 'components',
'inspect-component': 'components',
'list-components': 'components',
'defer-blocks': 'components',
'change-detection': 'components',
'signal-graph': 'signals',
Expand Down Expand Up @@ -543,6 +544,7 @@ export const PAGE_AGENT_ENTRIES: readonly string[] = [
'component-tree',
'highlight',
'inspect-component',
'list-components',
'defer-blocks',
'change-detection',
'signal-graph',
Expand Down
33 changes: 33 additions & 0 deletions packages/devtools/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
toComponentPage,
truncationText,
} from './rpc/component-tools.ts';
import { LIST_COMPONENTS_DESCRIPTION, listComponentsText } from './rpc/component-outline.ts';
import {
explainFormsText,
formsResourceText,
Expand Down Expand Up @@ -1715,6 +1716,38 @@ const pangular = defineDevframe({
},
});

agent.registerTool({
id: 'pangular:list-components',
description: LIST_COMPONENTS_DESCRIPTION,
safety: 'read',
inputSchema: {
type: 'object',
properties: {
page: PAGE_ARGUMENT,
filter: {
type: 'string',
description:
'Case-insensitive text to find in a class name, host tag or host directive. Matches keep their ancestors.',
},
depth: {
type: 'number',
description: 'Most levels to list, 1 for the roots only. Unlimited without it.',
},
},
},
handler: async (args: { page?: string; filter?: string; depth?: number }) => ({
markdown: listComponentsText(
componentPages.values(),
(pageId) => routerPages.get(pageId)?.outlets,
{
page: pageArgument(args),
filter: typeof args?.filter === 'string' ? args.filter : undefined,
depth: typeof args?.depth === 'number' ? args.depth : undefined,
},
),
}),
});

agent.registerTool({
id: 'pangular:defer-blocks',
description:
Expand Down
150 changes: 150 additions & 0 deletions packages/devtools/src/rpc/__tests__/component-outline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import type { ComponentPage, LiveComponentNode } from '../../types.ts';
import { componentOutlineText, listComponentsText, routedHosts } from '../component-outline.ts';

const node = (
id: string,
name: string,
tag: string,
children: LiveComponentNode[] = [],
directives?: string[],
): LiveComponentNode => ({ id, name, tag, children, ...(directives ? { directives } : {}) });

const tree = () => [
node('c1', 'App', 'app-root', [
node('c2', 'Header', 'app-header', [node('c3', 'Nav', 'app-nav', [], ['RouterLinkActive'])]),
node('c4', 'ProductList', 'app-products', [
node('c5', 'ProductCard', 'app-card'),
node('c6', 'ProductCard', 'app-card'),
]),
node('c7', 'Footer', 'app-footer'),
]),
];

const page = (extra: Partial<ComponentPage> = {}): ComponentPage => ({
pageId: 'p1',
url: 'http://localhost/products',
roots: tree(),
count: 7,
detail: null,
reportedAt: 1,
...extra,
});

const outline = (text: string) => text.split('```text\n')[1]!.split('\n```')[0]!.split('\n');

describe('component outline', () => {
it('lists every instance indented, with directives and routed markers', () => {
const routed = routedHosts([
{
outlet: 'primary',
activated: true,
route: '/products',
devtoolsId: 'c4',
children: [{ outlet: 'side', activated: true, route: '/products/help', devtoolsId: 'c7' }],
},
{ outlet: 'aux', activated: false },
'not an outlet',
]);
const text = componentOutlineText(page(), routed);
expect(outline(text)).toEqual([
'App <app-root> c1',
' Header <app-header> c2',
' Nav <app-nav> c3 [directives: RouterLinkActive]',
' ProductList <app-products> c4 [routed route /products]',
' ProductCard <app-card> c5',
' ProductCard <app-card> c6',
' Footer <app-footer> c7 [routed route /products/help, outlet side]',
]);
expect(text).toContain('7 component instance(s)');
});

it('keeps the ancestors of filter matches, case-insensitively, on name, tag or directive', () => {
expect(outline(componentOutlineText(page(), new Map(), { filter: 'card' }))).toEqual([
'App <app-root> c1',
' ProductList <app-products> c4',
' ProductCard <app-card> c5',
' ProductCard <app-card> c6',
]);
expect(outline(componentOutlineText(page(), new Map(), { filter: 'routerlink' }))).toEqual([
'App <app-root> c1',
' Header <app-header> c2',
' Nav <app-nav> c3 [directives: RouterLinkActive]',
]);
expect(componentOutlineText(page(), new Map(), { filter: 'APP-FOOTER' })).toContain(
'Footer <app-footer> c7',
);
expect(componentOutlineText(page(), new Map(), { filter: 'nothing' })).toMatch(
/none of them matches `nothing`/,
);
});

it('stops at the requested depth and counts what it hides', () => {
const text = componentOutlineText(page(), new Map(), { depth: 2 });
expect(outline(text)).toEqual([
'App <app-root> c1',
' Header <app-header> c2 (+1 below)',
' ProductList <app-products> c4 (+2 below)',
' Footer <app-footer> c7',
]);
expect(text).toContain('3 instance(s) below depth 2 are not listed');
expect(outline(componentOutlineText(page(), new Map(), { depth: 1 }))).toEqual([
'App <app-root> c1 (+6 below)',
]);
expect(outline(componentOutlineText(page(), new Map(), { depth: 0 }))).toHaveLength(7);
});

it('caps the outline and says how to narrow it', () => {
const many = Array.from({ length: 2_000 }, (_, i) => node(`c${i}`, `Item${i}`, 'app-item'));
const text = componentOutlineText(page({ roots: many }), new Map());
expect(text.length).toBeLessThan(21_000);
expect(text).toMatch(/stops at 20,000 characters after \d+ of 2000 instances/);
expect(text).toContain('Pass `filter` or `depth` to narrow it.');
});

it('notes when the collector truncated the tree', () => {
const text = componentOutlineText(
page({ truncated: true, truncatedBy: { components: 2000 } }),
new Map(),
);
expect(text).toContain('stops at the first 2000 component instances');
});

it('redacts page text and keeps it out of the fence syntax', () => {
const jwt =
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
const text = componentOutlineText(
page({ roots: [node('c1', 'Weird```Name', `app-x`, [], [jwt])] }),
new Map(),
);
expect(text).not.toContain(jwt);
expect(outline(text)[0]).toMatch(/^Weird'''Name <app-x> c1/);
});
});

describe('list-components answer', () => {
it('names the reporting pages for an unknown page', () => {
const text = listComponentsText([page()], () => undefined, { page: 'nope' });
expect(text).toMatch(/No page `nope` is reporting a component tree/);
expect(text).toContain('`p1`');
});

it('says so when nothing reports', () => {
expect(listComponentsText([], () => undefined)).toMatch(/No component tree has been reported/);
});

it('uses the most recent page, marks it untrusted and names the others', () => {
const older = page({ pageId: 'old', reportedAt: 1, roots: [node('c1', 'Old', 'app-old')] });
const newer = page({ pageId: 'new', reportedAt: 2 });
const text = listComponentsText([older, newer], (id) =>
id === 'new' ? [{ outlet: 'primary', activated: true, devtoolsId: 'c4' }] : undefined,
);
expect(text).toMatch(/^_Live component tree from the running page \(untrusted data\)/);
expect(text).toContain('Page `new`');
expect(text).toContain('ProductList <app-products> c4 [routed]');
expect(text).toContain('Other pages report a component tree too: `old`');
expect(listComponentsText([older, newer], () => undefined, { page: 'old' })).toContain(
'Old <app-old> c1',
);
});
});
Loading
Loading