@tonyclaw/agent-inspector 2.0.2 → 2.0.4

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 (60) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-Bp7_x-5N.js → CompareDrawer-BCH_fsLm.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-D85_UANk.js +101 -0
  4. package/.output/public/assets/{ReplayDialog-DFHCd0yx.js → ReplayDialog-DTeaHHit.js} +1 -1
  5. package/.output/public/assets/RequestAnatomy-DZ8grAih.js +1 -0
  6. package/.output/public/assets/ResponseView-Cldm6RCi.js +1 -0
  7. package/.output/public/assets/{StreamingChunkSequence-Bjs4Lqwn.js → StreamingChunkSequence-3x4p-yT7.js} +1 -1
  8. package/.output/public/assets/_sessionId-YqWFBu6d.js +1 -0
  9. package/.output/public/assets/index-BIw2H6jO.js +1 -0
  10. package/.output/public/assets/index-CobXD0yH.css +1 -0
  11. package/.output/public/assets/{json-viewer-6uV_YXws.js → json-viewer-BrzjD7qI.js} +1 -1
  12. package/.output/public/assets/{main-FSGUGtEL.js → main-mgxeUdZQ.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +8 -8
  14. package/.output/server/{_sessionId-_bf9vUww.mjs → _sessionId-C4xsxIWm.mjs} +2 -2
  15. package/.output/server/_ssr/{CompareDrawer-DIth2DQM.mjs → CompareDrawer-DuWEpqQ7.mjs} +4 -4
  16. package/.output/server/_ssr/{ProxyViewerContainer-249bTH-T.mjs → ProxyViewerContainer-Cckz5qKu.mjs} +519 -89
  17. package/.output/server/_ssr/{ReplayDialog-C1aGx0y1.mjs → ReplayDialog-BDRcr8E5.mjs} +4 -4
  18. package/.output/server/_ssr/{RequestAnatomy-D2bCiEJn.mjs → RequestAnatomy-BoO2_Ij0.mjs} +5 -5
  19. package/.output/server/_ssr/{ResponseView-DP6k4Xs_.mjs → ResponseView-DZiPBxvO.mjs} +21 -17
  20. package/.output/server/_ssr/{StreamingChunkSequence-HyXZV-b5.mjs → StreamingChunkSequence-D-be7KEL.mjs} +3 -3
  21. package/.output/server/_ssr/{index-Bt47f9pn.mjs → index-5RImHKfu.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-Co-YRwUP.mjs → json-viewer-aJhb93ZK.mjs} +2 -2
  24. package/.output/server/_ssr/{router-to_OJirX.mjs → router-Dgkv5nKP.mjs} +38 -99
  25. package/.output/server/{_tanstack-start-manifest_v-Bd-2YRWo.mjs → _tanstack-start-manifest_v-B8rrWXjr.mjs} +1 -1
  26. package/.output/server/index.mjs +63 -63
  27. package/README.md +5 -2
  28. package/package.json +1 -1
  29. package/src/components/ProxyViewer.tsx +25 -15
  30. package/src/components/ProxyViewerContainer.tsx +2 -1
  31. package/src/components/providers/SettingsDialog.tsx +45 -1
  32. package/src/components/proxy-viewer/AgentTraceSummary.tsx +276 -0
  33. package/src/components/proxy-viewer/AnswerMarkdown.tsx +16 -0
  34. package/src/components/proxy-viewer/ConversationGroup.tsx +18 -0
  35. package/src/components/proxy-viewer/ConversationHeader.tsx +6 -6
  36. package/src/components/proxy-viewer/LogEntry.tsx +5 -5
  37. package/src/components/proxy-viewer/LogEntryHeader.tsx +9 -14
  38. package/src/components/proxy-viewer/ResponseView.tsx +2 -6
  39. package/src/components/proxy-viewer/ToolTraceEvents.tsx +32 -0
  40. package/src/components/proxy-viewer/TurnGroup.tsx +15 -1
  41. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +2 -2
  42. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +6 -12
  43. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +10 -14
  44. package/src/components/proxy-viewer/viewerState.ts +177 -0
  45. package/src/lib/runtimeConfig.ts +6 -0
  46. package/src/lib/timeDisplay.ts +22 -0
  47. package/src/lib/useOnboarding.ts +2 -0
  48. package/src/lib/useStripConfig.ts +16 -0
  49. package/src/proxy/chunkStorage.ts +3 -4
  50. package/src/proxy/config.ts +3 -0
  51. package/src/proxy/logger.ts +8 -15
  52. package/src/proxy/store.ts +8 -16
  53. package/src/routes/api/config.ts +5 -1
  54. package/src/routes/api/providers.$providerId.test.log.ts +0 -79
  55. package/.output/public/assets/ProxyViewerContainer-USuxPy-K.js +0 -101
  56. package/.output/public/assets/RequestAnatomy-ehyrskxt.js +0 -1
  57. package/.output/public/assets/ResponseView-BNGyc8e_.js +0 -1
  58. package/.output/public/assets/_sessionId-D_SeK_qp.js +0 -1
  59. package/.output/public/assets/index-BGGOWR7A.js +0 -1
  60. package/.output/public/assets/index-CIL46Z2y.css +0 -1
@@ -0,0 +1,276 @@
1
+ import { useCallback, useMemo, useState, type JSX } from "react";
2
+ import {
3
+ AlertTriangle,
4
+ Brain,
5
+ ChevronDown,
6
+ ChevronRight,
7
+ Clock,
8
+ Loader2,
9
+ MessageSquare,
10
+ Wrench,
11
+ Zap,
12
+ } from "lucide-react";
13
+ import { z } from "zod";
14
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
15
+ import { formatTimestampRange } from "../../lib/timeDisplay";
16
+ import { formatTokens } from "../../lib/utils";
17
+ import { parseJsonResponse, readApiError } from "../../lib/apiClient";
18
+ import { KnowledgeCandidateSchema, type KnowledgeCandidate } from "../../knowledge/types";
19
+ import type { CapturedLog } from "../../proxy/schemas";
20
+ import { Badge } from "../ui/badge";
21
+ import { Button } from "../ui/button";
22
+ import { buildTraceSummary } from "./viewerState";
23
+
24
+ const CandidateResponseSchema = z.object({
25
+ candidates: z.array(KnowledgeCandidateSchema),
26
+ });
27
+
28
+ type CandidateLoadState =
29
+ | { status: "idle"; error: null }
30
+ | { status: "loading"; error: null }
31
+ | { status: "ready"; error: null }
32
+ | { status: "failed"; error: string };
33
+
34
+ type AgentTraceSummaryProps = {
35
+ logs: CapturedLog[];
36
+ scopeId: string;
37
+ slowResponseThresholdSeconds: number;
38
+ showRollupMetrics: boolean;
39
+ timeDisplayFormat: TimeDisplayFormat;
40
+ };
41
+
42
+ function formatElapsed(ms: number | null): string {
43
+ if (ms === null) return "-";
44
+ if (ms < 1000) return `${String(ms)}ms`;
45
+ return `${(ms / 1000).toFixed(1)}s`;
46
+ }
47
+
48
+ function formatTimeRange(
49
+ startedAt: string | null,
50
+ endedAt: string | null,
51
+ timeDisplayFormat: TimeDisplayFormat,
52
+ ): string | null {
53
+ if (startedAt === null || endedAt === null) return null;
54
+ return formatTimestampRange(startedAt, endedAt, timeDisplayFormat);
55
+ }
56
+
57
+ function formatCandidateCount(count: number): string {
58
+ return `${String(count)} candidate${count === 1 ? "" : "s"}`;
59
+ }
60
+
61
+ function getLogAnchor(logId: number): string {
62
+ return `log-${String(logId)}`;
63
+ }
64
+
65
+ function jumpToLog(logId: number): void {
66
+ const anchor = getLogAnchor(logId);
67
+ const target = document.getElementById(anchor);
68
+ window.history.replaceState(null, "", `#${anchor}`);
69
+ if (!(target instanceof HTMLElement)) return;
70
+ target.scrollIntoView({ block: "center", behavior: "smooth" });
71
+ target.focus({ preventScroll: true });
72
+ }
73
+
74
+ function CandidateList({ candidates }: { candidates: KnowledgeCandidate[] }): JSX.Element | null {
75
+ if (candidates.length === 0) return null;
76
+ return (
77
+ <div className="mt-2 grid gap-1.5">
78
+ {candidates.map((candidate) => (
79
+ <div
80
+ key={candidate.id}
81
+ className="rounded-md border border-border/80 bg-background/60 px-2.5 py-2"
82
+ >
83
+ <div className="flex min-w-0 items-center gap-2">
84
+ <Badge variant="outline" className="h-5 px-1.5 text-[10px] font-mono">
85
+ {candidate.type}
86
+ </Badge>
87
+ <span className="min-w-0 flex-1 truncate text-xs font-medium" title={candidate.title}>
88
+ {candidate.title}
89
+ </span>
90
+ <span className="shrink-0 font-mono text-[10px] text-muted-foreground">
91
+ {candidate.status}
92
+ </span>
93
+ </div>
94
+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
95
+ {candidate.logIds.map((logId) => (
96
+ <a
97
+ key={logId}
98
+ href={`#${getLogAnchor(logId)}`}
99
+ onClick={(event) => {
100
+ event.preventDefault();
101
+ jumpToLog(logId);
102
+ }}
103
+ className="rounded border border-blue-400/25 px-1.5 py-0.5 font-mono text-[10px] text-blue-400 underline-offset-2 transition-colors hover:bg-blue-400/10 hover:text-blue-300 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
104
+ aria-label={`Jump to evidence log ${String(logId)}`}
105
+ >
106
+ #{logId}
107
+ </a>
108
+ ))}
109
+ </div>
110
+ </div>
111
+ ))}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ export function AgentTraceSummary({
117
+ logs,
118
+ scopeId,
119
+ slowResponseThresholdSeconds,
120
+ showRollupMetrics,
121
+ timeDisplayFormat,
122
+ }: AgentTraceSummaryProps): JSX.Element | null {
123
+ const [candidates, setCandidates] = useState<KnowledgeCandidate[]>([]);
124
+ const [candidatesExpanded, setCandidatesExpanded] = useState(true);
125
+ const [candidateState, setCandidateState] = useState<CandidateLoadState>({
126
+ status: "idle",
127
+ error: null,
128
+ });
129
+ const hasCandidates = candidates.length > 0;
130
+ const summary = useMemo(
131
+ () => buildTraceSummary(logs, slowResponseThresholdSeconds, candidates.length),
132
+ [candidates.length, logs, slowResponseThresholdSeconds],
133
+ );
134
+ const showElapsedSummary = showRollupMetrics || summary.maxElapsedMs !== null;
135
+ const timeRange = useMemo(
136
+ () => formatTimeRange(summary.startedAt, summary.endedAt, timeDisplayFormat),
137
+ [summary.endedAt, summary.startedAt, timeDisplayFormat],
138
+ );
139
+
140
+ const createCandidates = useCallback(() => {
141
+ if (logs.length === 0 || candidateState.status === "loading") return;
142
+ setCandidateState({ status: "loading", error: null });
143
+ void (async () => {
144
+ try {
145
+ const response = await fetch(
146
+ `/api/knowledge/sessions/${encodeURIComponent(scopeId)}/candidates`,
147
+ { method: "POST" },
148
+ );
149
+ if (!response.ok) {
150
+ const message = await readApiError(
151
+ response,
152
+ `Candidate generation failed with ${String(response.status)}`,
153
+ );
154
+ setCandidateState({ status: "failed", error: message });
155
+ return;
156
+ }
157
+ const parsed = await parseJsonResponse(response, CandidateResponseSchema);
158
+ setCandidates(parsed.candidates);
159
+ setCandidatesExpanded(parsed.candidates.length > 0);
160
+ setCandidateState({ status: "ready", error: null });
161
+ } catch (error) {
162
+ setCandidateState({
163
+ status: "failed",
164
+ error: error instanceof Error ? error.message : "Candidate response was invalid",
165
+ });
166
+ }
167
+ })();
168
+ }, [candidateState.status, logs.length, scopeId]);
169
+
170
+ if (logs.length === 0) return null;
171
+
172
+ return (
173
+ <section className="mb-2 rounded-lg border border-border bg-muted/10 px-3 py-2">
174
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs">
175
+ {showRollupMetrics && (
176
+ <span className="inline-flex items-center gap-1.5 font-semibold text-foreground">
177
+ <MessageSquare className="size-3.5 text-blue-400" />
178
+ {summary.llmCallCount} LLM
179
+ </span>
180
+ )}
181
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
182
+ <Wrench className="size-3.5 text-sky-400/70" />
183
+ {summary.toolCallCount} tools
184
+ </span>
185
+ {showRollupMetrics && (
186
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
187
+ <Zap className="size-3.5 text-emerald-400" />
188
+ <span className="font-mono">
189
+ {formatTokens(summary.totalInputTokens)} / {formatTokens(summary.totalOutputTokens)}
190
+ </span>
191
+ </span>
192
+ )}
193
+ {(summary.totalCacheCreationInputTokens > 0 || summary.totalCacheReadInputTokens > 0) && (
194
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
195
+ <Zap className="size-3.5 text-purple-400" />
196
+ <span className="font-mono">
197
+ +{formatTokens(summary.totalCacheCreationInputTokens)} / ~
198
+ {formatTokens(summary.totalCacheReadInputTokens)}
199
+ </span>
200
+ </span>
201
+ )}
202
+ {showElapsedSummary && (
203
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
204
+ <Clock className="size-3.5" />
205
+ {showRollupMetrics && (
206
+ <span className="font-mono">{formatElapsed(summary.totalElapsedMs)}</span>
207
+ )}
208
+ {summary.maxElapsedMs !== null && (
209
+ <span className="font-mono text-muted-foreground/70">
210
+ max {formatElapsed(summary.maxElapsedMs)}
211
+ </span>
212
+ )}
213
+ </span>
214
+ )}
215
+ {showRollupMetrics && timeRange !== null && (
216
+ <span className="font-mono text-muted-foreground/70">{timeRange}</span>
217
+ )}
218
+ {(summary.failedCallCount > 0 ||
219
+ summary.pendingCallCount > 0 ||
220
+ summary.slowCallCount > 0) && (
221
+ <span className="inline-flex items-center gap-1.5 text-amber-400">
222
+ <AlertTriangle className="size-3.5" />
223
+ <span className="font-mono">
224
+ {summary.failedCallCount} fail / {summary.pendingCallCount} pending /{" "}
225
+ {summary.slowCallCount} slow
226
+ </span>
227
+ </span>
228
+ )}
229
+ <span className="flex-1" />
230
+ {hasCandidates && (
231
+ <Badge variant="outline" className="h-6 px-2 text-[10px] font-mono">
232
+ {formatCandidateCount(summary.knowledgeCandidateCount)}
233
+ </Badge>
234
+ )}
235
+ <Button
236
+ type="button"
237
+ variant="outline"
238
+ size="sm"
239
+ className="h-7 gap-1.5 px-2 text-xs"
240
+ onClick={createCandidates}
241
+ disabled={candidateState.status === "loading"}
242
+ >
243
+ {candidateState.status === "loading" ? (
244
+ <Loader2 className="size-3.5 animate-spin" />
245
+ ) : (
246
+ <Brain className="size-3.5" />
247
+ )}
248
+ Candidate
249
+ </Button>
250
+ {hasCandidates && (
251
+ <Button
252
+ type="button"
253
+ variant="ghost"
254
+ size="icon"
255
+ className="size-7 text-muted-foreground"
256
+ onClick={() => setCandidatesExpanded((value) => !value)}
257
+ aria-expanded={candidatesExpanded}
258
+ aria-label={
259
+ candidatesExpanded ? "Collapse memory candidates" : "Expand memory candidates"
260
+ }
261
+ >
262
+ {candidatesExpanded ? (
263
+ <ChevronDown className="size-3.5" />
264
+ ) : (
265
+ <ChevronRight className="size-3.5" />
266
+ )}
267
+ </Button>
268
+ )}
269
+ </div>
270
+ {candidateState.status === "failed" && (
271
+ <p className="mt-2 text-xs text-destructive">{candidateState.error}</p>
272
+ )}
273
+ {candidatesExpanded && <CandidateList candidates={candidates} />}
274
+ </section>
275
+ );
276
+ }
@@ -0,0 +1,16 @@
1
+ import type { JSX } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+
4
+ const ANSWER_MARKDOWN_CLASS =
5
+ "prose prose-sm dark:prose-invert max-w-none text-[13px] leading-[1.65] " +
6
+ "[&_pre]:bg-muted [&_pre]:text-foreground [&_code]:text-[0.8em] " +
7
+ "[&_p]:my-1 [&_p]:leading-[1.65] [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5 " +
8
+ "[&_li]:leading-[1.6]";
9
+
10
+ export function AnswerMarkdown({ text }: { text: string }): JSX.Element {
11
+ return (
12
+ <div className={ANSWER_MARKDOWN_CLASS}>
13
+ <ReactMarkdown>{text}</ReactMarkdown>
14
+ </div>
15
+ );
16
+ }
@@ -1,5 +1,6 @@
1
1
  import { useState, memo, useMemo } from "react";
2
2
  import type { JSX } from "react";
3
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
3
4
  import type { CapturedLog } from "../../proxy/schemas";
4
5
  import {
5
6
  ConversationHeader,
@@ -8,6 +9,7 @@ import {
8
9
  type ConversationGroupData,
9
10
  } from "./ConversationHeader";
10
11
  import { TurnGroup } from "./TurnGroup";
12
+ import { AgentTraceSummary } from "./AgentTraceSummary";
11
13
  import type { CacheTrendEntry } from "./cacheTrend";
12
14
  import { buildTurnGroups, shouldRenderConversationContent } from "./viewerState";
13
15
 
@@ -28,6 +30,10 @@ export type ConversationGroupProps = {
28
30
  comparisonPredecessors: Map<number, CapturedLog>;
29
31
  /** When true, skip the group header and render content directly. */
30
32
  standalone?: boolean;
33
+ /** When true, the route-level session context already shows rollup metrics. */
34
+ hasPinnedSessionContext?: boolean;
35
+ /** Controls whether timestamps render as compact local time or full ISO strings. */
36
+ timeDisplayFormat: TimeDisplayFormat;
31
37
  /** Clear all logs that belong to this group. */
32
38
  onClearGroup: (ids: number[]) => void;
33
39
  };
@@ -55,6 +61,8 @@ export const ConversationGroup = memo(function ({
55
61
  comparisonPredecessors,
56
62
  onClearGroup,
57
63
  standalone = false,
64
+ hasPinnedSessionContext = false,
65
+ timeDisplayFormat,
58
66
  }: ConversationGroupProps): JSX.Element {
59
67
  const [expanded, setExpanded] = useState(false);
60
68
  const stats = useMemo(() => computeStats(group.logs), [group.logs]);
@@ -62,6 +70,7 @@ export const ConversationGroup = memo(function ({
62
70
  const endTime = group.logs[group.logs.length - 1]?.timestamp ?? new Date().toISOString();
63
71
  const mixed = hasMixedApiFormat(group.logs);
64
72
  const isLoading = group.logs.some((log) => log.responseStatus === null);
73
+ const showTraceRollupMetrics = standalone && !hasPinnedSessionContext;
65
74
 
66
75
  // Pre-compute stop reasons for each log — used by turnIndices
67
76
  const turnGroups = useMemo(() => buildTurnGroups(group.logs), [group.logs]);
@@ -81,12 +90,20 @@ export const ConversationGroup = memo(function ({
81
90
  hideApiFormat={mixed}
82
91
  isLoading={isLoading}
83
92
  userAgent={group.logs[0]?.userAgent ?? null}
93
+ timeDisplayFormat={timeDisplayFormat}
84
94
  onClear={() => onClearGroup(group.logs.map((l) => l.id))}
85
95
  />
86
96
  )}
87
97
 
88
98
  {shouldRenderConversationContent(standalone, expanded) && (
89
99
  <div>
100
+ <AgentTraceSummary
101
+ logs={group.logs}
102
+ scopeId={group.conversationId}
103
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
104
+ showRollupMetrics={showTraceRollupMetrics}
105
+ timeDisplayFormat={timeDisplayFormat}
106
+ />
90
107
  {turnGroups.map((tg) => (
91
108
  <TurnGroup
92
109
  key={tg.turnIndex}
@@ -94,6 +111,7 @@ export const ConversationGroup = memo(function ({
94
111
  viewMode={viewMode}
95
112
  strip={strip}
96
113
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
114
+ timeDisplayFormat={timeDisplayFormat}
97
115
  cacheTrends={cacheTrends}
98
116
  onCompareWithPrevious={onCompareWithPrevious}
99
117
  comparisonPredecessors={comparisonPredecessors}
@@ -12,6 +12,8 @@ import {
12
12
  } from "lucide-react";
13
13
  import type { JSX } from "react";
14
14
  import { getSessionPath } from "../../lib/sessionUrl";
15
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
16
+ import { formatTimestampRange } from "../../lib/timeDisplay";
15
17
  import { cn, formatTokens } from "../../lib/utils";
16
18
  import type { CapturedLog } from "../../proxy/schemas";
17
19
  import { Badge } from "../ui/badge";
@@ -43,16 +45,13 @@ export type ConversationHeaderProps = {
43
45
  isLoading?: boolean;
44
46
  /** User-Agent string from the first log in the group. */
45
47
  userAgent?: string | null;
48
+ /** Controls whether timestamps render as compact local time or full ISO strings. */
49
+ timeDisplayFormat: TimeDisplayFormat;
46
50
  /** Clear all logs in this group. After confirmation the parent removes them
47
51
  * from the in-memory store; this header is then unmounted. */
48
52
  onClear?: () => void;
49
53
  };
50
54
 
51
- function formatTimestamp(iso: string): string {
52
- const date = new Date(iso);
53
- return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
54
- }
55
-
56
55
  export function ConversationHeader({
57
56
  conversationId,
58
57
  startTime,
@@ -66,6 +65,7 @@ export function ConversationHeader({
66
65
  hideApiFormat = false,
67
66
  isLoading = false,
68
67
  userAgent,
68
+ timeDisplayFormat,
69
69
  onClear,
70
70
  }: ConversationHeaderProps): JSX.Element {
71
71
  const [confirmOpen, setConfirmOpen] = useState(false);
@@ -157,7 +157,7 @@ export function ConversationHeader({
157
157
  <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
158
158
  <Clock className="size-3" />
159
159
  <span className="font-mono tabular-nums">
160
- {formatTimestamp(startTime)} - {formatTimestamp(endTime)}
160
+ {formatTimestampRange(startTime, endTime, timeDisplayFormat)}
161
161
  </span>
162
162
  </span>
163
163
 
@@ -2,6 +2,7 @@ import { GitCompareArrows } from "lucide-react";
2
2
  import { Suspense, type JSX } from "react";
3
3
  import { useMemo, useRef, useState, memo } from "react";
4
4
  import type { CapturedLog } from "../../proxy/schemas";
5
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
5
6
  import { stripClaudeCodeBillingHeader } from "../../proxy/claudeCodeStrip";
6
7
  import { Button } from "../ui/button";
7
8
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
@@ -48,6 +49,8 @@ export type LogEntryProps = {
48
49
  strip: boolean;
49
50
  /** Slow-response threshold in seconds. `0` disables the warning indicator. */
50
51
  slowResponseThresholdSeconds: number;
52
+ /** Controls whether timestamps render as compact local time or full ISO strings. */
53
+ timeDisplayFormat: TimeDisplayFormat;
51
54
  /**
52
55
  * Per-log cache token trend, looked up in the viewer-level trend map.
53
56
  * `null` (or absent) means the header should render with no arrows.
@@ -119,6 +122,7 @@ export const LogEntry = memo(function ({
119
122
  viewMode = "simple",
120
123
  strip,
121
124
  slowResponseThresholdSeconds,
125
+ timeDisplayFormat,
122
126
  cacheTrend = null,
123
127
  onCompareWithPrevious,
124
128
  }: LogEntryProps): JSX.Element {
@@ -135,10 +139,6 @@ export const LogEntry = memo(function ({
135
139
  () => adapter.analyzeRequest(log.rawRequestBody),
136
140
  [adapter, log.rawRequestBody],
137
141
  );
138
- const responseAnalysis = useMemo(
139
- () => adapter.analyzeResponse(log.responseText),
140
- [adapter, log.responseText],
141
- );
142
142
  const strippedRequestBody = useMemo(() => {
143
143
  if (!strip || resolvedFormat !== "anthropic" || log.rawRequestBody === null) {
144
144
  return null;
@@ -296,9 +296,9 @@ export const LogEntry = memo(function ({
296
296
  toolCount={requestAnalysis.toolCount}
297
297
  expanded={expanded}
298
298
  onToggle={() => setExpanded(!expanded)}
299
- responseToolNames={responseAnalysis.toolNames}
300
299
  cacheTrend={cacheTrend}
301
300
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
301
+ timeDisplayFormat={timeDisplayFormat}
302
302
  activeTab={activeTab}
303
303
  tabActions={tabActions}
304
304
  onReplay={
@@ -22,7 +22,9 @@ import {
22
22
  Zap,
23
23
  } from "lucide-react";
24
24
  import type { JSX, MouseEvent } from "react";
25
- import { memo, useMemo } from "react";
25
+ import { memo } from "react";
26
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
27
+ import { formatTimestamp } from "../../lib/timeDisplay";
26
28
  import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
27
29
  import type { CapturedLog } from "../../proxy/schemas";
28
30
  import { Badge } from "../ui/badge";
@@ -105,8 +107,6 @@ export type LogEntryHeaderProps = {
105
107
  toolCount?: number | null;
106
108
  expanded: boolean;
107
109
  onToggle: () => void;
108
- /** Tool call names extracted from the model response (e.g., ["read_file", "grep"]). */
109
- responseToolNames?: string[] | null;
110
110
  /** Per-log cache token trend (creation + read) relative to the previous log
111
111
  * in the same conversation group. When `undefined` or a field is `null`,
112
112
  * the corresponding cache span renders as it did before — no arrow.
@@ -124,6 +124,8 @@ export type LogEntryHeaderProps = {
124
124
  onReplay?: () => void;
125
125
  /** Slow-response threshold in seconds. `0` disables the warning indicator. */
126
126
  slowResponseThresholdSeconds?: number;
127
+ /** Controls whether timestamps render as compact local time or full ISO strings. */
128
+ timeDisplayFormat: TimeDisplayFormat;
127
129
  };
128
130
 
129
131
  export const LogEntryHeader = memo(function ({
@@ -132,12 +134,12 @@ export const LogEntryHeader = memo(function ({
132
134
  toolCount = null,
133
135
  expanded,
134
136
  onToggle,
135
- responseToolNames = null,
136
137
  cacheTrend = null,
137
138
  activeTab,
138
139
  tabActions,
139
140
  onReplay,
140
141
  slowResponseThresholdSeconds = 0,
142
+ timeDisplayFormat,
141
143
  }: LogEntryHeaderProps): JSX.Element {
142
144
  const statusCategory = getStatusCategory(log.responseStatus);
143
145
  const isSlowResponse =
@@ -146,7 +148,6 @@ export const LogEntryHeader = memo(function ({
146
148
  log.elapsedMs > slowResponseThresholdSeconds * 1000;
147
149
 
148
150
  const hasTokens = log.inputTokens !== null || log.outputTokens !== null;
149
- const toolNamesJoined = useMemo(() => responseToolNames?.join(", ") ?? null, [responseToolNames]);
150
151
 
151
152
  return (
152
153
  <TooltipProvider>
@@ -178,7 +179,9 @@ export const LogEntryHeader = memo(function ({
178
179
  {/* Request start time */}
179
180
  <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
180
181
  <Clock className="size-3" />
181
- <span className="font-mono tabular-nums">{log.timestamp}</span>
182
+ <span className="font-mono tabular-nums" title={log.timestamp}>
183
+ {formatTimestamp(log.timestamp, timeDisplayFormat)}
184
+ </span>
182
185
  </span>
183
186
 
184
187
  {/* Model — logo icon only, model name in tooltip */}
@@ -303,14 +306,6 @@ export const LogEntryHeader = memo(function ({
303
306
  </span>
304
307
  )}
305
308
 
306
- {/* Response tool calls — tool names the model requested to invoke */}
307
- {responseToolNames !== null && responseToolNames.length > 0 && (
308
- <span className="flex items-center gap-1 text-amber-400/80 text-xs shrink-0">
309
- <Wrench className="size-3" />
310
- <span className="font-mono tabular-nums truncate max-w-[160px]">{toolNamesJoined}</span>
311
- </span>
312
- )}
313
-
314
309
  {/* Origin */}
315
310
  {log.origin !== null && (
316
311
  <span
@@ -2,9 +2,9 @@ import { AlertTriangle, Zap } from "lucide-react";
2
2
  import type { JSX } from "react";
3
3
  import { memo } from "react";
4
4
  import { useMemo } from "react";
5
- import ReactMarkdown from "react-markdown";
6
5
  import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
7
6
  import type { RequestFormat } from "../../proxy/schemas";
7
+ import { AnswerMarkdown } from "./AnswerMarkdown";
8
8
  import { formatViewFor } from "./formats";
9
9
  import { getLogFormatAdapter } from "./log-formats";
10
10
 
@@ -61,11 +61,7 @@ function ErrorResponseView({ text }: { text: string }): JSX.Element {
61
61
  }
62
62
 
63
63
  function MarkdownFallbackView({ text }: { text: string }): JSX.Element {
64
- return (
65
- <div className="prose prose-sm dark:prose-invert max-w-none [&_pre]:bg-muted [&_pre]:text-foreground [&_code]:text-[0.8em] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1">
66
- <ReactMarkdown>{text}</ReactMarkdown>
67
- </div>
68
- );
64
+ return <AnswerMarkdown text={text} />;
69
65
  }
70
66
 
71
67
  export const ResponseView = memo(function ResponseView({
@@ -0,0 +1,32 @@
1
+ import { type JSX } from "react";
2
+ import { ChevronRight, Wrench } from "lucide-react";
3
+ import type { ToolTraceEvent } from "./viewerState";
4
+
5
+ type ToolTraceEventsProps = {
6
+ events: ToolTraceEvent[];
7
+ };
8
+
9
+ export function ToolTraceEvents({ events }: ToolTraceEventsProps): JSX.Element | null {
10
+ if (events.length === 0) return null;
11
+ return (
12
+ <div className="mx-3 mb-2 grid gap-1.5">
13
+ {events.map((event) => (
14
+ <div
15
+ key={event.id}
16
+ className="flex min-w-0 items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-2.5 py-1.5 text-xs"
17
+ >
18
+ <Wrench className="size-3.5 shrink-0 text-sky-400/70" />
19
+ <span className="font-mono font-semibold text-foreground/80">{event.name}</span>
20
+ {event.argumentsPreview !== null && (
21
+ <>
22
+ <ChevronRight className="size-3 shrink-0 text-muted-foreground/60" />
23
+ <span className="min-w-0 truncate font-mono text-muted-foreground">
24
+ {event.argumentsPreview}
25
+ </span>
26
+ </>
27
+ )}
28
+ </div>
29
+ ))}
30
+ </div>
31
+ );
32
+ }
@@ -1,6 +1,7 @@
1
1
  import { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { AlertTriangle, ChevronRight, Clock, Zap } from "lucide-react";
3
3
  import { isTurnBoundary } from "../../lib/stopReason";
4
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
4
5
  import { cn, formatTokens } from "../../lib/utils";
5
6
  import type { CapturedLog } from "../../proxy/schemas";
6
7
  import { getCrabVariant } from "../ui/crab-variants";
@@ -9,7 +10,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/
9
10
  import type { CacheTrendEntry } from "./cacheTrend";
10
11
  import { LogEntry } from "./LogEntry";
11
12
  import { ThreadConnector } from "./ThreadConnector";
12
- import { isTurnCollapsible, type TurnEntry } from "./viewerState";
13
+ import { ToolTraceEvents } from "./ToolTraceEvents";
14
+ import { extractToolTraceEvents, isTurnCollapsible, type TurnEntry } from "./viewerState";
13
15
 
14
16
  function formatElapsed(ms: number): string {
15
17
  if (ms < 1000) return `${ms}ms`;
@@ -25,6 +27,7 @@ type TurnGroupProps = {
25
27
  onCompareWithPrevious: (log: CapturedLog) => void;
26
28
  comparisonPredecessors: Map<number, CapturedLog>;
27
29
  turnIndex?: number;
30
+ timeDisplayFormat: TimeDisplayFormat;
28
31
  };
29
32
 
30
33
  export const TurnGroup = memo(function TurnGroup({
@@ -36,6 +39,7 @@ export const TurnGroup = memo(function TurnGroup({
36
39
  onCompareWithPrevious,
37
40
  comparisonPredecessors,
38
41
  turnIndex = 0,
42
+ timeDisplayFormat,
39
43
  }: TurnGroupProps): JSX.Element {
40
44
  const lastIdx = entries.length - 1;
41
45
  const lastStop = entries[lastIdx]?.stopReason ?? null;
@@ -106,6 +110,14 @@ export const TurnGroup = memo(function TurnGroup({
106
110
  aggregate.maxElapsed !== null &&
107
111
  slowResponseThresholdSeconds > 0 &&
108
112
  aggregate.maxElapsed > slowResponseThresholdSeconds * 1000;
113
+ const toolEventsByLogId = useMemo(() => {
114
+ const events = new Map<number, ReturnType<typeof extractToolTraceEvents>>();
115
+ for (const entry of entries) {
116
+ const extracted = extractToolTraceEvents(entry.log);
117
+ if (extracted.length > 0) events.set(entry.log.id, extracted);
118
+ }
119
+ return events;
120
+ }, [entries]);
109
121
 
110
122
  // ResizeObserver → re-render connectors when any LogEntry height changes
111
123
  const [layoutVersion, setLayoutVersion] = useState(0);
@@ -322,11 +334,13 @@ export const TurnGroup = memo(function TurnGroup({
322
334
  viewMode={viewMode}
323
335
  strip={strip}
324
336
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
337
+ timeDisplayFormat={timeDisplayFormat}
325
338
  cacheTrend={cacheTrends?.get(log.id) ?? null}
326
339
  onCompareWithPrevious={
327
340
  comparisonPredecessors.has(log.id) ? onCompareWithPrevious : undefined
328
341
  }
329
342
  />
343
+ <ToolTraceEvents events={toolEventsByLogId.get(log.id) ?? []} />
330
344
  </div>
331
345
  </div>
332
346
  );
@@ -7,7 +7,7 @@ const ROLE_COLOR_CLASSES: Record<AnatomyRole, string> = {
7
7
  system: "bg-sky-500/70",
8
8
  user: "bg-emerald-500/70",
9
9
  assistant: "bg-violet-500/70",
10
- tool: "bg-amber-500/70",
10
+ tool: "bg-sky-400/55",
11
11
  tools: "bg-slate-500/70",
12
12
  };
13
13
 
@@ -15,7 +15,7 @@ const ROLE_FOCUS_RING: Record<AnatomyRole, string> = {
15
15
  system: "focus-visible:ring-sky-300",
16
16
  user: "focus-visible:ring-emerald-300",
17
17
  assistant: "focus-visible:ring-violet-300",
18
- tool: "focus-visible:ring-amber-300",
18
+ tool: "focus-visible:ring-sky-300",
19
19
  tools: "focus-visible:ring-slate-300",
20
20
  };
21
21