@sentry/junior-dashboard 0.61.0 → 0.63.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 (38) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.js +44 -37
  3. package/dist/assets.d.ts +2 -0
  4. package/dist/client/api.d.ts +1 -1
  5. package/dist/client/components/Metric.d.ts +22 -0
  6. package/dist/client/components/TelemetryMetrics.d.ts +24 -0
  7. package/dist/client/components/TranscriptText.d.ts +1 -0
  8. package/dist/client/components/transcriptRenderModel.d.ts +24 -8
  9. package/dist/client/format.d.ts +65 -13
  10. package/dist/client/toolInvocations.d.ts +7 -0
  11. package/dist/client/types.d.ts +17 -100
  12. package/dist/client.js +59 -55992
  13. package/dist/handler.js +44 -37
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.js +564 -0
  16. package/dist/nitro.d.ts +6 -1
  17. package/dist/tailwind.css +1 -1
  18. package/dist/url.d.ts +13 -0
  19. package/package.json +8 -5
  20. package/src/app.ts +24 -16
  21. package/src/assets.ts +2 -0
  22. package/src/auth.ts +2 -35
  23. package/src/client/components/ConversationRowStats.tsx +3 -2
  24. package/src/client/components/Metric.tsx +159 -0
  25. package/src/client/components/TelemetryMetrics.tsx +124 -0
  26. package/src/client/components/ToolFrame.tsx +3 -3
  27. package/src/client/components/TranscriptText.tsx +5 -2
  28. package/src/client/components/TranscriptToolView.tsx +9 -7
  29. package/src/client/components/TranscriptTurn.tsx +435 -84
  30. package/src/client/components/TurnDurationChart.tsx +229 -78
  31. package/src/client/components/transcriptRenderModel.ts +66 -22
  32. package/src/client/format.ts +369 -103
  33. package/src/client/pages/ConversationPage.tsx +77 -38
  34. package/src/client/toolInvocations.ts +16 -0
  35. package/src/client/types.ts +34 -90
  36. package/src/index.ts +74 -0
  37. package/src/nitro.ts +6 -1
  38. package/src/url.ts +68 -0
@@ -4,16 +4,24 @@ import { useConversationData } from "../api";
4
4
  import { StatusBadge } from "../components/StatusBadge";
5
5
  import {
6
6
  buildConversations,
7
+ conversationIdentityMeta,
7
8
  conversationDisplayTitle,
8
9
  formatConversationDuration,
9
10
  formatRelativeTime,
10
11
  formatTime,
11
- formatUsageTotal,
12
12
  slackLocationLabel,
13
- turnMessageCount,
14
- turnToolCallCount,
13
+ summarizeMessages,
14
+ summarizeToolCalls,
15
+ summarizeUsage,
15
16
  visualStatusForConversation,
16
17
  } from "../format";
18
+ import { MetricList, type MetricListItem } from "../components/Metric";
19
+ import {
20
+ DurationMetric,
21
+ MessagesMetric,
22
+ TokenMetric,
23
+ ToolCallsMetric,
24
+ } from "../components/TelemetryMetrics";
17
25
  import { Transcript } from "../components/Transcript";
18
26
  import { TranscriptLoading } from "../components/TranscriptLoading";
19
27
  import type {
@@ -81,15 +89,9 @@ function ConversationIdentity(props: {
81
89
  conversation: Conversation | undefined;
82
90
  conversationId: string | undefined;
83
91
  }) {
84
- const id = props.conversationId ?? "missing conversation id";
85
- const owner =
86
- props.conversation?.requesterIdentity?.email ??
87
- props.conversation?.requester ??
88
- props.conversation?.requesterIdentity?.slackUserName;
89
92
  return (
90
93
  <>
91
- {owner ? `${owner} · ` : ""}
92
- {id}
94
+ {conversationIdentityMeta(props.conversation, props.conversationId)}
93
95
  {props.conversation?.sentryConversationUrl ? (
94
96
  <>
95
97
  {" · "}
@@ -112,41 +114,78 @@ function ConversationStats(props: {
112
114
  detail?: ConversationDetailFeed;
113
115
  }) {
114
116
  if (!props.conversation) return null;
115
- const messages = props.detail
116
- ? props.detail.turns.reduce(
117
- (count, turn) => count + turnMessageCount(turn),
118
- 0,
119
- )
117
+ const messageSummary = props.detail
118
+ ? summarizeMessages(props.detail.turns)
120
119
  : undefined;
121
- const toolCalls = props.detail
122
- ? props.detail.turns.reduce(
123
- (count, turn) => count + turnToolCallCount(turn),
124
- 0,
125
- )
120
+ const toolSummary = props.detail
121
+ ? summarizeToolCalls(props.detail.turns)
126
122
  : undefined;
127
- const tokens = formatUsageTotal(
123
+ const tokenSummary = summarizeUsage(
128
124
  (props.detail?.turns ?? props.conversation.turns).map(
129
125
  (turn) => turn.cumulativeUsage,
130
126
  ),
131
127
  );
132
- const stats = [
133
- slackLocationLabel(props.conversation, { includeId: false }),
134
- `${props.conversation.turns.length} turns`,
135
- messages === undefined ? "messages loading" : `${messages} messages`,
136
- toolCalls === undefined ? "tool calls loading" : `${toolCalls} tool calls`,
137
- tokens,
138
- formatConversationDuration(props.conversation),
139
- `started ${formatTime(props.conversation.startedAt)}`,
140
- ].filter(Boolean);
128
+ const location = slackLocationLabel(props.conversation, {
129
+ includeId: false,
130
+ });
131
+ const durationLabel = formatConversationDuration(props.conversation);
132
+ const turnCount = props.conversation.turns.length;
133
+ const rawStats: Array<MetricListItem | undefined> = [
134
+ location
135
+ ? {
136
+ content: location,
137
+ key: "location",
138
+ }
139
+ : undefined,
140
+ {
141
+ content: `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`,
142
+ key: "turns",
143
+ },
144
+ {
145
+ content: (
146
+ <MessagesMetric loading={!props.detail} summary={messageSummary} />
147
+ ),
148
+ key: "messages",
149
+ },
150
+ !props.detail || (toolSummary && toolSummary.total > 0)
151
+ ? {
152
+ content: (
153
+ <ToolCallsMetric loading={!props.detail} summary={toolSummary} />
154
+ ),
155
+ key: "tools",
156
+ }
157
+ : undefined,
158
+ tokenSummary
159
+ ? {
160
+ content: <TokenMetric summary={tokenSummary} />,
161
+ key: "tokens",
162
+ }
163
+ : undefined,
164
+ durationLabel !== "none"
165
+ ? {
166
+ content: (
167
+ <DurationMetric
168
+ endedAt={props.conversation.lastSeenAt}
169
+ label={durationLabel}
170
+ startedAt={props.conversation.startedAt}
171
+ />
172
+ ),
173
+ key: "duration",
174
+ }
175
+ : undefined,
176
+ {
177
+ content: `started ${formatTime(props.conversation.startedAt)}`,
178
+ key: "started",
179
+ },
180
+ ];
181
+ const stats = rawStats.filter(
182
+ (item): item is MetricListItem => item !== undefined,
183
+ );
141
184
 
142
185
  return (
143
- <div className="col-span-full flex flex-wrap gap-x-3 gap-y-1 break-words text-[0.76rem] leading-[1.45] text-[#888]">
144
- {stats.map((value, index) => (
145
- <span key={`${index}-${value}`}>
146
- {index > 0 ? <span className="mr-3 text-[#666]">·</span> : null}
147
- {value}
148
- </span>
149
- ))}
150
- </div>
186
+ <MetricList
187
+ className="col-span-full break-words text-[0.76rem] leading-[1.45] text-[#888]"
188
+ items={stats}
189
+ />
151
190
  );
152
191
  }
@@ -0,0 +1,16 @@
1
+ type ToolInvocationRef = {
2
+ id?: string;
3
+ name?: string;
4
+ };
5
+
6
+ /** Match tool call/result refs without inferring relationships from missing metadata. */
7
+ export function sameToolInvocation(
8
+ left: ToolInvocationRef,
9
+ right: ToolInvocationRef,
10
+ ): boolean {
11
+ if (left.id || right.id) {
12
+ return Boolean(left.id && right.id && left.id === right.id);
13
+ }
14
+ if (left.name && right.name) return left.name === right.name;
15
+ return false;
16
+ }
@@ -1,119 +1,59 @@
1
1
  import type { BundledLanguage } from "shiki/bundle/web";
2
+ import type {
3
+ DashboardConversationReport,
4
+ DashboardRequesterIdentity,
5
+ DashboardSessionFeed,
6
+ DashboardSessionReport,
7
+ DashboardTurnReport,
8
+ DashboardTurnUsage,
9
+ HealthReport,
10
+ PluginReport,
11
+ RuntimeInfoReport,
12
+ SkillReport,
13
+ } from "@sentry/junior/reporting";
2
14
 
3
- export type Health = { service: string; status: string; timestamp: string };
15
+ export type Health = HealthReport;
4
16
 
5
- export type Runtime = {
6
- cwd: string;
7
- descriptionText?: string;
8
- homeDir: string;
9
- packagedContent: { packageNames: string[] };
10
- };
17
+ export type Runtime = RuntimeInfoReport;
11
18
 
12
- export type Plugin = { name: string };
19
+ export type Plugin = PluginReport;
13
20
 
14
- export type Skill = { name: string; pluginProvider?: string };
21
+ export type Skill = SkillReport;
15
22
 
16
- export type RequesterIdentity = {
17
- email?: string;
18
- fullName?: string;
19
- slackUserId?: string;
20
- slackUserName?: string;
21
- };
23
+ export type RequesterIdentity = DashboardRequesterIdentity;
22
24
 
23
- export type TurnUsage = {
24
- cachedInputTokens?: number;
25
- cacheCreationTokens?: number;
26
- inputTokens?: number;
27
- outputTokens?: number;
28
- totalTokens?: number;
29
- };
25
+ export type TurnUsage = DashboardTurnUsage;
30
26
 
31
- export type Session = {
32
- channel?: string;
33
- channelName?: string;
34
- conversationId?: string;
35
- conversationTitle?: string;
36
- cumulativeDurationMs?: number;
37
- cumulativeUsage?: TurnUsage;
38
- id: string;
39
- lastProgressAt?: string;
40
- lastSeenAt?: string;
41
- requester?: string;
42
- requesterIdentity?: RequesterIdentity;
43
- sentryConversationUrl?: string;
44
- sentryTraceUrl?: string;
45
- startedAt?: string;
46
- status: string;
47
- surface?: string;
48
- title?: string;
49
- traceId?: string;
50
- };
27
+ export type Session = DashboardSessionReport;
51
28
 
52
- export type TranscriptPart = {
53
- bytes?: number;
54
- chars?: number;
55
- id?: string;
56
- input?: unknown;
57
- inputKeys?: string[];
58
- inputSizeBytes?: number;
59
- inputSizeChars?: number;
60
- inputType?: string;
61
- name?: string;
62
- output?: unknown;
63
- outputKeys?: string[];
64
- outputSizeBytes?: number;
65
- outputSizeChars?: number;
66
- outputType?: string;
67
- redacted?: boolean;
68
- text?: string;
69
- type: string;
70
- };
29
+ export type TranscriptPart =
30
+ DashboardTurnReport["transcript"][number]["parts"][number];
71
31
 
72
- export type TranscriptMessage = {
73
- parts: TranscriptPart[];
74
- role: string;
75
- timestamp?: number;
76
- };
32
+ export type TranscriptMessage = DashboardTurnReport["transcript"][number];
77
33
 
78
- export type ConversationTurn = Session & {
79
- transcript: TranscriptMessage[];
80
- transcriptAvailable: boolean;
81
- transcriptMetadata?: TranscriptMessage[];
82
- transcriptMessageCount?: number;
83
- transcriptRedacted?: boolean;
84
- transcriptRedactionReason?: "non_public_conversation";
85
- };
34
+ export type ConversationTurn = DashboardTurnReport;
86
35
 
87
- export type ConversationDetailFeed = {
88
- conversationId: string;
89
- generatedAt: string;
90
- turns: ConversationTurn[];
91
- };
36
+ export type ConversationDetailFeed = DashboardConversationReport;
92
37
 
93
38
  export type Conversation = {
94
39
  channel?: string;
95
40
  channelName?: string;
96
41
  conversationTitle?: string;
97
42
  id: string;
98
- lastProgressAt?: string;
99
- lastSeenAt?: string;
100
- requester?: string;
43
+ lastProgressAt: string;
44
+ lastSeenAt: string;
101
45
  requesterIdentity?: RequesterIdentity;
102
46
  sentryConversationUrl?: string;
103
47
  sentryTraceUrl?: string;
104
- startedAt?: string;
48
+ startedAt: string;
105
49
  status: Session["status"];
106
- surface?: string;
50
+ surface: Session["surface"];
107
51
  title: string;
108
52
  traceId?: string;
109
53
  turns: Session[];
110
54
  };
111
55
 
112
- export type SessionFeed = {
113
- generatedAt?: string;
114
- sessions: Session[];
115
- source: string;
116
- };
56
+ export type SessionFeed = DashboardSessionFeed;
117
57
 
118
58
  export type Identity = { user: { email?: string; hostedDomain?: string } };
119
59
 
@@ -141,7 +81,11 @@ export type SessionFilter = "active" | "recent" | "hung" | "failed" | "all";
141
81
 
142
82
  export type VisualStatus = "active" | "failed" | "hung" | "idle";
143
83
 
144
- export type CodeBlock = { code: string; fenced?: boolean; language: BundledLanguage };
84
+ export type CodeBlock = {
85
+ code: string;
86
+ fenced?: boolean;
87
+ language: BundledLanguage;
88
+ };
145
89
 
146
90
  export type MarkupNode =
147
91
  | {
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ import {
2
+ type AgentPluginRoute,
3
+ defineJuniorPlugin,
4
+ type JuniorPlugin,
5
+ } from "@sentry/junior-plugin-api";
6
+ import { buildDashboardConversationURL, normalizeDashboardPath } from "./url";
7
+ import { createDashboardApp, type JuniorDashboardOptions } from "./app";
8
+
9
+ export { createDashboardApp, type JuniorDashboardOptions } from "./app";
10
+
11
+ export interface JuniorDashboardPluginOptions extends JuniorDashboardOptions {
12
+ disabled?: boolean;
13
+ }
14
+
15
+ function dashboardRoutePaths(options: JuniorDashboardPluginOptions): string[] {
16
+ const basePath = normalizeDashboardPath(options.basePath, "/");
17
+ const authPath = normalizeDashboardPath(options.authPath, "/api/auth");
18
+ const pagePaths =
19
+ basePath === "/"
20
+ ? ["/", "/conversations", "/conversations/*", "/sessions", "/sessions/*"]
21
+ : [basePath, `${basePath}/*`];
22
+
23
+ return [
24
+ ...pagePaths,
25
+ "/favicon.ico",
26
+ "/api/dashboard/*",
27
+ authPath,
28
+ `${authPath}/*`,
29
+ ];
30
+ }
31
+
32
+ function dashboardRoutes(
33
+ options: JuniorDashboardPluginOptions,
34
+ ): AgentPluginRoute[] {
35
+ let app: ReturnType<typeof createDashboardApp> | undefined;
36
+ const fetch = (request: Request) => {
37
+ app ??= createDashboardApp(options);
38
+ return app.fetch(request);
39
+ };
40
+
41
+ return dashboardRoutePaths(options).map((path) => ({
42
+ handler: fetch,
43
+ path,
44
+ }));
45
+ }
46
+
47
+ /** Register dashboard routes and Slack footer links through trusted plugin hooks. */
48
+ export function juniorDashboardPlugin(
49
+ options: JuniorDashboardPluginOptions = {},
50
+ ): JuniorPlugin {
51
+ return defineJuniorPlugin({
52
+ name: "dashboard",
53
+ hooks: {
54
+ routes() {
55
+ if (options.disabled) {
56
+ return [];
57
+ }
58
+ return dashboardRoutes(options);
59
+ },
60
+ slackConversationLink(ctx) {
61
+ if (options.disabled) {
62
+ return undefined;
63
+ }
64
+ return {
65
+ url: buildDashboardConversationURL({
66
+ basePath: options.basePath,
67
+ baseURL: options.baseURL,
68
+ conversationId: ctx.conversationId,
69
+ }),
70
+ };
71
+ },
72
+ },
73
+ });
74
+ }
package/src/nitro.ts CHANGED
@@ -104,7 +104,12 @@ function dashboardPageRoutes(
104
104
  };
105
105
  }
106
106
 
107
- /** Mount the authenticated Junior dashboard into a Nitro deployment. */
107
+ /**
108
+ * Mount the authenticated Junior dashboard into a Nitro deployment.
109
+ *
110
+ * @deprecated Register `juniorDashboardPlugin()` in `createApp({ plugins })`;
111
+ * this helper remains for existing Nitro apps.
112
+ */
108
113
  export function juniorDashboardNitro(options: JuniorDashboardNitroOptions): {
109
114
  nitro: { setup(nitro: unknown): void };
110
115
  } {
package/src/url.ts ADDED
@@ -0,0 +1,68 @@
1
+ export interface DashboardBaseURLConfig {
2
+ baseURL?: string;
3
+ }
4
+
5
+ export interface DashboardConversationURLConfig extends DashboardBaseURLConfig {
6
+ basePath?: string;
7
+ conversationId: string;
8
+ }
9
+
10
+ function withHttps(host: string): string {
11
+ return /^https?:\/\//.test(host) ? host : `https://${host}`;
12
+ }
13
+
14
+ function stripTrailingSlashes(value: string): string {
15
+ let end = value.length;
16
+ while (end > 1 && value.charCodeAt(end - 1) === 47) {
17
+ end -= 1;
18
+ }
19
+ return end === value.length ? value : value.slice(0, end);
20
+ }
21
+
22
+ /** Normalize dashboard route prefixes so plugin and Nitro paths agree. */
23
+ export function normalizeDashboardPath(
24
+ path: string | undefined,
25
+ fallback: string,
26
+ ): string {
27
+ const value = path?.trim() || fallback;
28
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
29
+ return stripTrailingSlashes(withSlash);
30
+ }
31
+
32
+ /** Resolve the dashboard origin used for browser auth and external links. */
33
+ export function resolveDashboardBaseURL(
34
+ config: DashboardBaseURLConfig = {},
35
+ ): string {
36
+ const explicit =
37
+ config.baseURL ??
38
+ process.env.BETTER_AUTH_URL ??
39
+ process.env.JUNIOR_BASE_URL;
40
+ if (explicit?.trim()) {
41
+ return stripTrailingSlashes(withHttps(explicit.trim()));
42
+ }
43
+
44
+ const vercelProd = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
45
+ if (vercelProd) {
46
+ return stripTrailingSlashes(withHttps(vercelProd));
47
+ }
48
+
49
+ const vercelUrl = process.env.VERCEL_URL?.trim();
50
+ if (vercelUrl) {
51
+ return stripTrailingSlashes(withHttps(vercelUrl));
52
+ }
53
+
54
+ return "http://localhost:3000";
55
+ }
56
+
57
+ /** Build the dashboard conversation URL shown from outside the browser app. */
58
+ export function buildDashboardConversationURL(
59
+ config: DashboardConversationURLConfig,
60
+ ): string {
61
+ const baseURL = resolveDashboardBaseURL({ baseURL: config.baseURL });
62
+ const basePath = normalizeDashboardPath(config.basePath, "/");
63
+ const path =
64
+ basePath === "/"
65
+ ? `/conversations/${encodeURIComponent(config.conversationId)}`
66
+ : `${basePath}/conversations/${encodeURIComponent(config.conversationId)}`;
67
+ return `${baseURL}${path}`;
68
+ }