@tonyclaw/llm-inspector 1.14.7 → 1.14.9

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 (34) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-Dv-dj1xH.js +105 -0
  3. package/.output/public/assets/index-bqeypwJB.css +1 -0
  4. package/.output/public/assets/{main-BV7uNIIz.js → main-C8OUJKbz.js} +1 -1
  5. package/.output/server/_libs/lucide-react.mjs +87 -79
  6. package/.output/server/_libs/radix-ui__react-id.mjs +1 -1
  7. package/.output/server/_ssr/{index-BvHLASu8.mjs → index-_9xcAkkw.mjs} +861 -608
  8. package/.output/server/_ssr/index.mjs +2 -2
  9. package/.output/server/_ssr/{router-lUOA8pi6.mjs → router-CmanwZJc.mjs} +45 -14
  10. package/.output/server/{_tanstack-start-manifest_v-XNH7fVPN.mjs → _tanstack-start-manifest_v-BVIiyDeJ.mjs} +1 -1
  11. package/.output/server/index.mjs +23 -23
  12. package/package.json +1 -1
  13. package/src/components/ProxyViewer.tsx +137 -146
  14. package/src/components/providers/ProviderCard.tsx +79 -26
  15. package/src/components/providers/ProviderForm.tsx +37 -22
  16. package/src/components/providers/ProvidersPanel.tsx +79 -47
  17. package/src/components/providers/SettingsDialog.tsx +25 -15
  18. package/src/components/proxy-viewer/ConversationGroup.tsx +74 -11
  19. package/src/components/proxy-viewer/ConversationHeader.tsx +63 -2
  20. package/src/components/proxy-viewer/LogEntry.tsx +184 -54
  21. package/src/components/proxy-viewer/LogEntryHeader.tsx +148 -143
  22. package/src/components/proxy-viewer/ReplayDialog.tsx +16 -6
  23. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +24 -16
  24. package/src/components/proxy-viewer/ThreadConnector.tsx +93 -0
  25. package/src/components/proxy-viewer/index.ts +2 -1
  26. package/src/lib/stopReason.ts +57 -0
  27. package/src/proxy/formats/anthropic/handler.ts +2 -5
  28. package/src/proxy/formats/openai/handler.ts +33 -7
  29. package/src/proxy/formats/openai/schemas.ts +1 -0
  30. package/src/proxy/formats/openai/stream.ts +24 -0
  31. package/src/proxy/handler.ts +8 -2
  32. package/src/proxy/schemas.ts +6 -3
  33. package/.output/public/assets/index-Cmi8TfeU.js +0 -105
  34. package/.output/public/assets/index-DXUNTCVh.css +0 -1
@@ -1,5 +1,6 @@
1
1
  import { type JSX, type ReactNode, useCallback, useRef, useState } from "react";
2
2
  import { Button } from "../ui/button";
3
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
3
4
  import {
4
5
  Eye,
5
6
  EyeOff,
@@ -109,16 +110,30 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
109
110
  }
110
111
  if (result.cacheCreationInputTokens !== undefined && result.cacheCreationInputTokens > 0) {
111
112
  tokenParts.push(
112
- <span key="cache-create" className="font-mono tabular-nums text-emerald-400">
113
- +{result.cacheCreationInputTokens} cache
114
- </span>,
113
+ <TooltipProvider key="cache-create">
114
+ <Tooltip>
115
+ <TooltipTrigger asChild>
116
+ <span className="font-mono tabular-nums text-emerald-400">
117
+ +{result.cacheCreationInputTokens} cache
118
+ </span>
119
+ </TooltipTrigger>
120
+ <TooltipContent>Tokens cached for reuse, reducing future API cost</TooltipContent>
121
+ </Tooltip>
122
+ </TooltipProvider>,
115
123
  );
116
124
  }
117
125
  if (result.cacheReadInputTokens !== undefined && result.cacheReadInputTokens > 0) {
118
126
  tokenParts.push(
119
- <span key="cache-read" className="font-mono tabular-nums text-purple-400">
120
- ~{result.cacheReadInputTokens} cached
121
- </span>,
127
+ <TooltipProvider key="cache-read">
128
+ <Tooltip>
129
+ <TooltipTrigger asChild>
130
+ <span className="font-mono tabular-nums text-purple-400">
131
+ ~{result.cacheReadInputTokens} cached
132
+ </span>
133
+ </TooltipTrigger>
134
+ <TooltipContent>Tokens served from cache, reducing API cost</TooltipContent>
135
+ </Tooltip>
136
+ </TooltipProvider>,
122
137
  );
123
138
  }
124
139
  const displayTokens: ReactNode[] = [];
@@ -127,11 +142,20 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
127
142
  displayTokens.push(tokenParts[i]);
128
143
  }
129
144
  return (
130
- <div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
131
- <CheckCircle className="size-3" />
132
- <span>Connected</span>
133
- {tokenParts.length > 0 && <span className="text-muted-foreground">({displayTokens})</span>}
134
- </div>
145
+ <TooltipProvider>
146
+ <Tooltip>
147
+ <TooltipTrigger asChild>
148
+ <div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
149
+ <CheckCircle className="size-3" />
150
+ <span>Connected</span>
151
+ {tokenParts.length > 0 && (
152
+ <span className="text-muted-foreground">({displayTokens})</span>
153
+ )}
154
+ </div>
155
+ </TooltipTrigger>
156
+ <TooltipContent>Connection test passed</TooltipContent>
157
+ </Tooltip>
158
+ </TooltipProvider>
135
159
  );
136
160
  }
137
161
 
@@ -145,16 +169,31 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
145
169
  <div className="flex flex-col gap-1 shrink-0">
146
170
  <div className="flex items-center gap-1 text-xs text-red-600 max-w-[200px]">
147
171
  {getErrorIcon(errorType)}
148
- <span className="truncate">{errorMessage}</span>
172
+ <TooltipProvider>
173
+ <Tooltip>
174
+ <TooltipTrigger asChild>
175
+ <span className="truncate">{errorMessage}</span>
176
+ </TooltipTrigger>
177
+ <TooltipContent>Connection test failed</TooltipContent>
178
+ </Tooltip>
179
+ </TooltipProvider>
149
180
  {errorDetails !== undefined && (
150
- <button
151
- type="button"
152
- onClick={() => setShowDetails(!showDetails)}
153
- className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
154
- title={showDetails ? "Hide details" : "Show details"}
155
- >
156
- {showDetails ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
157
- </button>
181
+ <TooltipProvider>
182
+ <Tooltip>
183
+ <TooltipTrigger asChild>
184
+ <button
185
+ type="button"
186
+ onClick={() => setShowDetails(!showDetails)}
187
+ className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
188
+ >
189
+ {showDetails ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
190
+ </button>
191
+ </TooltipTrigger>
192
+ <TooltipContent>
193
+ {showDetails ? "Hide error details" : "Show detailed error information"}
194
+ </TooltipContent>
195
+ </Tooltip>
196
+ </TooltipProvider>
158
197
  )}
159
198
  </div>
160
199
  {showDetails && errorDetails !== undefined && (
@@ -243,14 +282,28 @@ export function ProviderCard({
243
282
  <div className="flex items-center gap-2 min-w-0">
244
283
  <span className="font-medium truncate">{provider.name}</span>
245
284
  {provider.source === "company" && (
246
- <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 shrink-0">
247
- 公司
248
- </span>
285
+ <TooltipProvider>
286
+ <Tooltip>
287
+ <TooltipTrigger asChild>
288
+ <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 shrink-0">
289
+ 公司
290
+ </span>
291
+ </TooltipTrigger>
292
+ <TooltipContent>Company-provided API key</TooltipContent>
293
+ </Tooltip>
294
+ </TooltipProvider>
249
295
  )}
250
296
  {provider.source === "personal" && (
251
- <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 shrink-0">
252
- 个人
253
- </span>
297
+ <TooltipProvider>
298
+ <Tooltip>
299
+ <TooltipTrigger asChild>
300
+ <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 shrink-0">
301
+ 个人
302
+ </span>
303
+ </TooltipTrigger>
304
+ <TooltipContent>Your personal API key</TooltipContent>
305
+ </Tooltip>
306
+ </TooltipProvider>
254
307
  )}
255
308
  </div>
256
309
  {docsUrl !== undefined && (
@@ -1,5 +1,6 @@
1
1
  import { type JSX, useState, useEffect, useRef } from "react";
2
2
  import { Button } from "../ui/button";
3
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
3
4
  import { Eye, EyeOff, Copy, Check, ChevronDown } from "lucide-react";
4
5
  import type { ProviderConfig } from "../../proxy/providers";
5
6
  import { maskApiKey } from "../../lib/mask";
@@ -371,28 +372,42 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
371
372
 
372
373
  <div className="space-y-2">
373
374
  <div className="flex gap-1 border-b border-border">
374
- <button
375
- type="button"
376
- onClick={() => setActiveTab("anthropic")}
377
- className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
378
- activeTab === "anthropic"
379
- ? "border-primary text-primary"
380
- : "border-transparent text-muted-foreground hover:text-foreground"
381
- }`}
382
- >
383
- Anthropic Format
384
- </button>
385
- <button
386
- type="button"
387
- onClick={() => setActiveTab("openai")}
388
- className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
389
- activeTab === "openai"
390
- ? "border-primary text-primary"
391
- : "border-transparent text-muted-foreground hover:text-foreground"
392
- }`}
393
- >
394
- OpenAI Format
395
- </button>
375
+ <TooltipProvider>
376
+ <Tooltip>
377
+ <TooltipTrigger asChild>
378
+ <button
379
+ type="button"
380
+ onClick={() => setActiveTab("anthropic")}
381
+ className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
382
+ activeTab === "anthropic"
383
+ ? "border-primary text-primary"
384
+ : "border-transparent text-muted-foreground hover:text-foreground"
385
+ }`}
386
+ >
387
+ Anthropic Format
388
+ </button>
389
+ </TooltipTrigger>
390
+ <TooltipContent>Anthropic Messages API protocol</TooltipContent>
391
+ </Tooltip>
392
+ </TooltipProvider>
393
+ <TooltipProvider>
394
+ <Tooltip>
395
+ <TooltipTrigger asChild>
396
+ <button
397
+ type="button"
398
+ onClick={() => setActiveTab("openai")}
399
+ className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
400
+ activeTab === "openai"
401
+ ? "border-primary text-primary"
402
+ : "border-transparent text-muted-foreground hover:text-foreground"
403
+ }`}
404
+ >
405
+ OpenAI Format
406
+ </button>
407
+ </TooltipTrigger>
408
+ <TooltipContent>OpenAI Chat Completions API protocol</TooltipContent>
409
+ </Tooltip>
410
+ </TooltipProvider>
396
411
  </div>
397
412
  {errors.format !== undefined && <p className="text-xs text-destructive">{errors.format}</p>}
398
413
  </div>
@@ -434,24 +434,38 @@ export function ProvidersPanel({
434
434
  <div className="flex items-center justify-between sticky top-0 z-10 bg-background pb-2">
435
435
  <h3 className="text-lg font-medium">Providers</h3>
436
436
  <div className="flex items-center gap-2">
437
- <Button
438
- variant="outline"
439
- size="sm"
440
- onClick={() => handleExport(false)}
441
- className="gap-1 hover:bg-muted"
442
- >
443
- <Download className="size-3" />
444
- Export
445
- </Button>
446
- <Button
447
- variant="outline"
448
- size="sm"
449
- onClick={handleImportClick}
450
- className="gap-1 hover:bg-muted"
451
- >
452
- <Upload className="size-3" />
453
- Import
454
- </Button>
437
+ <TooltipProvider>
438
+ <Tooltip>
439
+ <TooltipTrigger asChild>
440
+ <Button
441
+ variant="outline"
442
+ size="sm"
443
+ onClick={() => handleExport(false)}
444
+ className="gap-1 hover:bg-muted"
445
+ >
446
+ <Download className="size-3" />
447
+ Export
448
+ </Button>
449
+ </TooltipTrigger>
450
+ <TooltipContent>Download providers as JSON for backup or sharing</TooltipContent>
451
+ </Tooltip>
452
+ </TooltipProvider>
453
+ <TooltipProvider>
454
+ <Tooltip>
455
+ <TooltipTrigger asChild>
456
+ <Button
457
+ variant="outline"
458
+ size="sm"
459
+ onClick={handleImportClick}
460
+ className="gap-1 hover:bg-muted"
461
+ >
462
+ <Upload className="size-3" />
463
+ Import
464
+ </Button>
465
+ </TooltipTrigger>
466
+ <TooltipContent>Import providers from an exported JSON file</TooltipContent>
467
+ </Tooltip>
468
+ </TooltipProvider>
455
469
  <input
456
470
  type="file"
457
471
  ref={fileInputRef}
@@ -490,23 +504,29 @@ export function ProvidersPanel({
490
504
  <span className="font-mono truncate" title={configPath}>
491
505
  {configPath}
492
506
  </span>
493
- <button
494
- type="button"
495
- onClick={() => {
496
- void window.navigator.clipboard.writeText(configPath).then(() => {
497
- setConfigPathCopied(true);
498
- setTimeout(() => setConfigPathCopied(false), 2000);
499
- });
500
- }}
501
- className="shrink-0 ml-auto text-muted-foreground hover:text-foreground transition-colors"
502
- title="Copy path"
503
- >
504
- {configPathCopied ? (
505
- <Check className="size-3 text-green-500" />
506
- ) : (
507
- <Copy className="size-3" />
508
- )}
509
- </button>
507
+ <TooltipProvider>
508
+ <Tooltip>
509
+ <TooltipTrigger asChild>
510
+ <button
511
+ type="button"
512
+ onClick={() => {
513
+ void window.navigator.clipboard.writeText(configPath).then(() => {
514
+ setConfigPathCopied(true);
515
+ setTimeout(() => setConfigPathCopied(false), 2000);
516
+ });
517
+ }}
518
+ className="shrink-0 ml-auto text-muted-foreground hover:text-foreground transition-colors"
519
+ >
520
+ {configPathCopied ? (
521
+ <Check className="size-3 text-green-500" />
522
+ ) : (
523
+ <Copy className="size-3" />
524
+ )}
525
+ </button>
526
+ </TooltipTrigger>
527
+ <TooltipContent>Copy config file path to clipboard</TooltipContent>
528
+ </Tooltip>
529
+ </TooltipProvider>
510
530
  </div>
511
531
  )}
512
532
 
@@ -529,18 +549,30 @@ export function ProvidersPanel({
529
549
  <div className="space-y-3">
530
550
  <div className="flex gap-1 border-b border-border">
531
551
  {(["all", "personal", "company"] as const).map((tab) => (
532
- <button
533
- key={tab}
534
- type="button"
535
- onClick={() => setSourceFilter(tab)}
536
- className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
537
- sourceFilter === tab
538
- ? "border-primary text-primary"
539
- : "border-transparent text-muted-foreground hover:text-foreground"
540
- }`}
541
- >
542
- {tab === "all" ? "All" : tab === "personal" ? "Personal" : "Company"}
543
- </button>
552
+ <TooltipProvider key={tab}>
553
+ <Tooltip>
554
+ <TooltipTrigger asChild>
555
+ <button
556
+ type="button"
557
+ onClick={() => setSourceFilter(tab)}
558
+ className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
559
+ sourceFilter === tab
560
+ ? "border-primary text-primary"
561
+ : "border-transparent text-muted-foreground hover:text-foreground"
562
+ }`}
563
+ >
564
+ {tab === "all" ? "All" : tab === "personal" ? "Personal" : "Company"}
565
+ </button>
566
+ </TooltipTrigger>
567
+ <TooltipContent>
568
+ {tab === "all"
569
+ ? "Show all providers"
570
+ : tab === "personal"
571
+ ? "Providers you configured yourself"
572
+ : "Providers set by your organization"}
573
+ </TooltipContent>
574
+ </Tooltip>
575
+ </TooltipProvider>
544
576
  ))}
545
577
  </div>
546
578
  <div ref={listScrollRef} className="space-y-3">
@@ -3,6 +3,7 @@ import { Settings } from "lucide-react";
3
3
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
4
4
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
5
5
  import { Button } from "../ui/button";
6
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
6
7
  import { ProvidersPanel } from "./ProvidersPanel";
7
8
  import { useProviders } from "../../lib/useProviders";
8
9
  import { useStripConfig } from "../../lib/useStripConfig";
@@ -129,21 +130,30 @@ function ProxySettingsTab(): JSX.Element {
129
130
  </p>
130
131
  </div>
131
132
 
132
- <label className="flex items-center gap-3">
133
- <input
134
- type="checkbox"
135
- role="switch"
136
- checked={strip}
137
- disabled={isLoading || pending}
138
- onChange={(e) => {
139
- void handleToggle(e.currentTarget.checked);
140
- }}
141
- className="size-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
142
- />
143
- <span className="text-sm">
144
- {isLoading ? "Loading…" : strip ? "Stripping enabled" : "Stripping disabled"}
145
- </span>
146
- </label>
133
+ <TooltipProvider>
134
+ <Tooltip>
135
+ <TooltipTrigger asChild>
136
+ <label className="flex items-center gap-3">
137
+ <input
138
+ type="checkbox"
139
+ role="switch"
140
+ checked={strip}
141
+ disabled={isLoading || pending}
142
+ onChange={(e) => {
143
+ void handleToggle(e.currentTarget.checked);
144
+ }}
145
+ className="size-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
146
+ />
147
+ <span className="text-sm">
148
+ {isLoading ? "Loading…" : strip ? "Stripping enabled" : "Stripping disabled"}
149
+ </span>
150
+ </label>
151
+ </TooltipTrigger>
152
+ <TooltipContent>
153
+ Strip Claude Code billing header to improve cache hit rates
154
+ </TooltipContent>
155
+ </Tooltip>
156
+ </TooltipProvider>
147
157
 
148
158
  {error !== null && <p className="text-xs text-destructive">Failed to save: {error}</p>}
149
159
  </div>
@@ -1,14 +1,18 @@
1
- import { useState, memo } from "react";
1
+ import { useState, memo, useMemo, useEffect } from "react";
2
2
  import type { JSX } from "react";
3
3
  import type { CapturedLog } from "../../proxy/schemas";
4
+ import { extractStopReason } from "../../lib/stopReason";
5
+ import { cn } from "../../lib/utils";
4
6
  import {
5
7
  ConversationHeader,
6
8
  getConversationId,
7
9
  getGroupApiFormat,
8
10
  hasMixedApiFormat,
9
11
  type ConversationGroupData,
12
+ type ViewMode,
10
13
  } from "./ConversationHeader";
11
14
  import { LogEntry } from "./LogEntry";
15
+ import { ThreadConnector } from "./ThreadConnector";
12
16
  import type { CacheTrendEntry } from "./cacheTrend";
13
17
 
14
18
  export type ConversationGroupProps = {
@@ -21,10 +25,10 @@ export type ConversationGroupProps = {
21
25
  * across the whole viewer. Each `LogEntry` looks up its own entry.
22
26
  */
23
27
  cacheTrends?: Map<number, CacheTrendEntry>;
24
- /** Set of log ids currently marked for comparison. Forwarded to each `LogEntry`. */
25
- selectedSet: Set<number>;
26
- /** Toggle a log in/out of the comparison selection. */
27
- onToggleSelect: (logId: number) => void;
28
+ /** Callback to open CompareDrawer with a log and its immediate predecessor. */
29
+ onCompareWithPrevious: (log: CapturedLog) => void;
30
+ /** Default display mode for new groups (from global toggle). */
31
+ defaultGroupViewMode?: ViewMode;
28
32
  };
29
33
 
30
34
  function computeStats(logs: CapturedLog[]): {
@@ -45,15 +49,38 @@ export const ConversationGroup = memo(function ({
45
49
  viewMode = "simple",
46
50
  strip,
47
51
  cacheTrends,
48
- selectedSet,
49
- onToggleSelect,
52
+ onCompareWithPrevious,
53
+ defaultGroupViewMode = "thread",
50
54
  }: ConversationGroupProps): JSX.Element {
51
55
  const [expanded, setExpanded] = useState(false);
56
+ const [groupViewMode, setGroupViewMode] = useState<ViewMode>(defaultGroupViewMode);
57
+
58
+ // Sync local view mode when global toggle changes (it only acts as default on mount otherwise)
59
+ useEffect(() => {
60
+ setGroupViewMode(defaultGroupViewMode);
61
+ }, [defaultGroupViewMode]);
52
62
 
53
63
  const stats = computeStats(group.logs);
54
64
  const startTime = group.logs[0]?.timestamp ?? new Date().toISOString();
55
65
  const endTime = group.logs[group.logs.length - 1]?.timestamp ?? new Date().toISOString();
56
66
  const mixed = hasMixedApiFormat(group.logs);
67
+ const isLoading = group.logs.some((log) => log.responseStatus === null);
68
+
69
+ // Pre-compute stop reasons for each log — used by ThreadConnector
70
+ const stopReasons = useMemo(() => group.logs.map((log) => extractStopReason(log)), [group.logs]);
71
+
72
+ // Compute turn indices for alternating background colours
73
+ const turnIndices = useMemo(() => {
74
+ const indices: number[] = [];
75
+ let turn = 0;
76
+ for (let i = 0; i < stopReasons.length; i++) {
77
+ if (i > 0 && (stopReasons[i - 1] === "end_turn" || stopReasons[i - 1] === "stop")) {
78
+ turn++;
79
+ }
80
+ indices.push(turn);
81
+ }
82
+ return indices;
83
+ }, [stopReasons]);
57
84
 
58
85
  const displayId =
59
86
  group.conversationId.startsWith("PID:") || group.conversationId.includes("|")
@@ -75,24 +102,60 @@ export const ConversationGroup = memo(function ({
75
102
  expanded={expanded}
76
103
  onToggle={() => setExpanded(!expanded)}
77
104
  hideApiFormat={mixed}
105
+ isLoading={isLoading}
106
+ userAgent={group.logs[0]?.userAgent ?? null}
107
+ viewMode={groupViewMode}
108
+ onToggleViewMode={() => setGroupViewMode((prev) => (prev === "thread" ? "flat" : "thread"))}
78
109
  />
79
110
 
80
- {expanded && (
111
+ {expanded && groupViewMode === "flat" && (
81
112
  <div className="pl-4 border-l-2 border-muted ml-3">
82
113
  {group.logs.map((log) => (
83
114
  <LogEntry
84
115
  key={log.id}
85
116
  log={log}
86
117
  viewMode={viewMode}
87
- suppressApiFormatBadge={!mixed}
88
118
  strip={strip}
89
119
  cacheTrend={cacheTrends?.get(log.id) ?? null}
90
- isSelected={selectedSet.has(log.id)}
91
- onToggleSelect={onToggleSelect}
120
+ onCompareWithPrevious={() => onCompareWithPrevious(log)}
92
121
  />
93
122
  ))}
94
123
  </div>
95
124
  )}
125
+
126
+ {expanded && groupViewMode === "thread" && (
127
+ <div className="ml-3">
128
+ {group.logs.map((log, idx) => {
129
+ const isTurnStart =
130
+ idx === 0 || stopReasons[idx - 1] === "end_turn" || stopReasons[idx - 1] === "stop";
131
+ return (
132
+ <div key={log.id} className="flex items-stretch">
133
+ <ThreadConnector
134
+ stopReason={stopReasons[idx] ?? null}
135
+ isPending={log.responseStatus === null}
136
+ isFirst={idx === 0}
137
+ isLast={idx === group.logs.length - 1}
138
+ isTurnStart={isTurnStart}
139
+ />
140
+ <div
141
+ className={cn(
142
+ "flex-1 min-w-0 mb-2 rounded-lg",
143
+ (turnIndices[idx] ?? 0) % 2 === 0 ? "bg-muted/10" : "bg-muted/25",
144
+ )}
145
+ >
146
+ <LogEntry
147
+ log={log}
148
+ viewMode={viewMode}
149
+ strip={strip}
150
+ cacheTrend={cacheTrends?.get(log.id) ?? null}
151
+ onCompareWithPrevious={() => onCompareWithPrevious(log)}
152
+ />
153
+ </div>
154
+ </div>
155
+ );
156
+ })}
157
+ </div>
158
+ )}
96
159
  </div>
97
160
  );
98
161
  });