@tonyclaw/llm-inspector 1.16.4 → 1.17.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/nitro.json +1 -1
- package/.output/public/assets/CompareDrawer-C4fie5g5.js +1 -0
- package/.output/public/assets/ReplayDialog-Dme5uOR9.js +1 -0
- package/.output/public/assets/RequestAnatomy-ChBLDNFH.js +1 -0
- package/.output/public/assets/ResponseView-wGeqBzVU.js +1 -0
- package/.output/public/assets/StreamingChunkSequence-zeJZQLqT.js +1 -0
- package/.output/public/assets/index-DoGvsnbA.css +1 -0
- package/.output/public/assets/index-DpbutOvo.js +101 -0
- package/.output/public/assets/json-viewer-BV-WUszW.js +14 -0
- package/.output/public/assets/{main-DbWwVQFh.js → main-DRu10KNQ.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +105 -85
- package/.output/server/_ssr/CompareDrawer-C4-CQL5w.mjs +1040 -0
- package/.output/server/_ssr/ReplayDialog-BTb1Bam8.mjs +321 -0
- package/.output/server/_ssr/RequestAnatomy-CZFV1IvL.mjs +351 -0
- package/.output/server/_ssr/ResponseView-CTZekh65.mjs +601 -0
- package/.output/server/_ssr/StreamingChunkSequence-C38Ynabd.mjs +301 -0
- package/.output/server/_ssr/{index-C-z-fZtq.mjs → index-Cnu-QzAy.mjs} +1141 -2443
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/json-viewer-DROqpjS9.mjs +510 -0
- package/.output/server/_ssr/{router-CNM9Kbi0.mjs → router-pP4GCTQx.mjs} +42 -18
- package/.output/server/{_tanstack-start-manifest_v-BWfLeIsC.mjs → _tanstack-start-manifest_v-CphS4rZd.mjs} +1 -1
- package/.output/server/index.mjs +69 -27
- package/package.json +1 -1
- package/src/components/OnboardingBanner.tsx +2 -2
- package/src/components/ProxyViewer.tsx +44 -27
- package/src/components/ProxyViewerContainer.tsx +5 -25
- package/src/components/providers/SettingsDialog.tsx +52 -1
- package/src/components/proxy-viewer/ConversationGroup.tsx +5 -1
- package/src/components/proxy-viewer/ConversationHeader.tsx +4 -1
- package/src/components/proxy-viewer/LogEntry.tsx +217 -181
- package/src/components/proxy-viewer/LogEntryHeader.tsx +181 -40
- package/src/components/proxy-viewer/ThreadConnector.tsx +17 -2
- package/src/components/proxy-viewer/TurnGroup.tsx +124 -72
- package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +98 -0
- package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +196 -0
- package/src/components/proxy-viewer/anatomy/tokenEstimate.ts +53 -0
- package/src/components/proxy-viewer/anatomy/types.ts +39 -0
- package/src/components/proxy-viewer/anatomy/useAnatomyJump.ts +114 -0
- package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +3 -23
- package/src/components/proxy-viewer/formats/anthropic/thinkingExtract.ts +21 -0
- package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +5 -3
- package/src/components/proxy-viewer/lazy.ts +37 -0
- package/src/components/proxy-viewer/log-formats/anthropic.ts +146 -0
- package/src/components/proxy-viewer/log-formats/openai.ts +127 -0
- package/src/components/proxy-viewer/log-formats/types.ts +7 -0
- package/src/components/proxy-viewer/log-formats/unknown.ts +4 -0
- package/src/components/proxy-viewer/logEntryVisibility.ts +39 -0
- package/src/components/proxy-viewer/useKeyboardNavigation.ts +190 -0
- package/src/components/proxy-viewer/viewerState.ts +8 -0
- package/src/components/ui/crab-variants.tsx +11 -0
- package/src/components/ui/json-expansion-button.tsx +56 -0
- package/src/components/ui/json-viewer-bulk.ts +97 -0
- package/src/components/ui/json-viewer.tsx +58 -183
- package/src/lib/runtimeConfig.ts +9 -0
- package/src/lib/useOnboarding.ts +7 -1
- package/src/lib/useStripConfig.ts +33 -2
- package/src/lib/utils.ts +2 -3
- package/src/proxy/config.ts +17 -7
- package/src/routes/api/config.ts +7 -0
- package/src/routes/api/logs.stream.ts +26 -16
- package/.output/public/assets/index-DRRCmu5p.css +0 -1
- package/.output/public/assets/index-X7CHS7fS.js +0 -107
|
@@ -1,38 +1,54 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AlertTriangle,
|
|
2
3
|
ArrowDown,
|
|
3
4
|
ArrowUp,
|
|
5
|
+
Check,
|
|
4
6
|
ChevronDown,
|
|
5
7
|
ChevronRight,
|
|
8
|
+
ChevronsDown,
|
|
9
|
+
ChevronsUp,
|
|
6
10
|
Clock,
|
|
11
|
+
Copy,
|
|
7
12
|
FileTerminal,
|
|
8
13
|
Globe,
|
|
9
14
|
Loader2,
|
|
10
15
|
MessageSquare,
|
|
16
|
+
OctagonAlert,
|
|
11
17
|
Radio,
|
|
18
|
+
RotateCcw,
|
|
12
19
|
Wrench,
|
|
13
20
|
Zap,
|
|
14
21
|
} from "lucide-react";
|
|
15
|
-
import type { JSX } from "react";
|
|
22
|
+
import type { JSX, MouseEvent } from "react";
|
|
16
23
|
import { memo, useMemo } from "react";
|
|
17
24
|
import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
|
|
18
25
|
import type { CapturedLog } from "../../proxy/schemas";
|
|
19
26
|
import { Badge } from "../ui/badge";
|
|
27
|
+
import { Button } from "../ui/button";
|
|
20
28
|
import { ProviderLogo, detectProvider } from "../providers/ProviderLogo";
|
|
21
29
|
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
|
|
22
30
|
import type { CacheTrend } from "./cacheTrend";
|
|
23
31
|
|
|
24
|
-
function formatElapsed(ms: number): string {
|
|
25
|
-
if (ms < 1000) return `${ms}ms`;
|
|
26
|
-
return `${(ms / 1000).toFixed(1)}s`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
32
|
const STATUS_BADGE_CLASSES: Record<StatusCategory, string> = {
|
|
30
33
|
success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
|
|
31
34
|
client_error: "bg-amber-500/15 text-amber-400 border-amber-500/25",
|
|
32
|
-
server_error: "",
|
|
35
|
+
server_error: "bg-rose-500/15 text-rose-400 border-rose-500/25",
|
|
33
36
|
pending: "bg-muted text-muted-foreground border-border",
|
|
34
37
|
};
|
|
35
38
|
|
|
39
|
+
function formatElapsed(ms: number): string {
|
|
40
|
+
if (ms < 1000) return `${ms}ms`;
|
|
41
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatTimestamp(iso: string): string {
|
|
45
|
+
const d = new Date(iso);
|
|
46
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
47
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
48
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
49
|
+
return `${hh}:${mm}:${ss}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
/**
|
|
37
53
|
* Inline trend indicator: small arrow (green up / red down) plus the absolute
|
|
38
54
|
* delta in compact form. Returns `null` when there is no trend to display.
|
|
@@ -68,6 +84,23 @@ export type LogEntryHeaderProps = {
|
|
|
68
84
|
* the corresponding cache span renders as it did before — no arrow.
|
|
69
85
|
*/
|
|
70
86
|
cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
|
|
87
|
+
/** Re-send this request to the provider. Rendered in the header row when
|
|
88
|
+
* `expanded` is true. */
|
|
89
|
+
onReplay?: () => void;
|
|
90
|
+
/** Copy the request body to the clipboard. Omit to hide the button. */
|
|
91
|
+
onCopyRequest?: (event: MouseEvent) => void;
|
|
92
|
+
/** Whether the latest copy of the request body succeeded (shows "Copied!"). */
|
|
93
|
+
requestCopied?: boolean;
|
|
94
|
+
/** Toggle the JSON bulk-expansion state for the request body. */
|
|
95
|
+
onToggleRequestExpansion?: () => void;
|
|
96
|
+
/** Current state of the JSON bulk-expansion button. `null` means the
|
|
97
|
+
* request body is not JSON, so the button is hidden. */
|
|
98
|
+
requestExpansionState?: {
|
|
99
|
+
isExpanded: boolean;
|
|
100
|
+
isPending: boolean;
|
|
101
|
+
} | null;
|
|
102
|
+
/** Slow-response threshold in seconds. `0` disables the warning indicator. */
|
|
103
|
+
slowResponseThresholdSeconds?: number;
|
|
71
104
|
};
|
|
72
105
|
|
|
73
106
|
export const LogEntryHeader = memo(function ({
|
|
@@ -78,8 +111,18 @@ export const LogEntryHeader = memo(function ({
|
|
|
78
111
|
onToggle,
|
|
79
112
|
responseToolNames = null,
|
|
80
113
|
cacheTrend = null,
|
|
114
|
+
onReplay,
|
|
115
|
+
onCopyRequest,
|
|
116
|
+
requestCopied = false,
|
|
117
|
+
onToggleRequestExpansion,
|
|
118
|
+
requestExpansionState = null,
|
|
119
|
+
slowResponseThresholdSeconds = 0,
|
|
81
120
|
}: LogEntryHeaderProps): JSX.Element {
|
|
82
121
|
const statusCategory = getStatusCategory(log.responseStatus);
|
|
122
|
+
const isSlowResponse =
|
|
123
|
+
log.elapsedMs !== null &&
|
|
124
|
+
slowResponseThresholdSeconds > 0 &&
|
|
125
|
+
log.elapsedMs > slowResponseThresholdSeconds * 1000;
|
|
83
126
|
|
|
84
127
|
const hasTokens = log.inputTokens !== null || log.outputTokens !== null;
|
|
85
128
|
const toolNamesJoined = useMemo(() => responseToolNames?.join(", ") ?? null, [responseToolNames]);
|
|
@@ -89,10 +132,13 @@ export const LogEntryHeader = memo(function ({
|
|
|
89
132
|
<div
|
|
90
133
|
role="button"
|
|
91
134
|
tabIndex={0}
|
|
135
|
+
data-nav-id={`log-${log.id}`}
|
|
136
|
+
data-nav-action={expanded ? "collapse" : "expand"}
|
|
92
137
|
className={cn(
|
|
93
138
|
"flex items-center gap-2 px-3 py-1 cursor-pointer transition-colors",
|
|
94
139
|
"hover:bg-muted/50",
|
|
95
140
|
"select-none",
|
|
141
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none",
|
|
96
142
|
)}
|
|
97
143
|
onClick={onToggle}
|
|
98
144
|
onKeyDown={(e) => {
|
|
@@ -107,6 +153,17 @@ export const LogEntryHeader = memo(function ({
|
|
|
107
153
|
#{log.id}
|
|
108
154
|
</span>
|
|
109
155
|
|
|
156
|
+
{/* Timestamp */}
|
|
157
|
+
<Tooltip>
|
|
158
|
+
<TooltipTrigger asChild>
|
|
159
|
+
<span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
|
|
160
|
+
<Clock className="size-3" />
|
|
161
|
+
<span className="font-mono tabular-nums">{formatTimestamp(log.timestamp)}</span>
|
|
162
|
+
</span>
|
|
163
|
+
</TooltipTrigger>
|
|
164
|
+
<TooltipContent>{log.timestamp}</TooltipContent>
|
|
165
|
+
</Tooltip>
|
|
166
|
+
|
|
110
167
|
{/* Model — logo icon only, model name in tooltip */}
|
|
111
168
|
{log.model !== null && (
|
|
112
169
|
<Tooltip>
|
|
@@ -119,46 +176,51 @@ export const LogEntryHeader = memo(function ({
|
|
|
119
176
|
</Tooltip>
|
|
120
177
|
)}
|
|
121
178
|
|
|
122
|
-
{/* Response Status — only shown for non-200 or pending
|
|
179
|
+
{/* Response Status — only shown for non-200 or pending. Each category
|
|
180
|
+
carries a distinct icon in addition to color so the meaning is
|
|
181
|
+
legible without color perception. */}
|
|
123
182
|
{statusCategory !== "success" && (
|
|
124
|
-
|
|
183
|
+
<Badge
|
|
184
|
+
variant="outline"
|
|
185
|
+
className={cn(
|
|
186
|
+
"text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums gap-1",
|
|
187
|
+
STATUS_BADGE_CLASSES[statusCategory],
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
125
190
|
{statusCategory === "server_error" ? (
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
>
|
|
130
|
-
{log.responseStatus}
|
|
131
|
-
</Badge>
|
|
191
|
+
<OctagonAlert className="size-3" aria-label="Server error" />
|
|
192
|
+
) : statusCategory === "client_error" ? (
|
|
193
|
+
<AlertTriangle className="size-3" aria-label="Client error" />
|
|
132
194
|
) : statusCategory === "pending" ? (
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
STATUS_BADGE_CLASSES[statusCategory],
|
|
138
|
-
)}
|
|
139
|
-
>
|
|
140
|
-
<Loader2 className="size-3 animate-spin" />
|
|
141
|
-
</Badge>
|
|
142
|
-
) : (
|
|
143
|
-
<Badge
|
|
144
|
-
variant="outline"
|
|
145
|
-
className={cn(
|
|
146
|
-
"text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums",
|
|
147
|
-
STATUS_BADGE_CLASSES[statusCategory],
|
|
148
|
-
)}
|
|
149
|
-
>
|
|
150
|
-
{log.responseStatus}
|
|
151
|
-
</Badge>
|
|
152
|
-
)}
|
|
153
|
-
</>
|
|
195
|
+
<Loader2 className="size-3 animate-spin" aria-label="Pending" />
|
|
196
|
+
) : null}
|
|
197
|
+
{log.responseStatus}
|
|
198
|
+
</Badge>
|
|
154
199
|
)}
|
|
155
200
|
|
|
156
201
|
{/* Elapsed time */}
|
|
157
202
|
{log.elapsedMs !== null && (
|
|
158
|
-
<
|
|
159
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
203
|
+
<Tooltip>
|
|
204
|
+
<TooltipTrigger asChild>
|
|
205
|
+
<span
|
|
206
|
+
className={cn(
|
|
207
|
+
"flex items-center gap-1 text-xs shrink-0",
|
|
208
|
+
isSlowResponse ? "text-amber-400" : "text-muted-foreground",
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
<Clock className="size-3" />
|
|
212
|
+
<span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
|
|
213
|
+
{isSlowResponse && <AlertTriangle className="size-3" aria-label="Slow response" />}
|
|
214
|
+
</span>
|
|
215
|
+
</TooltipTrigger>
|
|
216
|
+
<TooltipContent>
|
|
217
|
+
{isSlowResponse
|
|
218
|
+
? `Slow response: ${formatElapsed(log.elapsedMs)} exceeds ${formatElapsed(
|
|
219
|
+
slowResponseThresholdSeconds * 1000,
|
|
220
|
+
)}`
|
|
221
|
+
: "Elapsed response time"}
|
|
222
|
+
</TooltipContent>
|
|
223
|
+
</Tooltip>
|
|
162
224
|
)}
|
|
163
225
|
|
|
164
226
|
{/* Token counts */}
|
|
@@ -279,6 +341,85 @@ export const LogEntryHeader = memo(function ({
|
|
|
279
341
|
{/* Spacer */}
|
|
280
342
|
<span className="flex-1 min-w-0" />
|
|
281
343
|
|
|
344
|
+
{/* Header actions — only when expanded, so the collapsed view stays
|
|
345
|
+
compact. Buttons stop propagation so they don't toggle the log. */}
|
|
346
|
+
{expanded && (
|
|
347
|
+
<span
|
|
348
|
+
className="flex items-center gap-1.5 shrink-0"
|
|
349
|
+
onClick={(e) => e.stopPropagation()}
|
|
350
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
351
|
+
>
|
|
352
|
+
{requestExpansionState !== null && onToggleRequestExpansion !== undefined && (
|
|
353
|
+
<Tooltip>
|
|
354
|
+
<TooltipTrigger asChild>
|
|
355
|
+
<Button
|
|
356
|
+
variant="outline"
|
|
357
|
+
size="icon"
|
|
358
|
+
className="size-8"
|
|
359
|
+
onClick={onToggleRequestExpansion}
|
|
360
|
+
disabled={requestExpansionState.isPending}
|
|
361
|
+
aria-pressed={requestExpansionState.isExpanded}
|
|
362
|
+
aria-label={
|
|
363
|
+
requestExpansionState.isExpanded ? "Collapse all JSON" : "Expand all JSON"
|
|
364
|
+
}
|
|
365
|
+
>
|
|
366
|
+
{requestExpansionState.isExpanded ? (
|
|
367
|
+
<ChevronsUp className="size-3.5" />
|
|
368
|
+
) : (
|
|
369
|
+
<ChevronsDown className="size-3.5" />
|
|
370
|
+
)}
|
|
371
|
+
</Button>
|
|
372
|
+
</TooltipTrigger>
|
|
373
|
+
<TooltipContent>
|
|
374
|
+
{requestExpansionState.isExpanded
|
|
375
|
+
? "Collapse all JSON nodes"
|
|
376
|
+
: "Expand all JSON nodes"}
|
|
377
|
+
</TooltipContent>
|
|
378
|
+
</Tooltip>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{onCopyRequest !== undefined && (
|
|
382
|
+
<Tooltip>
|
|
383
|
+
<TooltipTrigger asChild>
|
|
384
|
+
<Button
|
|
385
|
+
variant="outline"
|
|
386
|
+
size="icon"
|
|
387
|
+
className="size-8"
|
|
388
|
+
onClick={onCopyRequest}
|
|
389
|
+
aria-label="Copy request body"
|
|
390
|
+
>
|
|
391
|
+
{requestCopied ? (
|
|
392
|
+
<Check className="size-3.5 text-emerald-500" />
|
|
393
|
+
) : (
|
|
394
|
+
<Copy className="size-3.5" />
|
|
395
|
+
)}
|
|
396
|
+
</Button>
|
|
397
|
+
</TooltipTrigger>
|
|
398
|
+
<TooltipContent>
|
|
399
|
+
{requestCopied ? "Copied to clipboard" : "Copy request body"}
|
|
400
|
+
</TooltipContent>
|
|
401
|
+
</Tooltip>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
{onReplay !== undefined && (
|
|
405
|
+
<Tooltip>
|
|
406
|
+
<TooltipTrigger asChild>
|
|
407
|
+
<Button
|
|
408
|
+
variant="outline"
|
|
409
|
+
size="icon"
|
|
410
|
+
className="size-8"
|
|
411
|
+
onClick={onReplay}
|
|
412
|
+
aria-label="Replay request"
|
|
413
|
+
>
|
|
414
|
+
<RotateCcw className="size-3.5" />
|
|
415
|
+
</Button>
|
|
416
|
+
</TooltipTrigger>
|
|
417
|
+
<TooltipContent>Re-send this request to the provider</TooltipContent>
|
|
418
|
+
</Tooltip>
|
|
419
|
+
)}
|
|
420
|
+
</span>
|
|
421
|
+
)}
|
|
422
|
+
|
|
282
423
|
{/* Expand chevron */}
|
|
283
424
|
{expanded ? (
|
|
284
425
|
<ChevronDown className="size-4 text-muted-foreground shrink-0" />
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type JSX, useMemo } from "react";
|
|
2
2
|
import { cn } from "../../lib/utils";
|
|
3
3
|
import type { StopReason } from "../../lib/stopReason";
|
|
4
|
-
import { getCrabVariant } from "../ui/crab-variants";
|
|
4
|
+
import { getCrabVariant, getInteriorCrabVariant } from "../ui/crab-variants";
|
|
5
5
|
|
|
6
6
|
export type ThreadConnectorProps = {
|
|
7
7
|
stopReason: StopReason;
|
|
@@ -9,6 +9,8 @@ export type ThreadConnectorProps = {
|
|
|
9
9
|
isFirst: boolean;
|
|
10
10
|
/** True when this entry starts a new turn (first overall, or after end_turn/stop). */
|
|
11
11
|
isTurnStart: boolean;
|
|
12
|
+
/** True when this entry is the turn's only request. */
|
|
13
|
+
isOnlyEntry?: boolean;
|
|
12
14
|
/** Seed for crab variant selection (0-11). */
|
|
13
15
|
crabIndex?: number;
|
|
14
16
|
/** When true the crab is clickable (collapse / expand a turn). */
|
|
@@ -28,13 +30,16 @@ export function ThreadConnector({
|
|
|
28
30
|
isPending,
|
|
29
31
|
isFirst,
|
|
30
32
|
isTurnStart,
|
|
33
|
+
isOnlyEntry = false,
|
|
31
34
|
crabIndex = 0,
|
|
32
35
|
collapsible = false,
|
|
33
36
|
onToggle,
|
|
34
37
|
}: ThreadConnectorProps): JSX.Element {
|
|
35
38
|
const isBoundary = stopReason === "end_turn" || stopReason === "stop";
|
|
39
|
+
const isFusedBoundary = isOnlyEntry && isTurnStart && isBoundary;
|
|
36
40
|
const isRunning = isPending && !isBoundary;
|
|
37
41
|
const Crab = useMemo(() => getCrabVariant(crabIndex), [crabIndex]);
|
|
42
|
+
const FusedCrab = useMemo(() => getInteriorCrabVariant(crabIndex), [crabIndex]);
|
|
38
43
|
|
|
39
44
|
const interactiveProps =
|
|
40
45
|
collapsible && onToggle
|
|
@@ -68,7 +73,17 @@ export function ThreadConnector({
|
|
|
68
73
|
{/* Crab — pinned to the fixed offset above so it never drifts
|
|
69
74
|
* when the LogEntry expands / collapses. Clickable when the
|
|
70
75
|
* turn is collapsible (replaces the old chevron toggle). */}
|
|
71
|
-
{
|
|
76
|
+
{isFusedBoundary ? (
|
|
77
|
+
<span title="Start and end of turn">
|
|
78
|
+
<FusedCrab
|
|
79
|
+
className={cn(
|
|
80
|
+
"size-3.5 text-[#80FF00]",
|
|
81
|
+
"animate-crab-settle",
|
|
82
|
+
"drop-shadow-[0_0_4px_rgba(128,255,0,0.5)]",
|
|
83
|
+
)}
|
|
84
|
+
/>
|
|
85
|
+
</span>
|
|
86
|
+
) : isBoundary ? (
|
|
72
87
|
<span
|
|
73
88
|
title={stopReason === "end_turn" ? "End of Turn (Anthropic)" : "End of Turn (OpenAI)"}
|
|
74
89
|
{...interactiveProps}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { ChevronRight, Clock, Zap } from "lucide-react";
|
|
2
|
+
import { AlertTriangle, ChevronRight, Clock, Zap } from "lucide-react";
|
|
3
3
|
import { isTurnBoundary } from "../../lib/stopReason";
|
|
4
4
|
import { cn, formatTokens } from "../../lib/utils";
|
|
5
5
|
import type { CapturedLog } from "../../proxy/schemas";
|
|
6
6
|
import { getCrabVariant } from "../ui/crab-variants";
|
|
7
7
|
import { ProviderLogo, detectProvider, type Provider } from "../providers/ProviderLogo";
|
|
8
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
|
8
9
|
import type { CacheTrendEntry } from "./cacheTrend";
|
|
9
10
|
import { LogEntry } from "./LogEntry";
|
|
10
11
|
import { ThreadConnector } from "./ThreadConnector";
|
|
11
|
-
import type
|
|
12
|
+
import { isTurnCollapsible, type TurnEntry } from "./viewerState";
|
|
12
13
|
|
|
13
14
|
function formatElapsed(ms: number): string {
|
|
14
15
|
if (ms < 1000) return `${ms}ms`;
|
|
@@ -19,6 +20,7 @@ type TurnGroupProps = {
|
|
|
19
20
|
entries: TurnEntry[];
|
|
20
21
|
viewMode: "simple" | "full";
|
|
21
22
|
strip: boolean;
|
|
23
|
+
slowResponseThresholdSeconds: number;
|
|
22
24
|
cacheTrends?: Map<number, CacheTrendEntry>;
|
|
23
25
|
onCompareWithPrevious: (log: CapturedLog) => void;
|
|
24
26
|
comparisonPredecessors: Map<number, CapturedLog>;
|
|
@@ -29,6 +31,7 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
29
31
|
entries,
|
|
30
32
|
viewMode,
|
|
31
33
|
strip,
|
|
34
|
+
slowResponseThresholdSeconds,
|
|
32
35
|
cacheTrends,
|
|
33
36
|
onCompareWithPrevious,
|
|
34
37
|
comparisonPredecessors,
|
|
@@ -38,17 +41,20 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
38
41
|
const lastStop = entries[lastIdx]?.stopReason ?? null;
|
|
39
42
|
const isComplete = lastStop !== null ? isTurnBoundary(lastStop) : false;
|
|
40
43
|
const isPending = entries[lastIdx]?.log.responseStatus === null;
|
|
41
|
-
const
|
|
44
|
+
const isSingleLog = entries.length === 1;
|
|
45
|
+
const collapsible = isTurnCollapsible(entries.length, isComplete, isPending);
|
|
42
46
|
const [collapsed, setCollapsed] = useState(false);
|
|
43
47
|
|
|
44
48
|
// Auto-collapse when the turn finishes (transitions from incomplete → complete)
|
|
45
|
-
const prevCompleteRef = useRef(
|
|
49
|
+
const prevCompleteRef = useRef(false);
|
|
46
50
|
useEffect(() => {
|
|
47
|
-
if (
|
|
51
|
+
if (!collapsible) {
|
|
52
|
+
setCollapsed(false);
|
|
53
|
+
} else if (isComplete && !prevCompleteRef.current) {
|
|
48
54
|
setCollapsed(true);
|
|
49
55
|
}
|
|
50
56
|
prevCompleteRef.current = isComplete;
|
|
51
|
-
}, [isComplete]);
|
|
57
|
+
}, [collapsible, isComplete]);
|
|
52
58
|
|
|
53
59
|
const toggleCollapse = useCallback(() => {
|
|
54
60
|
if (collapsible) setCollapsed((prev) => !prev);
|
|
@@ -99,6 +105,10 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
99
105
|
const EndCrab = useMemo(() => getCrabVariant(entries[lastIdx]?.log.id ?? 0), [entries, lastIdx]);
|
|
100
106
|
|
|
101
107
|
const bgClass = turnIndex % 2 === 0 ? "bg-muted/10" : "bg-muted/25";
|
|
108
|
+
const aggregateIsSlow =
|
|
109
|
+
aggregate.hasElapsed &&
|
|
110
|
+
slowResponseThresholdSeconds > 0 &&
|
|
111
|
+
aggregate.totalElapsed > slowResponseThresholdSeconds * 1000;
|
|
102
112
|
|
|
103
113
|
// ResizeObserver → re-render connectors when any LogEntry height changes
|
|
104
114
|
const [layoutVersion, setLayoutVersion] = useState(0);
|
|
@@ -124,11 +134,30 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
124
134
|
className={cn("border rounded-lg", isPending ? "border-amber-500/10" : "border-transparent")}
|
|
125
135
|
>
|
|
126
136
|
{collapsed ? (
|
|
127
|
-
/* ---- Collapsed: dual-crab + summary ---- */
|
|
128
|
-
<div
|
|
129
|
-
{
|
|
137
|
+
/* ---- Collapsed: dual-crab (+ summary card for multi-log turns) ---- */
|
|
138
|
+
<div
|
|
139
|
+
data-nav-id={`turn-collapsed-${entries[0]?.log.id ?? turnIndex}`}
|
|
140
|
+
data-nav-action="expand"
|
|
141
|
+
role="button"
|
|
142
|
+
tabIndex={0}
|
|
143
|
+
className="flex items-stretch cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none rounded-lg"
|
|
144
|
+
onClick={toggleCollapse}
|
|
145
|
+
onKeyDown={(e) => {
|
|
146
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
toggleCollapse();
|
|
149
|
+
}
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
{/* Turn number */}
|
|
153
|
+
<div className="w-5 shrink-0 flex items-start pt-1.5">
|
|
154
|
+
<span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums leading-none select-none">
|
|
155
|
+
{turnIndex + 1}
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Dual-crab connector for collapsed multi-request turns. */}
|
|
130
160
|
<div className="w-6 shrink-0 flex flex-col items-center pt-0.5 pb-0.5">
|
|
131
|
-
{/* Start turn crab (clickable to expand) */}
|
|
132
161
|
<div className="flex justify-center h-[calc(0.75rem-8px)]" />
|
|
133
162
|
<span
|
|
134
163
|
role="button"
|
|
@@ -149,18 +178,15 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
149
178
|
<StartCrab
|
|
150
179
|
className={cn(
|
|
151
180
|
"size-3.5 text-emerald-400",
|
|
152
|
-
"animate-crab-appear",
|
|
153
|
-
"drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
|
|
181
|
+
"animate-crab-appear drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
|
|
154
182
|
)}
|
|
155
183
|
/>
|
|
156
184
|
</span>
|
|
157
185
|
|
|
158
|
-
{/* Connecting line between the two crabs */}
|
|
159
186
|
<div className="flex-1 flex justify-center min-h-0">
|
|
160
187
|
<div className="w-0.5 bg-muted-foreground/30 h-full" />
|
|
161
188
|
</div>
|
|
162
189
|
|
|
163
|
-
{/* End turn crab (clickable to expand) */}
|
|
164
190
|
<span
|
|
165
191
|
role="button"
|
|
166
192
|
tabIndex={0}
|
|
@@ -180,76 +206,91 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
180
206
|
<EndCrab
|
|
181
207
|
className={cn(
|
|
182
208
|
"size-3.5 text-amber-400",
|
|
183
|
-
"animate-crab-settle",
|
|
184
|
-
"drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
|
|
209
|
+
"animate-crab-settle drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
|
|
185
210
|
)}
|
|
186
211
|
/>
|
|
187
212
|
</span>
|
|
188
213
|
</div>
|
|
189
214
|
|
|
190
|
-
{/* Summary content —
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
toggleCollapse();
|
|
203
|
-
}
|
|
204
|
-
}}
|
|
205
|
-
>
|
|
206
|
-
{/* ID range */}
|
|
207
|
-
<span className="text-blue-400/80 font-mono font-semibold tabular-nums shrink-0">
|
|
208
|
-
#{entries[0]?.log.id ?? "?"} ~ #{entries[lastIdx]?.log.id ?? "?"}
|
|
209
|
-
</span>
|
|
210
|
-
|
|
211
|
-
{/* Request count */}
|
|
212
|
-
<span className="text-muted-foreground shrink-0">
|
|
213
|
-
{entries.length} request{entries.length > 1 ? "s" : ""}
|
|
214
|
-
</span>
|
|
215
|
+
{/* Summary content — hidden for single-log turns. */}
|
|
216
|
+
{entries.length > 1 && (
|
|
217
|
+
<div
|
|
218
|
+
className={cn(
|
|
219
|
+
"flex-1 min-w-0 mb-0.5 rounded-lg border border-border py-1 px-3 flex items-center gap-3 text-xs",
|
|
220
|
+
bgClass,
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
{/* ID range */}
|
|
224
|
+
<span className="text-blue-400/80 font-mono font-semibold tabular-nums shrink-0">
|
|
225
|
+
#{entries[0]?.log.id ?? "?"} ~ #{entries[lastIdx]?.log.id ?? "?"}
|
|
226
|
+
</span>
|
|
215
227
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
{uniqueProviders.map((p) => (
|
|
220
|
-
<ProviderLogo key={p} provider={p} className="size-4" />
|
|
221
|
-
))}
|
|
228
|
+
{/* Request count */}
|
|
229
|
+
<span className="text-muted-foreground shrink-0">
|
|
230
|
+
{entries.length} request{entries.length > 1 ? "s" : ""}
|
|
222
231
|
</span>
|
|
223
|
-
)}
|
|
224
232
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
233
|
+
{/* Model logos — one per unique provider */}
|
|
234
|
+
{uniqueProviders.length > 0 && (
|
|
235
|
+
<span className="flex items-center gap-0.5 shrink-0">
|
|
236
|
+
{uniqueProviders.map((p) => (
|
|
237
|
+
<ProviderLogo key={p} provider={p} className="size-4" />
|
|
238
|
+
))}
|
|
231
239
|
</span>
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{/* Elapsed */}
|
|
243
|
+
{aggregate.hasElapsed && (
|
|
244
|
+
<TooltipProvider>
|
|
245
|
+
<Tooltip>
|
|
246
|
+
<TooltipTrigger asChild>
|
|
247
|
+
<span
|
|
248
|
+
className={cn(
|
|
249
|
+
"flex items-center gap-1 shrink-0",
|
|
250
|
+
aggregateIsSlow ? "text-amber-400" : "text-muted-foreground",
|
|
251
|
+
)}
|
|
252
|
+
>
|
|
253
|
+
<Clock className="size-3" />
|
|
254
|
+
<span className="font-mono tabular-nums">
|
|
255
|
+
{formatElapsed(aggregate.totalElapsed)}
|
|
256
|
+
</span>
|
|
257
|
+
{aggregateIsSlow && (
|
|
258
|
+
<AlertTriangle className="size-3" aria-label="Slow response" />
|
|
259
|
+
)}
|
|
260
|
+
</span>
|
|
261
|
+
</TooltipTrigger>
|
|
262
|
+
<TooltipContent>
|
|
263
|
+
{aggregateIsSlow
|
|
264
|
+
? `Slow response: ${formatElapsed(
|
|
265
|
+
aggregate.totalElapsed,
|
|
266
|
+
)} exceeds ${formatElapsed(slowResponseThresholdSeconds * 1000)}`
|
|
267
|
+
: "Total elapsed response time"}
|
|
268
|
+
</TooltipContent>
|
|
269
|
+
</Tooltip>
|
|
270
|
+
</TooltipProvider>
|
|
271
|
+
)}
|
|
234
272
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
273
|
+
{/* Tokens */}
|
|
274
|
+
{aggregate.hasTokens && (
|
|
275
|
+
<span className="flex items-center gap-1 shrink-0">
|
|
276
|
+
<Zap className="size-3 text-muted-foreground" />
|
|
277
|
+
<span className="font-mono tabular-nums">
|
|
278
|
+
<span className="text-blue-400">IN {formatTokens(aggregate.totalInput)}</span>
|
|
279
|
+
{" / "}
|
|
280
|
+
<span className="text-amber-400">
|
|
281
|
+
OUT {formatTokens(aggregate.totalOutput)}
|
|
282
|
+
</span>
|
|
283
|
+
</span>
|
|
243
284
|
</span>
|
|
244
|
-
|
|
245
|
-
)}
|
|
285
|
+
)}
|
|
246
286
|
|
|
247
|
-
|
|
248
|
-
|
|
287
|
+
{/* Spacer */}
|
|
288
|
+
<span className="flex-1 min-w-0" />
|
|
249
289
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
290
|
+
{/* Expand chevron */}
|
|
291
|
+
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
253
294
|
</div>
|
|
254
295
|
) : (
|
|
255
296
|
/* ---- Expanded: full entries ---- */
|
|
@@ -259,13 +300,23 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
259
300
|
|
|
260
301
|
return (
|
|
261
302
|
<div key={log.id} className="flex items-stretch">
|
|
303
|
+
{isTurnStart ? (
|
|
304
|
+
<div className="w-5 shrink-0 flex items-start pt-1.5">
|
|
305
|
+
<span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums leading-none select-none">
|
|
306
|
+
{turnIndex + 1}
|
|
307
|
+
</span>
|
|
308
|
+
</div>
|
|
309
|
+
) : (
|
|
310
|
+
<div className="w-5 shrink-0" />
|
|
311
|
+
)}
|
|
262
312
|
<ThreadConnector
|
|
263
313
|
stopReason={reason}
|
|
264
314
|
isPending={log.responseStatus === null}
|
|
265
315
|
isFirst={visibleIdx === 0}
|
|
266
316
|
isTurnStart={isTurnStart}
|
|
317
|
+
isOnlyEntry={isSingleLog}
|
|
267
318
|
crabIndex={log.id % 12}
|
|
268
|
-
collapsible={collapsible &&
|
|
319
|
+
collapsible={collapsible && isTurnStart}
|
|
269
320
|
onToggle={toggleCollapse}
|
|
270
321
|
/>
|
|
271
322
|
<div className={cn("flex-1 min-w-0 mb-0.5 rounded-lg", bgClass)}>
|
|
@@ -273,6 +324,7 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
273
324
|
log={log}
|
|
274
325
|
viewMode={viewMode}
|
|
275
326
|
strip={strip}
|
|
327
|
+
slowResponseThresholdSeconds={slowResponseThresholdSeconds}
|
|
276
328
|
cacheTrend={cacheTrends?.get(log.id) ?? null}
|
|
277
329
|
onCompareWithPrevious={
|
|
278
330
|
comparisonPredecessors.has(log.id) ? onCompareWithPrevious : undefined
|