@sentry/junior-dashboard 0.232.0 → 0.234.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 (45) hide show
  1. package/dist/app.js +163 -46
  2. package/dist/client.js +55 -52
  3. package/dist/index.js +163 -46
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/App.tsx +359 -345
  7. package/src/client/api.ts +17 -1
  8. package/src/client/components/ActorAvatar.tsx +37 -0
  9. package/src/client/components/Button.tsx +12 -10
  10. package/src/client/components/Field.tsx +14 -1
  11. package/src/client/components/FormNotice.tsx +40 -0
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/Select.tsx +18 -0
  15. package/src/client/components/StatusChip.tsx +15 -12
  16. package/src/client/components/TextInput.tsx +25 -5
  17. package/src/client/conversations/ConversationEventLog.tsx +49 -9
  18. package/src/client/conversations/EventDetails.tsx +94 -11
  19. package/src/client/conversations/EventUsage.tsx +118 -0
  20. package/src/client/conversations/eventLog.ts +59 -6
  21. package/src/client/conversations/eventTranscript.ts +2 -0
  22. package/src/client/conversations/transcriptBottomPinning.ts +2 -2
  23. package/src/client/http.ts +31 -3
  24. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
  25. package/src/client/pages/automations/AutomationEditPage.tsx +172 -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 +171 -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 +358 -0
  34. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  35. package/src/client/pages/automations/AutomationsPage.tsx +157 -408
  36. package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
  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/automationOutcomes.ts +23 -0
  40. package/src/client/pages/dev/ComponentsPage.tsx +50 -4
  41. package/src/client/searchParams.ts +4 -2
  42. package/src/client.tsx +7 -4
  43. package/src/mock-reporting/automation-collection.ts +101 -0
  44. package/src/mock-reporting/fixtures.ts +82 -32
  45. package/src/mock-reporting/routes.ts +14 -9
@@ -11,7 +11,11 @@ import { SecondaryNavigation } from "../../components/layout/SecondaryNavigation
11
11
  const RESERVED_TASK_SEGMENTS = new Set(["list", "runs"]);
12
12
 
13
13
  function isTasksListPath(pathname: string): boolean {
14
- if (pathname === "/automations/list") return true;
14
+ if (
15
+ pathname === "/automations/list" ||
16
+ /^\/automations\/(scheduled|event)\/[^/]+\/edit$/.test(pathname)
17
+ )
18
+ return true;
15
19
  const match = pathname.match(/^\/automations\/([^/]+)$/);
16
20
  return Boolean(match?.[1] && !RESERVED_TASK_SEGMENTS.has(match[1]));
17
21
  }
@@ -22,14 +26,21 @@ const taskNavigationItems = [
22
26
  { label: "Runs", to: "/automations/runs" },
23
27
  ];
24
28
 
29
+ /** Keep Automation navigation consistent across lists, details, and editing. */
30
+ export function AutomationsNavigation() {
31
+ return (
32
+ <SecondaryNavigation
33
+ ariaLabel="Automations navigation"
34
+ items={taskNavigationItems}
35
+ />
36
+ );
37
+ }
38
+
25
39
  /** Place the shared secondary navigation above one Automations page. */
26
40
  export function AutomationsPageLayout(props: { children: ReactNode }) {
27
41
  return (
28
42
  <>
29
- <SecondaryNavigation
30
- ariaLabel="Automations navigation"
31
- items={taskNavigationItems}
32
- />
43
+ <AutomationsNavigation />
33
44
  <PageLayout>{props.children}</PageLayout>
34
45
  </>
35
46
  );
@@ -0,0 +1,19 @@
1
+ # Automation editor
2
+
3
+ Core owns validation, creator access, revision checks, Schedule previews, and
4
+ the Event catalog. Preview requests cannot save or run work. Completed scheduled
5
+ Automations remain read-only.
6
+
7
+ The editor sends only changed fields. Keep unsupported triggers and stored
8
+ Destinations intact unless the user replaces them. Outcomes keep their order.
9
+ New messages can target only the current Destination or the creator.
10
+
11
+ Query refreshes must not replace an unsaved draft. After a stale save, the user
12
+ can keep their changed fields or load the latest saved version. Unchanged fields
13
+ come from that version; core checks its revision again on save.
14
+
15
+ The data router blocks navigation with unsaved changes. Browser unload uses the
16
+ native prompt. Save and Cancel keep the list query string.
17
+
18
+ Browser behavior lives in `e2e/automation-editor.spec.ts`. API and persistence
19
+ coverage lives in core's `tests/integration/api/automation-edits.test.ts`.
@@ -0,0 +1,110 @@
1
+ import type {
2
+ AutomationEdit,
3
+ AutomationScheduleIntent,
4
+ AutomationUpdate,
5
+ } from "@sentry/junior/api/schema";
6
+
7
+ export const weekdays = [
8
+ "sunday",
9
+ "monday",
10
+ "tuesday",
11
+ "wednesday",
12
+ "thursday",
13
+ "friday",
14
+ "saturday",
15
+ ] as const;
16
+ export type AutomationScheduleDraft =
17
+ | Extract<AutomationScheduleIntent, { kind: "recurring" }>
18
+ | {
19
+ kind: "one_off";
20
+ timezone: string;
21
+ timing: { type: "at"; date: string; time: string };
22
+ };
23
+
24
+ export type AutomationDraft = {
25
+ title: string;
26
+ instruction: string;
27
+ credentialMode: AutomationEdit["credentialMode"];
28
+ outcomes: NonNullable<AutomationUpdate["outcomes"]>;
29
+ schedule?: AutomationScheduleDraft;
30
+ trigger?: Extract<AutomationEdit, { kind: "event" }>["trigger"];
31
+ };
32
+
33
+ /** Keep stored values intact until a user edits that field. */
34
+ export function createAutomationDraft(value: AutomationEdit): AutomationDraft {
35
+ return {
36
+ title: value.title ?? "",
37
+ instruction: value.instruction,
38
+ credentialMode: value.credentialMode,
39
+ outcomes: value.outcomes,
40
+ trigger: value.kind === "event" ? value.trigger : undefined,
41
+ };
42
+ }
43
+
44
+ /** Populate calendar controls without changing the saved start date. */
45
+ export function scheduleDraft(
46
+ value: Extract<AutomationEdit, { kind: "scheduled" }>,
47
+ ): AutomationScheduleDraft {
48
+ const recurrence = value.schedule.recurrence;
49
+ if (value.schedule.kind === "recurring" && recurrence) {
50
+ const { time, weekdays: days, ...fields } = recurrence;
51
+ return {
52
+ kind: "recurring",
53
+ ...fields,
54
+ timezone: value.schedule.timezone,
55
+ time: `${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}`,
56
+ weekdays: days?.map((day) => weekdays[day]),
57
+ };
58
+ }
59
+ if (value.nextRunAtMs) {
60
+ const parts = new Intl.DateTimeFormat("en-CA", {
61
+ timeZone: value.schedule.timezone,
62
+ year: "numeric",
63
+ month: "2-digit",
64
+ day: "2-digit",
65
+ hour: "2-digit",
66
+ minute: "2-digit",
67
+ hourCycle: "h23",
68
+ }).formatToParts(value.nextRunAtMs);
69
+ const part = (name: string) =>
70
+ parts.find((p) => p.type === name)?.value ?? "";
71
+ return {
72
+ kind: "one_off",
73
+ timezone: value.schedule.timezone,
74
+ timing: {
75
+ type: "at",
76
+ date: `${part("year")}-${part("month")}-${part("day")}`,
77
+ time: `${part("hour")}:${part("minute")}`,
78
+ },
79
+ };
80
+ }
81
+ return {
82
+ kind: "recurring",
83
+ frequency: "daily",
84
+ time: "09:00",
85
+ timezone: value.schedule.timezone,
86
+ };
87
+ }
88
+
89
+ /** Keep metadata edits from replacing unchanged triggers or outcomes. */
90
+ export function automationDraftChanges(
91
+ original: AutomationEdit,
92
+ draft: AutomationDraft,
93
+ ): Partial<AutomationDraft> {
94
+ const changes: Partial<AutomationDraft> = {};
95
+ if (draft.title !== (original.title ?? "")) changes.title = draft.title;
96
+ if (draft.instruction !== original.instruction)
97
+ changes.instruction = draft.instruction;
98
+ if (draft.credentialMode !== original.credentialMode)
99
+ changes.credentialMode = draft.credentialMode;
100
+ if (JSON.stringify(draft.outcomes) !== JSON.stringify(original.outcomes))
101
+ changes.outcomes = draft.outcomes;
102
+ if (original.kind === "scheduled" && draft.schedule)
103
+ changes.schedule = draft.schedule;
104
+ if (
105
+ original.kind === "event" &&
106
+ JSON.stringify(draft.trigger) !== JSON.stringify(original.trigger)
107
+ )
108
+ changes.trigger = draft.trigger;
109
+ return changes;
110
+ }
@@ -0,0 +1,23 @@
1
+ import type {
2
+ AutomationSummary,
3
+ AutomationUpdate,
4
+ } from "@sentry/junior/api/schema";
5
+
6
+ type Outcome = NonNullable<AutomationUpdate["outcomes"]>[number];
7
+
8
+ /** Describe a retained Destination without guessing a recipient from a private id. */
9
+ export function automationOutcomeLabel(
10
+ outcome: Outcome,
11
+ destination: AutomationSummary["destination"],
12
+ ): string {
13
+ if (typeof outcome.destination === "string")
14
+ return outcome.destination === "task_creator"
15
+ ? "Creator · Direct message"
16
+ : `${destination.label} · ${destination.visibility}`;
17
+ if (
18
+ outcome.destination.channelId === destination.channelId &&
19
+ outcome.destination.teamId === destination.teamId
20
+ )
21
+ return `${destination.label} · ${destination.visibility}`;
22
+ return `Saved destination · ${outcome.destination.channelId}`;
23
+ }
@@ -1,3 +1,5 @@
1
+ import { FormNotice } from "../../components/FormNotice";
2
+ import { Select } from "../../components/Select";
1
3
  import { ObjectFixtures } from "./ObjectFixtures";
2
4
  import { ChartFixtures } from "./ChartFixtures";
3
5
  import {
@@ -33,6 +35,7 @@ import { DashboardHeader } from "../../components/layout/DashboardHeader";
33
35
  import { PageHeader } from "../../components/layout/PageHeader";
34
36
  import { MetricList, MetricValue } from "../../components/Metric";
35
37
  import { PageContentSkeleton } from "../../components/PageContentSkeleton";
38
+ import { ActorAvatar } from "../../components/ActorAvatar";
36
39
  import { ParticipantAvatarStack } from "../../components/ParticipantAvatarStack";
37
40
  import { StatCard } from "../../components/metrics/StatCard";
38
41
  import { StatusChip } from "../../components/StatusChip";
@@ -308,6 +311,34 @@ function FoundationsGalleryPage() {
308
311
  sectionId="foundations"
309
312
  title="Foundations"
310
313
  >
314
+ <Fixture title="Editor controls">
315
+ <Field
316
+ label="Instruction"
317
+ htmlFor="gallery-instruction"
318
+ error="Enter an instruction."
319
+ >
320
+ <TextArea
321
+ prose
322
+ id="gallery-instruction"
323
+ aria-invalid
324
+ aria-describedby="gallery-instruction-error"
325
+ />
326
+ </Field>
327
+ <Field label="Repeat" htmlFor="gallery-repeat">
328
+ <TextInput
329
+ size="comfortable"
330
+ aria-label="Resource identifier"
331
+ defaultValue="getsentry/junior#42"
332
+ />
333
+ <Select id="gallery-repeat">
334
+ <option>Every week</option>
335
+ </Select>
336
+ </Field>
337
+ <Button tone="primary">Save changes</Button>
338
+ <FormNotice title="This automation changed while you were editing.">
339
+ Your edits are still here. Review the latest version before saving.
340
+ </FormNotice>
341
+ </Fixture>
311
342
  <Fixture title="Conversation navigation">
312
343
  <DashboardChromeProvider>
313
344
  <DashboardHeader
@@ -563,19 +594,34 @@ function FoundationsGalleryPage() {
563
594
  />
564
595
  </div>
565
596
  </Fixture>
566
- <Fixture title="Status chips">
567
- <div className="flex flex-wrap items-center gap-2">
597
+ <Fixture title="Status labels">
598
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-3">
568
599
  <StatusChip tone="neutral">private</StatusChip>
569
600
  <StatusChip tone="success">completed</StatusChip>
570
601
  <StatusChip tone="danger">failed</StatusChip>
571
602
  <StatusChip tone="warning">blocked</StatusChip>
572
603
  <StatusChip tone="info">preference</StatusChip>
573
604
  <StatusChip tone="accent">knowledge</StatusChip>
574
- <StatusChip size="compact" tone="success">
575
- public
605
+ <StatusChip size="compact" tone="warning">
606
+ Trigger unavailable
576
607
  </StatusChip>
577
608
  </div>
578
609
  </Fixture>
610
+ <Fixture title="Actor avatars">
611
+ <div className="flex items-center gap-4">
612
+ <ActorAvatar
613
+ name="Ada Lovelace"
614
+ imageUrl="/_junior/dashboard/avatar.png"
615
+ size="detail"
616
+ />
617
+ <ActorAvatar name="Grace Hopper" size="detail" />
618
+ <ActorAvatar
619
+ name="Alan Turing"
620
+ imageUrl="/missing-avatar.png"
621
+ size="list"
622
+ />
623
+ </div>
624
+ </Fixture>
579
625
  <Fixture title="Participant avatars">
580
626
  <div className="flex flex-wrap items-center gap-8">
581
627
  <ParticipantAvatarStack
@@ -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
+ }
@@ -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,
@@ -162,8 +163,9 @@ function reportEvent(
162
163
  seq: number,
163
164
  createdAt: string,
164
165
  data: ConversationReportEventData,
166
+ metadata?: Pick<ConversationReportEvent, "model" | "modelCall">,
165
167
  ): ConversationReportEvent {
166
- return { seq, createdAt, data };
168
+ return { seq, createdAt, data, ...metadata };
167
169
  }
168
170
 
169
171
  type DetailOptions = Omit<
@@ -453,38 +455,81 @@ function activeConversation(nowMs: number): ConversationDetailReport {
453
455
  reasoningLevel: "high",
454
456
  confidence: 0.93,
455
457
  source: "router",
458
+ costUsd: 0.0012,
456
459
  }),
457
- reportEvent(4, iso(Date.parse(startedAt), 10_000), {
458
- type: "tool_calls",
459
- calls: [
460
- {
461
- toolCallId: "active-search",
462
- name: "webSearch",
463
- status: "running",
460
+ reportEvent(
461
+ 4,
462
+ iso(Date.parse(startedAt), 10_000),
463
+ {
464
+ type: "tool_calls",
465
+ calls: [
466
+ {
467
+ toolCallId: "active-search",
468
+ name: "webSearch",
469
+ status: "running",
470
+ },
471
+ ],
472
+ },
473
+ {
474
+ model: {
475
+ modelId: "openai/gpt-5.6-sol",
476
+ modelProfile: "handoff",
477
+ reasoningLevel: "high",
464
478
  },
465
- ],
466
- }),
467
- reportEvent(5, iso(Date.parse(startedAt), 14_000), {
468
- type: "tool_calls",
469
- calls: [
470
- {
471
- toolCallId: "active-search",
472
- name: "webSearch",
473
- status: "completed",
474
- startedSeq: 4,
475
- startedAt: iso(Date.parse(startedAt), 10_000),
476
- input: { query: "checkout latency last deployment" },
477
- output: {
478
- results: [
479
- {
480
- title: "payments-v42 deploy notes",
481
- url: "https://docs.sentry.io",
482
- },
483
- ],
479
+ modelCall: {
480
+ provider: "vercel-ai-gateway",
481
+ api: "openai-responses",
482
+ stopReason: "toolUse",
483
+ usage: {
484
+ inputTokens: 12500,
485
+ outputTokens: 800,
486
+ cachedInputTokens: 32000,
487
+ cacheCreationTokens: 2000,
488
+ reasoningTokens: 600,
489
+ totalTokens: 47300,
490
+ cost: {
491
+ input: 0.025,
492
+ output: 0.008,
493
+ cacheRead: 0.0064,
494
+ cacheWrite: 0.005,
495
+ total: 0.0444,
496
+ },
484
497
  },
485
498
  },
486
- ],
487
- }),
499
+ },
500
+ ),
501
+ reportEvent(
502
+ 5,
503
+ iso(Date.parse(startedAt), 14_000),
504
+ {
505
+ type: "tool_calls",
506
+ calls: [
507
+ {
508
+ toolCallId: "active-search",
509
+ name: "webSearch",
510
+ status: "completed",
511
+ startedSeq: 4,
512
+ startedAt: iso(Date.parse(startedAt), 10_000),
513
+ input: { query: "checkout latency last deployment" },
514
+ output: {
515
+ results: [
516
+ {
517
+ title: "payments-v42 deploy notes",
518
+ url: "https://docs.sentry.io",
519
+ },
520
+ ],
521
+ },
522
+ },
523
+ ],
524
+ },
525
+ {
526
+ model: {
527
+ modelId: "openai/gpt-5.6-sol",
528
+ modelProfile: "handoff",
529
+ reasoningLevel: "high",
530
+ },
531
+ },
532
+ ),
488
533
  // Mixed markdown keeps font/legibility QA honest for long assistant replies.
489
534
  reportEvent(6, iso(Date.parse(startedAt), 22_000), {
490
535
  type: "message",
@@ -2625,8 +2670,10 @@ function mockTasks(): AutomationSummary[] {
2625
2670
  lastConversationId: "scheduler:daily-ops-digest",
2626
2671
  lastRunAt: "2026-08-06T16:00:00.000Z",
2627
2672
  nextRunAt: "2026-08-10T16:00:00.000Z",
2673
+ credentialMode: "creator",
2628
2674
  ownedByViewer: true,
2629
2675
  runs: { 1: 1, 7: 3, 30: 12, 90: 48 },
2676
+ timezone: "America/Los_Angeles",
2630
2677
  schedule: "Every Monday at 9:00 AM",
2631
2678
  status: "active",
2632
2679
  outcomes: [
@@ -2654,6 +2701,7 @@ function mockTasks(): AutomationSummary[] {
2654
2701
  kind: "event",
2655
2702
  lastConversationId: "agent-dispatch:event-1",
2656
2703
  lastRunAt: "2026-08-05T18:30:00.000Z",
2704
+ credentialMode: "creator",
2657
2705
  ownedByViewer: true,
2658
2706
  resource: "Issue · ACME-42",
2659
2707
  runs: { 1: 0, 7: 1, 30: 4, 90: 7 },
@@ -2662,6 +2710,7 @@ function mockTasks(): AutomationSummary[] {
2662
2710
  title: "Closed issue summary",
2663
2711
  totalRuns: 7,
2664
2712
  triggerAvailable: true,
2713
+ status: "active",
2665
2714
  },
2666
2715
  {
2667
2716
  createdAt: "2026-07-30T16:00:00.000Z",
@@ -2677,6 +2726,7 @@ function mockTasks(): AutomationSummary[] {
2677
2726
  id: "event-2",
2678
2727
  instruction: "Notify responders when the incident changes",
2679
2728
  kind: "event",
2729
+ credentialMode: "creator",
2680
2730
  ownedByViewer: false,
2681
2731
  resource: "Incident · INC-17",
2682
2732
  runs: { 1: 0, 7: 0, 30: 0, 90: 0 },
@@ -2690,13 +2740,14 @@ function mockTasks(): AutomationSummary[] {
2690
2740
  title: "Incident change alerts",
2691
2741
  totalRuns: 0,
2692
2742
  triggerAvailable: false,
2743
+ status: "active",
2693
2744
  },
2694
2745
  ];
2695
2746
  }
2696
2747
 
2697
2748
  /** Build mock Tasks list for local dashboard development. */
2698
2749
  export function readMockAutomationList(nowMs = NOW_MS): AutomationList {
2699
- return {
2750
+ return mockAutomationCollection({
2700
2751
  executionDays: mockAutomationExecutionDays(nowMs),
2701
2752
  executionHours: trailingMetricHours(nowMs, (date) => ({
2702
2753
  costUsd: 0,
@@ -2715,8 +2766,7 @@ export function readMockAutomationList(nowMs = NOW_MS): AutomationList {
2715
2766
  (date) => ({ costUsd: 0, date, event: 0, scheduled: 0 }),
2716
2767
  ),
2717
2768
  automations: mockTasks(),
2718
- truncated: false,
2719
- };
2769
+ });
2720
2770
  }
2721
2771
 
2722
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 {
@@ -26,6 +27,7 @@ import {
26
27
  automationExecutionListSchema,
27
28
  automationListSchema,
28
29
  automationParamsSchema,
30
+ automationSummarySchema,
29
31
  automationRunListSchema,
30
32
  } from "@sentry/junior/api/schema";
31
33
  import { mockChartPng } from "./chart-png";
@@ -253,15 +255,10 @@ export function createMockReportingApi(): Hono<{
253
255
  });
254
256
  app.get("/automations", (c) => {
255
257
  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
- });
258
+ return jsonResponse(
259
+ automationListSchema,
260
+ mockAutomationCollection(report, new URL(c.req.url).searchParams),
261
+ );
265
262
  });
266
263
  app.get("/automations/runs", () => {
267
264
  const automations = readMockAutomationList().automations;
@@ -284,6 +281,14 @@ export function createMockReportingApi(): Hono<{
284
281
  truncated: false,
285
282
  });
286
283
  });
284
+ app.get("/automations/:id", (c) => {
285
+ const automation = readMockAutomationList().automations.find(
286
+ (automation) => automation.id === c.req.param("id"),
287
+ );
288
+ return automation
289
+ ? jsonResponse(automationSummarySchema, automation)
290
+ : errorResponse("Automation not found.", 404);
291
+ });
287
292
  app.get("/automations/:kind/:id/executions", (c) => {
288
293
  const params = automationParamsSchema.safeParse(c.req.param());
289
294
  if (!params.success) {