@sentry/junior-dashboard 0.58.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 (89) hide show
  1. package/LICENSE +201 -0
  2. package/dist/app.d.ts +22 -0
  3. package/dist/app.js +492 -0
  4. package/dist/auth.d.ts +30 -0
  5. package/dist/client/App.d.ts +2 -0
  6. package/dist/client/api.d.ts +10 -0
  7. package/dist/client/code.d.ts +15 -0
  8. package/dist/client/components/CommandRail.d.ts +6 -0
  9. package/dist/client/components/ConversationList.d.ts +7 -0
  10. package/dist/client/components/ConversationRowStats.d.ts +6 -0
  11. package/dist/client/components/ConversationStack.d.ts +5 -0
  12. package/dist/client/components/ConversationSummary.d.ts +5 -0
  13. package/dist/client/components/EmptyTelemetry.d.ts +5 -0
  14. package/dist/client/components/FilterTabs.d.ts +6 -0
  15. package/dist/client/components/JuniorLogo.d.ts +2 -0
  16. package/dist/client/components/LoadingView.d.ts +4 -0
  17. package/dist/client/components/Section.d.ts +6 -0
  18. package/dist/client/components/SectionHeader.d.ts +6 -0
  19. package/dist/client/components/SectionTitle.d.ts +5 -0
  20. package/dist/client/components/StatusBadge.d.ts +6 -0
  21. package/dist/client/components/ToolFrame.d.ts +10 -0
  22. package/dist/client/components/Transcript.d.ts +5 -0
  23. package/dist/client/components/TranscriptHeader.d.ts +7 -0
  24. package/dist/client/components/TranscriptLoading.d.ts +2 -0
  25. package/dist/client/components/TranscriptText.d.ts +6 -0
  26. package/dist/client/components/TranscriptToolView.d.ts +9 -0
  27. package/dist/client/components/TranscriptTurn.d.ts +8 -0
  28. package/dist/client/components/TurnDurationChart.d.ts +6 -0
  29. package/dist/client/components/statusStyles.d.ts +3 -0
  30. package/dist/client/components/transcriptPreview.d.ts +4 -0
  31. package/dist/client/components/transcriptRenderModel.d.ts +30 -0
  32. package/dist/client/components/transcriptStyles.d.ts +4 -0
  33. package/dist/client/format.d.ts +64 -0
  34. package/dist/client/pages/CommandCenter.d.ts +6 -0
  35. package/dist/client/pages/ConversationPage.d.ts +5 -0
  36. package/dist/client/pages/ConversationsPage.d.ts +5 -0
  37. package/dist/client/styles.d.ts +2 -0
  38. package/dist/client/types.d.ts +153 -0
  39. package/dist/client.d.ts +7 -0
  40. package/dist/client.js +56012 -0
  41. package/dist/config.d.ts +4 -0
  42. package/dist/handler.d.ts +2 -0
  43. package/dist/handler.js +563 -0
  44. package/dist/nitro.d.ts +16 -0
  45. package/dist/nitro.js +80 -0
  46. package/dist/tailwind.css +2 -0
  47. package/package.json +60 -0
  48. package/src/app.ts +421 -0
  49. package/src/auth.ts +202 -0
  50. package/src/client/App.tsx +175 -0
  51. package/src/client/api.ts +99 -0
  52. package/src/client/code.tsx +139 -0
  53. package/src/client/components/CommandRail.tsx +74 -0
  54. package/src/client/components/ConversationList.tsx +94 -0
  55. package/src/client/components/ConversationRowStats.tsx +21 -0
  56. package/src/client/components/ConversationStack.tsx +67 -0
  57. package/src/client/components/ConversationSummary.tsx +40 -0
  58. package/src/client/components/EmptyTelemetry.tsx +11 -0
  59. package/src/client/components/FilterTabs.tsx +35 -0
  60. package/src/client/components/JuniorLogo.tsx +8 -0
  61. package/src/client/components/LoadingView.tsx +16 -0
  62. package/src/client/components/Section.tsx +17 -0
  63. package/src/client/components/SectionHeader.tsx +16 -0
  64. package/src/client/components/SectionTitle.tsx +10 -0
  65. package/src/client/components/StatusBadge.tsx +40 -0
  66. package/src/client/components/ToolFrame.tsx +52 -0
  67. package/src/client/components/Transcript.tsx +39 -0
  68. package/src/client/components/TranscriptHeader.tsx +52 -0
  69. package/src/client/components/TranscriptLoading.tsx +10 -0
  70. package/src/client/components/TranscriptText.tsx +45 -0
  71. package/src/client/components/TranscriptToolView.tsx +207 -0
  72. package/src/client/components/TranscriptTurn.tsx +487 -0
  73. package/src/client/components/TurnDurationChart.tsx +349 -0
  74. package/src/client/components/statusStyles.ts +9 -0
  75. package/src/client/components/transcriptPreview.ts +20 -0
  76. package/src/client/components/transcriptRenderModel.ts +193 -0
  77. package/src/client/components/transcriptStyles.ts +11 -0
  78. package/src/client/format.ts +628 -0
  79. package/src/client/pages/CommandCenter.tsx +37 -0
  80. package/src/client/pages/ConversationPage.tsx +145 -0
  81. package/src/client/pages/ConversationsPage.tsx +59 -0
  82. package/src/client/styles.ts +6 -0
  83. package/src/client/types.ts +153 -0
  84. package/src/client.tsx +79 -0
  85. package/src/config.ts +72 -0
  86. package/src/handler.ts +26 -0
  87. package/src/nitro.ts +110 -0
  88. package/src/tailwind.css +13 -0
  89. package/src/virtual-modules.d.ts +5 -0
@@ -0,0 +1,349 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { useNavigate } from "react-router";
3
+ import {
4
+ CartesianGrid,
5
+ ResponsiveContainer,
6
+ Scatter,
7
+ ScatterChart,
8
+ Tooltip,
9
+ XAxis,
10
+ YAxis,
11
+ } from "recharts";
12
+
13
+ import { readConversationData } from "../api";
14
+ import {
15
+ conversationIdForSession,
16
+ conversationPath,
17
+ formatMs,
18
+ formatTokenTotal,
19
+ requesterLabel,
20
+ slackLocationLabel,
21
+ turnToolCallCount,
22
+ visualStatusForSession,
23
+ } from "../format";
24
+ import { cn } from "../styles";
25
+ import type { ConversationDetailFeed, Session, VisualStatus } from "../types";
26
+ import { Section } from "./Section";
27
+ import { SectionHeader } from "./SectionHeader";
28
+ import { SectionTitle } from "./SectionTitle";
29
+ import { statusBorderClass } from "./statusStyles";
30
+
31
+ /** Render recent turns by start time and duration. */
32
+ export function TurnDurationChart(props: {
33
+ sessions: Session[];
34
+ timeZone: string;
35
+ }) {
36
+ const navigate = useNavigate();
37
+ const nowMs = Date.now();
38
+ const rangeStartMs = nowMs - 7 * 24 * 60 * 60 * 1000;
39
+ const rangeEndMs = nowMs;
40
+ const chartEdgePaddingMs = 6 * 60 * 60 * 1000;
41
+ const chartRangeStartMs = rangeStartMs - chartEdgePaddingMs;
42
+ const chartRangeEndMs = rangeEndMs + chartEdgePaddingMs;
43
+ const points = props.sessions
44
+ .map((session) => turnPoint(session, props.timeZone))
45
+ .filter((point): point is TurnDurationPoint => Boolean(point))
46
+ .filter((point) => point.x >= rangeStartMs && point.x <= rangeEndMs)
47
+ .sort((left, right) => left.x - right.x);
48
+ const totals = points.reduce(
49
+ (sum, point) => ({
50
+ failed: sum.failed + (point.status === "failed" ? 1 : 0),
51
+ hung: sum.hung + (point.status === "hung" ? 1 : 0),
52
+ total: sum.total + 1,
53
+ }),
54
+ { failed: 0, hung: 0, total: 0 },
55
+ );
56
+ const maxDurationMs = points.reduce(
57
+ (max, point) => Math.max(max, point.durationMs),
58
+ 0,
59
+ );
60
+ const durationAxisMaxMs = Math.max(1000, Math.ceil(maxDurationMs * 1.12));
61
+ const dayTicks = Array.from({ length: 7 }, (_, index) => {
62
+ return rangeStartMs + index * 24 * 60 * 60 * 1000;
63
+ });
64
+ const openPoint = (point: TurnDurationPoint) => {
65
+ navigate(conversationPath(conversationIdForSession(point.session)));
66
+ };
67
+
68
+ return (
69
+ <Section>
70
+ <SectionHeader
71
+ actions={
72
+ <div className="flex flex-wrap items-center gap-3 text-[0.78rem] font-semibold uppercase leading-none text-[#888]">
73
+ <ChartLegendItem className="bg-[#b8b8b8]" label="Complete" />
74
+ <ChartLegendItem className="bg-amber-400" label="Hung" />
75
+ <ChartLegendItem className="bg-rose-400" label="Error" />
76
+ </div>
77
+ }
78
+ >
79
+ <SectionTitle>Turns</SectionTitle>
80
+ </SectionHeader>
81
+ <div
82
+ className="min-h-48 px-3 pb-2 pt-4"
83
+ aria-label="Turn duration over the last 7 days"
84
+ >
85
+ <ResponsiveContainer height={190} width="100%">
86
+ <ScatterChart margin={{ bottom: 0, left: 0, right: 4, top: 14 }}>
87
+ <CartesianGrid stroke="rgba(255, 255, 255, 0.1)" vertical={false} />
88
+ <XAxis
89
+ axisLine={false}
90
+ dataKey="x"
91
+ domain={[chartRangeStartMs, chartRangeEndMs]}
92
+ tickFormatter={(value) =>
93
+ bucketLabel(Number(value), props.timeZone)
94
+ }
95
+ tick={{
96
+ fill: "#888",
97
+ fontFamily:
98
+ '-apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif',
99
+ fontSize: 12,
100
+ }}
101
+ tickLine={false}
102
+ ticks={dayTicks}
103
+ type="number"
104
+ />
105
+ <YAxis
106
+ allowDecimals={false}
107
+ axisLine={false}
108
+ dataKey="durationMs"
109
+ domain={[0, durationAxisMaxMs]}
110
+ tickFormatter={(value) => formatMs(Number(value))}
111
+ tick={{
112
+ fill: "#888",
113
+ fontFamily:
114
+ '-apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif',
115
+ fontSize: 11,
116
+ }}
117
+ tickLine={false}
118
+ type="number"
119
+ />
120
+ <Tooltip
121
+ content={<TurnDurationTooltip />}
122
+ cursor={{ stroke: "rgba(255, 255, 255, 0.22)" }}
123
+ />
124
+ <Scatter
125
+ activeShape={durationDot(openPoint, true)}
126
+ data={points}
127
+ isAnimationActive={false}
128
+ shape={durationDot(openPoint, false)}
129
+ />
130
+ </ScatterChart>
131
+ </ResponsiveContainer>
132
+ </div>
133
+ <div className="border-t border-white/10 px-4 py-3 text-[0.84rem] leading-tight text-[#888]">
134
+ {totals.total} turns / {totals.hung} hung / {totals.failed} errors
135
+ </div>
136
+ </Section>
137
+ );
138
+ }
139
+
140
+ function ChartLegendItem(props: { className: string; label: string }) {
141
+ return (
142
+ <span className="inline-flex items-center gap-1.5">
143
+ <span className={cn("size-2 rounded-full", props.className)} />
144
+ {props.label}
145
+ </span>
146
+ );
147
+ }
148
+
149
+ type PlottedTurnStatus = Exclude<VisualStatus, "active">;
150
+
151
+ type TurnDurationPoint = {
152
+ durationMs: number;
153
+ tooltipLabel: string;
154
+ session: Session;
155
+ status: PlottedTurnStatus;
156
+ x: number;
157
+ };
158
+
159
+ function turnPoint(
160
+ session: Session,
161
+ timeZone: string,
162
+ ): TurnDurationPoint | null {
163
+ const startedAtMs = Date.parse(session.startedAt ?? "");
164
+ if (!Number.isFinite(startedAtMs)) {
165
+ return null;
166
+ }
167
+ const status = visualStatusForSession(session);
168
+ if (status === "active") {
169
+ return null;
170
+ }
171
+
172
+ const lastSeenAtMs = Date.parse(session.lastSeenAt ?? "");
173
+ const durationMs =
174
+ session.cumulativeDurationMs ??
175
+ (Number.isFinite(lastSeenAtMs)
176
+ ? Math.max(0, lastSeenAtMs - startedAtMs)
177
+ : 0);
178
+ return {
179
+ durationMs,
180
+ session,
181
+ status,
182
+ tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, {
183
+ timeZone,
184
+ }),
185
+ x: startedAtMs,
186
+ };
187
+ }
188
+
189
+ type DurationDotProps = {
190
+ cx?: number;
191
+ cy?: number;
192
+ payload?: TurnDurationPoint;
193
+ };
194
+
195
+ function durationDot(
196
+ onOpen: (point: TurnDurationPoint) => void,
197
+ active: boolean,
198
+ ) {
199
+ return (props: DurationDotProps) => {
200
+ if (props.cx == null || props.cy == null || !props.payload) {
201
+ return <g />;
202
+ }
203
+
204
+ const point = props.payload;
205
+ const fill = durationDotFill(point.status, active);
206
+ return (
207
+ <circle
208
+ aria-label={`Open ${point.session.title ?? point.session.id}`}
209
+ className="cursor-pointer outline-none transition-[filter,stroke,stroke-width] hover:brightness-125 focus-visible:stroke-emerald-400 focus-visible:stroke-2"
210
+ cx={props.cx}
211
+ cy={props.cy}
212
+ fill={fill}
213
+ onClick={() => onOpen(point)}
214
+ onKeyDown={(event) => {
215
+ if (event.key === "Enter" || event.key === " ") {
216
+ event.preventDefault();
217
+ onOpen(point);
218
+ }
219
+ }}
220
+ r={active ? 5 : 4}
221
+ role="link"
222
+ stroke="rgba(0, 0, 0, 0.96)"
223
+ strokeWidth={1}
224
+ tabIndex={0}
225
+ />
226
+ );
227
+ };
228
+ }
229
+
230
+ function durationDotFill(status: PlottedTurnStatus, active: boolean): string {
231
+ if (status === "failed") {
232
+ return active ? "rgba(251, 113, 133, 1)" : "rgba(244, 63, 94, 0.95)";
233
+ }
234
+ if (status === "hung") {
235
+ return active ? "rgba(251, 191, 36, 1)" : "rgba(245, 158, 11, 0.94)";
236
+ }
237
+ return active ? "rgba(250, 250, 250, 0.96)" : "rgba(184, 184, 184, 0.82)";
238
+ }
239
+
240
+ function TurnDurationTooltip(props: {
241
+ active?: boolean;
242
+ payload?: Array<{ payload: TurnDurationPoint }>;
243
+ }) {
244
+ const point = props.payload?.[0]?.payload;
245
+ const conversationId = point
246
+ ? conversationIdForSession(point.session)
247
+ : undefined;
248
+ const detail = useQuery({
249
+ enabled: Boolean(props.active && conversationId),
250
+ queryKey: ["conversation", conversationId],
251
+ queryFn: async () => readConversationData(conversationId!),
252
+ retry: false,
253
+ staleTime: 5_000,
254
+ });
255
+
256
+ if (!props.active || !point) {
257
+ return null;
258
+ }
259
+ const rows = turnTooltipRows(point, detail.data);
260
+ return (
261
+ <div
262
+ className={cn(
263
+ "min-w-64 max-w-sm border border-l-4 border-white/15 bg-[#050505] px-3 py-2.5 text-[0.82rem] leading-relaxed text-[#b8b8b8]",
264
+ statusBorderClass(point.status),
265
+ )}
266
+ >
267
+ <div className="flex items-start justify-between gap-3">
268
+ <div className="min-w-0">
269
+ <div className="truncate text-[0.92rem] font-bold leading-tight text-white">
270
+ {turnTooltipTitle(point.session)}
271
+ </div>
272
+ <div className="mt-0.5 text-[0.78rem] leading-tight text-[#888]">
273
+ {point.tooltipLabel}
274
+ </div>
275
+ </div>
276
+ <span className={chartTooltipStatusClass(point.status)}>
277
+ {point.status}
278
+ </span>
279
+ </div>
280
+ <div className="mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
281
+ {rows.map(([label, value]) => (
282
+ <div className="contents" key={label}>
283
+ <span className="text-[0.72rem] font-semibold uppercase leading-relaxed text-[#777]">
284
+ {label}
285
+ </span>
286
+ <span className="min-w-0 break-words text-right text-[#d6d6d6]">
287
+ {value}
288
+ </span>
289
+ </div>
290
+ ))}
291
+ </div>
292
+ </div>
293
+ );
294
+ }
295
+
296
+ function turnTooltipRows(
297
+ point: TurnDurationPoint,
298
+ detail: ConversationDetailFeed | undefined,
299
+ ): Array<[string, string]> {
300
+ const session = point.session;
301
+ const requester = requesterLabel(
302
+ session.requesterIdentity,
303
+ session.requester,
304
+ );
305
+ const location = slackLocationLabel(session, { includeId: false });
306
+ const tokens = formatTokenTotal(session.cumulativeUsage);
307
+ return [
308
+ ["duration", formatMs(point.durationMs)],
309
+ tokens ? ["tokens", tokens] : null,
310
+ ["tool calls", turnTooltipToolCalls(point, detail)],
311
+ requester ? ["requester", requester] : null,
312
+ location ? ["surface", location] : null,
313
+ ].filter((row): row is [string, string] => Boolean(row));
314
+ }
315
+
316
+ function turnTooltipToolCalls(
317
+ point: TurnDurationPoint,
318
+ detail: ConversationDetailFeed | undefined,
319
+ ): string {
320
+ if (!detail) {
321
+ return "loading";
322
+ }
323
+ const turn = detail.turns.find((item) => item.id === point.session.id);
324
+ return String(turn ? turnToolCallCount(turn) : 0);
325
+ }
326
+
327
+ function turnTooltipTitle(session: Session): string {
328
+ return (
329
+ session.conversationTitle ??
330
+ session.title ??
331
+ conversationIdForSession(session)
332
+ );
333
+ }
334
+
335
+ function chartTooltipStatusClass(status: PlottedTurnStatus): string {
336
+ return cn(
337
+ "shrink-0 text-[0.68rem] font-bold uppercase leading-none",
338
+ status === "failed" && "text-rose-300",
339
+ status === "hung" && "text-amber-300",
340
+ status === "idle" && "text-[#b8b8b8]",
341
+ );
342
+ }
343
+
344
+ function bucketLabel(timestampMs: number, timeZone: string): string {
345
+ return new Date(timestampMs).toLocaleDateString(undefined, {
346
+ timeZone,
347
+ weekday: "short",
348
+ });
349
+ }
@@ -0,0 +1,9 @@
1
+ import type { VisualStatus } from "../types";
2
+
3
+ /** Map turn health to the shared left-border severity accent. */
4
+ export function statusBorderClass(status: VisualStatus): string {
5
+ if (status === "active") return "border-l-emerald-400";
6
+ if (status === "hung") return "border-l-amber-400";
7
+ if (status === "failed") return "border-l-rose-400";
8
+ return "border-l-white/25";
9
+ }
@@ -0,0 +1,20 @@
1
+ /** Summarize a transcript payload value for closed tool/details headers. */
2
+ export function previewToolValue(value: unknown): string {
3
+ if (!isPreviewableValue(value)) return "no arguments";
4
+ const source =
5
+ typeof value === "string"
6
+ ? value
7
+ : JSON.stringify(value, (_key, nested) =>
8
+ typeof nested === "string" && nested.length > 80
9
+ ? `${nested.slice(0, 77)}...`
10
+ : nested,
11
+ );
12
+ return source.length > 120 ? `${source.slice(0, 117)}...` : source;
13
+ }
14
+
15
+ /** Decide whether a transcript payload has useful preview text. */
16
+ export function isPreviewableValue(value: unknown): boolean {
17
+ if (value == null || value === "") return false;
18
+ if (typeof value === "string") return value.trim().length > 0;
19
+ return true;
20
+ }
@@ -0,0 +1,193 @@
1
+ import { countStructuredBlockChildren } from "../code";
2
+ import { parseMarkdownBlocks, stringifyPartValue } from "../format";
3
+ import type { TranscriptMessage, TranscriptPart } from "../types";
4
+
5
+ export type RenderedTranscriptPart =
6
+ | { kind: "part"; part: TranscriptPart }
7
+ | { kind: "tool"; call?: TranscriptPart; result?: TranscriptPart };
8
+
9
+ type RenderedTranscriptEntry =
10
+ | { kind: "message"; message: TranscriptMessage }
11
+ | RenderedToolEntry;
12
+
13
+ type RenderedToolEntry = {
14
+ call?: TranscriptPart;
15
+ kind: "tool";
16
+ result?: TranscriptPart;
17
+ resultTimestamp?: number;
18
+ timestamp?: number;
19
+ };
20
+
21
+ export type TranscriptViewMode = "raw" | "rich";
22
+
23
+ function isToolCall(part: TranscriptPart): boolean {
24
+ return part.type === "tool_call";
25
+ }
26
+
27
+ function isToolResult(part: TranscriptPart): boolean {
28
+ return part.type === "tool_result";
29
+ }
30
+
31
+ function isString(value: string | undefined): value is string {
32
+ return typeof value === "string" && value.length > 0;
33
+ }
34
+
35
+ function sameToolInvocation(
36
+ call: TranscriptPart,
37
+ result: TranscriptPart,
38
+ ): boolean {
39
+ if (call.id && result.id) return call.id === result.id;
40
+ if (call.name && result.name) return call.name === result.name;
41
+ return false;
42
+ }
43
+
44
+ /** Group inline transcript parts so matching tool calls/results render together. */
45
+ export function groupTranscriptParts(
46
+ parts: TranscriptPart[],
47
+ ): RenderedTranscriptPart[] {
48
+ const grouped: RenderedTranscriptPart[] = [];
49
+ const consumed = new Set<number>();
50
+
51
+ for (let index = 0; index < parts.length; index += 1) {
52
+ if (consumed.has(index)) continue;
53
+
54
+ const part = parts[index]!;
55
+ if (isToolCall(part)) {
56
+ const resultIndex = parts.findIndex(
57
+ (candidate, candidateIndex) =>
58
+ candidateIndex > index &&
59
+ !consumed.has(candidateIndex) &&
60
+ isToolResult(candidate) &&
61
+ sameToolInvocation(part, candidate),
62
+ );
63
+ if (resultIndex >= 0) {
64
+ consumed.add(resultIndex);
65
+ grouped.push({ kind: "tool", call: part, result: parts[resultIndex] });
66
+ } else {
67
+ grouped.push({ kind: "tool", call: part });
68
+ }
69
+ continue;
70
+ }
71
+
72
+ if (isToolResult(part)) {
73
+ grouped.push({ kind: "tool", result: part });
74
+ continue;
75
+ }
76
+
77
+ grouped.push({ kind: "part", part });
78
+ }
79
+
80
+ return grouped;
81
+ }
82
+
83
+ function findToolEntry(
84
+ entries: RenderedTranscriptEntry[],
85
+ result: TranscriptPart,
86
+ ): RenderedToolEntry | undefined {
87
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
88
+ const entry = entries[index]!;
89
+ if (entry.kind !== "tool" || entry.result) continue;
90
+ if (!entry.call || sameToolInvocation(entry.call, result)) {
91
+ return entry;
92
+ }
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /** Flatten message-local tool parts into turn-level events for scan-friendly rendering. */
98
+ export function groupTranscriptMessages(
99
+ messages: TranscriptMessage[],
100
+ ): RenderedTranscriptEntry[] {
101
+ const entries: RenderedTranscriptEntry[] = [];
102
+
103
+ for (const message of messages) {
104
+ let messageParts: TranscriptPart[] = [];
105
+ const flushMessage = () => {
106
+ if (messageParts.length === 0) return;
107
+ entries.push({
108
+ kind: "message",
109
+ message: { ...message, parts: messageParts },
110
+ });
111
+ messageParts = [];
112
+ };
113
+
114
+ for (const part of message.parts) {
115
+ if (isToolCall(part)) {
116
+ flushMessage();
117
+ entries.push({
118
+ call: part,
119
+ kind: "tool",
120
+ timestamp: message.timestamp,
121
+ });
122
+ continue;
123
+ }
124
+
125
+ if (isToolResult(part)) {
126
+ flushMessage();
127
+ const entry = findToolEntry(entries, part);
128
+ if (entry) {
129
+ entry.result = part;
130
+ entry.resultTimestamp = message.timestamp;
131
+ } else {
132
+ entries.push({
133
+ kind: "tool",
134
+ result: part,
135
+ resultTimestamp: message.timestamp,
136
+ });
137
+ }
138
+ continue;
139
+ }
140
+
141
+ messageParts.push(part);
142
+ }
143
+
144
+ flushMessage();
145
+ }
146
+
147
+ return entries;
148
+ }
149
+
150
+ /** Build the plain-text clipboard/raw view for one transcript message. */
151
+ export function messageRawText(message: TranscriptMessage): string {
152
+ return message.parts
153
+ .map((part) => {
154
+ if (part.type === "text") return part.text ?? "";
155
+ if (part.type === "thinking") return stringifyPartValue(part.output);
156
+ if (part.type === "tool_call") {
157
+ return [
158
+ `tool_call ${part.name ?? part.id ?? "unknown"}`,
159
+ stringifyPartValue(part.input),
160
+ ]
161
+ .filter(isString)
162
+ .join("\n");
163
+ }
164
+ if (part.type === "tool_result") {
165
+ return [
166
+ `tool_result ${part.name ?? part.id ?? "unknown"}`,
167
+ stringifyPartValue(part.output),
168
+ ]
169
+ .filter(isString)
170
+ .join("\n");
171
+ }
172
+ return stringifyPartValue(part.output ?? part.input ?? part.text ?? part);
173
+ })
174
+ .filter((part) => part.trim().length > 0)
175
+ .join("\n\n");
176
+ }
177
+
178
+ function countTextRenderedChildren(text: string): number {
179
+ return parseMarkdownBlocks(text).reduce((count, block) => {
180
+ return count + countStructuredBlockChildren(block);
181
+ }, 0);
182
+ }
183
+
184
+ /** Count rendered rows so structured transcript expansion opens the newest node. */
185
+ export function countRenderedTranscriptChildren(
186
+ part: RenderedTranscriptPart,
187
+ ): number {
188
+ if (part.kind === "tool") return 1;
189
+ if (part.part.type === "text") {
190
+ return countTextRenderedChildren(part.part.text ?? "");
191
+ }
192
+ return 1;
193
+ }
@@ -0,0 +1,11 @@
1
+ import { cn } from "../styles";
2
+
3
+ /** Share muted transcript metadata styling between turn and message chrome. */
4
+ export function mutedTranscriptMetaClass(size = "text-[0.82rem]"): string {
5
+ return cn("leading-relaxed text-[#b8b8b8]", size);
6
+ }
7
+
8
+ /** Share the transcript empty/unavailable frame across top-level and turn views. */
9
+ export function transcriptEmptyClass(): string {
10
+ return "border border-white/10 bg-[#050505] p-4 text-[0.9rem] leading-relaxed text-[#b8b8b8]";
11
+ }