@rozenite/network-activity-plugin 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
  4. package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
  5. package/dist/react-native/chunks/boot-recording.cjs +138 -14
  6. package/dist/react-native/chunks/boot-recording.js +138 -14
  7. package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
  8. package/dist/react-native/chunks/get-nitro-module.js +4 -1
  9. package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
  10. package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
  11. package/dist/react-native/index.d.ts +37 -1
  12. package/dist/rozenite.json +1 -1
  13. package/dist/sdk/index.d.ts +37 -1
  14. package/package.json +12 -7
  15. package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
  16. package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
  17. package/src/react-native/http/http-utils.ts +208 -25
  18. package/src/react-native/network-inspector.ts +2 -2
  19. package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
  20. package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
  21. package/src/shared/http-events.ts +40 -1
  22. package/src/ui/components/CodeBlock.tsx +45 -1
  23. package/src/ui/components/FilterBar.tsx +337 -61
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/NetworkTimeline.tsx +422 -0
  27. package/src/ui/components/RequestList.tsx +19 -40
  28. package/src/ui/components/SidePanel.tsx +42 -1
  29. package/src/ui/components/Toolbar.tsx +13 -1
  30. package/src/ui/components/ViewToggle.tsx +44 -0
  31. package/src/ui/components/XmlTree.tsx +160 -0
  32. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  33. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  34. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  35. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  36. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  37. package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
  38. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  39. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  40. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  41. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  42. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  43. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  44. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  45. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  46. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  47. package/src/ui/response-renderers/binary.tsx +31 -0
  48. package/src/ui/response-renderers/empty.tsx +14 -0
  49. package/src/ui/response-renderers/html.tsx +36 -0
  50. package/src/ui/response-renderers/image.tsx +37 -0
  51. package/src/ui/response-renderers/index.ts +55 -0
  52. package/src/ui/response-renderers/json.tsx +40 -0
  53. package/src/ui/response-renderers/svg.tsx +27 -0
  54. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  55. package/src/ui/response-renderers/types.ts +38 -0
  56. package/src/ui/response-renderers/unknown.tsx +18 -0
  57. package/src/ui/response-renderers/xml.tsx +46 -0
  58. package/src/ui/state/__tests__/store.test.ts +77 -0
  59. package/src/ui/state/derived.ts +14 -0
  60. package/src/ui/state/filter.ts +49 -0
  61. package/src/ui/state/hooks.ts +2 -2
  62. package/src/ui/state/model.ts +7 -1
  63. package/src/ui/state/store.ts +63 -4
  64. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  65. package/src/ui/tabs/ResponseTab.tsx +80 -97
  66. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  67. package/src/ui/utils/__tests__/download.test.ts +115 -0
  68. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  69. package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
  70. package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
  71. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  72. package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
  73. package/src/ui/utils/download.ts +161 -0
  74. package/src/ui/utils/hex.ts +59 -0
  75. package/src/ui/utils/initiator.ts +136 -0
  76. package/src/ui/utils/requestFilters.ts +183 -0
  77. package/src/ui/utils/sessionExport.ts +185 -0
  78. package/src/ui/utils/symbolication.ts +248 -0
  79. package/src/ui/utils/timelineModel.ts +352 -0
  80. package/src/ui/views/InspectorView.tsx +43 -8
  81. package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
  82. package/src/utils/getContentTypeMimeType.ts +14 -0
  83. package/vite.config.ts +5 -1
  84. package/vitest.setup.ts +31 -0
@@ -0,0 +1,422 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { CSSProperties, PointerEvent } from 'react';
3
+ import { X } from 'lucide-react';
4
+ import type { RequestId } from '../../shared/client';
5
+ import type { ProcessedRequest } from '../state/model';
6
+ import {
7
+ useNetworkActivityActions,
8
+ useSelectedRequestId,
9
+ } from '../state/hooks';
10
+ import {
11
+ formatTimelineOffset,
12
+ getTimelineBarTopOffset,
13
+ getTimelineModel,
14
+ getTimelineTrackTop,
15
+ isRequestActive,
16
+ TIMELINE_LAYOUT,
17
+ } from '../utils/timelineModel';
18
+ import type {
19
+ TimelineModel,
20
+ TimelineRangeSelection,
21
+ TimelineRow,
22
+ TimelineTick,
23
+ } from '../utils/timelineModel';
24
+
25
+ const REQUEST_TIMELINE_COLORS = {
26
+ error: 'bg-red-400',
27
+ primary: 'bg-gray-400',
28
+ active: 'bg-gray-500',
29
+ httpTtfb: 'bg-gray-200',
30
+ } as const;
31
+
32
+ const getPrimaryBarClassName = (request: ProcessedRequest) => {
33
+ if (request.status === 'failed' || request.status === 'error') {
34
+ return REQUEST_TIMELINE_COLORS.error;
35
+ }
36
+
37
+ return REQUEST_TIMELINE_COLORS.primary;
38
+ };
39
+
40
+ const getStyle = (
41
+ offsetPercent: number,
42
+ widthPercent: number,
43
+ ): CSSProperties => ({
44
+ left: `${offsetPercent}%`,
45
+ width: `${widthPercent}%`,
46
+ });
47
+
48
+ const GridLines = ({ ticks }: { ticks: TimelineTick[] }) => {
49
+ return (
50
+ <div className="pointer-events-none absolute inset-0">
51
+ {ticks.map((tick) => (
52
+ <div
53
+ key={`${tick.label}-${tick.offsetPercent}`}
54
+ className="absolute inset-y-0 border-l border-gray-800"
55
+ style={{ left: `${tick.offsetPercent}%` }}
56
+ />
57
+ ))}
58
+ </div>
59
+ );
60
+ };
61
+
62
+ const getTickLabelStyle = (tick: TimelineTick): CSSProperties => {
63
+ if (tick.offsetPercent === 0) {
64
+ return {
65
+ left: 4,
66
+ };
67
+ }
68
+
69
+ if (tick.offsetPercent === 100) {
70
+ return {
71
+ right: 4,
72
+ };
73
+ }
74
+
75
+ return {
76
+ left: `${tick.offsetPercent}%`,
77
+ };
78
+ };
79
+
80
+ const TimelineTrack = ({
81
+ row,
82
+ isSelected,
83
+ onSelect,
84
+ shouldSuppressSelect,
85
+ }: {
86
+ row: TimelineRow;
87
+ isSelected: boolean;
88
+ onSelect: (requestId: RequestId) => void;
89
+ shouldSuppressSelect: () => boolean;
90
+ }) => {
91
+ const primaryBarClassName = row.isActive
92
+ ? REQUEST_TIMELINE_COLORS.active
93
+ : getPrimaryBarClassName(row.request);
94
+ const isSplitHttpBar =
95
+ row.request.type === 'http' &&
96
+ row.ttfbPercent > 0 &&
97
+ row.receivePercent > 0;
98
+ const trackTop = getTimelineTrackTop(row.lane);
99
+ const barTop = getTimelineBarTopOffset();
100
+ const positionStyle = {
101
+ ...getStyle(row.offsetPercent, row.widthPercent),
102
+ top: trackTop,
103
+ };
104
+ const durationLabel = row.isActive
105
+ ? `${formatTimelineOffset(row.duration)}+`
106
+ : formatTimelineOffset(row.duration);
107
+ const label = `${row.request.method} ${row.request.name} - ${durationLabel}`;
108
+
109
+ return (
110
+ <button
111
+ type="button"
112
+ aria-label={label}
113
+ title={label}
114
+ data-timeline-track="true"
115
+ className="absolute rounded-sm text-left transition-opacity hover:opacity-80"
116
+ style={{
117
+ ...positionStyle,
118
+ height: TIMELINE_LAYOUT.laneHitTargetHeightPx,
119
+ }}
120
+ onClick={(event) => {
121
+ if (shouldSuppressSelect()) {
122
+ event.preventDefault();
123
+ event.stopPropagation();
124
+ return;
125
+ }
126
+
127
+ onSelect(row.request.id);
128
+ }}
129
+ >
130
+ {isSplitHttpBar ? (
131
+ <div
132
+ className={`absolute flex w-full overflow-hidden rounded-sm ${
133
+ isSelected
134
+ ? 'ring-1 ring-blue-300 ring-offset-1 ring-offset-gray-950'
135
+ : ''
136
+ }`}
137
+ style={{
138
+ top: barTop,
139
+ height: TIMELINE_LAYOUT.laneHeightPx,
140
+ }}
141
+ >
142
+ <div
143
+ className={`h-full ${REQUEST_TIMELINE_COLORS.httpTtfb}`}
144
+ style={{ width: `${row.ttfbPercent}%` }}
145
+ />
146
+ <div
147
+ className={`h-full ${REQUEST_TIMELINE_COLORS.primary}`}
148
+ style={{ width: `${row.receivePercent}%` }}
149
+ />
150
+ </div>
151
+ ) : (
152
+ <div
153
+ className={`absolute w-full rounded-sm ${primaryBarClassName} ${
154
+ isSelected
155
+ ? 'ring-1 ring-blue-300 ring-offset-1 ring-offset-gray-950'
156
+ : ''
157
+ }`}
158
+ style={{
159
+ top: barTop,
160
+ height: TIMELINE_LAYOUT.laneHeightPx,
161
+ }}
162
+ />
163
+ )}
164
+ </button>
165
+ );
166
+ };
167
+
168
+ type DraftSelection = {
169
+ anchorPercent: number;
170
+ currentPercent: number;
171
+ startedOnTrack: boolean;
172
+ };
173
+
174
+ const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100);
175
+
176
+ const getPointerPercent = (
177
+ event: PointerEvent<HTMLDivElement>,
178
+ element: HTMLDivElement,
179
+ ) => {
180
+ const rect = element.getBoundingClientRect();
181
+
182
+ if (rect.width === 0) {
183
+ return 0;
184
+ }
185
+
186
+ return clampPercent(((event.clientX - rect.left) / rect.width) * 100);
187
+ };
188
+
189
+ const getSelectionStyle = (
190
+ range: TimelineRangeSelection,
191
+ timeline: TimelineModel,
192
+ ): CSSProperties => {
193
+ const startPercent = clampPercent(
194
+ ((range.startTime - timeline.rangeStart) / timeline.rangeDuration) * 100,
195
+ );
196
+ const endPercent = clampPercent(
197
+ ((range.endTime - timeline.rangeStart) / timeline.rangeDuration) * 100,
198
+ );
199
+ const left = Math.min(startPercent, endPercent);
200
+ const width = Math.abs(endPercent - startPercent);
201
+
202
+ return {
203
+ left: `${left}%`,
204
+ width: `${width}%`,
205
+ top: TIMELINE_LAYOUT.rulerHeightPx,
206
+ };
207
+ };
208
+
209
+ const getDraftSelectionStyle = (draft: DraftSelection): CSSProperties => {
210
+ const left = Math.min(draft.anchorPercent, draft.currentPercent);
211
+ const width = Math.abs(draft.currentPercent - draft.anchorPercent);
212
+
213
+ return {
214
+ left: `${left}%`,
215
+ width: `${width}%`,
216
+ top: TIMELINE_LAYOUT.rulerHeightPx,
217
+ };
218
+ };
219
+
220
+ export type NetworkTimelineProps = {
221
+ requests: ProcessedRequest[];
222
+ selection: TimelineRangeSelection | null;
223
+ filteredRequestCount: number;
224
+ onSelectionChange: (selection: TimelineRangeSelection | null) => void;
225
+ };
226
+
227
+ export const NetworkTimeline = ({
228
+ requests,
229
+ selection,
230
+ filteredRequestCount,
231
+ onSelectionChange,
232
+ }: NetworkTimelineProps) => {
233
+ const actions = useNetworkActivityActions();
234
+ const selectedRequestId = useSelectedRequestId();
235
+ const [now, setNow] = useState(() => Date.now());
236
+ const [draftSelection, setDraftSelection] = useState<DraftSelection | null>(
237
+ null,
238
+ );
239
+ const chartRef = useRef<HTMLDivElement | null>(null);
240
+ const suppressTrackClickRef = useRef(false);
241
+
242
+ const hasActiveRequests = requests.some(isRequestActive);
243
+
244
+ useEffect(() => {
245
+ if (!hasActiveRequests) {
246
+ return;
247
+ }
248
+
249
+ const interval = window.setInterval(() => {
250
+ setNow(Date.now());
251
+ }, TIMELINE_LAYOUT.liveRefreshMs);
252
+
253
+ return () => window.clearInterval(interval);
254
+ }, [hasActiveRequests]);
255
+
256
+ const timeline = useMemo(() => {
257
+ return getTimelineModel(requests, now);
258
+ }, [requests, now]);
259
+
260
+ const onRequestSelect = (requestId: RequestId) => {
261
+ actions.setSelectedRequest(requestId);
262
+ };
263
+
264
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
265
+ if (event.button !== 0 || requests.length === 0) {
266
+ return;
267
+ }
268
+
269
+ const chartElement = chartRef.current;
270
+
271
+ if (!chartElement) {
272
+ return;
273
+ }
274
+
275
+ const percent = getPointerPercent(event, chartElement);
276
+ const target = event.target;
277
+ const startedOnTrack =
278
+ target instanceof Element &&
279
+ target.closest('[data-timeline-track="true"]') !== null;
280
+
281
+ setDraftSelection({
282
+ anchorPercent: percent,
283
+ currentPercent: percent,
284
+ startedOnTrack,
285
+ });
286
+ chartElement.setPointerCapture(event.pointerId);
287
+ };
288
+
289
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
290
+ if (!draftSelection) {
291
+ return;
292
+ }
293
+
294
+ const chartElement = chartRef.current;
295
+
296
+ if (!chartElement) {
297
+ return;
298
+ }
299
+
300
+ event.preventDefault();
301
+ const percent = getPointerPercent(event, chartElement);
302
+
303
+ setDraftSelection((current) =>
304
+ current ? { ...current, currentPercent: percent } : current,
305
+ );
306
+ };
307
+
308
+ const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
309
+ if (!draftSelection) {
310
+ return;
311
+ }
312
+
313
+ const chartElement = chartRef.current;
314
+ const currentPercent = chartElement
315
+ ? getPointerPercent(event, chartElement)
316
+ : draftSelection.currentPercent;
317
+ const distance = Math.abs(currentPercent - draftSelection.anchorPercent);
318
+
319
+ if (distance > 1) {
320
+ const startOffset =
321
+ (Math.min(draftSelection.anchorPercent, currentPercent) / 100) *
322
+ timeline.rangeDuration;
323
+ const endOffset =
324
+ (Math.max(draftSelection.anchorPercent, currentPercent) / 100) *
325
+ timeline.rangeDuration;
326
+
327
+ onSelectionChange({
328
+ startTime: timeline.rangeStart + startOffset,
329
+ endTime: timeline.rangeStart + endOffset,
330
+ });
331
+
332
+ suppressTrackClickRef.current = true;
333
+ window.setTimeout(() => {
334
+ suppressTrackClickRef.current = false;
335
+ }, 0);
336
+ } else if (!draftSelection.startedOnTrack) {
337
+ onSelectionChange(null);
338
+ }
339
+
340
+ setDraftSelection(null);
341
+
342
+ if (chartElement?.hasPointerCapture(event.pointerId)) {
343
+ chartElement.releasePointerCapture(event.pointerId);
344
+ }
345
+ };
346
+
347
+ return (
348
+ <div className="border-b border-gray-700 bg-gray-900 p-1.5">
349
+ <div
350
+ ref={chartRef}
351
+ className="relative overflow-hidden border border-gray-800 bg-gray-950"
352
+ style={{ height: timeline.chartHeight }}
353
+ onPointerDown={onPointerDown}
354
+ onPointerMove={onPointerMove}
355
+ onPointerUp={onPointerUp}
356
+ >
357
+ <GridLines ticks={timeline.ticks} />
358
+
359
+ <div
360
+ className="pointer-events-none absolute inset-x-0 border-b border-gray-800"
361
+ style={{ top: TIMELINE_LAYOUT.rulerHeightPx }}
362
+ />
363
+
364
+ {timeline.ticks.map((tick) => (
365
+ <div
366
+ key={`${tick.label}-${tick.offsetPercent}`}
367
+ className="absolute top-1 whitespace-nowrap tabular-nums text-xs text-gray-200"
368
+ style={getTickLabelStyle(tick)}
369
+ >
370
+ {tick.label}
371
+ </div>
372
+ ))}
373
+
374
+ {selection && (
375
+ <div
376
+ className="pointer-events-none absolute bottom-0 border-x border-blue-300/70 bg-blue-400/10"
377
+ style={getSelectionStyle(selection, timeline)}
378
+ />
379
+ )}
380
+
381
+ {draftSelection && (
382
+ <div
383
+ className="pointer-events-none absolute bottom-0 border-x border-blue-300/70 bg-blue-400/15"
384
+ style={getDraftSelectionStyle(draftSelection)}
385
+ />
386
+ )}
387
+
388
+ {timeline.rows.map((row) => (
389
+ <TimelineTrack
390
+ key={row.request.id}
391
+ row={row}
392
+ isSelected={selectedRequestId === row.request.id}
393
+ onSelect={onRequestSelect}
394
+ shouldSuppressSelect={() => suppressTrackClickRef.current}
395
+ />
396
+ ))}
397
+
398
+ {selection && (
399
+ <div className="absolute bottom-1 right-1 flex items-center gap-1 rounded border border-gray-700 bg-gray-900/95 px-1.5 py-0.5 text-xs text-gray-400">
400
+ <span>{filteredRequestCount} in range</span>
401
+ <button
402
+ type="button"
403
+ title="Clear timeline selection"
404
+ aria-label="Clear timeline selection"
405
+ className="rounded p-0.5 text-gray-400 hover:bg-gray-800 hover:text-gray-100"
406
+ onClick={() => onSelectionChange(null)}
407
+ >
408
+ <X className="h-3 w-3" />
409
+ </button>
410
+ </div>
411
+ )}
412
+
413
+ {timeline.hiddenRequestCount > 0 && (
414
+ <div className="absolute bottom-1 left-1 rounded border border-gray-700 bg-gray-900/95 px-1.5 py-0.5 text-xs text-gray-400">
415
+ Showing latest {timeline.rows.length} of{' '}
416
+ {timeline.totalRequestCount}
417
+ </div>
418
+ )}
419
+ </div>
420
+ </div>
421
+ );
422
+ };
@@ -8,30 +8,35 @@ import {
8
8
  SortingState,
9
9
  useReactTable,
10
10
  } from '@tanstack/react-table';
11
- import { ProcessedRequest } from '../state/model';
12
- import { RequestId, RequestOverride } from '../../shared/client';
11
+ import type { ProcessedRequest } from '../state/model';
12
+ import type {
13
+ NetworkEventSource,
14
+ RequestId,
15
+ RequestOverride,
16
+ } from '../../shared/client';
13
17
  import {
14
18
  useNetworkActivityActions,
15
19
  useOverrides,
16
- useProcessedRequests,
17
20
  useSelectedRequestId,
18
21
  useClientUISettings,
19
22
  } from '../state/hooks';
20
23
  import { getStatusColor } from '../utils/getStatusColor';
21
- import { FilterState } from './FilterBar';
22
24
  import { isNumber } from '../../utils/typeChecks';
23
- import type { NetworkEventSource } from '../../shared/client';
24
25
 
25
26
  type NetworkRequest = {
26
27
  id: RequestId;
27
28
  name: string;
28
29
  status: string | number;
29
- method: string;
30
+ statusCode?: number;
31
+ method: ProcessedRequest['method'];
30
32
  domain: string;
31
33
  path: string;
34
+ contentType?: string;
32
35
  size: string;
36
+ sizeBytes: number | null;
33
37
  time: string;
34
- type: string;
38
+ durationMs: number;
39
+ type: ProcessedRequest['type'];
35
40
  source?: NetworkEventSource;
36
41
  startTime: string;
37
42
  hasOverride: boolean;
@@ -105,7 +110,6 @@ const sortSize: SortingFn<NetworkRequest> = (rowA, rowB, columnId) => {
105
110
  const a = rowA.getValue(columnId) as string;
106
111
  const b = rowB.getValue(columnId) as string;
107
112
 
108
- // Extract numeric values from formatted strings like "1.2 kB", "500 B", etc.
109
113
  const getNumericValue = (str: string) => {
110
114
  const match = str.match(/^([\d.]+)\s*([KMGT]?B)$/);
111
115
  if (!match) return 0;
@@ -127,7 +131,6 @@ const sortTime: SortingFn<NetworkRequest> = (rowA, rowB, columnId) => {
127
131
  const a = rowA.getValue(columnId) as string;
128
132
  const b = rowB.getValue(columnId) as string;
129
133
 
130
- // Extract numeric values from formatted strings like "150 ms", "1.2 s", etc.
131
134
  const getNumericValue = (str: string) => {
132
135
  const match = str.match(/^([\d.]+)\s*(ms|s)$/);
133
136
  if (!match) return 0;
@@ -161,15 +164,19 @@ const processNetworkRequests = (
161
164
  id: request.id,
162
165
  name: generateName(request.name, showEntirePathAsName),
163
166
  status: statusDisplay,
167
+ statusCode: request.httpStatus || undefined,
164
168
  method: request.method,
165
169
  domain,
166
170
  path,
171
+ contentType: request.contentType,
167
172
  size: isNumber(request.size) ? formatSize(request.size) : '—',
173
+ sizeBytes: isNumber(request.size) ? request.size : null,
168
174
  time: formatDuration(duration),
175
+ durationMs: duration,
169
176
  type: request.type,
170
177
  source: request.source,
171
178
  startTime: formatStartTime(request.timestamp),
172
- hasOverride: hasOverride,
179
+ hasOverride,
173
180
  };
174
181
  });
175
182
  };
@@ -245,43 +252,16 @@ const columns = [
245
252
  ];
246
253
 
247
254
  export type RequestListProps = {
248
- filter: FilterState;
255
+ requests: ProcessedRequest[];
249
256
  };
250
257
 
251
- export const RequestList = ({ filter }: RequestListProps) => {
258
+ export const RequestList = ({ requests: filteredRequests }: RequestListProps) => {
252
259
  const actions = useNetworkActivityActions();
253
- const processedRequests = useProcessedRequests();
254
260
  const selectedRequestId = useSelectedRequestId();
255
261
  const [sorting, setSorting] = useState<SortingState>([]);
256
262
  const overrides = useOverrides();
257
263
  const clientUISettings = useClientUISettings();
258
264
 
259
- // Filter requests based on current filter state
260
- const filteredRequests = useMemo(() => {
261
- return processedRequests.filter((request) => {
262
- // Type filter
263
- if (!filter.types.has(request.type)) {
264
- return false;
265
- }
266
-
267
- // Text filter
268
- if (filter.text) {
269
- const searchText = filter.text.toLowerCase();
270
- const searchableFields = [
271
- request.name,
272
- request.method,
273
- request.status.toString(),
274
- ]
275
- .join(' ')
276
- .toLowerCase();
277
-
278
- return searchableFields.includes(searchText);
279
- }
280
-
281
- return true;
282
- });
283
- }, [processedRequests, filter]);
284
-
285
265
  const requests = useMemo(() => {
286
266
  return processNetworkRequests(
287
267
  filteredRequests,
@@ -369,7 +349,6 @@ export const RequestList = ({ filter }: RequestListProps) => {
369
349
  );
370
350
  };
371
351
 
372
- // Export helper functions for use in other components
373
352
  export {
374
353
  formatSize,
375
354
  formatDuration,
@@ -1,3 +1,4 @@
1
+ import { useState } from 'react';
1
2
  import { Badge } from './Badge';
2
3
  import { Button } from './Button';
3
4
  import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
@@ -6,6 +7,7 @@ import { RequestTab } from '../tabs/RequestTab';
6
7
  import { ResponseTab } from '../tabs/ResponseTab';
7
8
  import { CookiesTab } from '../tabs/CookiesTab';
8
9
  import { TimingTab } from '../tabs/TimingTab';
10
+ import { InitiatorTab } from '../tabs/InitiatorTab';
9
11
  import { X } from 'lucide-react';
10
12
  import {
11
13
  useNetworkActivityActions,
@@ -17,6 +19,7 @@ import { NetworkEntry as OldNetworkEntry } from '../types';
17
19
  import { getStatusColor } from '../utils/getStatusColor';
18
20
  import { MessagesTab } from '../tabs/MessagesTab';
19
21
  import { SSEMessagesTab } from '../tabs/SSEMessagesTab';
22
+ import type { ResponseView } from '../response-renderers';
20
23
 
21
24
  const getTypeColor = (type: string) => {
22
25
  const colors: Record<string, string> = {
@@ -87,6 +90,14 @@ export const SidePanel = () => {
87
90
  const selectedRequest = useSelectedRequest();
88
91
  const client = useNetworkActivityStore((state) => state._client);
89
92
  const overrides = useOverrides();
93
+ // Sticky Preview/Raw preference. Lives here, not in ResponseTab,
94
+ // because the `<Tabs key={selectedRequest.id}>` below intentionally
95
+ // remounts the Tabs subtree on every request switch (so the active
96
+ // inner tab resets). SidePanel itself stays mounted across request
97
+ // switches, so the preference survives — flipping to Raw on one
98
+ // response keeps Raw selected for every subsequent response whose
99
+ // renderer supports it. Resets when the panel is closed.
100
+ const [preferredView, setPreferredView] = useState<ResponseView>('preview');
90
101
 
91
102
  const onClose = (): void => {
92
103
  actions.setSelectedRequest(null);
@@ -166,6 +177,12 @@ export const SidePanel = () => {
166
177
  >
167
178
  Cookies
168
179
  </TabsTrigger>
180
+ <TabsTrigger
181
+ value="initiator"
182
+ className="data-[state=active]:bg-gray-700"
183
+ >
184
+ Initiator
185
+ </TabsTrigger>
169
186
  <TabsTrigger
170
187
  value="timing"
171
188
  className="data-[state=active]:bg-gray-700"
@@ -197,6 +214,12 @@ export const SidePanel = () => {
197
214
  >
198
215
  Messages
199
216
  </TabsTrigger>
217
+ <TabsTrigger
218
+ value="initiator"
219
+ className="data-[state=active]:bg-gray-700"
220
+ >
221
+ Initiator
222
+ </TabsTrigger>
200
223
  </>
201
224
  );
202
225
  }
@@ -229,6 +252,8 @@ export const SidePanel = () => {
229
252
  <ResponseTab
230
253
  selectedRequest={httpDetails}
231
254
  supportsOverrides={supportsOverrides}
255
+ preferredView={preferredView}
256
+ onPreferredViewChange={setPreferredView}
232
257
  onRequestResponseBody={(requestId) => {
233
258
  if (client) {
234
259
  client.send('get-response-body', {
@@ -243,6 +268,10 @@ export const SidePanel = () => {
243
268
  <CookiesTab selectedRequest={httpDetails} />
244
269
  </TabsContent>
245
270
 
271
+ <TabsContent value="initiator" className="flex-1 m-0 overflow-hidden">
272
+ <InitiatorTab selectedRequest={httpDetails} />
273
+ </TabsContent>
274
+
246
275
  <TabsContent value="timing" className="flex-1 m-0 overflow-hidden">
247
276
  <TimingTab selectedRequest={httpDetails} />
248
277
  </TabsContent>
@@ -275,6 +304,10 @@ export const SidePanel = () => {
275
304
  <SSEMessagesTab selectedRequest={sseDetails} />
276
305
  </TabsContent>
277
306
 
307
+ <TabsContent value="initiator" className="flex-1 m-0 overflow-hidden">
308
+ <InitiatorTab selectedRequest={sseDetails} />
309
+ </TabsContent>
310
+
278
311
  <TabsContent value="cookies" className="flex-1 m-0 overflow-hidden">
279
312
  <CookiesTab selectedRequest={sseDetails} />
280
313
  </TabsContent>
@@ -324,7 +357,15 @@ export const SidePanel = () => {
324
357
  }
325
358
  className="h-full flex flex-col"
326
359
  >
327
- <TabsList className="grid w-full grid-cols-5 bg-gray-800 rounded-none border-b border-gray-700">
360
+ <TabsList
361
+ className={`grid w-full ${
362
+ selectedRequest.type === 'http'
363
+ ? 'grid-cols-6'
364
+ : selectedRequest.type === 'sse'
365
+ ? 'grid-cols-4'
366
+ : 'grid-cols-1'
367
+ } bg-gray-800 rounded-none border-b border-gray-700`}
368
+ >
328
369
  {getTabsListTriggers()}
329
370
  </TabsList>
330
371
 
@@ -1,10 +1,12 @@
1
1
  import { Button } from './Button';
2
- import { Circle, Square, Trash2 } from 'lucide-react';
2
+ import { Circle, Download, Square, Trash2 } from 'lucide-react';
3
3
  import { useIsRecording, useNetworkActivityActions } from '../state/hooks';
4
+ import { useNetworkActivitySessionExport } from '../hooks/useNetworkActivitySessionExport';
4
5
 
5
6
  export const Toolbar = () => {
6
7
  const actions = useNetworkActivityActions();
7
8
  const isRecording = useIsRecording();
9
+ const { canExportSession, exportSession } = useNetworkActivitySessionExport();
8
10
 
9
11
  const onToggleRecording = (): void => {
10
12
  actions.setRecording(!isRecording);
@@ -41,6 +43,16 @@ export const Toolbar = () => {
41
43
  >
42
44
  <Trash2 className="h-4 w-4" />
43
45
  </Button>
46
+ <Button
47
+ variant="ghost"
48
+ size="sm"
49
+ onClick={exportSession}
50
+ disabled={!canExportSession}
51
+ className="ml-auto h-8 w-8 p-0 text-gray-400 hover:text-blue-400"
52
+ title="Export session"
53
+ >
54
+ <Download className="h-4 w-4" />
55
+ </Button>
44
56
  </div>
45
57
  );
46
58
  };