@tonyclaw/llm-inspector 1.14.9 → 1.15.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.
@@ -1,18 +1,14 @@
1
- import { useState, memo, useMemo, useEffect } from "react";
1
+ import { useState, memo, useMemo } from "react";
2
2
  import type { JSX } from "react";
3
3
  import type { CapturedLog } from "../../proxy/schemas";
4
4
  import { extractStopReason } from "../../lib/stopReason";
5
- import { cn } from "../../lib/utils";
6
5
  import {
7
6
  ConversationHeader,
8
- getConversationId,
9
7
  getGroupApiFormat,
10
8
  hasMixedApiFormat,
11
9
  type ConversationGroupData,
12
- type ViewMode,
13
10
  } from "./ConversationHeader";
14
- import { LogEntry } from "./LogEntry";
15
- import { ThreadConnector } from "./ThreadConnector";
11
+ import { TurnGroup } from "./TurnGroup";
16
12
  import type { CacheTrendEntry } from "./cacheTrend";
17
13
 
18
14
  export type ConversationGroupProps = {
@@ -27,8 +23,8 @@ export type ConversationGroupProps = {
27
23
  cacheTrends?: Map<number, CacheTrendEntry>;
28
24
  /** Callback to open CompareDrawer with a log and its immediate predecessor. */
29
25
  onCompareWithPrevious: (log: CapturedLog) => void;
30
- /** Default display mode for new groups (from global toggle). */
31
- defaultGroupViewMode?: ViewMode;
26
+ /** When true, skip the group header and render content directly. */
27
+ standalone?: boolean;
32
28
  };
33
29
 
34
30
  function computeStats(logs: CapturedLog[]): {
@@ -50,23 +46,16 @@ export const ConversationGroup = memo(function ({
50
46
  strip,
51
47
  cacheTrends,
52
48
  onCompareWithPrevious,
53
- defaultGroupViewMode = "thread",
49
+ standalone = false,
54
50
  }: ConversationGroupProps): JSX.Element {
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]);
62
-
51
+ const [expanded, setExpanded] = useState(standalone);
63
52
  const stats = computeStats(group.logs);
64
53
  const startTime = group.logs[0]?.timestamp ?? new Date().toISOString();
65
54
  const endTime = group.logs[group.logs.length - 1]?.timestamp ?? new Date().toISOString();
66
55
  const mixed = hasMixedApiFormat(group.logs);
67
56
  const isLoading = group.logs.some((log) => log.responseStatus === null);
68
57
 
69
- // Pre-compute stop reasons for each log — used by ThreadConnector
58
+ // Pre-compute stop reasons for each log — used by turnIndices
70
59
  const stopReasons = useMemo(() => group.logs.map((log) => extractStopReason(log)), [group.logs]);
71
60
 
72
61
  // Compute turn indices for alternating background colours
@@ -82,6 +71,31 @@ export const ConversationGroup = memo(function ({
82
71
  return indices;
83
72
  }, [stopReasons]);
84
73
 
74
+ // Group logs into turns for collapsible turn display
75
+ const turnGroups = useMemo(() => {
76
+ const groups: { logs: CapturedLog[]; turnIndex: number }[] = [];
77
+ let currentGroup: CapturedLog[] = [];
78
+ let currentTurn = 0;
79
+
80
+ for (let i = 0; i < group.logs.length; i++) {
81
+ const turnIdx = turnIndices[i] ?? 0;
82
+ const log = group.logs[i];
83
+ if (!log) continue;
84
+ if (turnIdx !== currentTurn) {
85
+ if (currentGroup.length > 0) {
86
+ groups.push({ logs: currentGroup, turnIndex: currentTurn });
87
+ }
88
+ currentGroup = [log];
89
+ currentTurn = turnIdx;
90
+ } else {
91
+ currentGroup.push(log);
92
+ }
93
+ }
94
+ if (currentGroup.length > 0) {
95
+ groups.push({ logs: currentGroup, turnIndex: currentTurn });
96
+ }
97
+ return groups;
98
+ }, [group.logs, turnIndices]);
85
99
  const displayId =
86
100
  group.conversationId.startsWith("PID:") || group.conversationId.includes("|")
87
101
  ? group.conversationId
@@ -91,71 +105,38 @@ export const ConversationGroup = memo(function ({
91
105
 
92
106
  return (
93
107
  <div className="mb-4">
94
- <ConversationHeader
95
- conversationId={displayId}
96
- startTime={startTime}
97
- endTime={endTime}
98
- totalCalls={group.logs.length}
99
- totalInputTokens={stats.totalInputTokens}
100
- totalOutputTokens={stats.totalOutputTokens}
101
- apiFormat={getGroupApiFormat(group.logs)}
102
- expanded={expanded}
103
- onToggle={() => setExpanded(!expanded)}
104
- hideApiFormat={mixed}
105
- isLoading={isLoading}
106
- userAgent={group.logs[0]?.userAgent ?? null}
107
- viewMode={groupViewMode}
108
- onToggleViewMode={() => setGroupViewMode((prev) => (prev === "thread" ? "flat" : "thread"))}
109
- />
108
+ {!standalone && (
109
+ <ConversationHeader
110
+ conversationId={displayId}
111
+ startTime={startTime}
112
+ endTime={endTime}
113
+ totalCalls={group.logs.length}
114
+ totalInputTokens={stats.totalInputTokens}
115
+ totalOutputTokens={stats.totalOutputTokens}
116
+ apiFormat={getGroupApiFormat(group.logs)}
117
+ expanded={expanded}
118
+ onToggle={() => setExpanded(!expanded)}
119
+ hideApiFormat={mixed}
120
+ isLoading={isLoading}
121
+ userAgent={group.logs[0]?.userAgent ?? null}
122
+ />
123
+ )}
110
124
 
111
- {expanded && groupViewMode === "flat" && (
112
- <div className="pl-4 border-l-2 border-muted ml-3">
113
- {group.logs.map((log) => (
114
- <LogEntry
115
- key={log.id}
116
- log={log}
125
+ {expanded && (
126
+ <div>
127
+ {turnGroups.map((tg) => (
128
+ <TurnGroup
129
+ key={tg.turnIndex}
130
+ logs={tg.logs}
117
131
  viewMode={viewMode}
118
132
  strip={strip}
119
- cacheTrend={cacheTrends?.get(log.id) ?? null}
120
- onCompareWithPrevious={() => onCompareWithPrevious(log)}
133
+ cacheTrends={cacheTrends}
134
+ onCompareWithPrevious={onCompareWithPrevious}
135
+ turnIndex={tg.turnIndex}
121
136
  />
122
137
  ))}
123
138
  </div>
124
139
  )}
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
- )}
159
140
  </div>
160
141
  );
161
142
  });
@@ -1,13 +1,4 @@
1
- import {
2
- ChevronDown,
3
- ChevronRight,
4
- Clock,
5
- GitBranch,
6
- Loader2,
7
- MessageSquare,
8
- User,
9
- Zap,
10
- } from "lucide-react";
1
+ import { ChevronDown, ChevronRight, Clock, Loader2, MessageSquare, User, Zap } from "lucide-react";
11
2
  import type { JSX } from "react";
12
3
  import { cn, formatTokens } from "../../lib/utils";
13
4
  import type { CapturedLog } from "../../proxy/schemas";
@@ -37,10 +28,6 @@ export type ConversationHeaderProps = {
37
28
  /** When true and the group is collapsed, show a spinner instead of the
38
29
  * expand chevron to indicate an in-flight request inside the group. */
39
30
  isLoading?: boolean;
40
- /** Current display mode for this group (flat cards or threaded timeline). */
41
- viewMode?: ViewMode;
42
- /** Toggle between flat and thread display modes for this group. */
43
- onToggleViewMode?: () => void;
44
31
  /** User-Agent string from the first log in the group. */
45
32
  userAgent?: string | null;
46
33
  };
@@ -62,8 +49,6 @@ export function ConversationHeader({
62
49
  onToggle,
63
50
  hideApiFormat = false,
64
51
  isLoading = false,
65
- viewMode,
66
- onToggleViewMode,
67
52
  userAgent,
68
53
  }: ConversationHeaderProps): JSX.Element {
69
54
  return (
@@ -93,30 +78,6 @@ export function ConversationHeader({
93
78
  <ChevronRight className="size-4 text-muted-foreground shrink-0" />
94
79
  )}
95
80
 
96
- {/* Thread/flat view toggle — only shown when expanded */}
97
- {expanded && onToggleViewMode !== undefined && (
98
- <button
99
- type="button"
100
- onClick={(e) => {
101
- e.stopPropagation();
102
- onToggleViewMode();
103
- }}
104
- className={cn(
105
- "px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors shrink-0 cursor-pointer",
106
- viewMode === "thread"
107
- ? "bg-amber-500/15 text-amber-400 border border-amber-500/30"
108
- : "bg-muted text-muted-foreground border border-border hover:text-foreground",
109
- )}
110
- title={
111
- viewMode === "thread"
112
- ? "Thread view — click for flat view"
113
- : "Flat view — click for thread view"
114
- }
115
- >
116
- <GitBranch className="size-3" />
117
- </button>
118
- )}
119
-
120
81
  {/* Conversation ID */}
121
82
  <span
122
83
  className="text-purple-400/90 font-mono text-xs font-semibold shrink-0"
@@ -155,7 +155,7 @@ export const LogEntryHeader = memo(function ({
155
155
 
156
156
  {/* Elapsed time */}
157
157
  {log.elapsedMs !== null && (
158
- <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
158
+ <span className="hidden xl:flex items-center gap-1 text-muted-foreground text-xs shrink-0">
159
159
  <Clock className="size-3" />
160
160
  <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
161
161
  </span>
@@ -260,7 +260,7 @@ export const LogEntryHeader = memo(function ({
260
260
  {/* Origin */}
261
261
  {log.origin !== null && (
262
262
  <span
263
- className="flex items-center gap-1 text-muted-foreground text-xs shrink-0"
263
+ className="hidden xl:flex items-center gap-1 text-muted-foreground text-xs shrink-0"
264
264
  title={`Origin: ${log.origin}`}
265
265
  >
266
266
  <Globe className="size-3" />
@@ -275,7 +275,7 @@ export const LogEntryHeader = memo(function ({
275
275
  <TooltipProvider>
276
276
  <Tooltip>
277
277
  <TooltipTrigger asChild>
278
- <span className="flex items-center gap-1 text-purple-400/80 text-xs shrink-0">
278
+ <span className="hidden xl:flex items-center gap-1 text-purple-400/80 text-xs shrink-0">
279
279
  <FileTerminal className="size-3" />
280
280
  {log.clientProjectFolder !== null ? (
281
281
  <span className="font-mono tabular-nums">{log.clientProjectFolder}</span>
@@ -1,79 +1,103 @@
1
- import type { JSX } from "react";
1
+ import { type JSX, useMemo } from "react";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
2
3
  import { cn } from "../../lib/utils";
3
4
  import type { StopReason } from "../../lib/stopReason";
5
+ import { getCrabVariant } from "../ui/crab-variants";
4
6
 
5
7
  export type ThreadConnectorProps = {
6
8
  stopReason: StopReason;
7
9
  isPending: boolean;
8
10
  isFirst: boolean;
9
- isLast: boolean;
10
11
  /** True when this entry starts a new turn (first overall, or after end_turn/stop). */
11
12
  isTurnStart: boolean;
13
+ /** Seed for crab variant selection (0-11). */
14
+ crabIndex?: number;
15
+ /** When true, the boundary marker is clickable to collapse/expand the turn. */
16
+ collapsible?: boolean;
17
+ collapsed?: boolean;
18
+ onToggle?: () => void;
12
19
  };
13
20
 
14
21
  /**
15
22
  * Vertical timeline connector for thread view. Uses flexbox layout (no
16
23
  * absolute positioning) so the connector naturally tracks its sibling
17
24
  * LogEntry height — no scroll jitter.
25
+ *
26
+ * Markers use the CrabLogo. At turn starts the crab is static (amber),
27
+ * during processing it "crawls" downward with a bounce animation, and
28
+ * at turn boundaries it stops with a glow.
18
29
  */
19
30
  export function ThreadConnector({
20
31
  stopReason,
21
32
  isPending,
22
33
  isFirst,
23
- isLast: _isLast,
24
34
  isTurnStart,
35
+ crabIndex = 0,
36
+ collapsible = false,
37
+ collapsed = false,
38
+ onToggle,
25
39
  }: ThreadConnectorProps): JSX.Element {
26
40
  const isBoundary = stopReason === "end_turn" || stopReason === "stop";
27
- const isToolUse = stopReason === "tool_use";
41
+ const isRunning = isPending && !isBoundary;
42
+ const Crab = useMemo(() => getCrabVariant(crabIndex), [crabIndex]);
28
43
 
29
44
  return (
30
45
  <div className="flex flex-col items-center w-6 shrink-0">
31
- {/* Top: incoming line from previous entry, or empty spacer for first.
32
- Fixed height so the marker stays near the LogEntry header row. */}
33
- <div className="flex justify-center h-4">
46
+ {/* Top: incoming line */}
47
+ <div className="flex justify-center h-2.5">
34
48
  {!isFirst && <div className="w-0.5 bg-muted-foreground/30" />}
35
49
  </div>
36
50
 
37
- {/* Center markeraligned with the LogEntry header row */}
51
+ {/* Collapse togglesits above the crab, no overlap */}
52
+ {collapsible && (
53
+ <button
54
+ type="button"
55
+ onClick={onToggle}
56
+ title={collapsed ? "Expand turn" : "Collapse turn"}
57
+ className="cursor-pointer flex justify-center py-0.5"
58
+ >
59
+ {collapsed ? (
60
+ <ChevronRight className="size-3 text-muted-foreground hover:text-foreground transition-colors" />
61
+ ) : (
62
+ <ChevronDown className="size-3 text-muted-foreground hover:text-foreground transition-colors" />
63
+ )}
64
+ </button>
65
+ )}
66
+
67
+ {/* Center marker — CrabLogo */}
38
68
  <div className="flex items-center justify-center py-0.5">
39
69
  {isBoundary ? (
40
- <div
41
- className={cn(
42
- "size-2.5 rounded-full border-2",
43
- "bg-background border-amber-400",
44
- "shadow-[0_0_6px_rgba(251,191,36,0.4)]",
45
- )}
70
+ <span
46
71
  title={stopReason === "end_turn" ? "End of Turn (Anthropic)" : "End of Turn (OpenAI)"}
47
- />
48
- ) : isToolUse ? (
49
- <div
50
- className={cn(
51
- "size-2 rounded-full",
52
- isTurnStart
53
- ? "bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
54
- : "bg-muted-foreground/25",
55
- )}
56
- title={isTurnStart ? "Tool Use — start of turn" : "Tool Use — turn continues"}
57
- />
58
- ) : isPending ? (
59
- <div
60
- className="size-2.5 rounded-full border-2 border-dashed border-muted-foreground/30 animate-pulse"
61
- title="Response pending"
62
- />
72
+ >
73
+ <Crab
74
+ className={cn(
75
+ "size-3.5 text-amber-400",
76
+ "animate-crab-settle",
77
+ "drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
78
+ )}
79
+ />
80
+ </span>
81
+ ) : isTurnStart ? (
82
+ <span title="Start of turn">
83
+ <Crab
84
+ className={cn(
85
+ "size-3.5 text-emerald-400",
86
+ "animate-crab-appear",
87
+ "drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
88
+ )}
89
+ />
90
+ </span>
91
+ ) : isRunning ? (
92
+ <span title="Processing…">
93
+ <Crab className={cn("size-3.5 text-amber-300/80", "animate-crab-crawl")} />
94
+ </span>
63
95
  ) : (
64
- <div
65
- className={cn(
66
- "size-1.5 rounded-full",
67
- isTurnStart
68
- ? "bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
69
- : "bg-muted-foreground/30",
70
- )}
71
- />
96
+ <Crab className="size-3.5 text-muted-foreground/40" />
72
97
  )}
73
98
  </div>
74
99
 
75
- {/* Bottom: outgoing line to next entry, or short terminator at boundaries.
76
- flex-1 fills the remaining height of the LogEntry card. */}
100
+ {/* Bottom: outgoing line */}
77
101
  <div className="flex-1 flex justify-center min-h-1">
78
102
  {isBoundary ? (
79
103
  <div className="w-0.5 bg-muted-foreground/10 h-4" />
@@ -0,0 +1,83 @@
1
+ import { useState, useMemo, type JSX, useCallback } from "react";
2
+ import type { CapturedLog } from "../../proxy/schemas";
3
+ import { cn } from "../../lib/utils";
4
+ import { extractStopReason, isTurnBoundary } from "../../lib/stopReason";
5
+ import { LogEntry } from "./LogEntry";
6
+ import { ThreadConnector } from "./ThreadConnector";
7
+ import type { CacheTrendEntry } from "./cacheTrend";
8
+
9
+ type TurnGroupProps = {
10
+ logs: CapturedLog[];
11
+ viewMode: "simple" | "full";
12
+ strip: boolean;
13
+ cacheTrends?: Map<number, CacheTrendEntry>;
14
+ onCompareWithPrevious: (log: CapturedLog) => void;
15
+ /** Turn index for alternating background colours. */
16
+ turnIndex?: number;
17
+ };
18
+
19
+ export function TurnGroup({
20
+ logs,
21
+ viewMode,
22
+ strip,
23
+ cacheTrends,
24
+ onCompareWithPrevious,
25
+ turnIndex = 0,
26
+ }: TurnGroupProps): JSX.Element {
27
+ const stopReasons = useMemo(() => logs.map((l) => extractStopReason(l)), [logs]);
28
+ const lastIdx = logs.length - 1;
29
+ const lastStop = stopReasons[lastIdx] ?? null;
30
+ const isComplete = lastStop !== null ? isTurnBoundary(lastStop) : false;
31
+ const isPending = logs[lastIdx]?.responseStatus === null;
32
+ const collapsible = isComplete && !isPending;
33
+ const [collapsed, setCollapsed] = useState(false);
34
+
35
+ const toggleCollapse = useCallback(() => {
36
+ if (collapsible) setCollapsed((prev) => !prev);
37
+ }, [collapsible]);
38
+
39
+ // When collapsed, only show the boundary entry
40
+ const visibleLogs = useMemo(() => {
41
+ if (!collapsed) return logs;
42
+ const last = logs[lastIdx];
43
+ return last !== undefined ? [last] : [];
44
+ }, [logs, collapsed, lastIdx]);
45
+
46
+ const bgClass = turnIndex % 2 === 0 ? "bg-muted/10" : "bg-muted/25";
47
+
48
+ return (
49
+ <div
50
+ className={cn("border rounded-lg", isPending ? "border-amber-500/10" : "border-transparent")}
51
+ >
52
+ {visibleLogs.map((log, visibleIdx) => {
53
+ const originalIdx = collapsed ? lastIdx : visibleIdx;
54
+ const reason = stopReasons[originalIdx] ?? null;
55
+ const isBoundary = reason === "end_turn" || reason === "stop";
56
+
57
+ return (
58
+ <div key={log.id} className="flex items-stretch">
59
+ <ThreadConnector
60
+ stopReason={reason}
61
+ isPending={log.responseStatus === null}
62
+ isFirst={originalIdx === 0}
63
+ isTurnStart={originalIdx === 0}
64
+ crabIndex={log.id % 12}
65
+ collapsible={collapsible && isBoundary && logs.length > 1}
66
+ collapsed={collapsed}
67
+ onToggle={toggleCollapse}
68
+ />
69
+ <div className={cn("flex-1 min-w-0 mb-2 rounded-lg", bgClass)}>
70
+ <LogEntry
71
+ log={log}
72
+ viewMode={viewMode}
73
+ strip={strip}
74
+ cacheTrend={cacheTrends?.get(log.id) ?? null}
75
+ onCompareWithPrevious={() => onCompareWithPrevious(log)}
76
+ />
77
+ </div>
78
+ </div>
79
+ );
80
+ })}
81
+ </div>
82
+ );
83
+ }