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
12 changes: 12 additions & 0 deletions .changeset/profile-timeline-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"agent-react-devtools": minor
---

Pagination and sorting for `profile timeline`

Large profiling sessions no longer flood agent context with hundreds of commits:

- **Default limit of 20**: `profile timeline` returns at most 20 entries unless `--limit N` is specified.
- **`--offset N` flag**: Skip the first N commits for pagination.
- **`--sort duration`**: Sort commits by render duration (slowest first) instead of chronological order.
- **Paginated header**: Output shows `Commit timeline (showing 1–20 of 87):` when paginated, or `Commit timeline (87 commits):` when all results fit on one page.
15 changes: 14 additions & 1 deletion packages/agent-react-devtools/skills/react-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ agent-react-devtools profile slow # Slowest components by avg rend
agent-react-devtools profile slow --limit 10 # Top 10
agent-react-devtools profile rerenders # Most re-rendered components
agent-react-devtools profile report @c5 # Detailed report for one component
agent-react-devtools profile timeline # Chronological commit list
agent-react-devtools profile timeline --limit 10 # First 10 commits (use --limit; uncapped can dump 300+ lines)
agent-react-devtools profile timeline --limit 10 --offset 10 # Next 10 (pagination)
agent-react-devtools profile timeline --sort duration --limit 5 # Top 5 most expensive commits
agent-react-devtools profile timeline --sort timeline --limit 5 # Explicit chronological order (same as default)
agent-react-devtools profile commit 3 # Detail for commit #3
agent-react-devtools profile export profile.json # Export as React DevTools Profiler JSON
agent-react-devtools profile diff before.json after.json # Compare two exports
Expand Down Expand Up @@ -125,6 +128,16 @@ agent-react-devtools profile rerenders --limit 5

Then inspect the worst offenders with `get component @cN` and `profile report @cN`.

### Browse a long timeline in chunks

```bash
agent-react-devtools profile timeline --limit 20 # commits 0–19
agent-react-devtools profile timeline --limit 20 --offset 20 # commits 20–39
agent-react-devtools profile timeline --offset 30 --limit 10 # skip warm-up, show 30–39
```

Use `profile commit <N>` to drill into a specific commit once you spot a spike.

### Find a component and check its state

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,14 @@ Output columns: label, type tag, component name, render count, all causes, chang
### `agent-react-devtools profile report <@cN | id>`
Detailed render report for a single component: render count, avg/max/total duration, all render causes, changed keys.

### `agent-react-devtools profile timeline [--limit N]`
Chronological list of React commits during the profiling session. Each entry: index, duration, component count.
### `agent-react-devtools profile timeline [--limit N] [--offset N] [--sort duration|timeline]`
Chronological list of React commits during the profiling session. Each entry: index, duration, component count. Default limit: 20.

The header shows how many commits were returned and the total: `Commit timeline (showing 1–20 of 87):`. When all commits fit, it shows `Commit timeline (42 commits):`.

Default order is chronological (timeline order). `--sort duration` re-orders entries by duration descending (most expensive first) before applying `--limit` — use this to find the heaviest commits. `--sort timeline` explicitly requests chronological order (same as the default).

`--offset N` skips the first N entries (after sorting). Use with `--limit` to page through commits or skip a known-good warm-up region.

### `agent-react-devtools profile commit <N | #N> [--limit N]`
Detail for a specific commit by index. Shows per-component self/total duration, render causes, and changed keys.
Expand Down
66 changes: 55 additions & 11 deletions packages/agent-react-devtools/src/__tests__/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '../formatters.js';
import type { TreeNode } from '../component-tree.js';
import type { InspectedElement, StatusInfo, ComponentRenderReport, ConnectionHealth, ChangedKeys } from '../types.js';
import type { ProfileSummary, TimelineEntry, CommitDetail } from '../profiler.js';
import type { ProfileSummary, TimelineResult, CommitDetail } from '../profiler.js';

describe('formatTree', () => {
it('should format empty tree', () => {
Expand Down Expand Up @@ -356,17 +356,61 @@ describe('formatRerenders', () => {
});

describe('formatTimeline', () => {
it('should format timeline entries', () => {
const entries: TimelineEntry[] = [
{ index: 0, timestamp: 1000, duration: 12.5, componentCount: 5 },
{ index: 1, timestamp: 2000, duration: 8.3, componentCount: 3 },
];
it('should format timeline entries showing all commits', () => {
const result: TimelineResult = {
entries: [
{ index: 0, timestamp: 1000, duration: 12.5, componentCount: 5 },
{ index: 1, timestamp: 2000, duration: 8.3, componentCount: 3 },
],
total: 2,
offset: 0,
};

const output = formatTimeline(result);
expect(output).toContain('2 commits');
expect(output).toContain('#0');
expect(output).toContain('12.5ms');
expect(output).toContain('#1');
expect(output).toContain('8.3ms');
});

it('should show pagination info when limited', () => {
const result: TimelineResult = {
entries: [
{ index: 0, timestamp: 1000, duration: 12.5, componentCount: 5 },
{ index: 1, timestamp: 2000, duration: 8.3, componentCount: 3 },
],
total: 50,
offset: 0,
};

const output = formatTimeline(result);
expect(output).toContain('showing 1–2 of 50');
});

it('should show correct range with offset', () => {
const result: TimelineResult = {
entries: [
{ index: 20, timestamp: 1000, duration: 5.0, componentCount: 2 },
],
total: 50,
offset: 20,
};

const output = formatTimeline(result);
expect(output).toContain('showing 21–21 of 50');
});

it('should return no data message when empty', () => {
const result: TimelineResult = { entries: [], total: 0, offset: 0 };
expect(formatTimeline(result)).toBe('No profiling data');
});

const result = formatTimeline(entries);
expect(result).toContain('#0');
expect(result).toContain('12.5ms');
expect(result).toContain('#1');
expect(result).toContain('8.3ms');
it('should handle offset past end gracefully', () => {
const result: TimelineResult = { entries: [], total: 50, offset: 50 };
const output = formatTimeline(result);
expect(output).toContain('0 of 50');
expect(output).not.toMatch(/showing \d+–\d+/);
});
});

Expand Down
11 changes: 6 additions & 5 deletions packages/agent-react-devtools/src/__tests__/profiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,12 @@ describe('Profiler', () => {
});

const timeline = profiler.getTimeline();
expect(timeline).toHaveLength(2);
expect(timeline[0].duration).toBe(10);
expect(timeline[0].componentCount).toBe(1);
expect(timeline[1].duration).toBe(20);
expect(timeline[1].componentCount).toBe(2);
expect(timeline.entries).toHaveLength(2);
expect(timeline.total).toBe(2);
expect(timeline.entries[0].duration).toBe(10);
expect(timeline.entries[0].componentCount).toBe(1);
expect(timeline.entries[1].duration).toBe(20);
expect(timeline.entries[1].componentCount).toBe(2);
});

describe('getExportData', () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-react-devtools/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,12 @@ async function main(): Promise<void> {

if (cmd0 === 'profile' && cmd1 === 'timeline') {
const limit = parseNumericFlag(flags, 'limit');
const resp = await sendCommand({ type: 'profile-timeline', limit });
const offset = parseNumericFlag(flags, 'offset');
const sortFlag = flags['sort'];
const sort = sortFlag === 'duration' ? 'duration' as const
: sortFlag === 'timeline' ? 'timeline' as const
: undefined;
const resp = await sendCommand({ type: 'profile-timeline', limit, offset, sort });
if (resp.ok) {
console.log(formatTimeline(resp.data as any));
} else {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-react-devtools/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ class Daemon {
case 'profile-timeline':
return {
ok: true,
data: this.profiler.getTimeline(cmd.limit),
data: this.profiler.getTimeline(cmd.limit, cmd.offset, cmd.sort),
};

case 'profile-commit': {
Expand Down
23 changes: 19 additions & 4 deletions packages/agent-react-devtools/src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
ChangedKeys,
} from './types.js';
import type { TreeNode } from './component-tree.js';
import type { ProfileSummary, TimelineEntry, CommitDetail } from './profiler.js';
import type { ProfileSummary, TimelineResult, CommitDetail } from './profiler.js';
import type { ProfileDiffResult, DiffEntry } from './profile-diff.js';

// ── Abbreviations for component types ──
Expand Down Expand Up @@ -261,10 +261,25 @@ export function formatRerenders(reports: ComponentRenderReport[]): string {
return lines.join('\n');
}

export function formatTimeline(entries: TimelineEntry[]): string {
if (entries.length === 0) return 'No profiling data';
export function formatTimeline(result: TimelineResult): string {
if (result.total === 0) return 'No profiling data';

const lines: string[] = ['Commit timeline:'];
const { entries, total, offset } = result;

if (entries.length === 0) {
return `Commit timeline (showing 0 of ${total}): offset past end`;
}

let header: string;
if (entries.length === total) {
header = `Commit timeline (${total} commits):`;
} else {
const from = offset + 1;
const to = offset + entries.length;
header = `Commit timeline (showing ${from}–${to} of ${total}):`;
}

const lines: string[] = [header];
for (const e of entries) {
lines.push(
` #${e.index} ${e.duration.toFixed(1)}ms ${e.componentCount} components`,
Expand Down
21 changes: 16 additions & 5 deletions packages/agent-react-devtools/src/profiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface TimelineEntry {
componentCount: number;
}

export interface TimelineResult {
entries: TimelineEntry[];
total: number;
offset: number;
}

export interface CommitDetail {
index: number;
timestamp: number;
Expand Down Expand Up @@ -307,18 +313,23 @@ export class Profiler {
};
}

getTimeline(limit?: number): TimelineEntry[] {
if (!this.session) return [];
getTimeline(limit: number = 20, offset?: number, sort?: 'duration' | 'timeline'): TimelineResult {
if (!this.session) return { entries: [], total: 0, offset: offset ?? 0 };

const entries = this.session.commits.map((commit, index) => ({
const all = this.session.commits.map((commit, index) => ({
index,
timestamp: commit.timestamp,
duration: commit.duration,
componentCount: commit.fiberActualDurations.size,
}));

if (limit) return entries.slice(0, limit);
return entries;
if (sort === 'duration') all.sort((a, b) => b.duration - a.duration);
const start = Math.max(0, offset ?? 0);
return {
entries: all.slice(start, start + limit),
total: all.length,
offset: start,
};
}

getExportData(tree: ComponentTree): ProfilingDataExport | null {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-react-devtools/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export type IpcCommand =
| { type: 'profile-report'; componentId: number | string }
| { type: 'profile-slow'; limit?: number }
| { type: 'profile-rerenders'; limit?: number }
| { type: 'profile-timeline'; limit?: number }
| { type: 'profile-timeline'; limit?: number; offset?: number; sort?: 'duration' | 'timeline' }
| { type: 'profile-commit'; index: number; limit?: number }
| { type: 'profile-export' }
| { type: 'errors' }
Expand Down
Loading