@tonyclaw/llm-inspector 1.18.2 → 1.19.1

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 (42) hide show
  1. package/.output/cli.js +903 -139
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-C-4ypEWs.js → CompareDrawer-DtERUdIt.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-DfxRK7Nt.js +101 -0
  5. package/.output/public/assets/{ReplayDialog-CyBKOgba.js → ReplayDialog-VMsGnJSI.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-C0IrVQ3q.js → RequestAnatomy-Cx_vluvK.js} +1 -1
  7. package/.output/public/assets/{ResponseView-MogToC4i.js → ResponseView-5F8Ms5z4.js} +1 -1
  8. package/.output/public/assets/{StreamingChunkSequence-ClhUhT-s.js → StreamingChunkSequence-CKDCWfu9.js} +1 -1
  9. package/.output/public/assets/_sessionId-C-aKd1Ky.js +1 -0
  10. package/.output/public/assets/index-B8ttyigz.js +1 -0
  11. package/.output/public/assets/index-DeJyypsp.css +1 -0
  12. package/.output/public/assets/{json-viewer-BicGakI5.js → json-viewer-CztuZ9cT.js} +2 -2
  13. package/.output/public/assets/{main-Be2qqUUW.js → main-CR9IJlz1.js} +2 -2
  14. package/.output/server/_libs/lucide-react.mjs +93 -72
  15. package/.output/server/{_sessionId-DhKJIdQC.mjs → _sessionId-DvWQaDEm.mjs} +2 -2
  16. package/.output/server/_ssr/{CompareDrawer-BGUgukJ8.mjs → CompareDrawer-C5FsxSDS.mjs} +4 -4
  17. package/.output/server/_ssr/{ProxyViewerContainer--3K3o3Sm.mjs → ProxyViewerContainer-v0cvR8f5.mjs} +354 -343
  18. package/.output/server/_ssr/{ReplayDialog-Bo86xZI4.mjs → ReplayDialog-C3KOv9OW.mjs} +4 -4
  19. package/.output/server/_ssr/{RequestAnatomy-jRU5qgwB.mjs → RequestAnatomy-BYRe33eG.mjs} +3 -3
  20. package/.output/server/_ssr/{ResponseView-DdO_-79a.mjs → ResponseView-va7yQDeL.mjs} +4 -4
  21. package/.output/server/_ssr/{StreamingChunkSequence-BigLwhh4.mjs → StreamingChunkSequence-BJlI-gWl.mjs} +3 -3
  22. package/.output/server/_ssr/{index-BHG6vOnr.mjs → index-CS0fA2GT.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-B4c_WjXD.mjs → json-viewer-Dg8rqrxL.mjs} +9 -5
  25. package/.output/server/_ssr/{router-DVixpJO-.mjs → router-D_Boe9Bu.mjs} +3 -3
  26. package/.output/server/{_tanstack-start-manifest_v-BbvWUF4v.mjs → _tanstack-start-manifest_v-KFXyNRGC.mjs} +1 -1
  27. package/.output/server/index.mjs +65 -65
  28. package/package.json +2 -1
  29. package/src/cli/detect-tools.ts +146 -0
  30. package/src/cli/onboard.ts +229 -0
  31. package/src/cli/templates/command-onboard.ts +17 -0
  32. package/src/cli/templates/skill-onboard.ts +458 -0
  33. package/src/cli.ts +193 -163
  34. package/src/components/ProxyViewer.tsx +153 -142
  35. package/src/components/proxy-viewer/LogEntry.tsx +136 -157
  36. package/src/components/proxy-viewer/LogEntryHeader.tsx +147 -66
  37. package/src/components/proxy-viewer/useCopyFeedback.ts +36 -0
  38. package/src/components/ui/json-viewer.tsx +12 -0
  39. package/.output/public/assets/ProxyViewerContainer-WRenRpeh.js +0 -101
  40. package/.output/public/assets/_sessionId-BO47oA3Z.js +0 -1
  41. package/.output/public/assets/index-BRvz6-L6.css +0 -1
  42. package/.output/public/assets/index-Btw8ec7-.js +0 -1
@@ -1,13 +1,14 @@
1
- import { Check, Copy, GitCompareArrows } from "lucide-react";
1
+ import { GitCompareArrows } from "lucide-react";
2
2
  import { Suspense, type JSX } from "react";
3
- import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
3
+ import { useMemo, useRef, useState, memo } from "react";
4
4
  import type { CapturedLog } from "../../proxy/schemas";
5
5
  import { stripClaudeCodeBillingHeader } from "../../proxy/claudeCodeStrip";
6
6
  import { Button } from "../ui/button";
7
7
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
8
- import { JsonExpansionButton } from "../ui/json-expansion-button";
9
8
  import { useJsonBulkExpansion } from "../ui/json-viewer-bulk";
10
9
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
10
+ import { useCopyFeedback } from "./useCopyFeedback";
11
+ import type { HeaderTabActions } from "./LogEntryHeader";
11
12
  import {
12
13
  LazyJsonViewer,
13
14
  LazyJsonViewerFromString,
@@ -56,41 +57,6 @@ export type LogEntryProps = {
56
57
  onCompareWithPrevious?: (log: CapturedLog) => void;
57
58
  };
58
59
 
59
- function CopyButton({
60
- text,
61
- label,
62
- copied,
63
- onCopy,
64
- }: {
65
- text: string | null;
66
- label: string;
67
- copied: boolean;
68
- onCopy: (e: React.MouseEvent) => void;
69
- }): JSX.Element | null {
70
- if (text === null) return null;
71
- return (
72
- <Tooltip>
73
- <TooltipTrigger asChild>
74
- <Button
75
- variant="outline"
76
- size="sm"
77
- className="h-8 text-xs"
78
- onClick={onCopy}
79
- aria-label={label}
80
- >
81
- {copied ? (
82
- <Check className="size-3.5 mr-1 text-emerald-500" />
83
- ) : (
84
- <Copy className="size-3.5 mr-1" />
85
- )}
86
- {copied ? "Copied!" : label}
87
- </Button>
88
- </TooltipTrigger>
89
- <TooltipContent>{copied ? "Copied to clipboard" : label}</TooltipContent>
90
- </Tooltip>
91
- );
92
- }
93
-
94
60
  function DiffToggleButton({
95
61
  active,
96
62
  onClick,
@@ -148,36 +114,6 @@ const HeadersDiffContent = memo(function ({
148
114
  return <DiffView result={result} emptyLabel={emptyLabel} />;
149
115
  });
150
116
 
151
- function useCopyFeedback(text: string | null): {
152
- copied: boolean;
153
- copy: (event: React.MouseEvent) => void;
154
- } {
155
- const [copied, setCopied] = useState(false);
156
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
157
-
158
- useEffect(
159
- () => () => {
160
- if (timerRef.current !== null) clearTimeout(timerRef.current);
161
- },
162
- [],
163
- );
164
-
165
- const copy = useCallback(
166
- (event: React.MouseEvent) => {
167
- event.stopPropagation();
168
- if (text === null) return;
169
- void window.navigator.clipboard.writeText(text).then(() => {
170
- setCopied(true);
171
- if (timerRef.current !== null) clearTimeout(timerRef.current);
172
- timerRef.current = setTimeout(() => setCopied(false), 2000);
173
- });
174
- },
175
- [text],
176
- );
177
-
178
- return { copied, copy };
179
- }
180
-
181
117
  export const LogEntry = memo(function ({
182
118
  log,
183
119
  viewMode = "simple",
@@ -210,11 +146,128 @@ export const LogEntry = memo(function ({
210
146
  return stripClaudeCodeBillingHeader(log.rawRequestBody).body;
211
147
  }, [log.rawRequestBody, resolvedFormat, strip]);
212
148
  const displayedRequestBody = strippedRequestBody ?? log.rawRequestBody;
149
+ const requestExpansion = useJsonBulkExpansion(displayedRequestBody);
150
+ const rawRequestExpansion = useJsonBulkExpansion(log.rawRequestBody);
151
+ const responseExpansion = useJsonBulkExpansion(log.responseText);
152
+
153
+ // Headers are rendered as a flat list, so we copy them as pretty-printed JSON.
154
+ // Only build the string when there's at least one entry — empty headers would
155
+ // otherwise copy "{}", which is misleading.
156
+ const headersText = useMemo(
157
+ () =>
158
+ log.headers && Object.keys(log.headers).length > 0
159
+ ? JSON.stringify(log.headers, null, 2)
160
+ : null,
161
+ [log.headers],
162
+ );
163
+ const rawHeadersText = useMemo(
164
+ () =>
165
+ log.rawHeaders && Object.keys(log.rawHeaders).length > 0
166
+ ? JSON.stringify(log.rawHeaders, null, 2)
167
+ : null,
168
+ [log.rawHeaders],
169
+ );
170
+
171
+ // One copy-feedback hook per JSON-bearing tab, so each tab can surface its
172
+ // own Copy button in the header with independent "Copied!" feedback.
213
173
  const requestCopy = useCopyFeedback(displayedRequestBody);
214
174
  const rawRequestCopy = useCopyFeedback(log.rawRequestBody);
175
+ const headersCopy = useCopyFeedback(headersText);
176
+ const rawHeadersCopy = useCopyFeedback(rawHeadersText);
215
177
  const responseCopy = useCopyFeedback(log.responseText);
216
- const requestExpansion = useJsonBulkExpansion(displayedRequestBody);
217
- const rawRequestExpansion = useJsonBulkExpansion(log.rawRequestBody);
178
+
179
+ // Per-tab action bundles consumed by the header. The header renders the
180
+ // entry whose key matches `activeTab`. Tabs without an entry (Anatomy,
181
+ // Parsed Response) render no header buttons.
182
+ const tabActions: HeaderTabActions = useMemo(
183
+ () => ({
184
+ request: {
185
+ copyLabel: "Copy request body",
186
+ copyText: displayedRequestBody,
187
+ copyCopied: requestCopy.copied,
188
+ onCopy: requestCopy.copy,
189
+ expansion: {
190
+ isExpanded: requestExpansion.isExpanded,
191
+ isPending: requestExpansion.isPending,
192
+ onToggle: requestExpansion.toggle,
193
+ },
194
+ // "Diff with Raw" only makes sense when there's a raw body to compare
195
+ // against (Anthropic + strip pipeline produces one). "Diff with
196
+ // Previous" only exists when the parent wired up the compare drawer.
197
+ diffWithRaw: shouldShowRequestDiffButton(
198
+ resolvedFormat,
199
+ viewMode,
200
+ strip,
201
+ log.rawRequestBody !== null,
202
+ )
203
+ ? { active: requestDiff, onToggle: () => setRequestDiff(!requestDiff) }
204
+ : undefined,
205
+ diffWithPrevious:
206
+ onCompareWithPrevious === undefined
207
+ ? undefined
208
+ : () => {
209
+ onCompareWithPrevious(log);
210
+ },
211
+ },
212
+ "raw-request": {
213
+ copyLabel: "Copy raw request",
214
+ copyText: log.rawRequestBody,
215
+ copyCopied: rawRequestCopy.copied,
216
+ onCopy: rawRequestCopy.copy,
217
+ expansion: {
218
+ isExpanded: rawRequestExpansion.isExpanded,
219
+ isPending: rawRequestExpansion.isPending,
220
+ onToggle: rawRequestExpansion.toggle,
221
+ },
222
+ },
223
+ headers: {
224
+ copyLabel: "Copy headers",
225
+ copyText: headersText,
226
+ copyCopied: headersCopy.copied,
227
+ onCopy: headersCopy.copy,
228
+ // Headers are a flat dict, no JSON tree to expand.
229
+ expansion: null,
230
+ },
231
+ "raw-headers": {
232
+ copyLabel: "Copy raw headers",
233
+ copyText: rawHeadersText,
234
+ copyCopied: rawHeadersCopy.copied,
235
+ onCopy: rawHeadersCopy.copy,
236
+ expansion: null,
237
+ },
238
+ raw: {
239
+ copyLabel: "Copy response",
240
+ copyText: log.responseText,
241
+ copyCopied: responseCopy.copied,
242
+ onCopy: responseCopy.copy,
243
+ expansion: {
244
+ isExpanded: responseExpansion.isExpanded,
245
+ isPending: responseExpansion.isPending,
246
+ onToggle: responseExpansion.toggle,
247
+ },
248
+ },
249
+ }),
250
+ [
251
+ displayedRequestBody,
252
+ requestCopy,
253
+ requestExpansion,
254
+ requestDiff,
255
+ log.rawRequestBody,
256
+ rawRequestCopy,
257
+ rawRequestExpansion,
258
+ headersText,
259
+ headersCopy,
260
+ rawHeadersText,
261
+ rawHeadersCopy,
262
+ log.responseText,
263
+ responseCopy,
264
+ responseExpansion,
265
+ resolvedFormat,
266
+ viewMode,
267
+ strip,
268
+ onCompareWithPrevious,
269
+ ],
270
+ );
218
271
  const anatomySegments = useMemo(
219
272
  () =>
220
273
  requestExpansion.parsedData !== null
@@ -246,6 +299,8 @@ export const LogEntry = memo(function ({
246
299
  responseToolNames={responseAnalysis.toolNames}
247
300
  cacheTrend={cacheTrend}
248
301
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
302
+ activeTab={activeTab}
303
+ tabActions={tabActions}
249
304
  onReplay={
250
305
  onCompareWithPrevious === undefined
251
306
  ? undefined
@@ -253,23 +308,6 @@ export const LogEntry = memo(function ({
253
308
  setReplayOpen(true);
254
309
  }
255
310
  }
256
- onCopyRequest={
257
- displayedRequestBody === null
258
- ? undefined
259
- : (e) => {
260
- requestCopy.copy(e);
261
- }
262
- }
263
- requestCopied={requestCopy.copied}
264
- onToggleRequestExpansion={requestExpansion.toggle}
265
- requestExpansionState={
266
- requestExpansion.policy === null
267
- ? null
268
- : {
269
- isExpanded: requestExpansion.isExpanded,
270
- isPending: requestExpansion.isPending,
271
- }
272
- }
273
311
  />
274
312
 
275
313
  {expanded && (
@@ -291,20 +329,6 @@ export const LogEntry = memo(function ({
291
329
  <TabsContent value="raw-request">
292
330
  {activeTab === "raw-request" && (
293
331
  <div className="px-4 pt-1 pb-3">
294
- <div className="flex justify-end gap-2 mb-2">
295
- <JsonExpansionButton
296
- policy={rawRequestExpansion.policy}
297
- isExpanded={rawRequestExpansion.isExpanded}
298
- isPending={rawRequestExpansion.isPending}
299
- onToggle={rawRequestExpansion.toggle}
300
- />
301
- <CopyButton
302
- text={log.rawRequestBody}
303
- label="Copy"
304
- copied={rawRequestCopy.copied}
305
- onCopy={rawRequestCopy.copy}
306
- />
307
- </div>
308
332
  {log.rawRequestBody === null ? (
309
333
  <p className="text-xs text-muted-foreground italic">No request body</p>
310
334
  ) : rawRequestExpansion.parsedData !== null ? (
@@ -328,53 +352,9 @@ export const LogEntry = memo(function ({
328
352
  <TabsContent value="request">
329
353
  {activeTab === "request" && (
330
354
  <div className="px-4 pt-1 pb-3">
331
- {/* Per-tab secondary actions (diff toggles, compare-with-previous).
332
- Replay / Copy / Expand-all JSON now live in the header. */}
333
- {(shouldShowRequestDiffButton(
334
- resolvedFormat,
335
- viewMode,
336
- strip,
337
- log.rawRequestBody !== null,
338
- ) ||
339
- onCompareWithPrevious !== undefined) && (
340
- <div className="flex justify-end gap-2 mb-2">
341
- {shouldShowRequestDiffButton(
342
- resolvedFormat,
343
- viewMode,
344
- strip,
345
- log.rawRequestBody !== null,
346
- ) && (
347
- <DiffToggleButton
348
- active={requestDiff}
349
- onClick={(e) => {
350
- e.stopPropagation();
351
- setRequestDiff(!requestDiff);
352
- }}
353
- />
354
- )}
355
- {onCompareWithPrevious !== undefined && (
356
- <Tooltip>
357
- <TooltipTrigger asChild>
358
- <Button
359
- variant="outline"
360
- size="sm"
361
- className="h-8 text-xs"
362
- onClick={(e) => {
363
- e.stopPropagation();
364
- onCompareWithPrevious(log);
365
- }}
366
- >
367
- <GitCompareArrows className="size-3 mr-1" />
368
- Diff with Previous
369
- </Button>
370
- </TooltipTrigger>
371
- <TooltipContent>
372
- Compare this request with the immediately preceding one
373
- </TooltipContent>
374
- </Tooltip>
375
- )}
376
- </div>
377
- )}
355
+ {/* All tab actions (Copy, Expand, Diff with Raw, Diff with
356
+ Previous) live in the log header. The body is just
357
+ the diff or JSON view, depending on toggle state. */}
378
358
  {requestDiff ? (
379
359
  <RequestDiffContent
380
360
  rawBody={log.rawRequestBody}
@@ -425,6 +405,7 @@ export const LogEntry = memo(function ({
425
405
  <TabsContent value="headers">
426
406
  {activeTab === "headers" && (
427
407
  <div className="px-4 pt-1 pb-3">
408
+ {/* Copy lives in the log header. */}
428
409
  <div className="flex justify-end gap-2 mb-2">
429
410
  {shouldShowHeadersDiffButton(
430
411
  viewMode,
@@ -472,6 +453,7 @@ export const LogEntry = memo(function ({
472
453
  <TabsContent value="raw-headers">
473
454
  {activeTab === "raw-headers" && (
474
455
  <div className="px-4 pt-1 pb-3">
456
+ {/* Copy lives in the log header. */}
475
457
  {log.rawHeaders && Object.keys(log.rawHeaders).length > 0 ? (
476
458
  <div className="space-y-1 font-mono text-xs">
477
459
  {Object.entries(log.rawHeaders)
@@ -506,17 +488,14 @@ export const LogEntry = memo(function ({
506
488
  <div className="text-muted-foreground font-mono">{log.error}</div>
507
489
  </div>
508
490
  )}
509
- <div className="flex justify-end">
510
- <CopyButton
511
- text={log.responseText}
512
- label="Copy"
513
- copied={responseCopy.copied}
514
- onCopy={responseCopy.copy}
515
- />
516
- </div>
517
491
  {log.responseText !== null ? (
518
492
  <Suspense fallback={<TabFallback />}>
519
- <LazyJsonViewerFromString text={log.responseText} defaultExpandDepth={0} />
493
+ <LazyJsonViewerFromString
494
+ text={log.responseText}
495
+ defaultExpandDepth={0}
496
+ bulkDepth={responseExpansion.bulkDepth}
497
+ bulkRevision={responseExpansion.bulkRevision}
498
+ />
520
499
  </Suspense>
521
500
  ) : (
522
501
  <p className="text-xs text-muted-foreground italic">No response</p>
@@ -9,8 +9,10 @@ import {
9
9
  ChevronsUp,
10
10
  Clock,
11
11
  Copy,
12
+ FileDiff,
12
13
  FileTerminal,
13
14
  Globe,
15
+ History,
14
16
  Loader2,
15
17
  MessageSquare,
16
18
  OctagonAlert,
@@ -61,6 +63,40 @@ function CacheTrendIndicator({ trend }: { trend: CacheTrend | null }): JSX.Eleme
61
63
  );
62
64
  }
63
65
 
66
+ /**
67
+ * Per-tab action bundle surfaced in the header when the tab is active.
68
+ * - `copyText === null` → Copy button hidden (e.g. tab has no body to copy)
69
+ * - `expansion === null` → Expand-all button hidden (e.g. flat dict, or
70
+ * not valid JSON). When present, the button reflects the JSON viewer's
71
+ * bulk-expansion state and the caller is responsible for forwarding the
72
+ * matching `bulkDepth` / `bulkRevision` to the underlying viewer.
73
+ * - `diffWithRaw` / `diffWithPrevious` → optional Diff buttons. Undefined
74
+ * means "not applicable" (hidden). Today only the Request tab sets them.
75
+ */
76
+ export type HeaderTabAction = {
77
+ copyLabel: string;
78
+ copyText: string | null;
79
+ copyCopied: boolean;
80
+ onCopy: (event: MouseEvent) => void;
81
+ expansion: {
82
+ isExpanded: boolean;
83
+ isPending: boolean;
84
+ onToggle: () => void;
85
+ } | null;
86
+ /** Toggle for "Diff with Raw": display request body vs raw (pre-billing-strip). */
87
+ diffWithRaw?: { active: boolean; onToggle: () => void };
88
+ /** One-shot handler for "Diff with Previous": compare against the preceding log. */
89
+ diffWithPrevious?: () => void;
90
+ };
91
+
92
+ /**
93
+ * Tab actions keyed by Tabs value. Tabs without an entry (Anatomy, Parsed
94
+ * Response) leave the corresponding key unset, so the header renders no
95
+ * action buttons for them. Typed as a record (rather than a `Partial<...>`
96
+ * union) so the per-tab lookup in the header is type-safe.
97
+ */
98
+ export type HeaderTabActions = Record<string, HeaderTabAction | undefined>;
99
+
64
100
  export type LogEntryHeaderProps = {
65
101
  log: CapturedLog;
66
102
  /** Number of messages in the request (supports both Anthropic and OpenAI formats). */
@@ -76,21 +112,16 @@ export type LogEntryHeaderProps = {
76
112
  * the corresponding cache span renders as it did before — no arrow.
77
113
  */
78
114
  cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
115
+ /** Currently-active tab value (matches the `Tabs` value prop). The header
116
+ * uses this to pick the right entry from `tabActions`. */
117
+ activeTab?: string;
118
+ /** Per-tab Copy + Expand-all actions. Only tabs with an entry will show
119
+ * buttons when active. Tabs without an entry (Anatomy, Parsed Response)
120
+ * render no header buttons. */
121
+ tabActions?: HeaderTabActions;
79
122
  /** Re-send this request to the provider. Rendered in the header row when
80
123
  * `expanded` is true. */
81
124
  onReplay?: () => void;
82
- /** Copy the request body to the clipboard. Omit to hide the button. */
83
- onCopyRequest?: (event: MouseEvent) => void;
84
- /** Whether the latest copy of the request body succeeded (shows "Copied!"). */
85
- requestCopied?: boolean;
86
- /** Toggle the JSON bulk-expansion state for the request body. */
87
- onToggleRequestExpansion?: () => void;
88
- /** Current state of the JSON bulk-expansion button. `null` means the
89
- * request body is not JSON, so the button is hidden. */
90
- requestExpansionState?: {
91
- isExpanded: boolean;
92
- isPending: boolean;
93
- } | null;
94
125
  /** Slow-response threshold in seconds. `0` disables the warning indicator. */
95
126
  slowResponseThresholdSeconds?: number;
96
127
  };
@@ -103,11 +134,9 @@ export const LogEntryHeader = memo(function ({
103
134
  onToggle,
104
135
  responseToolNames = null,
105
136
  cacheTrend = null,
137
+ activeTab,
138
+ tabActions,
106
139
  onReplay,
107
- onCopyRequest,
108
- requestCopied = false,
109
- onToggleRequestExpansion,
110
- requestExpansionState = null,
111
140
  slowResponseThresholdSeconds = 0,
112
141
  }: LogEntryHeaderProps): JSX.Element {
113
142
  const statusCategory = getStatusCategory(log.responseStatus);
@@ -336,58 +365,110 @@ export const LogEntryHeader = memo(function ({
336
365
  onClick={(e) => e.stopPropagation()}
337
366
  onKeyDown={(e) => e.stopPropagation()}
338
367
  >
339
- {requestExpansionState !== null && onToggleRequestExpansion !== undefined && (
340
- <Tooltip>
341
- <TooltipTrigger asChild>
342
- <Button
343
- variant="outline"
344
- size="icon"
345
- className="size-8"
346
- onClick={onToggleRequestExpansion}
347
- disabled={requestExpansionState.isPending}
348
- aria-pressed={requestExpansionState.isExpanded}
349
- aria-label={
350
- requestExpansionState.isExpanded ? "Collapse all JSON" : "Expand all JSON"
351
- }
352
- >
353
- {requestExpansionState.isExpanded ? (
354
- <ChevronsUp className="size-3.5" />
355
- ) : (
356
- <ChevronsDown className="size-3.5" />
368
+ {tabActions !== undefined &&
369
+ activeTab !== undefined &&
370
+ (() => {
371
+ const action = tabActions[activeTab];
372
+ if (action === undefined) return null;
373
+ return (
374
+ <>
375
+ {action.expansion !== null && (
376
+ <Tooltip>
377
+ <TooltipTrigger asChild>
378
+ <Button
379
+ variant="outline"
380
+ size="icon"
381
+ className="size-8"
382
+ onClick={action.expansion.onToggle}
383
+ disabled={action.expansion.isPending}
384
+ aria-pressed={action.expansion.isExpanded}
385
+ aria-label={
386
+ action.expansion.isExpanded ? "Collapse all JSON" : "Expand all JSON"
387
+ }
388
+ >
389
+ {action.expansion.isExpanded ? (
390
+ <ChevronsUp className="size-3.5" />
391
+ ) : (
392
+ <ChevronsDown className="size-3.5" />
393
+ )}
394
+ </Button>
395
+ </TooltipTrigger>
396
+ <TooltipContent>
397
+ {action.expansion.isExpanded
398
+ ? "Collapse all JSON nodes"
399
+ : "Expand all JSON nodes"}
400
+ </TooltipContent>
401
+ </Tooltip>
357
402
  )}
358
- </Button>
359
- </TooltipTrigger>
360
- <TooltipContent>
361
- {requestExpansionState.isExpanded
362
- ? "Collapse all JSON nodes"
363
- : "Expand all JSON nodes"}
364
- </TooltipContent>
365
- </Tooltip>
366
- )}
367
-
368
- {onCopyRequest !== undefined && (
369
- <Tooltip>
370
- <TooltipTrigger asChild>
371
- <Button
372
- variant="outline"
373
- size="icon"
374
- className="size-8"
375
- onClick={onCopyRequest}
376
- aria-label="Copy request body"
377
- >
378
- {requestCopied ? (
379
- <Check className="size-3.5 text-emerald-500" />
380
- ) : (
381
- <Copy className="size-3.5" />
403
+ {action.diffWithRaw !== undefined && (
404
+ <Tooltip>
405
+ <TooltipTrigger asChild>
406
+ <Button
407
+ variant="outline"
408
+ size="icon"
409
+ className={cn(
410
+ "size-8",
411
+ action.diffWithRaw.active && "bg-accent text-accent-foreground",
412
+ )}
413
+ onClick={action.diffWithRaw.onToggle}
414
+ aria-pressed={action.diffWithRaw.active}
415
+ aria-label={
416
+ action.diffWithRaw.active ? "Hide raw diff" : "Diff with raw"
417
+ }
418
+ >
419
+ <FileDiff className="size-3.5" />
420
+ </Button>
421
+ </TooltipTrigger>
422
+ <TooltipContent>
423
+ {action.diffWithRaw.active
424
+ ? "Hide diff with raw request"
425
+ : "Show diff between displayed and raw request body"}
426
+ </TooltipContent>
427
+ </Tooltip>
382
428
  )}
383
- </Button>
384
- </TooltipTrigger>
385
- <TooltipContent>
386
- {requestCopied ? "Copied to clipboard" : "Copy request body"}
387
- </TooltipContent>
388
- </Tooltip>
389
- )}
390
-
429
+ {action.diffWithPrevious !== undefined && (
430
+ <Tooltip>
431
+ <TooltipTrigger asChild>
432
+ <Button
433
+ variant="outline"
434
+ size="icon"
435
+ className="size-8"
436
+ onClick={action.diffWithPrevious}
437
+ aria-label="Diff with previous"
438
+ >
439
+ <History className="size-3.5" />
440
+ </Button>
441
+ </TooltipTrigger>
442
+ <TooltipContent>
443
+ Compare this request with the immediately preceding one
444
+ </TooltipContent>
445
+ </Tooltip>
446
+ )}
447
+ {action.copyText !== null && (
448
+ <Tooltip>
449
+ <TooltipTrigger asChild>
450
+ <Button
451
+ variant="outline"
452
+ size="icon"
453
+ className="size-8"
454
+ onClick={action.onCopy}
455
+ aria-label={action.copyCopied ? "Copied" : action.copyLabel}
456
+ >
457
+ {action.copyCopied ? (
458
+ <Check className="size-3.5 text-emerald-500" />
459
+ ) : (
460
+ <Copy className="size-3.5" />
461
+ )}
462
+ </Button>
463
+ </TooltipTrigger>
464
+ <TooltipContent>
465
+ {action.copyCopied ? "Copied to clipboard" : action.copyLabel}
466
+ </TooltipContent>
467
+ </Tooltip>
468
+ )}
469
+ </>
470
+ );
471
+ })()}
391
472
  {onReplay !== undefined && (
392
473
  <Tooltip>
393
474
  <TooltipTrigger asChild>
@@ -0,0 +1,36 @@
1
+ import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
2
+
3
+ /**
4
+ * Clipboard write with a 2s "Copied!" indicator. Returns null-safe
5
+ * callbacks so callers can pass `text === null` through unconditionally
6
+ * (the hook still runs; the click handler short-circuits).
7
+ */
8
+ export function useCopyFeedback(text: string | null): {
9
+ copied: boolean;
10
+ copy: (event: MouseEvent) => void;
11
+ } {
12
+ const [copied, setCopied] = useState(false);
13
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
14
+
15
+ useEffect(
16
+ () => () => {
17
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
18
+ },
19
+ [],
20
+ );
21
+
22
+ const copy = useCallback(
23
+ (event: MouseEvent) => {
24
+ event.stopPropagation();
25
+ if (text === null) return;
26
+ void window.navigator.clipboard.writeText(text).then(() => {
27
+ setCopied(true);
28
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
29
+ timerRef.current = setTimeout(() => setCopied(false), 2000);
30
+ });
31
+ },
32
+ [text],
33
+ );
34
+
35
+ return { copied, copy };
36
+ }