@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.
Files changed (67) hide show
  1. package/dist/app.d.ts +2 -0
  2. package/dist/app.js +1285 -39
  3. package/dist/assets.d.ts +2 -0
  4. package/dist/client/api.d.ts +1 -1
  5. package/dist/client/components/Button.d.ts +15 -0
  6. package/dist/client/components/Metric.d.ts +22 -0
  7. package/dist/client/components/StatusBadge.d.ts +2 -1
  8. package/dist/client/components/TelemetryMetrics.d.ts +24 -0
  9. package/dist/client/components/ToolFrame.d.ts +3 -1
  10. package/dist/client/components/Transcript.d.ts +2 -0
  11. package/dist/client/components/TranscriptHeader.d.ts +2 -0
  12. package/dist/client/components/TranscriptHeadingRow.d.ts +16 -0
  13. package/dist/client/components/TranscriptThinkingView.d.ts +7 -0
  14. package/dist/client/components/TranscriptToolRun.d.ts +9 -0
  15. package/dist/client/components/transcriptRenderModel.d.ts +25 -9
  16. package/dist/client/format.d.ts +45 -8
  17. package/dist/client/markdownExport.d.ts +3 -0
  18. package/dist/client/toolInvocations.d.ts +7 -0
  19. package/dist/client/types.d.ts +17 -100
  20. package/dist/client.js +85 -55998
  21. package/dist/dashboardLoader.d.ts +1 -0
  22. package/dist/handler.js +1288 -40
  23. package/dist/index.d.ts +8 -0
  24. package/dist/index.js +1807 -0
  25. package/dist/mock-conversations.d.ts +3 -0
  26. package/dist/mock-release-conversation.d.ts +3 -0
  27. package/dist/nitro.d.ts +7 -1
  28. package/dist/tailwind.css +1 -1
  29. package/dist/url.d.ts +13 -0
  30. package/package.json +9 -5
  31. package/src/app.ts +42 -18
  32. package/src/assets.ts +2 -0
  33. package/src/auth.ts +2 -35
  34. package/src/client/App.tsx +33 -74
  35. package/src/client/code.tsx +1 -1
  36. package/src/client/components/Button.tsx +75 -0
  37. package/src/client/components/ConversationRowStats.tsx +3 -2
  38. package/src/client/components/FilterTabs.tsx +10 -11
  39. package/src/client/components/LoadingView.tsx +7 -2
  40. package/src/client/components/Metric.tsx +159 -0
  41. package/src/client/components/StatusBadge.tsx +10 -1
  42. package/src/client/components/TelemetryMetrics.tsx +124 -0
  43. package/src/client/components/ToolFrame.tsx +57 -14
  44. package/src/client/components/Transcript.tsx +6 -2
  45. package/src/client/components/TranscriptHeader.tsx +18 -19
  46. package/src/client/components/TranscriptHeadingRow.tsx +64 -0
  47. package/src/client/components/TranscriptThinkingView.tsx +157 -0
  48. package/src/client/components/TranscriptToolRun.tsx +66 -0
  49. package/src/client/components/TranscriptToolView.tsx +16 -8
  50. package/src/client/components/TranscriptTurn.tsx +368 -132
  51. package/src/client/components/TurnDurationChart.tsx +236 -78
  52. package/src/client/components/transcriptRenderModel.ts +60 -20
  53. package/src/client/format.ts +329 -87
  54. package/src/client/markdownExport.ts +360 -0
  55. package/src/client/pages/CommandCenter.tsx +1 -1
  56. package/src/client/pages/ConversationPage.tsx +142 -45
  57. package/src/client/pages/ConversationsPage.tsx +1 -1
  58. package/src/client/toolInvocations.ts +16 -0
  59. package/src/client/types.ts +34 -90
  60. package/src/config.ts +4 -0
  61. package/src/dashboardLoader.ts +12 -0
  62. package/src/index.ts +78 -0
  63. package/src/mock-conversations.ts +726 -0
  64. package/src/mock-release-conversation.ts +605 -0
  65. package/src/nitro.ts +7 -1
  66. package/src/tailwind.css +11 -0
  67. 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
- `${props.conversation.turns.length} turns`,
21
+ `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`,
21
22
  tokens,
22
- runtime ? `${runtime} runtime` : undefined,
23
+ runtime ? `${runtime} runtime` : "",
23
24
  ].filter(Boolean);
24
25
  const secondaryStats = [
25
26
  props.timeLabel,
@@ -1,4 +1,4 @@
1
- import { cn } from "../styles";
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 className="flex flex-wrap items-center justify-end gap-1">
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
- <button
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
- type="button"
26
+ pressed={props.current === filter}
27
+ variant="pill"
29
28
  >
30
29
  {filter}
31
- </button>
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 className="mt-3 h-1.5 w-full animate-pulse bg-white/20" />
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 { ReactNode } from "react";
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
- <span className="flex min-w-0 flex-wrap items-baseline gap-x-1 gap-y-0.5 overflow-hidden">
15
- {props.signature}
16
- </span>
17
- <span className="min-w-0 break-words text-right text-[0.8rem] text-[#888] max-md:text-left">
18
- {props.meta.join(" · ")}
19
- </span>
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 className={toolFrameClass()}>
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 "border border-[#beaaff]/20 bg-[#111] transition-colors hover:border-[#beaaff]/45 hover:bg-[rgba(190,170,255,0.06)]";
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
- "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 font-mono text-[0.84rem] leading-tight text-[#b8b8b8] max-md:grid-cols-1 max-md:gap-1",
90
+ "block py-1.5 font-mono text-[0.82rem] leading-tight text-[#b8b8b8]",
48
91
  interactive
49
- ? "cursor-pointer hover:bg-[rgba(190,170,255,0.07)]"
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: { turns: ConversationTurn[] }) {
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
- <div className="min-w-0">
12
- <div className="text-[0.78rem] font-semibold uppercase text-[#888]">
13
- Transcript
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
- {props.redacted ? (
16
- <div className="mt-2 break-words text-[0.88rem] leading-relaxed text-[#b8b8b8]">
17
- Hidden because this conversation is not public.
18
- </div>
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
- <button
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
- type="button"
43
+ pressed={props.value === option}
44
+ variant="text"
46
45
  >
47
46
  {option}
48
- </button>
47
+ </ToggleButton>
49
48
  ))}
50
49
  </div>
51
50
  );