@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,628 @@
1
+ import { bundledLanguages, type BundledLanguage } from "shiki/bundle/web";
2
+
3
+ import type {
4
+ CodeBlock,
5
+ Conversation,
6
+ ConversationTurn,
7
+ MarkupNode,
8
+ RequesterIdentity,
9
+ Session,
10
+ SessionFilter,
11
+ TurnUsage,
12
+ VisualStatus,
13
+ } from "./types";
14
+
15
+ let dashboardTimeZone = "America/Los_Angeles";
16
+
17
+ /** Set the dashboard display timezone returned by the authenticated config API. */
18
+ export function setDashboardTimeZone(timeZone: string): void {
19
+ dashboardTimeZone = timeZone;
20
+ }
21
+
22
+ function displayTimeZone(): string {
23
+ return dashboardTimeZone;
24
+ }
25
+
26
+ function isActiveSession(session: Session): boolean {
27
+ return session.status === "active" || session.status === "running";
28
+ }
29
+
30
+ /** Identify turn summaries that should appear in failed conversation filters. */
31
+ export function isFailedSession(session: Session): boolean {
32
+ return session.status === "failed";
33
+ }
34
+
35
+ function isHungSession(session: Session): boolean {
36
+ return session.status === "hung";
37
+ }
38
+
39
+ function isActiveConversation(conversation: Conversation): boolean {
40
+ return conversation.turns.some(
41
+ (turn) => visualStatusForSession(turn) === "active",
42
+ );
43
+ }
44
+
45
+ function isFailedConversation(conversation: Conversation): boolean {
46
+ return conversation.turns.some(isFailedSession);
47
+ }
48
+
49
+ function isHungConversation(conversation: Conversation): boolean {
50
+ return conversation.turns.some(isHungSession);
51
+ }
52
+
53
+ function parseTime(value: string | undefined): number | null {
54
+ if (!value) return null;
55
+ const time = Date.parse(value);
56
+ return Number.isFinite(time) ? time : null;
57
+ }
58
+
59
+ /** Format absolute dashboard timestamps with a stable empty fallback. */
60
+ export function formatTime(value: string | undefined): string {
61
+ const time = parseTime(value);
62
+ if (time == null) return "none";
63
+ return new Date(time).toLocaleString(undefined, {
64
+ timeZone: displayTimeZone(),
65
+ });
66
+ }
67
+
68
+ /** Format conversation activity timestamps as human-relative recency labels. */
69
+ export function formatRelativeTime(value: string | undefined): string {
70
+ const time = parseTime(value);
71
+ if (time == null) return "not updated yet";
72
+
73
+ const seconds = Math.round((time - Date.now()) / 1000);
74
+ const absoluteSeconds = Math.abs(seconds);
75
+ const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
76
+ ["year", 60 * 60 * 24 * 365],
77
+ ["month", 60 * 60 * 24 * 30],
78
+ ["week", 60 * 60 * 24 * 7],
79
+ ["day", 60 * 60 * 24],
80
+ ["hour", 60 * 60],
81
+ ["minute", 60],
82
+ ];
83
+
84
+ for (const [unit, unitSeconds] of units) {
85
+ if (absoluteSeconds >= unitSeconds) {
86
+ return new Intl.RelativeTimeFormat(undefined, {
87
+ numeric: "auto",
88
+ }).format(Math.round(seconds / unitSeconds), unit);
89
+ }
90
+ }
91
+
92
+ return "just now";
93
+ }
94
+
95
+ /** Format millisecond durations for compact transcript metadata. */
96
+ export function formatMs(value: number | undefined): string {
97
+ if (typeof value !== "number" || !Number.isFinite(value)) return "none";
98
+ const ms = Math.max(0, Math.floor(value));
99
+ if (ms < 1000) return `${ms}ms`;
100
+ const seconds = ms / 1000;
101
+ if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
102
+ const minutes = Math.floor(seconds / 60);
103
+ const remainingSeconds = Math.round(seconds % 60);
104
+ return `${minutes}m ${remainingSeconds}s`;
105
+ }
106
+
107
+ /** Format transcript event timestamps independently from turn start offsets. */
108
+ export function formatMessageTimestamp(value: number | undefined): string {
109
+ if (typeof value !== "number" || !Number.isFinite(value))
110
+ return "no timestamp";
111
+ return new Date(value).toLocaleTimeString(undefined, {
112
+ timeZone: displayTimeZone(),
113
+ });
114
+ }
115
+
116
+ /** Format a transcript event as an offset from the current turn start. */
117
+ export function formatMessageOffset(
118
+ turn: ConversationTurn,
119
+ value: number | undefined,
120
+ ): string | undefined {
121
+ const start = parseTime(turn.startedAt);
122
+ if (
123
+ start == null ||
124
+ typeof value !== "number" ||
125
+ !Number.isFinite(value) ||
126
+ value < start
127
+ ) {
128
+ return undefined;
129
+ }
130
+ return `+${formatMs(value - start)}`;
131
+ }
132
+
133
+ function formatNumber(value: number | undefined): string {
134
+ if (typeof value !== "number" || !Number.isFinite(value)) return "0";
135
+ const number = Math.max(0, Math.floor(value));
136
+ if (number < 1000) return String(number);
137
+
138
+ const units: Array<[string, number]> = [
139
+ ["m", 1_000_000],
140
+ ["k", 1_000],
141
+ ];
142
+ const [suffix, divisor] =
143
+ units.find(([, threshold]) => number >= threshold) ?? units[1]!;
144
+ const scaled = number / divisor;
145
+ const formatted =
146
+ scaled >= 100 || Number.isInteger(scaled)
147
+ ? Math.round(scaled).toString()
148
+ : scaled >= 10
149
+ ? Math.round(scaled).toString()
150
+ : (Math.floor(scaled * 10) / 10).toFixed(1).replace(/\.0$/, "");
151
+ return `${formatted}${suffix}`;
152
+ }
153
+
154
+ /** Format byte counts in lowercase compact units for transcript metadata. */
155
+ export function formatBytes(value: number | undefined): string {
156
+ if (typeof value !== "number" || !Number.isFinite(value)) return "0b";
157
+ const bytes = Math.max(0, Math.floor(value));
158
+ if (bytes < 1024) return `${bytes}b`;
159
+
160
+ const units: Array<[string, number]> = [
161
+ ["mb", 1024 * 1024],
162
+ ["kb", 1024],
163
+ ];
164
+ const [suffix, divisor] =
165
+ units.find(([, threshold]) => bytes >= threshold) ?? units[1]!;
166
+ const scaled = bytes / divisor;
167
+ const precision = scaled >= 10 || Number.isInteger(scaled) ? 0 : 1;
168
+ return `${scaled.toFixed(precision).replace(/\.0$/, "")}${suffix}`;
169
+ }
170
+
171
+ function transcriptSource(turn: ConversationTurn) {
172
+ return turn.transcriptAvailable
173
+ ? turn.transcript
174
+ : (turn.transcriptMetadata ?? []);
175
+ }
176
+
177
+ /** Count visible or redacted message records for a turn. */
178
+ export function turnMessageCount(turn: ConversationTurn): number {
179
+ return turn.transcriptMessageCount ?? transcriptSource(turn).length;
180
+ }
181
+
182
+ /** Count tool calls from visible transcripts or safe redacted metadata. */
183
+ export function turnToolCallCount(turn: ConversationTurn): number {
184
+ return transcriptSource(turn).reduce((count, message) => {
185
+ return (
186
+ count + message.parts.filter((part) => part.type === "tool_call").length
187
+ );
188
+ }, 0);
189
+ }
190
+
191
+ function totalUsageTokens(usage: TurnUsage | undefined): number | undefined {
192
+ if (!usage) return undefined;
193
+ if (
194
+ typeof usage.totalTokens === "number" &&
195
+ Number.isFinite(usage.totalTokens)
196
+ ) {
197
+ return usage.totalTokens;
198
+ }
199
+ return [
200
+ usage.inputTokens,
201
+ usage.outputTokens,
202
+ usage.cachedInputTokens,
203
+ usage.cacheCreationTokens,
204
+ ].reduce<number | undefined>((sum, value) => {
205
+ if (typeof value !== "number" || !Number.isFinite(value)) return sum;
206
+ return (sum ?? 0) + Math.max(0, Math.floor(value));
207
+ }, undefined);
208
+ }
209
+
210
+ /** Format known token counters without estimating per-message usage. */
211
+ export function formatTokenTotal(usage: TurnUsage | undefined): string {
212
+ const total = totalUsageTokens(usage);
213
+ return total === undefined ? "" : `${formatNumber(total)} tokens`;
214
+ }
215
+
216
+ /** Format known token counters with available input/output detail. */
217
+ export function formatUsage(usage: TurnUsage | undefined): string {
218
+ const total = totalUsageTokens(usage);
219
+ if (total === undefined) return "";
220
+ const pieces = [
221
+ usage?.inputTokens !== undefined
222
+ ? `${formatNumber(usage.inputTokens)} in`
223
+ : undefined,
224
+ usage?.outputTokens !== undefined
225
+ ? `${formatNumber(usage.outputTokens)} out`
226
+ : undefined,
227
+ usage?.cachedInputTokens !== undefined
228
+ ? `${formatNumber(usage.cachedInputTokens)} cached`
229
+ : undefined,
230
+ usage?.cacheCreationTokens !== undefined
231
+ ? `${formatNumber(usage.cacheCreationTokens)} cache-write`
232
+ : undefined,
233
+ ].filter(Boolean);
234
+ return pieces.length > 0
235
+ ? `${formatNumber(total)} tokens (${pieces.join(" / ")})`
236
+ : `${formatNumber(total)} tokens`;
237
+ }
238
+
239
+ /** Format a conversation span from first turn start to latest activity. */
240
+ export function formatConversationDuration(conversation: Conversation): string {
241
+ const start = parseTime(conversation.startedAt);
242
+ const end = parseTime(conversation.lastSeenAt) ?? Date.now();
243
+ if (start == null || end < start) return "none";
244
+ const seconds = Math.max(1, Math.round((end - start) / 1000));
245
+ if (seconds < 60) return `${seconds}s`;
246
+ const minutes = Math.round(seconds / 60);
247
+ if (minutes < 60) return `${minutes}m`;
248
+ return `${Math.round(minutes / 60)}h`;
249
+ }
250
+
251
+ /** Resolve the owning conversation id for a turn/session summary. */
252
+ export function conversationIdForSession(session: Session): string {
253
+ return session.conversationId || session.id;
254
+ }
255
+
256
+ function compareTimeDesc(a: string | undefined, b: string | undefined): number {
257
+ return (parseTime(b) ?? 0) - (parseTime(a) ?? 0);
258
+ }
259
+
260
+ function compareTimeAsc(a: string | undefined, b: string | undefined): number {
261
+ return (parseTime(a) ?? 0) - (parseTime(b) ?? 0);
262
+ }
263
+
264
+ function getConversationTitle(conversation: Conversation): string {
265
+ if (conversation.surface === "slack") {
266
+ return (
267
+ slackLocationLabel(conversation, { includeId: false }) ??
268
+ conversation.title
269
+ );
270
+ }
271
+ return conversation.title;
272
+ }
273
+
274
+ /** Choose the safe display title already prepared by the reporting API. */
275
+ export function conversationDisplayTitle(
276
+ conversation: Conversation | undefined,
277
+ ): string {
278
+ if (!conversation) return "Conversation";
279
+ return conversation.conversationTitle ?? getConversationTitle(conversation);
280
+ }
281
+
282
+ /** Prefer stable requester identifiers while keeping Slack ids as a last resort. */
283
+ export function requesterLabel(
284
+ requester: RequesterIdentity | undefined,
285
+ fallback: string | undefined,
286
+ ): string | undefined {
287
+ return (
288
+ requester?.email ??
289
+ requester?.slackUserName ??
290
+ requester?.fullName ??
291
+ fallback ??
292
+ requester?.slackUserId
293
+ );
294
+ }
295
+
296
+ /** Format the owner and permalink id line shared by conversation rows and headers. */
297
+ export function conversationIdentityMeta(
298
+ conversation: Conversation | undefined,
299
+ conversationId: string | undefined,
300
+ ): string {
301
+ const id = conversationId ?? "missing conversation id";
302
+ const owner = requesterLabel(
303
+ conversation?.requesterIdentity,
304
+ conversation?.requester,
305
+ );
306
+ return owner ? `${owner} · ${id}` : id;
307
+ }
308
+
309
+ /** Convert Slack channel ids and names into user-facing location labels. */
310
+ export function slackLocationLabel(
311
+ input: Pick<
312
+ Session,
313
+ "channel" | "channelName" | "requester" | "requesterIdentity"
314
+ >,
315
+ options: { includeId?: boolean } = {},
316
+ ): string | undefined {
317
+ const channelId = input.channel;
318
+ if (!channelId) return undefined;
319
+
320
+ const includeId = options.includeId ?? true;
321
+ const name = input.channelName?.replace(/^#/, "");
322
+ const idSuffix = includeId ? ` (${channelId})` : "";
323
+ if (channelId.startsWith("D")) {
324
+ return `Direct Message${idSuffix}`;
325
+ }
326
+
327
+ if (channelId.startsWith("C")) {
328
+ return name ? `#${name}${idSuffix}` : `Public Channel${idSuffix}`;
329
+ }
330
+
331
+ if (channelId.startsWith("G")) {
332
+ if (name?.startsWith("mpdm-")) return `Group DM${idSuffix}`;
333
+ return `Private Channel${idSuffix}`;
334
+ }
335
+
336
+ return name ? `${name}${idSuffix}` : channelId;
337
+ }
338
+
339
+ /** Collapse raw turn states into the dashboard's visual status language. */
340
+ export function visualStatusForSession(session: Session): VisualStatus {
341
+ if (isHungSession(session)) return "hung";
342
+ if (isFailedSession(session)) return "failed";
343
+ if (isActiveSession(session)) return "active";
344
+ return "idle";
345
+ }
346
+
347
+ /** Derive conversation status from its turn summaries. */
348
+ export function visualStatusForConversation(
349
+ conversation: Conversation,
350
+ ): VisualStatus {
351
+ if (isHungConversation(conversation)) return "hung";
352
+ if (isActiveConversation(conversation)) return "active";
353
+ if (isFailedConversation(conversation)) return "failed";
354
+ return "idle";
355
+ }
356
+
357
+ /** Explain why a transcript body is absent without exposing private content. */
358
+ export function unavailableTranscriptLabel(turn: ConversationTurn): string {
359
+ if (turn.transcriptRedacted) {
360
+ return "Transcript hidden because this conversation is not public.";
361
+ }
362
+ const status = visualStatusForSession(turn);
363
+ if (status === "active") {
364
+ return "Transcript pending for this active turn.";
365
+ }
366
+ if (status === "hung") {
367
+ return "Transcript pending for this hung turn.";
368
+ }
369
+ return "Transcript unavailable for this turn.";
370
+ }
371
+
372
+ /** Build the canonical permalink route for a conversation id. */
373
+ export function conversationPath(conversationId: string): string {
374
+ return `/conversations/${encodeURIComponent(conversationId)}`;
375
+ }
376
+
377
+ function normalizeLanguage(language: string | undefined): BundledLanguage {
378
+ const normalized = language?.trim().toLowerCase();
379
+ if (!normalized) return "markdown";
380
+ const aliases: Record<string, BundledLanguage> = {
381
+ console: "shellscript",
382
+ htm: "html",
383
+ js: "javascript",
384
+ jsonl: "json",
385
+ md: "markdown",
386
+ ndjson: "json",
387
+ sh: "shellscript",
388
+ text: "markdown",
389
+ txt: "markdown",
390
+ xml: "xml",
391
+ yml: "yaml",
392
+ };
393
+ const candidate = aliases[normalized] ?? normalized;
394
+ return candidate in bundledLanguages
395
+ ? (candidate as BundledLanguage)
396
+ : "markdown";
397
+ }
398
+
399
+ /** Detect the syntax highlighter language for raw transcript blocks. */
400
+ export function detectLanguage(text: string): BundledLanguage {
401
+ const trimmed = text.trim();
402
+ if (!trimmed) return "markdown";
403
+ try {
404
+ JSON.parse(trimmed);
405
+ return "json";
406
+ } catch {
407
+ // continue with heuristics
408
+ }
409
+ if (prettyJsonl(trimmed)) return "json";
410
+ if (/^<[\s\S]+>$/.test(trimmed) && /<\/?[a-zA-Z][^>]*>/.test(trimmed)) {
411
+ return "xml";
412
+ }
413
+ if (/```|^#{1,6}\s|\n[-*]\s|\n\d+\.\s|\[[^\]]+\]\([^)]+\)/m.test(trimmed)) {
414
+ return "markdown";
415
+ }
416
+ if (/\b(import|export|const|let|function|interface|type)\b/.test(trimmed)) {
417
+ return "typescript";
418
+ }
419
+ if (/^\s*(\$|pnpm|npm|git|curl|cd|ls|node)\b/m.test(trimmed)) {
420
+ return "shellscript";
421
+ }
422
+ return "markdown";
423
+ }
424
+
425
+ function prettyJson(text: string): string | undefined {
426
+ const trimmed = text.trim();
427
+ if (!trimmed) return undefined;
428
+ try {
429
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
430
+ } catch {
431
+ return undefined;
432
+ }
433
+ }
434
+
435
+ function prettyJsonl(text: string): string | undefined {
436
+ const lines = text
437
+ .trim()
438
+ .split(/\r?\n/)
439
+ .filter((line) => line.trim().length > 0);
440
+ if (lines.length < 2) return undefined;
441
+
442
+ const formatted: string[] = [];
443
+ for (const line of lines) {
444
+ const json = prettyJson(line);
445
+ if (!json) return undefined;
446
+ formatted.push(json);
447
+ }
448
+ return formatted.join("\n");
449
+ }
450
+
451
+ function prettyJsonData(text: string): string | undefined {
452
+ return prettyJson(text) ?? prettyJsonl(text);
453
+ }
454
+
455
+ function formatCodeBlock(code: string, language: BundledLanguage): string {
456
+ return language === "json" ? (prettyJsonData(code) ?? code) : code;
457
+ }
458
+
459
+ /** Decide whether a fenced block can use the interactive markup renderer. */
460
+ export function canRenderStructuredMarkup(language: BundledLanguage): boolean {
461
+ return language === "xml" || language === "html";
462
+ }
463
+
464
+ /** Parse markdown into renderable code blocks while preserving plain text blocks. */
465
+ export function parseMarkdownBlocks(text: string): CodeBlock[] {
466
+ const blocks: CodeBlock[] = [];
467
+ const fence = /```([A-Za-z0-9_-]+)?\n([\s\S]*?)```/g;
468
+ let cursor = 0;
469
+ let match: RegExpExecArray | null;
470
+ while ((match = fence.exec(text))) {
471
+ const prose = text.slice(cursor, match.index).trim();
472
+ if (prose) {
473
+ const language = detectLanguage(prose);
474
+ blocks.push({ code: formatCodeBlock(prose, language), language });
475
+ }
476
+ const language = normalizeLanguage(match[1]);
477
+ blocks.push({
478
+ code: formatCodeBlock(match[2] ?? "", language),
479
+ language,
480
+ });
481
+ cursor = match.index + match[0].length;
482
+ }
483
+ const rest = text.slice(cursor).trim();
484
+ if (rest) {
485
+ const language = detectLanguage(rest);
486
+ blocks.push({ code: formatCodeBlock(rest, language), language });
487
+ }
488
+ if (blocks.length > 0) return blocks;
489
+ const language = detectLanguage(text);
490
+ return [{ code: formatCodeBlock(text, language), language }];
491
+ }
492
+
493
+ /** Parse XML/HTML-ish fragments for the collapsible transcript renderer. */
494
+ export function parseMarkupNodes(
495
+ code: string,
496
+ language: BundledLanguage,
497
+ ): MarkupNode[] {
498
+ const parser = new DOMParser();
499
+ if (language === "xml") {
500
+ const document = parser.parseFromString(
501
+ `<junior-root>${code}</junior-root>`,
502
+ "text/xml",
503
+ );
504
+ if (!document.querySelector("parsererror")) {
505
+ return Array.from(document.documentElement.childNodes)
506
+ .map(markupNodeFromDom)
507
+ .filter(
508
+ (node) => node.type === "element" || node.text.trim().length > 0,
509
+ );
510
+ }
511
+ }
512
+
513
+ const document = parser.parseFromString(code, "text/html");
514
+ return Array.from(document.body.childNodes)
515
+ .map(markupNodeFromDom)
516
+ .filter((node) => node.type === "element" || node.text.trim().length > 0);
517
+ }
518
+
519
+ function markupNodeFromDom(node: ChildNode): MarkupNode {
520
+ if (node.nodeType === Node.ELEMENT_NODE) {
521
+ const element = node as Element;
522
+ return {
523
+ type: "element",
524
+ tagName: element.tagName.toLowerCase(),
525
+ attributes: Array.from(element.attributes).map((attribute) => [
526
+ attribute.name,
527
+ attribute.value,
528
+ ]),
529
+ children: Array.from(element.childNodes)
530
+ .map(markupNodeFromDom)
531
+ .filter(
532
+ (child) => child.type === "element" || child.text.trim().length > 0,
533
+ ),
534
+ };
535
+ }
536
+
537
+ return { type: "text", text: node.textContent ?? "" };
538
+ }
539
+
540
+ /** Group recent turn summaries into conversation rows. */
541
+ export function buildConversations(sessions: Session[]): Conversation[] {
542
+ const byId = new Map<string, Session[]>();
543
+ for (const session of sessions) {
544
+ const id = conversationIdForSession(session);
545
+ byId.set(id, [...(byId.get(id) ?? []), session]);
546
+ }
547
+
548
+ return [...byId.entries()]
549
+ .map(([id, turns]) => {
550
+ const sortedTurns = [...turns].sort((a, b) =>
551
+ compareTimeAsc(a.startedAt, b.startedAt),
552
+ );
553
+ const newest = [...turns].sort((a, b) =>
554
+ compareTimeDesc(
555
+ a.lastSeenAt ?? a.startedAt,
556
+ b.lastSeenAt ?? b.startedAt,
557
+ ),
558
+ )[0]!;
559
+ const oldest = sortedTurns.reduce((current, next) =>
560
+ (parseTime(next.startedAt) ?? Number.MAX_SAFE_INTEGER) <
561
+ (parseTime(current.startedAt) ?? Number.MAX_SAFE_INTEGER)
562
+ ? next
563
+ : current,
564
+ );
565
+ const status = sortedTurns.some(isHungSession)
566
+ ? "hung"
567
+ : sortedTurns.some(isActiveSession)
568
+ ? "active"
569
+ : sortedTurns.some(isFailedSession)
570
+ ? "failed"
571
+ : newest.status;
572
+ const requesterTurn =
573
+ sortedTurns.find((turn) => turn.requesterIdentity) ??
574
+ sortedTurns.find((turn) => turn.requester);
575
+
576
+ return {
577
+ channel: newest.channel,
578
+ channelName: sortedTurns.find((turn) => turn.channelName)?.channelName,
579
+ conversationTitle: sortedTurns.find((turn) => turn.conversationTitle)
580
+ ?.conversationTitle,
581
+ id,
582
+ lastSeenAt: newest.lastSeenAt,
583
+ requester: requesterLabel(
584
+ requesterTurn?.requesterIdentity,
585
+ requesterTurn?.requester,
586
+ ),
587
+ requesterIdentity: requesterTurn?.requesterIdentity,
588
+ sentryConversationUrl: newest.sentryConversationUrl,
589
+ sentryTraceUrl: newest.sentryTraceUrl,
590
+ startedAt: oldest.startedAt,
591
+ status,
592
+ surface: newest.surface,
593
+ title: newest.title || id,
594
+ traceId: newest.traceId,
595
+ turns: sortedTurns,
596
+ };
597
+ })
598
+ .sort((a, b) => compareTimeDesc(a.lastSeenAt, b.lastSeenAt));
599
+ }
600
+
601
+ /** Apply the dashboard conversation filter to grouped conversation rows. */
602
+ export function filterConversations(
603
+ conversations: Conversation[],
604
+ filter: SessionFilter,
605
+ ): Conversation[] {
606
+ if (filter === "all") return conversations;
607
+ if (filter === "active") return conversations.filter(isActiveConversation);
608
+ if (filter === "hung") return conversations.filter(isHungConversation);
609
+ if (filter === "failed") return conversations.filter(isFailedConversation);
610
+ return conversations;
611
+ }
612
+
613
+ /** Normalize URL filter params to the supported dashboard filter set. */
614
+ export function getFilter(value: string | null): SessionFilter {
615
+ return value === "active" ||
616
+ value === "hung" ||
617
+ value === "failed" ||
618
+ value === "all"
619
+ ? value
620
+ : "recent";
621
+ }
622
+
623
+ /** Serialize transcript part payloads for raw view and syntax highlighting. */
624
+ export function stringifyPartValue(value: unknown): string {
625
+ if (value == null || value === "") return "";
626
+ if (typeof value === "string") return prettyJsonData(value) ?? value;
627
+ return JSON.stringify(value, null, 2) ?? "";
628
+ }
@@ -0,0 +1,37 @@
1
+ import { CommandRail } from "../components/CommandRail";
2
+ import { ConversationStack } from "../components/ConversationStack";
3
+ import { Section } from "../components/Section";
4
+ import { SectionHeader } from "../components/SectionHeader";
5
+ import { SectionTitle } from "../components/SectionTitle";
6
+ import { TurnDurationChart } from "../components/TurnDurationChart";
7
+ import { buildConversations } from "../format";
8
+ import type { DashboardData } from "../types";
9
+
10
+ /** Render the dashboard home view with runtime pulse and recent conversations. */
11
+ export function CommandCenter(props: {
12
+ data?: DashboardData;
13
+ queryError: Error | null;
14
+ }) {
15
+ const sessions = props.data?.sessions.sessions ?? [];
16
+ const conversations = buildConversations(sessions);
17
+
18
+ return (
19
+ <div className="grid min-w-0 gap-4 px-4 py-4 md:px-8 lg:grid-cols-[minmax(21rem,0.32fr)_minmax(0,1fr)]">
20
+ <CommandRail data={props.data} error={props.queryError} />
21
+
22
+ <section className="min-w-0">
23
+ <TurnDurationChart
24
+ sessions={sessions}
25
+ timeZone={props.data?.config.timeZone ?? "America/Los_Angeles"}
26
+ />
27
+
28
+ <Section className="border-[#beaaff]/20">
29
+ <SectionHeader>
30
+ <SectionTitle>Latest Conversations</SectionTitle>
31
+ </SectionHeader>
32
+ <ConversationStack conversations={conversations.slice(0, 4)} />
33
+ </Section>
34
+ </section>
35
+ </div>
36
+ );
37
+ }