Skip to content

Commit e9c8a60

Browse files
authored
feat: connection health tracking and wait command (#27)
* feat: add connection health tracking Track connect, disconnect, and reconnect events with timestamps in DevToolsBridge. Show last connection event in status output and a contextual hint when get-tree returns empty after a disconnect. * feat: add wait command Add wait --connected and wait --component <name> to block until a condition is met. Uses long-poll over IPC with onStateChange listener. Supports --timeout (default 30s), exits non-zero on timeout. * fix: gate reconnect on 0→1 transition and validate --component arg Reconnect events now only fire when going from zero connected apps to one within the reconnect window, avoiding false reconnect events in multi-app sessions. Also use typeof check for --component flag to reject missing argument before the IPC call. * fix: validate numeric CLI flags with parseNumericFlag helper Extract a shared parseNumericFlag() that validates parseInt results and exits with a clear error on non-numeric input. Replaces inline parseInt calls for --port, --depth, --timeout, and --limit flags.
1 parent bd2e3c7 commit e9c8a60

File tree

14 files changed

+547
-30
lines changed

14 files changed

+547
-30
lines changed

.changeset/connection-health.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"agent-react-devtools": minor
3+
---
4+
5+
Show connection health in `status` and `get tree`
6+
7+
- Show last connection event in `status` (e.g. "app reconnected 3s ago")
8+
- Show contextual hint when `get tree` returns empty after a disconnect

.changeset/wait-command.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"agent-react-devtools": minor
3+
---
4+
5+
Add `wait` command
6+
7+
- `wait --connected` — block until a React app connects
8+
- `wait --component <name>` — block until a named component appears in the tree
9+
- Both support `--timeout` (default 30s) and exit non-zero on timeout

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ agent-react-devtools status
3535
Daemon: running (port 8097)
3636
Apps: 1 connected, 24 components
3737
Uptime: 12s
38+
Last event: app connected 3s ago
3839
```
3940

4041
Browse the component tree:
@@ -122,6 +123,17 @@ agent-react-devtools count # Component count by type
122123

123124
Components are labeled `@c1`, `@c2`, etc. You can use these labels or numeric IDs interchangeably.
124125

126+
### Wait
127+
128+
Block until a condition is met. Useful in scripts or agent workflows where the daemon starts before the app:
129+
130+
```sh
131+
agent-react-devtools wait --connected [--timeout 30] # Block until an app connects
132+
agent-react-devtools wait --component App [--timeout 30] # Block until a component appears
133+
```
134+
135+
Exits with code 0 when the condition is met, or code 1 on timeout.
136+
125137
### Profiling
126138

127139
```sh

packages/agent-react-devtools/skills/react-devtools/SKILL.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ CLI that connects to a running React or React Native app via the React DevTools
1010

1111
## Core Workflow
1212

13-
1. **Ensure connection** — check `agent-react-devtools status`. If the daemon is not running, start it with `agent-react-devtools start`.
13+
1. **Ensure connection** — check `agent-react-devtools status`. If the daemon is not running, start it with `agent-react-devtools start`. Use `agent-react-devtools wait --connected` to block until a React app connects.
1414
2. **Inspect** — get the component tree, search for components, inspect props/state/hooks.
1515
3. **Profile** — start profiling, trigger the interaction (or ask the user to), stop profiling, analyze results.
1616
4. **Act** — use the data to fix the bug, optimize performance, or explain what's happening.
@@ -22,7 +22,9 @@ CLI that connects to a running React or React Native app via the React DevTools
2222
```bash
2323
agent-react-devtools start # Start daemon (auto-starts on first command)
2424
agent-react-devtools stop # Stop daemon
25-
agent-react-devtools status # Check connection: port, connected apps, component count
25+
agent-react-devtools status # Check connection, component count, last event
26+
agent-react-devtools wait --connected # Block until a React app connects
27+
agent-react-devtools wait --component App # Block until a component appears
2628
```
2729

2830
### Component Inspection
@@ -95,6 +97,15 @@ Render causes: `props-changed`, `state-changed`, `hooks-changed`, `parent-render
9597

9698
## Common Patterns
9799

100+
### Wait for the app to connect after a reload
101+
102+
```bash
103+
agent-react-devtools wait --connected --timeout 10
104+
agent-react-devtools get tree
105+
```
106+
107+
Use this after triggering a page reload or HMR update to avoid querying empty state.
108+
98109
### Diagnose slow interactions
99110

100111
```bash
@@ -135,7 +146,7 @@ agent-react-devtools status # Should show 1 connected app
135146

136147
## Important Rules
137148

138-
- **Labels reset** when the app reloads or components unmount/remount. Always re-check with `get tree` or `find` after a page reload.
149+
- **Labels reset** when the app reloads or components unmount/remount. After a reload, use `wait --connected` then re-check with `get tree` or `find`.
139150
- **`status` first** — if status shows 0 connected apps, the React app is not connected. The user may need to run `npx agent-react-devtools init` in their project first.
140151
- **Headed browser required** — if using `agent-browser`, always use `--headed` mode. Headless Chromium does not properly load the devtools connect script.
141152
- **Profile while interacting** — profiling only captures renders that happen between `profile start` and `profile stop`. Make sure the relevant interaction happens during that window.

packages/agent-react-devtools/skills/react-devtools/references/commands.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,24 @@ Start the background daemon. Default port: 8097. The daemon listens for WebSocke
99
Stop the daemon process. All connection state is lost.
1010

1111
### `agent-react-devtools status`
12-
Show daemon status: port, connected apps, component count, profiling state, uptime.
12+
Show daemon status: port, connected apps, component count, profiling state, uptime, and last connection event.
1313

1414
Output:
1515
```
1616
Daemon: running (port 8097)
1717
Apps: 1 connected, 42 components
18+
Last event: app connected 3s ago
1819
Uptime: 120s
1920
```
2021

2122
If profiling is active, shows `Profiling: active`.
2223

24+
### `agent-react-devtools wait --connected [--timeout S]`
25+
Block until at least one React app connects via WebSocket. Resolves immediately if already connected. Default timeout: 30s. Exits non-zero on timeout.
26+
27+
### `agent-react-devtools wait --component <name> [--timeout S]`
28+
Block until a component with the given display name appears in the tree. Uses exact name matching. Useful after a reload to wait for a specific part of the UI to render. Default timeout: 30s. Exits non-zero on timeout.
29+
2330
## Component Inspection
2431

2532
### `agent-react-devtools get tree [--depth N]`

packages/agent-react-devtools/src/__tests__/formatters.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatSearchResults,
66
formatCount,
77
formatStatus,
8+
formatAgo,
89
formatProfileSummary,
910
formatProfileReport,
1011
formatSlowest,
@@ -13,12 +14,18 @@ import {
1314
formatCommitDetail,
1415
} from '../formatters.js';
1516
import type { TreeNode } from '../component-tree.js';
16-
import type { InspectedElement, StatusInfo, ComponentRenderReport } from '../types.js';
17+
import type { InspectedElement, StatusInfo, ComponentRenderReport, ConnectionHealth } from '../types.js';
1718
import type { ProfileSummary, TimelineEntry, CommitDetail } from '../profiler.js';
1819

1920
describe('formatTree', () => {
2021
it('should format empty tree', () => {
2122
expect(formatTree([])).toContain('No components');
23+
expect(formatTree([])).toContain('is a React app connected?');
24+
});
25+
26+
it('should show hint when provided for empty tree', () => {
27+
const result = formatTree([], 'app disconnected 5s ago, waiting for reconnect...');
28+
expect(result).toBe('No components (app disconnected 5s ago, waiting for reconnect...)');
2229
});
2330

2431
it('should format a simple tree', () => {
@@ -123,6 +130,13 @@ describe('formatCount', () => {
123130
});
124131

125132
describe('formatStatus', () => {
133+
const baseConnection: ConnectionHealth = {
134+
connectedApps: 1,
135+
hasEverConnected: true,
136+
lastDisconnectAt: null,
137+
recentEvents: [],
138+
};
139+
126140
it('should format status info', () => {
127141
const status: StatusInfo = {
128142
daemonRunning: true,
@@ -131,6 +145,7 @@ describe('formatStatus', () => {
131145
componentCount: 47,
132146
profilingActive: false,
133147
uptime: 12000,
148+
connection: baseConnection,
134149
};
135150

136151
const result = formatStatus(status);
@@ -139,6 +154,64 @@ describe('formatStatus', () => {
139154
expect(result).toContain('1 connected');
140155
expect(result).toContain('47 components');
141156
});
157+
158+
it('should show last connection event', () => {
159+
const now = Date.now();
160+
const status: StatusInfo = {
161+
daemonRunning: true,
162+
port: 8097,
163+
connectedApps: 1,
164+
componentCount: 10,
165+
profilingActive: false,
166+
uptime: 5000,
167+
connection: {
168+
...baseConnection,
169+
recentEvents: [
170+
{ type: 'connected', timestamp: now - 3000 },
171+
],
172+
},
173+
};
174+
175+
const result = formatStatus(status);
176+
expect(result).toContain('Last event: app connected 3s ago');
177+
});
178+
179+
it('should show reconnected event', () => {
180+
const now = Date.now();
181+
const status: StatusInfo = {
182+
daemonRunning: true,
183+
port: 8097,
184+
connectedApps: 1,
185+
componentCount: 10,
186+
profilingActive: false,
187+
uptime: 5000,
188+
connection: {
189+
...baseConnection,
190+
recentEvents: [
191+
{ type: 'disconnected', timestamp: now - 4000 },
192+
{ type: 'reconnected', timestamp: now - 2000 },
193+
],
194+
},
195+
};
196+
197+
const result = formatStatus(status);
198+
expect(result).toContain('Last event: app reconnected 2s ago');
199+
});
200+
201+
it('should not show events line when no events', () => {
202+
const status: StatusInfo = {
203+
daemonRunning: true,
204+
port: 8097,
205+
connectedApps: 0,
206+
componentCount: 0,
207+
profilingActive: false,
208+
uptime: 1000,
209+
connection: baseConnection,
210+
};
211+
212+
const result = formatStatus(status);
213+
expect(result).not.toContain('Last event');
214+
});
142215
});
143216

144217
describe('formatProfileSummary', () => {

packages/agent-react-devtools/src/cli.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ Components:
3737
find <name> [--exact] Search by display name
3838
count Component count by type
3939
40+
Wait:
41+
wait --connected [--timeout S] Block until an app connects
42+
wait --component <name> [--timeout S] Block until a component appears
43+
4044
Profiling:
4145
profile start [name] Start profiling session
4246
profile stop Stop profiling, collect data
@@ -78,6 +82,21 @@ function parseArgs(argv: string[]): {
7882
return { command, flags };
7983
}
8084

85+
function parseNumericFlag(
86+
flags: Record<string, string | boolean>,
87+
name: string,
88+
defaultValue?: number,
89+
): number | undefined {
90+
const raw = flags[name];
91+
if (raw === undefined || raw === true) return defaultValue;
92+
const n = parseInt(raw as string, 10);
93+
if (isNaN(n)) {
94+
console.error(`Invalid value for --${name}: expected a number`);
95+
process.exit(1);
96+
}
97+
return n;
98+
}
99+
81100
async function main(): Promise<void> {
82101
const { command, flags } = parseArgs(process.argv.slice(2));
83102

@@ -104,7 +123,7 @@ async function main(): Promise<void> {
104123

105124
// ── Daemon management ──
106125
if (cmd0 === 'start') {
107-
const port = flags['port'] ? parseInt(flags['port'] as string, 10) : undefined;
126+
const port = parseNumericFlag(flags, 'port');
108127
await ensureDaemon(port);
109128
const resp = await sendCommand({ type: 'status' });
110129
if (resp.ok) {
@@ -145,13 +164,11 @@ async function main(): Promise<void> {
145164

146165
// ── Component inspection ──
147166
if (cmd0 === 'get' && cmd1 === 'tree') {
148-
const depth = flags['depth']
149-
? parseInt(flags['depth'] as string, 10)
150-
: undefined;
167+
const depth = parseNumericFlag(flags, 'depth');
151168
const ipcCmd: IpcCommand = { type: 'get-tree', depth };
152169
const resp = await sendCommand(ipcCmd);
153170
if (resp.ok) {
154-
console.log(formatTree(resp.data as any));
171+
console.log(formatTree(resp.data as any, resp.hint));
155172
} else {
156173
console.error(resp.error);
157174
process.exit(1);
@@ -208,6 +225,42 @@ async function main(): Promise<void> {
208225
return;
209226
}
210227

228+
// ── Wait ──
229+
if (cmd0 === 'wait') {
230+
const timeoutSec = parseNumericFlag(flags, 'timeout', 30)!;
231+
const timeoutMs = timeoutSec * 1000;
232+
const socketTimeout = timeoutMs + 5000;
233+
234+
let ipcCmd: IpcCommand;
235+
if (flags['connected'] !== undefined) {
236+
ipcCmd = { type: 'wait', condition: 'connected', timeout: timeoutMs };
237+
} else if (flags['component'] !== undefined) {
238+
if (typeof flags['component'] !== 'string') {
239+
console.error('Usage: devtools wait --component <name> [--timeout S]');
240+
process.exit(1);
241+
}
242+
ipcCmd = { type: 'wait', condition: 'component', name: flags['component'], timeout: timeoutMs };
243+
} else {
244+
console.error('Usage: devtools wait --connected|--component <name> [--timeout S]');
245+
process.exit(1);
246+
}
247+
248+
const resp = await sendCommand(ipcCmd, socketTimeout);
249+
if (resp.ok) {
250+
const result = resp.data as { met: boolean; condition: string; timeout?: boolean };
251+
if (result.met) {
252+
console.log(`Condition met: ${result.condition}`);
253+
} else {
254+
console.error(`Timed out waiting for: ${result.condition}`);
255+
process.exit(1);
256+
}
257+
} else {
258+
console.error(resp.error);
259+
process.exit(1);
260+
}
261+
return;
262+
}
263+
211264
// ── Profiling ──
212265
if (cmd0 === 'profile' && cmd1 === 'start') {
213266
const name = command[2];
@@ -254,7 +307,7 @@ async function main(): Promise<void> {
254307
}
255308

256309
if (cmd0 === 'profile' && cmd1 === 'slow') {
257-
const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined;
310+
const limit = parseNumericFlag(flags, 'limit');
258311
const resp = await sendCommand({ type: 'profile-slow', limit });
259312
if (resp.ok) {
260313
console.log(formatSlowest(resp.data as any));
@@ -266,7 +319,7 @@ async function main(): Promise<void> {
266319
}
267320

268321
if (cmd0 === 'profile' && cmd1 === 'rerenders') {
269-
const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined;
322+
const limit = parseNumericFlag(flags, 'limit');
270323
const resp = await sendCommand({ type: 'profile-rerenders', limit });
271324
if (resp.ok) {
272325
console.log(formatRerenders(resp.data as any));
@@ -288,7 +341,7 @@ async function main(): Promise<void> {
288341
console.error('Usage: devtools profile commit <N | #N>');
289342
process.exit(1);
290343
}
291-
const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined;
344+
const limit = parseNumericFlag(flags, 'limit');
292345
const resp = await sendCommand({ type: 'profile-commit', index, limit });
293346
if (resp.ok) {
294347
console.log(formatCommitDetail(resp.data as any));
@@ -300,7 +353,7 @@ async function main(): Promise<void> {
300353
}
301354

302355
if (cmd0 === 'profile' && cmd1 === 'timeline') {
303-
const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined;
356+
const limit = parseNumericFlag(flags, 'limit');
304357
const resp = await sendCommand({ type: 'profile-timeline', limit });
305358
if (resp.ok) {
306359
console.log(formatTimeline(resp.data as any));

packages/agent-react-devtools/src/daemon-client.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export function stopDaemon(): boolean {
108108
}
109109
}
110110

111-
export function sendCommand(cmd: IpcCommand): Promise<IpcResponse> {
111+
export function sendCommand(cmd: IpcCommand, socketTimeout = 30_000): Promise<IpcResponse> {
112112
return new Promise((resolve, reject) => {
113113
const socketPath = getSocketPath();
114114

@@ -135,8 +135,7 @@ export function sendCommand(cmd: IpcCommand): Promise<IpcResponse> {
135135
reject(new Error(`Cannot connect to daemon: ${err.message}`));
136136
});
137137

138-
// Timeout after 30 seconds
139-
conn.setTimeout(30_000, () => {
138+
conn.setTimeout(socketTimeout, () => {
140139
conn.destroy();
141140
reject(new Error('Command timed out'));
142141
});

0 commit comments

Comments
 (0)