@sentry/junior-dashboard 0.233.0 → 0.235.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 (59) hide show
  1. package/dist/app.js +178 -29
  2. package/dist/client.js +53 -50
  3. package/dist/index.js +178 -29
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/assets/junior-avatar.png +0 -0
  7. package/src/assets/junior-favicon.png +0 -0
  8. package/src/assets.ts +1 -0
  9. package/src/client/App.tsx +372 -345
  10. package/src/client/api.ts +60 -1
  11. package/src/client/components/ActorAvatar.tsx +37 -0
  12. package/src/client/components/Button.tsx +12 -10
  13. package/src/client/components/Field.tsx +14 -1
  14. package/src/client/components/FormNotice.tsx +40 -0
  15. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  16. package/src/client/components/README.md +19 -6
  17. package/src/client/components/SegmentedNav.tsx +167 -0
  18. package/src/client/components/SegmentedTabs.tsx +24 -5
  19. package/src/client/components/Select.tsx +18 -0
  20. package/src/client/components/StatusChip.tsx +15 -12
  21. package/src/client/components/TextInput.tsx +25 -5
  22. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  23. package/src/client/http.ts +31 -3
  24. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +78 -68
  25. package/src/client/pages/automations/AutomationEditPage.tsx +171 -0
  26. package/src/client/pages/automations/AutomationEditor.tsx +397 -0
  27. package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
  28. package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
  29. package/src/client/pages/automations/AutomationFilters.tsx +179 -0
  30. package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
  31. package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
  32. package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
  33. package/src/client/pages/automations/AutomationRow.tsx +343 -0
  34. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  35. package/src/client/pages/automations/AutomationsPage.tsx +186 -439
  36. package/src/client/pages/automations/AutomationsPageLayout.tsx +19 -7
  37. package/src/client/pages/automations/README.md +19 -0
  38. package/src/client/pages/automations/automationDraft.ts +110 -0
  39. package/src/client/pages/automations/automationFormat.ts +43 -0
  40. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  41. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  42. package/src/client/pages/code/CodePage.tsx +18 -93
  43. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  44. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  45. package/src/client/pages/code/RepositorySections.tsx +108 -0
  46. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  47. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  48. package/src/client/pages/dev/ComponentsPage.tsx +55 -26
  49. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  50. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  51. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  52. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  53. package/src/client/searchParams.ts +4 -2
  54. package/src/client.tsx +7 -4
  55. package/src/mock-reporting/automation-collection.ts +101 -0
  56. package/src/mock-reporting/code-repository.ts +77 -0
  57. package/src/mock-reporting/fixtures.ts +9 -3
  58. package/src/mock-reporting/routes.ts +39 -17
  59. package/src/shell.ts +21 -5
@@ -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
  }
@@ -39,9 +39,10 @@ export function parseSearchParamEnum<T extends string>(
39
39
  /** Read and write one debounced free-text query param (default `q`). */
40
40
  export function useDebouncedSearchParam(
41
41
  key = "q",
42
- options: { delayMs?: number } = {},
42
+ options: { delayMs?: number; resetPage?: boolean } = {},
43
43
  ) {
44
44
  const delayMs = options.delayMs ?? 250;
45
+ const resetPage = options.resetPage ?? false;
45
46
  const [searchParams, setSearchParams] = useSearchParams();
46
47
  const committed = searchParams.get(key)?.trim() ?? "";
47
48
  const [value, setValue] = useState(committed);
@@ -58,6 +59,7 @@ export function useDebouncedSearchParam(
58
59
  setSearchParams(
59
60
  (current) => {
60
61
  const next = new URLSearchParams(current);
62
+ if (resetPage) next.delete("page");
61
63
  if (normalized) next.set(key, normalized);
62
64
  else next.delete(key);
63
65
  return next;
@@ -66,7 +68,7 @@ export function useDebouncedSearchParam(
66
68
  );
67
69
  }, delayMs);
68
70
  return () => window.clearTimeout(timeout);
69
- }, [committed, delayMs, key, setSearchParams, value]);
71
+ }, [committed, delayMs, key, resetPage, setSearchParams, value]);
70
72
 
71
73
  return [value, setValue, committed] as const;
72
74
  }
package/src/client.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
2
  import { Component, type ErrorInfo, type ReactNode } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
- import { BrowserRouter } from "react-router";
4
+ import { createBrowserRouter, RouterProvider } from "react-router";
5
5
 
6
6
  import { DashboardShell } from "./client/App";
7
7
  import { getDashboardAgentName } from "./client/agentName";
@@ -79,12 +79,15 @@ const queryClient = new QueryClient({
79
79
  },
80
80
  });
81
81
 
82
+ const router = createBrowserRouter(
83
+ [{ path: "*", element: <DashboardShell /> }],
84
+ { basename: window.__JUNIOR_DASHBOARD_BASE_PATH__ ?? "/" },
85
+ );
86
+
82
87
  createRoot(root).render(
83
88
  <DashboardErrorBoundary>
84
89
  <QueryClientProvider client={queryClient}>
85
- <BrowserRouter basename={window.__JUNIOR_DASHBOARD_BASE_PATH__ ?? "/"}>
86
- <DashboardShell />
87
- </BrowserRouter>
90
+ <RouterProvider router={router} />
88
91
  </QueryClientProvider>
89
92
  </DashboardErrorBoundary>,
90
93
  );
@@ -0,0 +1,101 @@
1
+ import {
2
+ automationListQuerySchema,
3
+ type AutomationList,
4
+ type AutomationSummary,
5
+ } from "@sentry/junior/api/schema";
6
+
7
+ function creator(automation: AutomationSummary) {
8
+ return automation.createdByEmail ?? automation.createdBy;
9
+ }
10
+ function destination(automation: AutomationSummary) {
11
+ return `${automation.destination.teamId}:${automation.destination.channelId}`;
12
+ }
13
+
14
+ /** Apply web collection controls to local preview and browser fixtures. */
15
+ export function mockAutomationCollection(
16
+ report: Pick<
17
+ AutomationList,
18
+ "automations" | "executionDays" | "executionHours" | "executionSixHours"
19
+ >,
20
+ params: URLSearchParams = new URLSearchParams(),
21
+ ): AutomationList {
22
+ const input = automationListQuerySchema.parse(Object.fromEntries(params));
23
+ const all = report.automations;
24
+ const filtered = all
25
+ .filter((automation) => {
26
+ const search =
27
+ `${automation.title} ${automation.instruction} ${automation.kind === "event" ? automation.resource : ""}`.toLowerCase();
28
+ return (
29
+ (input.scope !== "mine" || automation.ownedByViewer) &&
30
+ (input.scope !== "public" ||
31
+ automation.destination.visibility === "public") &&
32
+ (input.scope !== "attention" ||
33
+ (automation.status !== "paused" &&
34
+ automation.status !== "completed" &&
35
+ (automation.status === "blocked" ||
36
+ (automation.kind === "event" && !automation.triggerAvailable) ||
37
+ automation.lastRunStatus === "failed" ||
38
+ automation.lastRunStatus === "blocked"))) &&
39
+ (input.type === "all" || automation.kind === input.type) &&
40
+ (input.state === "all" ||
41
+ (input.state === "unavailable"
42
+ ? automation.kind === "event" && !automation.triggerAvailable
43
+ : automation.status === input.state)) &&
44
+ (!input.creator || creator(automation) === input.creator) &&
45
+ (!input.destination || destination(automation) === input.destination) &&
46
+ (!input.q || search.includes(input.q.toLowerCase()))
47
+ );
48
+ })
49
+ .sort((a, b) => {
50
+ const order =
51
+ input.sort === "title"
52
+ ? a.title.localeCompare(b.title)
53
+ : input.sort === "oldest"
54
+ ? a.createdAt.localeCompare(b.createdAt)
55
+ : b.createdAt.localeCompare(a.createdAt);
56
+ return order || b.id.localeCompare(a.id);
57
+ });
58
+ const page = Math.min(
59
+ input.page,
60
+ Math.max(1, Math.ceil(filtered.length / input.pageSize)),
61
+ );
62
+ return {
63
+ ...report,
64
+ automations: filtered.slice(
65
+ (page - 1) * input.pageSize,
66
+ page * input.pageSize,
67
+ ),
68
+ total: filtered.length,
69
+ page,
70
+ pageSize: input.pageSize,
71
+ counts: {
72
+ all: all.length,
73
+ mine: all.filter((automation) => automation.ownedByViewer).length,
74
+ public: all.filter(
75
+ (automation) => automation.destination.visibility === "public",
76
+ ).length,
77
+ private: all.filter(
78
+ (automation) => automation.destination.visibility === "private",
79
+ ).length,
80
+ },
81
+ creators: [
82
+ ...new Map(
83
+ all.map((automation) => [
84
+ creator(automation),
85
+ { value: creator(automation), label: automation.createdBy },
86
+ ]),
87
+ ).values(),
88
+ ],
89
+ destinations: [
90
+ ...new Map(
91
+ all.map((automation) => [
92
+ destination(automation),
93
+ {
94
+ value: destination(automation),
95
+ label: automation.destination.label,
96
+ },
97
+ ]),
98
+ ).values(),
99
+ ],
100
+ };
101
+ }
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { mockAutomationCollection } from "./automation-collection";
1
2
  /** Deterministic reporting fixtures for local dashboard development and QA. */
2
3
  import type {
3
4
  ActorDirectoryReport,
@@ -2669,8 +2670,10 @@ function mockTasks(): AutomationSummary[] {
2669
2670
  lastConversationId: "scheduler:daily-ops-digest",
2670
2671
  lastRunAt: "2026-08-06T16:00:00.000Z",
2671
2672
  nextRunAt: "2026-08-10T16:00:00.000Z",
2673
+ credentialMode: "creator",
2672
2674
  ownedByViewer: true,
2673
2675
  runs: { 1: 1, 7: 3, 30: 12, 90: 48 },
2676
+ timezone: "America/Los_Angeles",
2674
2677
  schedule: "Every Monday at 9:00 AM",
2675
2678
  status: "active",
2676
2679
  outcomes: [
@@ -2698,6 +2701,7 @@ function mockTasks(): AutomationSummary[] {
2698
2701
  kind: "event",
2699
2702
  lastConversationId: "agent-dispatch:event-1",
2700
2703
  lastRunAt: "2026-08-05T18:30:00.000Z",
2704
+ credentialMode: "creator",
2701
2705
  ownedByViewer: true,
2702
2706
  resource: "Issue · ACME-42",
2703
2707
  runs: { 1: 0, 7: 1, 30: 4, 90: 7 },
@@ -2706,6 +2710,7 @@ function mockTasks(): AutomationSummary[] {
2706
2710
  title: "Closed issue summary",
2707
2711
  totalRuns: 7,
2708
2712
  triggerAvailable: true,
2713
+ status: "active",
2709
2714
  },
2710
2715
  {
2711
2716
  createdAt: "2026-07-30T16:00:00.000Z",
@@ -2721,6 +2726,7 @@ function mockTasks(): AutomationSummary[] {
2721
2726
  id: "event-2",
2722
2727
  instruction: "Notify responders when the incident changes",
2723
2728
  kind: "event",
2729
+ credentialMode: "creator",
2724
2730
  ownedByViewer: false,
2725
2731
  resource: "Incident · INC-17",
2726
2732
  runs: { 1: 0, 7: 0, 30: 0, 90: 0 },
@@ -2734,13 +2740,14 @@ function mockTasks(): AutomationSummary[] {
2734
2740
  title: "Incident change alerts",
2735
2741
  totalRuns: 0,
2736
2742
  triggerAvailable: false,
2743
+ status: "active",
2737
2744
  },
2738
2745
  ];
2739
2746
  }
2740
2747
 
2741
2748
  /** Build mock Tasks list for local dashboard development. */
2742
2749
  export function readMockAutomationList(nowMs = NOW_MS): AutomationList {
2743
- return {
2750
+ return mockAutomationCollection({
2744
2751
  executionDays: mockAutomationExecutionDays(nowMs),
2745
2752
  executionHours: trailingMetricHours(nowMs, (date) => ({
2746
2753
  costUsd: 0,
@@ -2759,8 +2766,7 @@ export function readMockAutomationList(nowMs = NOW_MS): AutomationList {
2759
2766
  (date) => ({ costUsd: 0, date, event: 0, scheduled: 0 }),
2760
2767
  ),
2761
2768
  automations: mockTasks(),
2762
- truncated: false,
2763
- };
2769
+ });
2764
2770
  }
2765
2771
 
2766
2772
  function mockStatusDays(
@@ -1,3 +1,4 @@
1
+ import { mockAutomationCollection } from "./automation-collection";
1
2
  import { Hono } from "hono";
2
3
  import { jsonResponse, type JuniorApiVariables } from "@sentry/junior/api";
3
4
  import {
@@ -17,6 +18,8 @@ import {
17
18
  conversationStatsReportSchema,
18
19
  codeOverviewReportSchema,
19
20
  codePersonReportSchema,
21
+ codeRepositoryParamsSchema,
22
+ codeRepositoryReportSchema,
20
23
  locationDetailReportSchema,
21
24
  locationDirectoryReportSchema,
22
25
  locationParamsSchema,
@@ -26,9 +29,14 @@ import {
26
29
  automationExecutionListSchema,
27
30
  automationListSchema,
28
31
  automationParamsSchema,
32
+ automationSummarySchema,
29
33
  automationRunListSchema,
30
34
  } from "@sentry/junior/api/schema";
31
35
  import { mockChartPng } from "./chart-png";
36
+ import {
37
+ filterMockConversationsByRepository,
38
+ readMockCodeRepository,
39
+ } from "./code-repository";
32
40
  import {
33
41
  readMockConversationDetail,
34
42
  readMockConversationEvents,
@@ -62,6 +70,16 @@ export function createMockReportingApi(): Hono<{
62
70
  app.get("/code", () =>
63
71
  jsonResponse(codeOverviewReportSchema, readMockCodeOverview()),
64
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
+ });
65
83
  app.get("/plugin-reports", () =>
66
84
  jsonResponse(pluginOperationalReportFeedSchema, readMockPluginReports()),
67
85
  );
@@ -126,17 +144,18 @@ export function createMockReportingApi(): Hono<{
126
144
  if (!query.success) {
127
145
  return errorResponse("Invalid query parameters.", 400);
128
146
  }
129
- const report = readMockConversationFeed(
130
- query.data.actorEmail,
131
- query.data.status,
132
- );
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);
133
155
  if (!query.data.q) {
134
156
  return jsonResponse(conversationFeedSchema, report);
135
157
  }
136
- const archived = readMockConversationFeed(
137
- query.data.actorEmail,
138
- "archived",
139
- );
158
+ const archived = readFeed("archived");
140
159
  const search = query.data.q.toLowerCase();
141
160
  const conversations = new Map(
142
161
  [...report.conversations, ...archived.conversations].map(
@@ -253,15 +272,10 @@ export function createMockReportingApi(): Hono<{
253
272
  });
254
273
  app.get("/automations", (c) => {
255
274
  const report = readMockAutomationList();
256
- const query = c.req.query("q")?.trim().toLowerCase();
257
- return jsonResponse(automationListSchema, {
258
- ...report,
259
- automations: query
260
- ? report.automations.filter((automation) =>
261
- automation.title.toLowerCase().includes(query),
262
- )
263
- : report.automations,
264
- });
275
+ return jsonResponse(
276
+ automationListSchema,
277
+ mockAutomationCollection(report, new URL(c.req.url).searchParams),
278
+ );
265
279
  });
266
280
  app.get("/automations/runs", () => {
267
281
  const automations = readMockAutomationList().automations;
@@ -284,6 +298,14 @@ export function createMockReportingApi(): Hono<{
284
298
  truncated: false,
285
299
  });
286
300
  });
301
+ app.get("/automations/:id", (c) => {
302
+ const automation = readMockAutomationList().automations.find(
303
+ (automation) => automation.id === c.req.param("id"),
304
+ );
305
+ return automation
306
+ ? jsonResponse(automationSummarySchema, automation)
307
+ : errorResponse("Automation not found.", 404);
308
+ });
287
309
  app.get("/automations/:kind/:id/executions", (c) => {
288
310
  const params = automationParamsSchema.safeParse(c.req.param());
289
311
  if (!params.success) {
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. */