@sentry/junior-dashboard 0.62.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +2 -0
- package/dist/app.js +1285 -39
- package/dist/assets.d.ts +2 -0
- package/dist/client/api.d.ts +1 -1
- package/dist/client/components/Button.d.ts +15 -0
- package/dist/client/components/Metric.d.ts +22 -0
- package/dist/client/components/StatusBadge.d.ts +2 -1
- package/dist/client/components/TelemetryMetrics.d.ts +24 -0
- package/dist/client/components/ToolFrame.d.ts +3 -1
- package/dist/client/components/Transcript.d.ts +2 -0
- package/dist/client/components/TranscriptHeader.d.ts +2 -0
- package/dist/client/components/TranscriptHeadingRow.d.ts +16 -0
- package/dist/client/components/TranscriptThinkingView.d.ts +7 -0
- package/dist/client/components/TranscriptToolRun.d.ts +9 -0
- package/dist/client/components/transcriptRenderModel.d.ts +25 -9
- package/dist/client/format.d.ts +45 -8
- package/dist/client/markdownExport.d.ts +3 -0
- package/dist/client/toolInvocations.d.ts +7 -0
- package/dist/client/types.d.ts +17 -100
- package/dist/client.js +85 -55998
- package/dist/dashboardLoader.d.ts +1 -0
- package/dist/handler.js +1288 -40
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1807 -0
- package/dist/mock-conversations.d.ts +3 -0
- package/dist/mock-release-conversation.d.ts +3 -0
- package/dist/nitro.d.ts +7 -1
- package/dist/tailwind.css +1 -1
- package/dist/url.d.ts +13 -0
- package/package.json +9 -5
- package/src/app.ts +42 -18
- package/src/assets.ts +2 -0
- package/src/auth.ts +2 -35
- package/src/client/App.tsx +33 -74
- package/src/client/code.tsx +1 -1
- package/src/client/components/Button.tsx +75 -0
- package/src/client/components/ConversationRowStats.tsx +3 -2
- package/src/client/components/FilterTabs.tsx +10 -11
- package/src/client/components/LoadingView.tsx +7 -2
- package/src/client/components/Metric.tsx +159 -0
- package/src/client/components/StatusBadge.tsx +10 -1
- package/src/client/components/TelemetryMetrics.tsx +124 -0
- package/src/client/components/ToolFrame.tsx +57 -14
- package/src/client/components/Transcript.tsx +6 -2
- package/src/client/components/TranscriptHeader.tsx +18 -19
- package/src/client/components/TranscriptHeadingRow.tsx +64 -0
- package/src/client/components/TranscriptThinkingView.tsx +157 -0
- package/src/client/components/TranscriptToolRun.tsx +66 -0
- package/src/client/components/TranscriptToolView.tsx +16 -8
- package/src/client/components/TranscriptTurn.tsx +368 -132
- package/src/client/components/TurnDurationChart.tsx +236 -78
- package/src/client/components/transcriptRenderModel.ts +60 -20
- package/src/client/format.ts +329 -87
- package/src/client/markdownExport.ts +360 -0
- package/src/client/pages/CommandCenter.tsx +1 -1
- package/src/client/pages/ConversationPage.tsx +142 -45
- package/src/client/pages/ConversationsPage.tsx +1 -1
- package/src/client/toolInvocations.ts +16 -0
- package/src/client/types.ts +34 -90
- package/src/config.ts +4 -0
- package/src/dashboardLoader.ts +12 -0
- package/src/index.ts +78 -0
- package/src/mock-conversations.ts +726 -0
- package/src/mock-release-conversation.ts +605 -0
- package/src/nitro.ts +7 -1
- package/src/tailwind.css +11 -0
- package/src/url.ts +68 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../styles";
|
|
4
|
+
|
|
5
|
+
type ButtonSize = "default" | "icon";
|
|
6
|
+
type ToggleButtonVariant = "pill" | "text";
|
|
7
|
+
|
|
8
|
+
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
9
|
+
size?: ButtonSize;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ToggleButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
13
|
+
pressed: boolean;
|
|
14
|
+
variant: ToggleButtonVariant;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Render the dashboard's standard bordered command button surface. */
|
|
18
|
+
export function Button({
|
|
19
|
+
className,
|
|
20
|
+
size = "default",
|
|
21
|
+
type = "button",
|
|
22
|
+
...props
|
|
23
|
+
}: ButtonProps) {
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
{...props}
|
|
27
|
+
className={cn(
|
|
28
|
+
"border border-white/15 bg-[#0b0b0b] text-[#d6d6d6] transition-colors hover:border-white/30 hover:bg-[#151515] hover:text-white disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-white/15 disabled:hover:bg-[#0b0b0b] disabled:hover:text-[#d6d6d6]",
|
|
29
|
+
size === "icon"
|
|
30
|
+
? "grid size-9 place-items-center p-0"
|
|
31
|
+
: "inline-flex h-9 max-w-full items-center gap-2 px-3 text-[0.82rem] font-semibold leading-none",
|
|
32
|
+
props.disabled ? "" : "cursor-pointer",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
type={type}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Render a dashboard toggle button with a consistent pressed-state contract. */
|
|
41
|
+
export function ToggleButton({
|
|
42
|
+
className,
|
|
43
|
+
pressed,
|
|
44
|
+
type = "button",
|
|
45
|
+
variant,
|
|
46
|
+
...props
|
|
47
|
+
}: ToggleButtonProps) {
|
|
48
|
+
return (
|
|
49
|
+
<button
|
|
50
|
+
{...props}
|
|
51
|
+
aria-pressed={pressed}
|
|
52
|
+
className={cn(
|
|
53
|
+
toggleButtonBase[variant],
|
|
54
|
+
pressed ? toggleButtonPressed[variant] : toggleButtonIdle[variant],
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
type={type}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const toggleButtonBase: Record<ToggleButtonVariant, string> = {
|
|
63
|
+
pill: "cursor-pointer border px-2 py-1 text-[0.78rem] font-semibold uppercase leading-tight transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#beaaff]/55",
|
|
64
|
+
text: "cursor-pointer border-0 bg-transparent px-1.5 py-1 uppercase tracking-normal underline-offset-4 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#beaaff]/55",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const toggleButtonPressed: Record<ToggleButtonVariant, string> = {
|
|
68
|
+
pill: "border-white/30 bg-white text-black",
|
|
69
|
+
text: "text-white underline decoration-white",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const toggleButtonIdle: Record<ToggleButtonVariant, string> = {
|
|
73
|
+
pill: "border-white/10 bg-[#0b0b0b] text-[#888] hover:border-white/25 hover:bg-[#151515] hover:text-white",
|
|
74
|
+
text: "text-[#888] hover:text-white",
|
|
75
|
+
};
|
|
@@ -16,10 +16,11 @@ export function ConversationRowStats(props: {
|
|
|
16
16
|
const runtime = formatDurationTotal(
|
|
17
17
|
props.conversation.turns.map((turn) => turn.cumulativeDurationMs),
|
|
18
18
|
);
|
|
19
|
+
const turnCount = props.conversation.turns.length;
|
|
19
20
|
const primaryStats = [
|
|
20
|
-
`${
|
|
21
|
+
`${turnCount} ${turnCount === 1 ? "turn" : "turns"}`,
|
|
21
22
|
tokens,
|
|
22
|
-
runtime ? `${runtime} runtime` :
|
|
23
|
+
runtime ? `${runtime} runtime` : "",
|
|
23
24
|
].filter(Boolean);
|
|
24
25
|
const secondaryStats = [
|
|
25
26
|
props.timeLabel,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ToggleButton } from "./Button";
|
|
2
2
|
import type { SessionFilter } from "../types";
|
|
3
3
|
|
|
4
4
|
/** Render conversation filters while keeping URL state owned by the page. */
|
|
@@ -14,21 +14,20 @@ export function FilterTabs(props: {
|
|
|
14
14
|
"all",
|
|
15
15
|
];
|
|
16
16
|
return (
|
|
17
|
-
<div
|
|
17
|
+
<div
|
|
18
|
+
aria-label="Conversation filter"
|
|
19
|
+
className="flex flex-wrap items-center justify-end gap-1"
|
|
20
|
+
role="group"
|
|
21
|
+
>
|
|
18
22
|
{filters.map((filter) => (
|
|
19
|
-
<
|
|
20
|
-
className={cn(
|
|
21
|
-
"cursor-pointer border px-2 py-1 text-[0.78rem] font-semibold uppercase leading-tight transition-colors",
|
|
22
|
-
props.current === filter
|
|
23
|
-
? "border-white/30 bg-white text-black"
|
|
24
|
-
: "border-white/10 bg-[#0b0b0b] text-[#888] hover:border-white/25 hover:bg-[#151515] hover:text-white",
|
|
25
|
-
)}
|
|
23
|
+
<ToggleButton
|
|
26
24
|
key={filter}
|
|
27
25
|
onClick={() => props.onChange(filter)}
|
|
28
|
-
|
|
26
|
+
pressed={props.current === filter}
|
|
27
|
+
variant="pill"
|
|
29
28
|
>
|
|
30
29
|
{filter}
|
|
31
|
-
</
|
|
30
|
+
</ToggleButton>
|
|
32
31
|
))}
|
|
33
32
|
</div>
|
|
34
33
|
);
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { JuniorLogo } from "./JuniorLogo";
|
|
2
|
+
import { dashboardRainbowProgressClass } from "../../dashboardLoader";
|
|
2
3
|
|
|
3
4
|
/** Render the full-page loading treatment before the first dashboard payload lands. */
|
|
4
5
|
export function LoadingView(props: { label: string }) {
|
|
5
6
|
return (
|
|
6
|
-
<div className="grid min-h-[calc(100vh-5rem)] place-items-center px-4 py-8 md:px-8">
|
|
7
|
+
<div className="mx-auto grid min-h-[calc(100vh-5rem)] w-full max-w-screen-xl place-items-center px-4 py-8 md:px-8">
|
|
7
8
|
<section className="grid w-full max-w-lg grid-cols-[auto_minmax(0,1fr)] items-center gap-3 border border-white/15 bg-[#0b0b0b] p-4">
|
|
8
9
|
<JuniorLogo />
|
|
9
10
|
<div>
|
|
10
11
|
<div className="font-bold">{props.label}</div>
|
|
11
|
-
<div
|
|
12
|
+
<div
|
|
13
|
+
aria-label={props.label}
|
|
14
|
+
className={`${dashboardRainbowProgressClass} mt-3 h-1.5 w-full`}
|
|
15
|
+
role="progressbar"
|
|
16
|
+
/>
|
|
12
17
|
</div>
|
|
13
18
|
</section>
|
|
14
19
|
</div>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useId,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
|
|
9
|
+
import { cn } from "../styles";
|
|
10
|
+
|
|
11
|
+
export type MetricTooltipLine = {
|
|
12
|
+
label?: string;
|
|
13
|
+
labelStyle?: "code";
|
|
14
|
+
value: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type MetricListItem = {
|
|
18
|
+
content: ReactNode;
|
|
19
|
+
key: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type TooltipPosition = {
|
|
23
|
+
left: number;
|
|
24
|
+
top: number;
|
|
25
|
+
width: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function clamp(value: number, min: number, max: number): number {
|
|
29
|
+
return Math.min(Math.max(value, min), max);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tooltipPosition(
|
|
33
|
+
trigger: HTMLElement,
|
|
34
|
+
align: "left" | "right" | undefined,
|
|
35
|
+
): TooltipPosition {
|
|
36
|
+
const margin = 16;
|
|
37
|
+
const viewportWidth = window.innerWidth;
|
|
38
|
+
const width = Math.min(320, Math.max(256, viewportWidth - margin * 2));
|
|
39
|
+
const rect = trigger.getBoundingClientRect();
|
|
40
|
+
const preferredLeft = align === "right" ? rect.right - width : rect.left;
|
|
41
|
+
return {
|
|
42
|
+
left: Math.round(
|
|
43
|
+
clamp(preferredLeft, margin, viewportWidth - width - margin),
|
|
44
|
+
),
|
|
45
|
+
top: Math.round(rect.bottom + 8),
|
|
46
|
+
width,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Render compact metadata text with an optional styled hover/focus tooltip. */
|
|
51
|
+
export function MetricValue(props: {
|
|
52
|
+
align?: "left" | "right";
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
className?: string;
|
|
55
|
+
tooltip?: MetricTooltipLine[];
|
|
56
|
+
}) {
|
|
57
|
+
const tooltipId = useId();
|
|
58
|
+
const triggerRef = useRef<HTMLSpanElement>(null);
|
|
59
|
+
const [position, setPosition] = useState<TooltipPosition | null>(null);
|
|
60
|
+
const tooltip = props.tooltip?.filter((line) => line.value.trim());
|
|
61
|
+
if (!tooltip?.length) {
|
|
62
|
+
return <span className={props.className}>{props.children}</span>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const showTooltip = () => {
|
|
66
|
+
if (!triggerRef.current) return;
|
|
67
|
+
setPosition(tooltipPosition(triggerRef.current, props.align));
|
|
68
|
+
};
|
|
69
|
+
const hideTooltip = () => setPosition(null);
|
|
70
|
+
const tooltipStyle: CSSProperties | undefined = position
|
|
71
|
+
? {
|
|
72
|
+
left: position.left,
|
|
73
|
+
top: position.top,
|
|
74
|
+
width: position.width,
|
|
75
|
+
}
|
|
76
|
+
: undefined;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<span className={cn("relative inline-flex", props.className)}>
|
|
80
|
+
<span
|
|
81
|
+
aria-describedby={position ? tooltipId : undefined}
|
|
82
|
+
className="border-b border-dotted border-white/20 outline-none transition-colors hover:border-white/45 focus-visible:border-white/45"
|
|
83
|
+
onBlur={hideTooltip}
|
|
84
|
+
onFocus={showTooltip}
|
|
85
|
+
onMouseEnter={showTooltip}
|
|
86
|
+
onMouseLeave={hideTooltip}
|
|
87
|
+
ref={triggerRef}
|
|
88
|
+
tabIndex={0}
|
|
89
|
+
>
|
|
90
|
+
{props.children}
|
|
91
|
+
</span>
|
|
92
|
+
{position ? (
|
|
93
|
+
<span
|
|
94
|
+
className="pointer-events-none fixed z-30 border border-white/15 bg-[#050505] px-3 py-2 text-left text-[0.76rem] font-normal leading-relaxed text-[#b8b8b8] shadow-xl shadow-black/35"
|
|
95
|
+
id={tooltipId}
|
|
96
|
+
role="tooltip"
|
|
97
|
+
style={tooltipStyle}
|
|
98
|
+
>
|
|
99
|
+
<span className="grid max-h-72 grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1.5 overflow-y-auto">
|
|
100
|
+
{tooltip.map((line, index) => (
|
|
101
|
+
<span
|
|
102
|
+
className={
|
|
103
|
+
line.label
|
|
104
|
+
? "contents"
|
|
105
|
+
: "col-span-2 block min-w-0 break-words text-[#d6d6d6]"
|
|
106
|
+
}
|
|
107
|
+
key={`${index}-${line.label ?? ""}-${line.value}`}
|
|
108
|
+
>
|
|
109
|
+
{line.label ? (
|
|
110
|
+
<span
|
|
111
|
+
className={cn(
|
|
112
|
+
"min-w-0 break-words font-medium text-[#888]",
|
|
113
|
+
line.labelStyle === "code" &&
|
|
114
|
+
"break-all font-mono text-[0.74rem] text-[#d6d6d6]",
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{line.label}
|
|
118
|
+
</span>
|
|
119
|
+
) : null}
|
|
120
|
+
{line.label ? (
|
|
121
|
+
<span className="whitespace-nowrap text-right text-[#d6d6d6]">
|
|
122
|
+
{line.value}
|
|
123
|
+
</span>
|
|
124
|
+
) : (
|
|
125
|
+
line.value
|
|
126
|
+
)}
|
|
127
|
+
</span>
|
|
128
|
+
))}
|
|
129
|
+
</span>
|
|
130
|
+
</span>
|
|
131
|
+
) : null}
|
|
132
|
+
</span>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Render inline metadata with consistent dot spacing across dashboard headers. */
|
|
137
|
+
export function MetricList(props: {
|
|
138
|
+
className?: string;
|
|
139
|
+
items: MetricListItem[];
|
|
140
|
+
}) {
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className={cn(
|
|
144
|
+
"flex flex-wrap items-center gap-x-1.5 gap-y-1",
|
|
145
|
+
props.className,
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{props.items.map((item, index) => (
|
|
149
|
+
<span
|
|
150
|
+
className="inline-flex min-w-0 items-center gap-x-1.5"
|
|
151
|
+
key={item.key}
|
|
152
|
+
>
|
|
153
|
+
{index > 0 ? <span className="text-[#666]">·</span> : null}
|
|
154
|
+
<span className="min-w-0">{item.content}</span>
|
|
155
|
+
</span>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -4,9 +4,12 @@ import type { VisualStatus } from "../types";
|
|
|
4
4
|
/** Render readable status text while keeping severity color restrained. */
|
|
5
5
|
export function StatusBadge(props: {
|
|
6
6
|
label?: string;
|
|
7
|
+
showCompleted?: boolean;
|
|
7
8
|
status: VisualStatus | undefined;
|
|
8
9
|
}) {
|
|
9
10
|
const status = props.status ?? "idle";
|
|
11
|
+
if (status === "idle" && !props.showCompleted && !props.label) return null;
|
|
12
|
+
|
|
10
13
|
return (
|
|
11
14
|
<span
|
|
12
15
|
className={cn(
|
|
@@ -23,11 +26,17 @@ export function StatusBadge(props: {
|
|
|
23
26
|
status === "idle" && "bg-white/35",
|
|
24
27
|
)}
|
|
25
28
|
/>
|
|
26
|
-
{props.label ?? status}
|
|
29
|
+
{props.label ?? statusLabel(status)}
|
|
27
30
|
</span>
|
|
28
31
|
);
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
function statusLabel(status: VisualStatus): string {
|
|
35
|
+
if (status === "failed") return "error";
|
|
36
|
+
if (status === "idle") return "completed";
|
|
37
|
+
return status;
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
function statusBadgeClass(status: VisualStatus): string {
|
|
32
41
|
return cn(
|
|
33
42
|
"border px-1.5 py-0.5 text-[0.68rem] font-bold uppercase leading-none",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatCompactNumber,
|
|
3
|
+
formatMs,
|
|
4
|
+
formatTime,
|
|
5
|
+
formatTokenSummary,
|
|
6
|
+
type MessageSummary,
|
|
7
|
+
type TokenUsageSummary,
|
|
8
|
+
type ToolCallSummary,
|
|
9
|
+
} from "../format";
|
|
10
|
+
import { MetricValue, type MetricTooltipLine } from "./Metric";
|
|
11
|
+
|
|
12
|
+
function plural(label: string, count: number): string {
|
|
13
|
+
return `${formatCompactNumber(count)} ${label}${count === 1 ? "" : "s"}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isMetricTooltipLine(
|
|
17
|
+
line: MetricTooltipLine | undefined,
|
|
18
|
+
): line is MetricTooltipLine {
|
|
19
|
+
return Boolean(line);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function tokenTooltip(summary: TokenUsageSummary): MetricTooltipLine[] {
|
|
23
|
+
const lines: Array<MetricTooltipLine | undefined> = [
|
|
24
|
+
summary.inputTokens !== undefined
|
|
25
|
+
? { label: "input", value: formatCompactNumber(summary.inputTokens) }
|
|
26
|
+
: undefined,
|
|
27
|
+
summary.outputTokens !== undefined
|
|
28
|
+
? { label: "output", value: formatCompactNumber(summary.outputTokens) }
|
|
29
|
+
: undefined,
|
|
30
|
+
summary.cachedInputTokens !== undefined
|
|
31
|
+
? {
|
|
32
|
+
label: "cached",
|
|
33
|
+
value: formatCompactNumber(summary.cachedInputTokens),
|
|
34
|
+
}
|
|
35
|
+
: undefined,
|
|
36
|
+
summary.cacheCreationTokens !== undefined
|
|
37
|
+
? {
|
|
38
|
+
label: "cache write",
|
|
39
|
+
value: formatCompactNumber(summary.cacheCreationTokens),
|
|
40
|
+
}
|
|
41
|
+
: undefined,
|
|
42
|
+
summary.providerTotalTokens !== undefined
|
|
43
|
+
? {
|
|
44
|
+
label: "provider",
|
|
45
|
+
value: formatCompactNumber(summary.providerTotalTokens),
|
|
46
|
+
}
|
|
47
|
+
: undefined,
|
|
48
|
+
];
|
|
49
|
+
return lines.filter(isMetricTooltipLine);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Render total token usage with a hoverable breakdown. */
|
|
53
|
+
export function TokenMetric(props: {
|
|
54
|
+
align?: "left" | "right";
|
|
55
|
+
summary: TokenUsageSummary | undefined;
|
|
56
|
+
}) {
|
|
57
|
+
if (!props.summary) return null;
|
|
58
|
+
return (
|
|
59
|
+
<MetricValue align={props.align} tooltip={tokenTooltip(props.summary)}>
|
|
60
|
+
{formatTokenSummary(props.summary)}
|
|
61
|
+
</MetricValue>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Render a duration value with start/end timestamps in the tooltip. */
|
|
66
|
+
export function DurationMetric(props: {
|
|
67
|
+
align?: "left" | "right";
|
|
68
|
+
endedAt?: string;
|
|
69
|
+
label: string;
|
|
70
|
+
startedAt?: string;
|
|
71
|
+
}) {
|
|
72
|
+
if (!props.label || props.label === "none") return null;
|
|
73
|
+
const lines: Array<MetricTooltipLine | undefined> = [
|
|
74
|
+
props.startedAt
|
|
75
|
+
? { label: "started", value: formatTime(props.startedAt) }
|
|
76
|
+
: undefined,
|
|
77
|
+
props.endedAt
|
|
78
|
+
? { label: "ended", value: formatTime(props.endedAt) }
|
|
79
|
+
: undefined,
|
|
80
|
+
];
|
|
81
|
+
const tooltip = lines.filter(isMetricTooltipLine);
|
|
82
|
+
return (
|
|
83
|
+
<MetricValue align={props.align} tooltip={tooltip}>
|
|
84
|
+
{props.label}
|
|
85
|
+
</MetricValue>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Render a tool-call count with top tool names, counts, and matched duration. */
|
|
90
|
+
export function ToolCallsMetric(props: {
|
|
91
|
+
align?: "left" | "right";
|
|
92
|
+
loading?: boolean;
|
|
93
|
+
summary: ToolCallSummary | undefined;
|
|
94
|
+
}) {
|
|
95
|
+
if (props.loading) return <span>tool calls loading</span>;
|
|
96
|
+
if (!props.summary || props.summary.total <= 0) return null;
|
|
97
|
+
const tooltip = props.summary.items.map((item) => ({
|
|
98
|
+
label: item.name,
|
|
99
|
+
labelStyle: "code" as const,
|
|
100
|
+
value: [
|
|
101
|
+
plural("call", item.count),
|
|
102
|
+
item.totalDurationMs !== undefined
|
|
103
|
+
? formatMs(item.totalDurationMs)
|
|
104
|
+
: undefined,
|
|
105
|
+
]
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.join(" · "),
|
|
108
|
+
}));
|
|
109
|
+
return (
|
|
110
|
+
<MetricValue align={props.align} tooltip={tooltip}>
|
|
111
|
+
{plural("tool call", props.summary.total)}
|
|
112
|
+
</MetricValue>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Render a conversational message count. */
|
|
117
|
+
export function MessagesMetric(props: {
|
|
118
|
+
loading?: boolean;
|
|
119
|
+
summary: MessageSummary | undefined;
|
|
120
|
+
}) {
|
|
121
|
+
if (props.loading) return <span>messages loading</span>;
|
|
122
|
+
if (!props.summary) return null;
|
|
123
|
+
return <MetricValue>{plural("message", props.summary.total)}</MetricValue>;
|
|
124
|
+
}
|
|
@@ -1,37 +1,80 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { cn } from "../styles";
|
|
4
|
+
import {
|
|
5
|
+
TranscriptHeadingMeta,
|
|
6
|
+
TranscriptHeadingRow,
|
|
7
|
+
} from "./TranscriptHeadingRow";
|
|
4
8
|
|
|
5
9
|
/** Render the shared expandable/non-expandable frame for transcript tools. */
|
|
6
10
|
export function ToolFrame(props: {
|
|
7
11
|
children?: ReactNode;
|
|
12
|
+
expandable?: boolean;
|
|
8
13
|
meta: string[];
|
|
14
|
+
mobileSummaryMeta?: string;
|
|
9
15
|
raw?: boolean;
|
|
10
16
|
signature: ReactNode;
|
|
11
17
|
}) {
|
|
18
|
+
const [open, setOpen] = useState(false);
|
|
19
|
+
const metaText = props.meta.join(" · ");
|
|
20
|
+
const interactive = props.expandable ?? Boolean(props.children);
|
|
21
|
+
const mobileSummaryMeta =
|
|
22
|
+
props.mobileSummaryMeta && (!interactive || !open)
|
|
23
|
+
? props.mobileSummaryMeta
|
|
24
|
+
: undefined;
|
|
12
25
|
const header = (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
<TranscriptHeadingRow
|
|
27
|
+
left={
|
|
28
|
+
<>
|
|
29
|
+
{props.signature}
|
|
30
|
+
{mobileSummaryMeta ? (
|
|
31
|
+
<>
|
|
32
|
+
<span className="hidden text-[#777] max-md:inline">·</span>
|
|
33
|
+
<span className="hidden min-w-0 break-words text-[#888] max-md:inline">
|
|
34
|
+
{mobileSummaryMeta}
|
|
35
|
+
</span>
|
|
36
|
+
</>
|
|
37
|
+
) : null}
|
|
38
|
+
</>
|
|
39
|
+
}
|
|
40
|
+
leftClassName="flex-wrap gap-x-1 gap-y-0.5"
|
|
41
|
+
right={
|
|
42
|
+
metaText ? (
|
|
43
|
+
<TranscriptHeadingMeta className="min-w-0 break-words text-[0.8rem] text-[#888]">
|
|
44
|
+
{metaText}
|
|
45
|
+
</TranscriptHeadingMeta>
|
|
46
|
+
) : undefined
|
|
47
|
+
}
|
|
48
|
+
rightClassName="min-w-0 max-md:hidden"
|
|
49
|
+
/>
|
|
21
50
|
);
|
|
51
|
+
const mobileMeta =
|
|
52
|
+
metaText && props.children ? (
|
|
53
|
+
<div className="hidden min-w-0 break-words py-1 font-mono text-[0.78rem] leading-snug text-[#777] max-md:block">
|
|
54
|
+
{metaText}
|
|
55
|
+
</div>
|
|
56
|
+
) : null;
|
|
22
57
|
|
|
23
|
-
if (props.raw) {
|
|
58
|
+
if (props.raw || !interactive) {
|
|
24
59
|
return (
|
|
25
60
|
<div className={toolFrameClass()}>
|
|
26
61
|
<div className={toolHeaderClass(false)}>{header}</div>
|
|
62
|
+
{mobileMeta}
|
|
27
63
|
{props.children}
|
|
28
64
|
</div>
|
|
29
65
|
);
|
|
30
66
|
}
|
|
31
67
|
|
|
32
68
|
return (
|
|
33
|
-
<details
|
|
69
|
+
<details
|
|
70
|
+
className={toolFrameClass()}
|
|
71
|
+
onToggle={(event) => {
|
|
72
|
+
if (event.currentTarget !== event.target) return;
|
|
73
|
+
setOpen(event.currentTarget.open);
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
34
76
|
<summary className={toolHeaderClass(true)}>{header}</summary>
|
|
77
|
+
{mobileMeta}
|
|
35
78
|
{props.children}
|
|
36
79
|
</details>
|
|
37
80
|
);
|
|
@@ -39,14 +82,14 @@ export function ToolFrame(props: {
|
|
|
39
82
|
|
|
40
83
|
/** Provide the shared transcript tool-frame shell for nonstandard part views. */
|
|
41
84
|
export function toolFrameClass(): string {
|
|
42
|
-
return "
|
|
85
|
+
return "min-w-0 max-w-full overflow-hidden";
|
|
43
86
|
}
|
|
44
87
|
|
|
45
88
|
function toolHeaderClass(interactive: boolean): string {
|
|
46
89
|
return cn(
|
|
47
|
-
"
|
|
90
|
+
"block py-1.5 font-mono text-[0.82rem] leading-tight text-[#b8b8b8]",
|
|
48
91
|
interactive
|
|
49
|
-
? "cursor-pointer hover:
|
|
92
|
+
? "cursor-pointer list-none transition-colors hover:text-white hover:[&_*]:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#beaaff]/55 focus-visible:text-white focus-visible:[&_*]:text-white [&::-webkit-details-marker]:hidden"
|
|
50
93
|
: "cursor-default",
|
|
51
94
|
);
|
|
52
95
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import type { ConversationTurn } from "../types";
|
|
4
4
|
import { TranscriptHeader } from "./TranscriptHeader";
|
|
@@ -7,7 +7,10 @@ import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
|
7
7
|
import { transcriptEmptyClass } from "./transcriptStyles";
|
|
8
8
|
|
|
9
9
|
/** Render ordered conversation turns as message, thinking, and tool-call events. */
|
|
10
|
-
export function Transcript(props: {
|
|
10
|
+
export function Transcript(props: {
|
|
11
|
+
actions?: ReactNode;
|
|
12
|
+
turns: ConversationTurn[];
|
|
13
|
+
}) {
|
|
11
14
|
const [view, setView] = useState<TranscriptViewMode>("rich");
|
|
12
15
|
const hasRedactedTurns = props.turns.some((turn) => turn.transcriptRedacted);
|
|
13
16
|
|
|
@@ -22,6 +25,7 @@ export function Transcript(props: { turns: ConversationTurn[] }) {
|
|
|
22
25
|
return (
|
|
23
26
|
<div className="grid min-w-0">
|
|
24
27
|
<TranscriptHeader
|
|
28
|
+
actions={props.actions}
|
|
25
29
|
redacted={hasRedactedTurns}
|
|
26
30
|
value={view}
|
|
27
31
|
onChange={setView}
|
|
@@ -1,24 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { ToggleButton } from "./Button";
|
|
1
4
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
2
5
|
|
|
3
6
|
/** Render transcript controls without coupling them to turn rendering. */
|
|
4
7
|
export function TranscriptHeader(props: {
|
|
8
|
+
actions?: ReactNode;
|
|
5
9
|
onChange(value: TranscriptViewMode): void;
|
|
6
10
|
redacted: boolean;
|
|
7
11
|
value: TranscriptViewMode;
|
|
8
12
|
}) {
|
|
9
13
|
return (
|
|
10
|
-
<div className="mb-1 flex min-w-0 items-start justify-between gap-3 border-b border-[#beaaff]/20 pb-3 leading-none max-md:flex-col">
|
|
11
|
-
|
|
12
|
-
<div className="text-[0.
|
|
13
|
-
|
|
14
|
+
<div className="mb-1 flex min-w-0 items-start justify-between gap-3 border-b border-[#beaaff]/20 pb-3 leading-none max-md:flex-col max-md:items-start">
|
|
15
|
+
{props.redacted ? (
|
|
16
|
+
<div className="min-w-0 break-words text-[0.88rem] leading-relaxed text-[#b8b8b8]">
|
|
17
|
+
Hidden because this conversation is not public.
|
|
14
18
|
</div>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
) : null}
|
|
19
|
+
) : null}
|
|
20
|
+
<div className="ml-auto flex shrink-0 items-center gap-2 max-md:ml-0">
|
|
21
|
+
<TranscriptViewToggle value={props.value} onChange={props.onChange} />
|
|
22
|
+
{props.actions}
|
|
20
23
|
</div>
|
|
21
|
-
<TranscriptViewToggle value={props.value} onChange={props.onChange} />
|
|
22
24
|
</div>
|
|
23
25
|
);
|
|
24
26
|
}
|
|
@@ -30,22 +32,19 @@ function TranscriptViewToggle(props: {
|
|
|
30
32
|
const options: TranscriptViewMode[] = ["rich", "raw"];
|
|
31
33
|
return (
|
|
32
34
|
<div
|
|
33
|
-
className="inline-flex items-center gap-1 text-[0.82rem] font-semibold text-[#888]"
|
|
34
35
|
aria-label="Transcript view"
|
|
36
|
+
className="inline-flex items-center gap-1 text-[0.82rem] font-semibold text-[#888]"
|
|
37
|
+
role="group"
|
|
35
38
|
>
|
|
36
39
|
{options.map((option) => (
|
|
37
|
-
<
|
|
38
|
-
className={`cursor-pointer border-0 bg-transparent px-1.5 py-1 uppercase tracking-normal underline-offset-4 ${
|
|
39
|
-
props.value === option
|
|
40
|
-
? "text-white underline decoration-white"
|
|
41
|
-
: "text-[#888] hover:text-white"
|
|
42
|
-
}`}
|
|
40
|
+
<ToggleButton
|
|
43
41
|
key={option}
|
|
44
42
|
onClick={() => props.onChange(option)}
|
|
45
|
-
|
|
43
|
+
pressed={props.value === option}
|
|
44
|
+
variant="text"
|
|
46
45
|
>
|
|
47
46
|
{option}
|
|
48
|
-
</
|
|
47
|
+
</ToggleButton>
|
|
49
48
|
))}
|
|
50
49
|
</div>
|
|
51
50
|
);
|