diff --git a/.changeset/profile-timeline-pagination.md b/.changeset/profile-timeline-pagination.md new file mode 100644 index 0000000..357a5dd --- /dev/null +++ b/.changeset/profile-timeline-pagination.md @@ -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. diff --git a/packages/agent-react-devtools/skills/react-devtools/SKILL.md b/packages/agent-react-devtools/skills/react-devtools/SKILL.md index f740c0c..af5896c 100644 --- a/packages/agent-react-devtools/skills/react-devtools/SKILL.md +++ b/packages/agent-react-devtools/skills/react-devtools/SKILL.md @@ -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 @@ -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 ` to drill into a specific commit once you spot a spike. + ### Find a component and check its state ```bash diff --git a/packages/agent-react-devtools/skills/react-devtools/references/commands.md b/packages/agent-react-devtools/skills/react-devtools/references/commands.md index a48407b..70a7a2a 100644 --- a/packages/agent-react-devtools/skills/react-devtools/references/commands.md +++ b/packages/agent-react-devtools/skills/react-devtools/references/commands.md @@ -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 [--limit N]` Detail for a specific commit by index. Shows per-component self/total duration, render causes, and changed keys. diff --git a/packages/agent-react-devtools/src/__tests__/formatters.test.ts b/packages/agent-react-devtools/src/__tests__/formatters.test.ts index 39f4cb2..6938d87 100644 --- a/packages/agent-react-devtools/src/__tests__/formatters.test.ts +++ b/packages/agent-react-devtools/src/__tests__/formatters.test.ts @@ -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', () => { @@ -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+/); }); }); diff --git a/packages/agent-react-devtools/src/__tests__/profiler.test.ts b/packages/agent-react-devtools/src/__tests__/profiler.test.ts index 36f2cb4..d092199 100644 --- a/packages/agent-react-devtools/src/__tests__/profiler.test.ts +++ b/packages/agent-react-devtools/src/__tests__/profiler.test.ts @@ -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', () => { diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index ed9279a..fdc5210 100644 --- a/packages/agent-react-devtools/src/cli.ts +++ b/packages/agent-react-devtools/src/cli.ts @@ -402,7 +402,12 @@ async function main(): Promise { 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 { diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index a374d6f..6eea6a2 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -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': { diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index 63b5d81..31c30e9 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -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 ── @@ -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`, diff --git a/packages/agent-react-devtools/src/profiler.ts b/packages/agent-react-devtools/src/profiler.ts index 3fb9704..b947fea 100644 --- a/packages/agent-react-devtools/src/profiler.ts +++ b/packages/agent-react-devtools/src/profiler.ts @@ -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; @@ -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 { diff --git a/packages/agent-react-devtools/src/types.ts b/packages/agent-react-devtools/src/types.ts index 89af69c..bdc2fe0 100644 --- a/packages/agent-react-devtools/src/types.ts +++ b/packages/agent-react-devtools/src/types.ts @@ -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' }