@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.
Files changed (62) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/CompareDrawer-C4fie5g5.js +1 -0
  3. package/.output/public/assets/ReplayDialog-Dme5uOR9.js +1 -0
  4. package/.output/public/assets/RequestAnatomy-ChBLDNFH.js +1 -0
  5. package/.output/public/assets/ResponseView-wGeqBzVU.js +1 -0
  6. package/.output/public/assets/StreamingChunkSequence-zeJZQLqT.js +1 -0
  7. package/.output/public/assets/index-DoGvsnbA.css +1 -0
  8. package/.output/public/assets/index-DpbutOvo.js +101 -0
  9. package/.output/public/assets/json-viewer-BV-WUszW.js +14 -0
  10. package/.output/public/assets/{main-DbWwVQFh.js → main-DRu10KNQ.js} +1 -1
  11. package/.output/server/_libs/lucide-react.mjs +105 -85
  12. package/.output/server/_ssr/CompareDrawer-C4-CQL5w.mjs +1040 -0
  13. package/.output/server/_ssr/ReplayDialog-BTb1Bam8.mjs +321 -0
  14. package/.output/server/_ssr/RequestAnatomy-CZFV1IvL.mjs +351 -0
  15. package/.output/server/_ssr/ResponseView-CTZekh65.mjs +601 -0
  16. package/.output/server/_ssr/StreamingChunkSequence-C38Ynabd.mjs +301 -0
  17. package/.output/server/_ssr/{index-C-z-fZtq.mjs → index-Cnu-QzAy.mjs} +1141 -2443
  18. package/.output/server/_ssr/index.mjs +2 -2
  19. package/.output/server/_ssr/json-viewer-DROqpjS9.mjs +510 -0
  20. package/.output/server/_ssr/{router-CNM9Kbi0.mjs → router-pP4GCTQx.mjs} +42 -18
  21. package/.output/server/{_tanstack-start-manifest_v-BWfLeIsC.mjs → _tanstack-start-manifest_v-CphS4rZd.mjs} +1 -1
  22. package/.output/server/index.mjs +69 -27
  23. package/package.json +1 -1
  24. package/src/components/OnboardingBanner.tsx +2 -2
  25. package/src/components/ProxyViewer.tsx +44 -27
  26. package/src/components/ProxyViewerContainer.tsx +5 -25
  27. package/src/components/providers/SettingsDialog.tsx +52 -1
  28. package/src/components/proxy-viewer/ConversationGroup.tsx +5 -1
  29. package/src/components/proxy-viewer/ConversationHeader.tsx +4 -1
  30. package/src/components/proxy-viewer/LogEntry.tsx +217 -181
  31. package/src/components/proxy-viewer/LogEntryHeader.tsx +181 -40
  32. package/src/components/proxy-viewer/ThreadConnector.tsx +17 -2
  33. package/src/components/proxy-viewer/TurnGroup.tsx +124 -72
  34. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +98 -0
  35. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +196 -0
  36. package/src/components/proxy-viewer/anatomy/tokenEstimate.ts +53 -0
  37. package/src/components/proxy-viewer/anatomy/types.ts +39 -0
  38. package/src/components/proxy-viewer/anatomy/useAnatomyJump.ts +114 -0
  39. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +3 -23
  40. package/src/components/proxy-viewer/formats/anthropic/thinkingExtract.ts +21 -0
  41. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +5 -3
  42. package/src/components/proxy-viewer/lazy.ts +37 -0
  43. package/src/components/proxy-viewer/log-formats/anthropic.ts +146 -0
  44. package/src/components/proxy-viewer/log-formats/openai.ts +127 -0
  45. package/src/components/proxy-viewer/log-formats/types.ts +7 -0
  46. package/src/components/proxy-viewer/log-formats/unknown.ts +4 -0
  47. package/src/components/proxy-viewer/logEntryVisibility.ts +39 -0
  48. package/src/components/proxy-viewer/useKeyboardNavigation.ts +190 -0
  49. package/src/components/proxy-viewer/viewerState.ts +8 -0
  50. package/src/components/ui/crab-variants.tsx +11 -0
  51. package/src/components/ui/json-expansion-button.tsx +56 -0
  52. package/src/components/ui/json-viewer-bulk.ts +97 -0
  53. package/src/components/ui/json-viewer.tsx +58 -183
  54. package/src/lib/runtimeConfig.ts +9 -0
  55. package/src/lib/useOnboarding.ts +7 -1
  56. package/src/lib/useStripConfig.ts +33 -2
  57. package/src/lib/utils.ts +2 -3
  58. package/src/proxy/config.ts +17 -7
  59. package/src/routes/api/config.ts +7 -0
  60. package/src/routes/api/logs.stream.ts +26 -16
  61. package/.output/public/assets/index-DRRCmu5p.css +0 -1
  62. 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
- <Badge
127
- variant="destructive"
128
- className="text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums"
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
- <Badge
134
- variant="outline"
135
- className={cn(
136
- "text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums",
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
- <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
159
- <Clock className="size-3" />
160
- <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
161
- </span>
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
- {isBoundary ? (
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 { TurnEntry } from "./viewerState";
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 collapsible = isComplete && !isPending;
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(isComplete);
49
+ const prevCompleteRef = useRef(false);
46
50
  useEffect(() => {
47
- if (isComplete && !prevCompleteRef.current) {
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 className="flex items-stretch">
129
- {/* Dual-crab connector */}
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 — card-like appearance matching LogEntry */}
191
- <div
192
- className={cn(
193
- "flex-1 min-w-0 mb-0.5 rounded-lg border border-border py-1 px-3 flex items-center gap-3 text-xs cursor-pointer",
194
- bgClass,
195
- )}
196
- onClick={toggleCollapse}
197
- role="button"
198
- tabIndex={0}
199
- onKeyDown={(e) => {
200
- if (e.key === "Enter" || e.key === " ") {
201
- e.preventDefault();
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
- {/* Model logos — one per unique provider */}
217
- {uniqueProviders.length > 0 && (
218
- <span className="flex items-center gap-0.5 shrink-0">
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
- {/* Elapsed */}
226
- {aggregate.hasElapsed && (
227
- <span className="flex items-center gap-1 text-muted-foreground shrink-0">
228
- <Clock className="size-3" />
229
- <span className="font-mono tabular-nums">
230
- {formatElapsed(aggregate.totalElapsed)}
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
- </span>
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
- {/* Tokens */}
236
- {aggregate.hasTokens && (
237
- <span className="flex items-center gap-1 shrink-0">
238
- <Zap className="size-3 text-muted-foreground" />
239
- <span className="font-mono tabular-nums">
240
- <span className="text-blue-400">IN {formatTokens(aggregate.totalInput)}</span>
241
- {" / "}
242
- <span className="text-amber-400">OUT {formatTokens(aggregate.totalOutput)}</span>
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
- </span>
245
- )}
285
+ )}
246
286
 
247
- {/* Spacer */}
248
- <span className="flex-1 min-w-0" />
287
+ {/* Spacer */}
288
+ <span className="flex-1 min-w-0" />
249
289
 
250
- {/* Expand chevron */}
251
- <ChevronRight className="size-4 text-muted-foreground shrink-0" />
252
- </div>
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 && entries.length > 1 && isTurnStart}
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