@sentry/junior-dashboard 0.234.0 → 0.236.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 (53) hide show
  1. package/dist/app.js +100 -18
  2. package/dist/client.js +47 -46
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +100 -18
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +3 -3
  7. package/src/app.ts +7 -1
  8. package/src/assets/junior-avatar.png +0 -0
  9. package/src/assets/junior-favicon.png +0 -0
  10. package/src/assets.ts +1 -0
  11. package/src/client/App.tsx +35 -0
  12. package/src/client/api.ts +63 -0
  13. package/src/client/components/README.md +8 -4
  14. package/src/client/components/SegmentedNav.tsx +167 -0
  15. package/src/client/components/SegmentedTabs.tsx +24 -5
  16. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  17. package/src/client/conversations/ConversationEventLog.tsx +45 -5
  18. package/src/client/conversations/ConversationForkLinks.tsx +51 -0
  19. package/src/client/conversations/ConversationHeader.tsx +12 -1
  20. package/src/client/conversations/ConversationPage.tsx +72 -2
  21. package/src/client/conversations/ForkConversationButton.tsx +125 -0
  22. package/src/client/conversations/TranscriptMessageView.tsx +28 -7
  23. package/src/client/conversations/TranscriptView.tsx +4 -0
  24. package/src/client/conversations/conversationRoutes.ts +10 -0
  25. package/src/client/conversations/queries.ts +45 -0
  26. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +28 -27
  27. package/src/client/pages/automations/AutomationEditPage.tsx +23 -24
  28. package/src/client/pages/automations/AutomationEditor.tsx +66 -13
  29. package/src/client/pages/automations/AutomationFilters.tsx +22 -14
  30. package/src/client/pages/automations/AutomationLifecycle.tsx +6 -4
  31. package/src/client/pages/automations/AutomationOutcomeFields.tsx +18 -0
  32. package/src/client/pages/automations/AutomationRow.tsx +172 -187
  33. package/src/client/pages/automations/AutomationVersionsPage.tsx +356 -0
  34. package/src/client/pages/automations/AutomationsPage.tsx +40 -42
  35. package/src/client/pages/automations/AutomationsPageLayout.tsx +6 -5
  36. package/src/client/pages/automations/README.md +14 -3
  37. package/src/client/pages/automations/automationFormat.ts +43 -0
  38. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  39. package/src/client/pages/code/CodePage.tsx +18 -93
  40. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  41. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  42. package/src/client/pages/code/RepositorySections.tsx +108 -0
  43. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  44. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  45. package/src/client/pages/dev/ComponentsPage.tsx +5 -22
  46. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  47. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  48. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  49. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  50. package/src/mock-reporting/code-repository.ts +77 -0
  51. package/src/mock-reporting/fixtures.ts +8 -2
  52. package/src/mock-reporting/routes.ts +25 -8
  53. package/src/shell.ts +21 -5
@@ -0,0 +1,18 @@
1
+ /** Repository page tabs. Overview is the bare repository path. */
2
+ export type CodeRepositoryTab = "overview" | "conversations" | "changes";
3
+
4
+ /** Tab order in the repository page navigation. */
5
+ export const codeRepositoryTabs: readonly CodeRepositoryTab[] = [
6
+ "overview",
7
+ "conversations",
8
+ "changes",
9
+ ];
10
+
11
+ /** Build the dashboard path for one repository page tab. */
12
+ export function codeRepositoryPath(
13
+ repositoryId: string,
14
+ tab: CodeRepositoryTab = "overview",
15
+ ): string {
16
+ const base = `/code/${encodeURIComponent(repositoryId)}`;
17
+ return tab === "overview" ? base : `${base}/${tab}`;
18
+ }
@@ -2,6 +2,7 @@ import { FormNotice } from "../../components/FormNotice";
2
2
  import { Select } from "../../components/Select";
3
3
  import { ObjectFixtures } from "./ObjectFixtures";
4
4
  import { ChartFixtures } from "./ChartFixtures";
5
+ import { SegmentedNavFixture, SegmentedTabsFixture } from "./SegmentedFixtures";
5
6
  import {
6
7
  ConversationFixture,
7
8
  MessageAttachmentsFixture,
@@ -40,7 +41,6 @@ import { ParticipantAvatarStack } from "../../components/ParticipantAvatarStack"
40
41
  import { StatCard } from "../../components/metrics/StatCard";
41
42
  import { StatusChip } from "../../components/StatusChip";
42
43
  import { StatusDot } from "../../components/StatusDot";
43
- import { SegmentedTabs } from "../../components/SegmentedTabs";
44
44
  import { TextArea, TextInput } from "../../components/TextInput";
45
45
  import { TranscriptMarkdown } from "../../conversations/TranscriptMarkdown";
46
46
  import { TranscriptText } from "../../conversations/TranscriptText";
@@ -303,7 +303,6 @@ function FoundationsGalleryPage() {
303
303
  const [navigationOpen, setNavigationOpen] = useState(false);
304
304
  const [drawerOpen, setDrawerOpen] = useState(false);
305
305
  const [nestedDrawerOpen, setNestedDrawerOpen] = useState(false);
306
- const [tab, setTab] = useState<"details" | "memories" | "usage">("details");
307
306
 
308
307
  return (
309
308
  <GalleryShell
@@ -354,26 +353,10 @@ function FoundationsGalleryPage() {
354
353
  </DashboardChromeProvider>
355
354
  </Fixture>
356
355
  <Fixture title="Segmented tabs">
357
- <div className="max-w-md">
358
- <SegmentedTabs
359
- items={[
360
- { label: "Details", value: "details" },
361
- { label: "Memories", value: "memories" },
362
- { label: "Usage", value: "usage" },
363
- ]}
364
- label="Conversation panels"
365
- onChange={setTab}
366
- value={tab}
367
- >
368
- <p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
369
- {tab === "details"
370
- ? "Conversation summary and linked work."
371
- : tab === "memories"
372
- ? "What Junior learned from this conversation."
373
- : "Time, tokens, and cost for this conversation."}
374
- </p>
375
- </SegmentedTabs>
376
- </div>
356
+ <SegmentedTabsFixture />
357
+ </Fixture>
358
+ <Fixture title="Segmented page links">
359
+ <SegmentedNavFixture />
377
360
  </Fixture>
378
361
  <Fixture title="Narrow details drawer">
379
362
  <Button onClick={() => setDrawerOpen(true)}>Open details</Button>
@@ -0,0 +1,58 @@
1
+ import { useState } from "react";
2
+
3
+ import { SegmentedNav } from "../../components/SegmentedNav";
4
+ import { SegmentedTabs } from "../../components/SegmentedTabs";
5
+
6
+ /** Show panel tabs switching the panel below them. */
7
+ export function SegmentedTabsFixture() {
8
+ const [tab, setTab] = useState<"details" | "memories" | "usage">("details");
9
+ return (
10
+ <div className="max-w-md">
11
+ <SegmentedTabs
12
+ items={[
13
+ { label: "Details", value: "details" },
14
+ { label: "Memories", value: "memories" },
15
+ { label: "Usage", value: "usage" },
16
+ ]}
17
+ label="Conversation panels"
18
+ onChange={setTab}
19
+ value={tab}
20
+ >
21
+ <p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
22
+ {tab === "details"
23
+ ? "Conversation summary and linked work."
24
+ : tab === "memories"
25
+ ? "What Junior learned from this conversation."
26
+ : "Time, tokens, and cost for this conversation."}
27
+ </p>
28
+ </SegmentedTabs>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ const PAGE_LINKS = [
34
+ "Overview",
35
+ "People",
36
+ "Locations",
37
+ "Workspaces",
38
+ "Plugins",
39
+ ].map((label) => ({
40
+ isActive: () => label === "Overview",
41
+ label,
42
+ to: `#${label.toLowerCase()}`,
43
+ }));
44
+
45
+ /** Show page links at label width, filling a row, and overflowing a row. */
46
+ export function SegmentedNavFixture() {
47
+ return (
48
+ <div className="grid gap-4">
49
+ <SegmentedNav ariaLabel="Label width" items={PAGE_LINKS} />
50
+ <div className="max-w-md">
51
+ <SegmentedNav ariaLabel="Fill" fill items={PAGE_LINKS.slice(0, 2)} />
52
+ </div>
53
+ <div className="max-w-xs">
54
+ <SegmentedNav ariaLabel="Overflow" fill items={PAGE_LINKS} />
55
+ </div>
56
+ </div>
57
+ );
58
+ }
@@ -24,20 +24,19 @@ function isMemoriesLibraryPath(pathname: string): boolean {
24
24
  /** Place the shared secondary navigation above one Memories page. */
25
25
  export function MemoryPageLayout(props: { children: ReactNode }) {
26
26
  return (
27
- <>
28
- <SecondaryNavigation
29
- ariaLabel="Memory navigation"
30
- items={[
31
- { end: true, label: "Overview", to: MEMORY_BASE_PATH },
32
- {
33
- isActive: isMemoriesLibraryPath,
34
- label: "Memories",
35
- to: MEMORY_LIBRARY_PATH,
36
- },
37
- ]}
38
- />
27
+ <SecondaryNavigation
28
+ ariaLabel="Memory navigation"
29
+ items={[
30
+ { end: true, label: "Overview", to: MEMORY_BASE_PATH },
31
+ {
32
+ isActive: isMemoriesLibraryPath,
33
+ label: "Memories",
34
+ to: MEMORY_LIBRARY_PATH,
35
+ },
36
+ ]}
37
+ >
39
38
  <PageLayout className="gap-6 sm:gap-8">{props.children}</PageLayout>
40
- </>
39
+ </SecondaryNavigation>
41
40
  );
42
41
  }
43
42
 
@@ -1,3 +1,5 @@
1
+ import type { ReactNode } from "react";
2
+
1
3
  import { SecondaryNavigation } from "../../components/layout/SecondaryNavigation";
2
4
  import { systemPluginsPath } from "./SystemPlugins";
3
5
 
@@ -10,11 +12,13 @@ const systemNavigationItems = [
10
12
  ];
11
13
 
12
14
  /** Render the stable secondary navigation shared by System pages. */
13
- export function SystemNavigation() {
15
+ export function SystemNavigation(props: { children: ReactNode }) {
14
16
  return (
15
17
  <SecondaryNavigation
16
18
  ariaLabel="System navigation"
17
19
  items={systemNavigationItems}
18
- />
20
+ >
21
+ {props.children}
22
+ </SecondaryNavigation>
19
23
  );
20
24
  }
@@ -8,10 +8,9 @@ import { SystemNavigation } from "./SystemNavigation";
8
8
  /** Place the shared secondary navigation above one System page. */
9
9
  export function SystemPageLayout(props: { children: ReactNode }) {
10
10
  return (
11
- <>
12
- <SystemNavigation />
11
+ <SystemNavigation>
13
12
  <PageLayout>{props.children}</PageLayout>
14
- </>
13
+ </SystemNavigation>
15
14
  );
16
15
  }
17
16
 
@@ -23,9 +22,8 @@ export function SystemRouteLoading(props: {
23
22
  variant?: PageContentSkeletonVariant;
24
23
  }) {
25
24
  return (
26
- <>
27
- <SystemNavigation />
25
+ <SystemNavigation>
28
26
  <PageRouteLoading {...props} />
29
- </>
27
+ </SystemNavigation>
30
28
  );
31
29
  }
@@ -0,0 +1,77 @@
1
+ import type {
2
+ CodeRepositoryReport,
3
+ ConversationFeed,
4
+ } from "@sentry/junior/api/schema";
5
+
6
+ import { readMockCodeOverview } from "./fixtures";
7
+
8
+ const MOCK_WORKSPACES: Record<string, CodeRepositoryReport["workspaces"]> = {
9
+ "getsentry/junior": [{ id: "workspace-junior", name: "junior" }],
10
+ };
11
+
12
+ /** Build one mock repository report from the mock Code overview. */
13
+ export function readMockCodeRepository(
14
+ repositoryId: string,
15
+ ): CodeRepositoryReport | undefined {
16
+ const overview = readMockCodeOverview();
17
+ const repository = overview.repositories.find(
18
+ (entry) => entry.id === repositoryId,
19
+ );
20
+ if (!repository) return undefined;
21
+ return {
22
+ activityDays: overview.activityDays,
23
+ changes: overview.changes.filter(
24
+ (change) => change.repository === repository.name,
25
+ ),
26
+ generatedAt: overview.generatedAt,
27
+ repository: {
28
+ id: repository.id,
29
+ name: repository.name,
30
+ provider: repository.provider,
31
+ ...(repository.url ? { url: repository.url } : undefined),
32
+ },
33
+ summary: {
34
+ closed: repository.closed,
35
+ created: repository.created,
36
+ merged: repository.merged,
37
+ open: repository.open,
38
+ ...(repository.mergeRate === undefined
39
+ ? undefined
40
+ : { mergeRate: repository.mergeRate }),
41
+ ...(repository.medianCostUsd === undefined
42
+ ? undefined
43
+ : {
44
+ // Mock rows have no total cost; estimate it from the median.
45
+ costUsd: repository.medianCostUsd * repository.created,
46
+ medianCostUsd: repository.medianCostUsd,
47
+ }),
48
+ ...(overview.summary.medianMergeTimeMs === undefined
49
+ ? undefined
50
+ : { medianMergeTimeMs: overview.summary.medianMergeTimeMs }),
51
+ },
52
+ windowEnd: overview.windowEnd,
53
+ windowStart: overview.windowStart,
54
+ workspaces: MOCK_WORKSPACES[repository.name] ?? [],
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Keep mock conversations whose code change annotations name the repository.
60
+ * The real API links conversations through recorded code changes.
61
+ */
62
+ export function filterMockConversationsByRepository(
63
+ feed: ConversationFeed,
64
+ repositoryId: string,
65
+ ): ConversationFeed {
66
+ const name = readMockCodeRepository(repositoryId)?.repository.name;
67
+ return {
68
+ ...feed,
69
+ conversations: name
70
+ ? feed.conversations.filter((conversation) =>
71
+ conversation.sidebarAnnotations?.some((annotation) =>
72
+ annotation.key.startsWith(`${name}#`),
73
+ ),
74
+ )
75
+ : [],
76
+ };
77
+ }
@@ -1825,8 +1825,14 @@ export function readMockConversationDetail(
1825
1825
  (candidate) => candidate.conversationId === conversationId,
1826
1826
  );
1827
1827
  if (!conversation) return undefined;
1828
- const { parentConversationId: _parentConversationId, ...detail } =
1829
- conversation;
1828
+ const { parentConversationId, ...conversationDetail } = conversation;
1829
+ // Match the API: only readable root Conversations can be forked.
1830
+ const detail = {
1831
+ ...conversationDetail,
1832
+ canFork:
1833
+ !parentConversationId &&
1834
+ conversationDetail.eventHistory.status !== "redacted",
1835
+ };
1830
1836
  const withArchiveState = mockArchivedConversationIds.has(conversationId)
1831
1837
  ? {
1832
1838
  ...detail,
@@ -18,6 +18,8 @@ import {
18
18
  conversationStatsReportSchema,
19
19
  codeOverviewReportSchema,
20
20
  codePersonReportSchema,
21
+ codeRepositoryParamsSchema,
22
+ codeRepositoryReportSchema,
21
23
  locationDetailReportSchema,
22
24
  locationDirectoryReportSchema,
23
25
  locationParamsSchema,
@@ -31,6 +33,10 @@ import {
31
33
  automationRunListSchema,
32
34
  } from "@sentry/junior/api/schema";
33
35
  import { mockChartPng } from "./chart-png";
36
+ import {
37
+ filterMockConversationsByRepository,
38
+ readMockCodeRepository,
39
+ } from "./code-repository";
34
40
  import {
35
41
  readMockConversationDetail,
36
42
  readMockConversationEvents,
@@ -64,6 +70,16 @@ export function createMockReportingApi(): Hono<{
64
70
  app.get("/code", () =>
65
71
  jsonResponse(codeOverviewReportSchema, readMockCodeOverview()),
66
72
  );
73
+ app.get("/code/repositories/:repositoryId", (c) => {
74
+ const params = codeRepositoryParamsSchema.safeParse(c.req.param());
75
+ if (!params.success) {
76
+ return errorResponse("Invalid route parameters.", 400);
77
+ }
78
+ const report = readMockCodeRepository(params.data.repositoryId);
79
+ return report
80
+ ? jsonResponse(codeRepositoryReportSchema, report)
81
+ : errorResponse("Repository not found.", 404);
82
+ });
67
83
  app.get("/plugin-reports", () =>
68
84
  jsonResponse(pluginOperationalReportFeedSchema, readMockPluginReports()),
69
85
  );
@@ -128,17 +144,18 @@ export function createMockReportingApi(): Hono<{
128
144
  if (!query.success) {
129
145
  return errorResponse("Invalid query parameters.", 400);
130
146
  }
131
- const report = readMockConversationFeed(
132
- query.data.actorEmail,
133
- query.data.status,
134
- );
147
+ const repositoryId = query.data.codeRepositoryId;
148
+ const readFeed = (status: "active" | "archived") => {
149
+ const feed = readMockConversationFeed(query.data.actorEmail, status);
150
+ return repositoryId
151
+ ? filterMockConversationsByRepository(feed, repositoryId)
152
+ : feed;
153
+ };
154
+ const report = readFeed(query.data.status);
135
155
  if (!query.data.q) {
136
156
  return jsonResponse(conversationFeedSchema, report);
137
157
  }
138
- const archived = readMockConversationFeed(
139
- query.data.actorEmail,
140
- "archived",
141
- );
158
+ const archived = readFeed("archived");
142
159
  const search = query.data.q.toLowerCase();
143
160
  const conversations = new Map(
144
161
  [...report.conversations, ...archived.conversations].map(
package/src/shell.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import {
4
4
  dashboardAvatarHeaderAsset,
5
+ dashboardFaviconAsset,
5
6
  dashboardClientAsset,
6
7
  dashboardClientChunks,
7
8
  dashboardInstallIconAsset,
@@ -120,6 +121,7 @@ export function dashboardPagePaths(
120
121
  const paths: Array<{ nested?: boolean; path: string }> = [
121
122
  { path: basePath },
122
123
  {
124
+ nested: true,
123
125
  path: basePath === "/" ? "/code" : `${basePath}/code`,
124
126
  },
125
127
  {
@@ -298,12 +300,26 @@ export function renderDashboard(basePath: string, agentName: string): Response {
298
300
  );
299
301
  }
300
302
 
301
- /** Serve the dashboard favicon. */
303
+ function readDashboardFavicon(): ArrayBuffer {
304
+ if (dashboardFaviconAsset) {
305
+ return Uint8Array.from(Buffer.from(dashboardFaviconAsset, "base64")).buffer;
306
+ }
307
+
308
+ const assetUrl = new URL("./assets/junior-favicon.png", import.meta.url);
309
+ if (!existsSync(assetUrl)) {
310
+ throw new Error("Junior dashboard favicon was not found");
311
+ }
312
+ return Uint8Array.from(readFileSync(assetUrl)).buffer;
313
+ }
314
+
315
+ /** Serve the head-only dashboard favicon, which stays readable at 16px. */
302
316
  export function renderFavicon(): Response {
303
- return new Response(
304
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#000000"/><text x="16" y="20.5" fill="#ffffff" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="900" text-anchor="middle">Jr</text></svg>`,
305
- { headers: { "content-type": "image/svg+xml" } },
306
- );
317
+ return new Response(readDashboardFavicon(), {
318
+ headers: {
319
+ "cache-control": "public, max-age=0, must-revalidate",
320
+ "content-type": "image/png",
321
+ },
322
+ });
307
323
  }
308
324
 
309
325
  /** Render a browser-readable access denied page for blocked dashboard users. */