@tonyclaw/llm-inspector 1.18.2 → 1.19.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.
- package/.output/cli.js +776 -139
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-C-4ypEWs.js → CompareDrawer-DwayZPPO.js} +1 -1
- package/.output/public/assets/ProxyViewerContainer-iv3LVMEW.js +101 -0
- package/.output/public/assets/{ReplayDialog-CyBKOgba.js → ReplayDialog-CaV1elYO.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-C0IrVQ3q.js → RequestAnatomy-CSfnjK7j.js} +1 -1
- package/.output/public/assets/{ResponseView-MogToC4i.js → ResponseView-YkOL__xm.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-ClhUhT-s.js → StreamingChunkSequence-D_p6L-oB.js} +1 -1
- package/.output/public/assets/_sessionId-BgCVUC6R.js +1 -0
- package/.output/public/assets/index-CWA4S0FO.js +1 -0
- package/.output/public/assets/index-DeJyypsp.css +1 -0
- package/.output/public/assets/{json-viewer-BicGakI5.js → json-viewer-BB-9bqnP.js} +2 -2
- package/.output/public/assets/{main-Be2qqUUW.js → main-COVN451W.js} +2 -2
- package/.output/server/_libs/lucide-react.mjs +93 -72
- package/.output/server/{_sessionId-DhKJIdQC.mjs → _sessionId-BJT5qIib.mjs} +2 -2
- package/.output/server/_ssr/{CompareDrawer-BGUgukJ8.mjs → CompareDrawer-DNGYdUXs.mjs} +4 -4
- package/.output/server/_ssr/{ProxyViewerContainer--3K3o3Sm.mjs → ProxyViewerContainer-B-zDOLYE.mjs} +354 -343
- package/.output/server/_ssr/{ReplayDialog-Bo86xZI4.mjs → ReplayDialog-DWeqMA4y.mjs} +4 -4
- package/.output/server/_ssr/{RequestAnatomy-jRU5qgwB.mjs → RequestAnatomy-TOsrMu9-.mjs} +3 -3
- package/.output/server/_ssr/{ResponseView-DdO_-79a.mjs → ResponseView-BuqdPrzm.mjs} +4 -4
- package/.output/server/_ssr/{StreamingChunkSequence-BigLwhh4.mjs → StreamingChunkSequence-DuzNZkqL.mjs} +3 -3
- package/.output/server/_ssr/{index-BHG6vOnr.mjs → index-1nCQUt3y.mjs} +2 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{json-viewer-B4c_WjXD.mjs → json-viewer-BL8xhHbi.mjs} +9 -5
- package/.output/server/_ssr/{router-DVixpJO-.mjs → router-aCaUgVTW.mjs} +3 -3
- package/.output/server/{_tanstack-start-manifest_v-BbvWUF4v.mjs → _tanstack-start-manifest_v-cBRxvCjb.mjs} +1 -1
- package/.output/server/index.mjs +61 -61
- package/package.json +2 -1
- package/src/cli/detect-tools.ts +146 -0
- package/src/cli/onboard.ts +229 -0
- package/src/cli/templates/command-onboard.ts +17 -0
- package/src/cli/templates/skill-onboard.ts +325 -0
- package/src/cli.ts +185 -163
- package/src/components/ProxyViewer.tsx +153 -142
- package/src/components/proxy-viewer/LogEntry.tsx +136 -157
- package/src/components/proxy-viewer/LogEntryHeader.tsx +147 -66
- package/src/components/proxy-viewer/useCopyFeedback.ts +36 -0
- package/src/components/ui/json-viewer.tsx +12 -0
- package/.output/public/assets/ProxyViewerContainer-WRenRpeh.js +0 -101
- package/.output/public/assets/_sessionId-BO47oA3Z.js +0 -1
- package/.output/public/assets/index-BRvz6-L6.css +0 -1
- package/.output/public/assets/index-Btw8ec7-.js +0 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { GitCompareArrows } from "lucide-react";
|
|
2
2
|
import { Suspense, type JSX } from "react";
|
|
3
|
-
import {
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
{/*
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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
|
-
{
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
+
}
|