@sentry/junior-dashboard 0.266.0 → 0.268.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 (96) hide show
  1. package/dist/app.js +85 -12
  2. package/dist/client.js +94 -64
  3. package/dist/index.js +85 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +55 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -2
  13. package/src/client/components/README.md +15 -2
  14. package/src/client/components/UserInputForm.tsx +283 -0
  15. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  16. package/src/client/components/ui/alert.tsx +1 -1
  17. package/src/client/components/ui/button-group.tsx +59 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/components/ui/dialog.tsx +29 -35
  20. package/src/client/components/ui/drawer.tsx +140 -0
  21. package/src/client/components/ui/empty.tsx +30 -1
  22. package/src/client/components/ui/separator.tsx +22 -0
  23. package/src/client/components/ui/useSheet.ts +5 -1
  24. package/src/client/conversations/ComposerImages.tsx +66 -14
  25. package/src/client/conversations/ConversationComposer.tsx +84 -12
  26. package/src/client/conversations/ConversationDetails.tsx +3 -27
  27. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  28. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  29. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  30. package/src/client/conversations/ConversationPage.tsx +160 -2
  31. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  32. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  33. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  34. package/src/client/conversations/DetailsList.tsx +33 -0
  35. package/src/client/conversations/EventDetails.tsx +46 -19
  36. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  37. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  38. package/src/client/conversations/README.md +30 -13
  39. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  40. package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
  41. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  42. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  43. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  44. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  45. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  46. package/src/client/conversations/TranscriptMessageView.tsx +10 -1
  47. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  48. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  49. package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
  50. package/src/client/conversations/TranscriptView.tsx +7 -3
  51. package/src/client/conversations/UserInputRequestCard.tsx +39 -0
  52. package/src/client/conversations/conversationOutbox.ts +46 -0
  53. package/src/client/conversations/eventTime.ts +13 -1
  54. package/src/client/conversations/eventTranscript.ts +12 -5
  55. package/src/client/conversations/queries.ts +127 -26
  56. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  57. package/src/client/conversations/turnContext.ts +15 -0
  58. package/src/client/conversations/userInputRequest.ts +56 -0
  59. package/src/client/format.ts +31 -8
  60. package/src/client/pages/SettingsPage.tsx +103 -3
  61. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  62. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  63. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  64. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  65. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  66. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  67. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  69. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  70. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  71. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  72. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  73. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  74. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  75. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  76. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  77. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  78. package/src/client/pages/automations/README.md +56 -46
  79. package/src/client/pages/code/RepositorySections.tsx +1 -0
  80. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  81. package/src/client/primaryNav.ts +15 -2
  82. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  83. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  84. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  85. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  86. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  87. package/src/client/review-desk/queries.ts +164 -0
  88. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  89. package/src/client/types.ts +3 -0
  90. package/src/mock-reporting/fixtures.ts +54 -2
  91. package/src/mock-reporting/routes.ts +20 -0
  92. package/src/shell.ts +3 -0
  93. package/src/client/conversations/transcriptStyles.ts +0 -10
  94. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  95. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  96. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Version history for one Automation. The list selects a version, and the
3
+ * compare view shows what it changed from the version before it. Making a
4
+ * version active saves it again as a new version.
5
+ */
6
+ import { useState, type ReactNode } from "react";
7
+ import { useSearchParams } from "react-router";
8
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
9
+ import {
10
+ automationEditSchema,
11
+ type AutomationSummary,
12
+ type AutomationVersionList,
13
+ type ListedAutomationVersion,
14
+ } from "@sentry/junior/api/schema";
15
+
16
+ import { useAutomationVersionsData } from "../../api";
17
+ import {
18
+ AlertDialog,
19
+ AlertDialogAction,
20
+ AlertDialogCancel,
21
+ AlertDialogContent,
22
+ AlertDialogDescription,
23
+ AlertDialogFooter,
24
+ AlertDialogTitle,
25
+ } from "../../components/ui/alert-dialog";
26
+ import { Button } from "../../components/ui/button";
27
+ import { FormNotice } from "../../components/FormNotice";
28
+ import { InlineError } from "../../components/InlineError";
29
+ import { PageContentSkeleton } from "../../components/PageContentSkeleton";
30
+ import { StatusChip } from "../../components/StatusChip";
31
+ import { Card } from "../../components/layout/Card";
32
+ import { formatRelativeTime } from "../../format";
33
+ import { DashboardApiError, fetchDashboardJson, post } from "../../http";
34
+ import { cn } from "../../styles";
35
+ import { formatAutomationRunTime } from "./automationFormat";
36
+ import { automationOutcomeLabel } from "./automationOutcomes";
37
+ import { TriggerFilters } from "./TriggerFilters";
38
+
39
+ /** Search param that selects the version to compare. */
40
+ export const AUTOMATION_VERSION_PARAM = "version";
41
+
42
+ /** Load one Automation's versions and compare the selected one. */
43
+ export function AutomationVersionsTab(props: {
44
+ automation: AutomationSummary;
45
+ }) {
46
+ const versions = useAutomationVersionsData(true, props.automation.id);
47
+ if (versions.error) {
48
+ return (
49
+ <InlineError>
50
+ Version history could not be loaded.{" "}
51
+ <Button onClick={() => void versions.refetch()}>Try again</Button>
52
+ </InlineError>
53
+ );
54
+ }
55
+ if (!versions.data) {
56
+ return (
57
+ <PageContentSkeleton label="Loading version history" variant="list" />
58
+ );
59
+ }
60
+ return (
61
+ <AutomationVersionsView
62
+ automation={props.automation}
63
+ data={versions.data}
64
+ />
65
+ );
66
+ }
67
+
68
+ function AutomationVersionsView(props: {
69
+ automation: AutomationSummary;
70
+ data: AutomationVersionList;
71
+ }) {
72
+ const { automation, data } = props;
73
+ const [searchParams, setSearchParams] = useSearchParams();
74
+ const queryClient = useQueryClient();
75
+ const [activated, setActivated] = useState<number>();
76
+ const [confirmOpen, setConfirmOpen] = useState(false);
77
+ const canActivate = automation.status !== "completed";
78
+ const requested = Number(searchParams.get(AUTOMATION_VERSION_PARAM));
79
+ const selectedIndex = Math.max(
80
+ 0,
81
+ data.versions.findIndex((version) => version.version === requested),
82
+ );
83
+ const selected = data.versions[selectedIndex];
84
+ const previous = data.versions[selectedIndex + 1];
85
+ const select = (version: number) =>
86
+ setSearchParams(
87
+ (current) => {
88
+ const next = new URLSearchParams(current);
89
+ next.set(AUTOMATION_VERSION_PARAM, String(version));
90
+ return next;
91
+ },
92
+ { replace: true },
93
+ );
94
+ const path = `/api/automations/${encodeURIComponent(automation.id)}`;
95
+ const activate = useMutation({
96
+ mutationFn: async (version: number) => {
97
+ // The user chose an exact saved version, so the latest revision is safe here.
98
+ const current = await fetchDashboardJson(
99
+ automationEditSchema,
100
+ `${path}/edit`,
101
+ );
102
+ return post(
103
+ automationEditSchema,
104
+ `${path}/versions/${version}/activate`,
105
+ { revision: current.revision },
106
+ );
107
+ },
108
+ onMutate: () => setActivated(undefined),
109
+ onSuccess: async (_, version) => {
110
+ setActivated(version);
111
+ await queryClient.invalidateQueries({
112
+ queryKey: ["dashboard", "automations"],
113
+ });
114
+ },
115
+ });
116
+ if (!selected) {
117
+ return (
118
+ <Card padding="md">
119
+ <p className="m-0 text-sm text-dashboard-text-muted">
120
+ No versions are saved yet. Junior saves a version each time the
121
+ settings change.
122
+ </p>
123
+ </Card>
124
+ );
125
+ }
126
+ const active = selected.version === data.activeVersion;
127
+ return (
128
+ <>
129
+ <p className="m-0 max-w-2xl text-sm leading-relaxed text-dashboard-text-muted">
130
+ {canActivate
131
+ ? "Each save creates a version. Making a version active saves its settings as a new version. It does not run the automation."
132
+ : "This automation has completed. Its versions are read-only."}
133
+ </p>
134
+ {activated !== undefined ? (
135
+ <p role="status" className="m-0 text-sm text-emerald-300">
136
+ Version {activated} is active again. It was saved as a new version.
137
+ </p>
138
+ ) : null}
139
+ <div className="grid min-w-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)] lg:gap-6">
140
+ <nav aria-label="Versions" className="min-w-0">
141
+ <ol className="m-0 grid list-none gap-1 p-0 max-lg:max-h-64 max-lg:overflow-y-auto">
142
+ {data.versions.map((version, index) => (
143
+ <li key={version.version}>
144
+ <VersionListItem
145
+ active={version.version === data.activeVersion}
146
+ changes={versionChangeText(
147
+ version,
148
+ data.versions[index + 1],
149
+ data.truncated,
150
+ )}
151
+ onSelect={() => select(version.version)}
152
+ selected={version.version === selected.version}
153
+ version={version}
154
+ />
155
+ </li>
156
+ ))}
157
+ </ol>
158
+ {data.truncated ? (
159
+ <p className="m-0 mt-2 px-3 text-xs text-dashboard-text-muted">
160
+ Showing the 100 most recent versions.
161
+ </p>
162
+ ) : null}
163
+ </nav>
164
+ <Card as="section" className="min-w-0" padding="none" variant="default">
165
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-dashboard-border-subtle px-4 py-4 sm:px-5">
166
+ <div className="min-w-0">
167
+ <div className="flex flex-wrap items-center gap-2">
168
+ <h2 className="m-0 font-display text-lg font-medium">
169
+ Version {selected.version}
170
+ </h2>
171
+ {active ? (
172
+ <StatusChip size="compact" tone="success">
173
+ Active
174
+ </StatusChip>
175
+ ) : null}
176
+ </div>
177
+ <p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
178
+ {versionEditor(selected)} ·{" "}
179
+ {formatAutomationRunTime(selected.createdAt)}
180
+ {previous ? ` · Compared with version ${previous.version}` : ""}
181
+ </p>
182
+ </div>
183
+ {canActivate && !active ? (
184
+ <Button
185
+ aria-label={`Make version ${selected.version} active`}
186
+ disabled={activate.isPending}
187
+ onClick={() => setConfirmOpen(true)}
188
+ >
189
+ {activate.isPending && activate.variables === selected.version
190
+ ? "Saving…"
191
+ : "Make active"}
192
+ </Button>
193
+ ) : null}
194
+ </header>
195
+ {activate.error && activate.variables === selected.version ? (
196
+ <div className="px-4 pt-4 sm:px-5">
197
+ <FormNotice
198
+ tone="error"
199
+ title="This version could not be made active."
200
+ >
201
+ {activate.error instanceof DashboardApiError
202
+ ? activate.error.code === "conflict"
203
+ ? "The automation changed. Try again."
204
+ : (activate.error.apiError ?? "Try again.")
205
+ : "Try again."}
206
+ </FormNotice>
207
+ </div>
208
+ ) : null}
209
+ <VersionCompare
210
+ automation={automation}
211
+ previous={previous}
212
+ version={selected}
213
+ />
214
+ </Card>
215
+ </div>
216
+ <AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
217
+ <AlertDialogContent>
218
+ <AlertDialogTitle>
219
+ Make version {selected.version} active?
220
+ </AlertDialogTitle>
221
+ <AlertDialogDescription>
222
+ Junior saves these settings as a new version. Future runs use them.
223
+ {automation.ownedByViewer
224
+ ? null
225
+ : ` Only ${automation.createdBy} can turn on their connected accounts or change where results go.`}
226
+ </AlertDialogDescription>
227
+ <AlertDialogFooter>
228
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
229
+ <AlertDialogAction
230
+ onClick={() => {
231
+ activate.mutate(selected.version);
232
+ setConfirmOpen(false);
233
+ }}
234
+ variant="primary"
235
+ >
236
+ Make active
237
+ </AlertDialogAction>
238
+ </AlertDialogFooter>
239
+ </AlertDialogContent>
240
+ </AlertDialog>
241
+ </>
242
+ );
243
+ }
244
+
245
+ function VersionListItem(props: {
246
+ active: boolean;
247
+ changes: string | undefined;
248
+ onSelect(): void;
249
+ selected: boolean;
250
+ version: ListedAutomationVersion;
251
+ }) {
252
+ const { version } = props;
253
+ return (
254
+ <button
255
+ aria-current={props.selected ? "true" : undefined}
256
+ className={cn(
257
+ "grid w-full min-w-0 cursor-pointer gap-0.5 rounded-md border-0 px-3 py-2.5 text-left transition-colors focus-visible:outline-2 focus-visible:outline-dashboard-focus",
258
+ props.selected
259
+ ? "bg-dashboard-accent/[0.08] text-dashboard-text"
260
+ : "bg-transparent text-dashboard-text hover:bg-dashboard-fill-soft",
261
+ )}
262
+ onClick={props.onSelect}
263
+ type="button"
264
+ >
265
+ <span className="flex items-center gap-2 text-sm font-medium">
266
+ Version {version.version}
267
+ {props.active ? (
268
+ <StatusChip size="compact" tone="success">
269
+ Active
270
+ </StatusChip>
271
+ ) : null}
272
+ </span>
273
+ <span className="truncate text-xs text-dashboard-text-muted">
274
+ {versionEditor(version)} ·{" "}
275
+ <time dateTime={version.createdAt}>
276
+ {formatRelativeTime(version.createdAt)}
277
+ </time>
278
+ </span>
279
+ {props.changes ? (
280
+ <span className="truncate text-xs text-dashboard-text-muted">
281
+ {props.changes}
282
+ </span>
283
+ ) : null}
284
+ </button>
285
+ );
286
+ }
287
+
288
+ const VERSION_FIELDS = [
289
+ { key: "title", label: "Title" },
290
+ { key: "instruction", label: "What to do" },
291
+ { key: "when", label: "When to run" },
292
+ { key: "destination", label: "Destination" },
293
+ { key: "outcomes", label: "Where results go" },
294
+ { key: "credentialMode", label: "Credentials" },
295
+ ] as const;
296
+
297
+ type VersionField = (typeof VERSION_FIELDS)[number]["key"];
298
+
299
+ /** Show every saved field. Changed fields show the previous value too. */
300
+ function VersionCompare(props: {
301
+ automation: AutomationSummary;
302
+ previous: ListedAutomationVersion | undefined;
303
+ version: ListedAutomationVersion;
304
+ }) {
305
+ const { automation, previous, version } = props;
306
+ const changed = previous ? changedFields(previous, version) : [];
307
+ return (
308
+ <dl className="m-0 divide-y divide-dashboard-border-subtle">
309
+ {VERSION_FIELDS.map(({ key, label }) => {
310
+ const isChanged = changed.includes(key);
311
+ return (
312
+ <div
313
+ className={cn(
314
+ "grid min-w-0 gap-2 px-4 py-4 sm:grid-cols-[9rem_minmax(0,1fr)] sm:gap-6 sm:px-5",
315
+ isChanged &&
316
+ "shadow-[inset_2px_0_0_var(--color-dashboard-accent)]",
317
+ )}
318
+ key={key}
319
+ >
320
+ <dt className="text-xs font-medium text-dashboard-text-muted">
321
+ {label}
322
+ {isChanged ? (
323
+ <span className="mt-1 block font-normal text-dashboard-accent-text">
324
+ Changed
325
+ </span>
326
+ ) : null}
327
+ </dt>
328
+ <dd className="m-0 min-w-0 text-sm leading-relaxed break-words">
329
+ {isChanged && previous && key === "instruction" ? (
330
+ <LineDiff
331
+ after={version.definition.instruction}
332
+ before={previous.definition.instruction}
333
+ />
334
+ ) : isChanged && previous ? (
335
+ <div className="grid gap-2">
336
+ <div className="text-dashboard-text-muted line-through decoration-dashboard-text-muted/50">
337
+ <span className="sr-only">Before: </span>
338
+ {fieldValue(key, automation, previous)}
339
+ </div>
340
+ <div>
341
+ <span className="sr-only">After: </span>
342
+ {fieldValue(key, automation, version)}
343
+ </div>
344
+ </div>
345
+ ) : (
346
+ fieldValue(key, automation, version)
347
+ )}
348
+ </dd>
349
+ </div>
350
+ );
351
+ })}
352
+ </dl>
353
+ );
354
+ }
355
+
356
+ function fieldValue(
357
+ key: VersionField,
358
+ automation: AutomationSummary,
359
+ version: ListedAutomationVersion,
360
+ ): ReactNode {
361
+ const { definition } = version;
362
+ switch (key) {
363
+ case "title":
364
+ return definition.title ?? "First line of the instruction";
365
+ case "instruction":
366
+ return (
367
+ <span className="whitespace-pre-wrap">{definition.instruction}</span>
368
+ );
369
+ case "when":
370
+ if (version.kind === "scheduled") {
371
+ const { description, timezone } = version.definition.schedule;
372
+ return description.includes(timezone)
373
+ ? description
374
+ : `${description} (${timezone})`;
375
+ }
376
+ return (
377
+ <>
378
+ {version.definition.trigger.label}
379
+ {version.definition.trigger.identifier
380
+ ? ` · ${version.definition.trigger.identifier}`
381
+ : null}
382
+ <br />
383
+ {version.trigger.events.join(", ")}
384
+ <TriggerFilters filters={version.trigger.filters} />
385
+ </>
386
+ );
387
+ case "destination":
388
+ return definition.destination.channelId ===
389
+ automation.destination.channelId &&
390
+ definition.destination.teamId === automation.destination.teamId
391
+ ? automation.destination.label
392
+ : `Another destination · ${definition.destination.channelId}`;
393
+ case "outcomes":
394
+ return definition.outcomes.length ? (
395
+ <ol className="m-0 grid list-decimal gap-1 pl-5">
396
+ {definition.outcomes.map((outcome, index) => (
397
+ <li key={index}>
398
+ {automationOutcomeLabel(outcome, automation.destination)}
399
+ </li>
400
+ ))}
401
+ </ol>
402
+ ) : (
403
+ "No message on success"
404
+ );
405
+ case "credentialMode":
406
+ return definition.credentialMode === "creator"
407
+ ? `Uses accounts connected by ${automation.ownedByViewer ? "you" : automation.createdBy}.`
408
+ : "System credentials only";
409
+ }
410
+ }
411
+
412
+ /** Show removed and added instruction lines in place. */
413
+ function LineDiff(props: { after: string; before: string }) {
414
+ return (
415
+ <div className="grid overflow-hidden rounded-md border border-dashboard-border-subtle font-mono text-xs leading-relaxed">
416
+ {diffLines(props.before, props.after).map((line, index) => (
417
+ <div
418
+ className={cn(
419
+ "grid grid-cols-[1.5rem_minmax(0,1fr)] whitespace-pre-wrap px-2 py-0.5",
420
+ line.kind === "added" && "bg-emerald-500/10 text-emerald-200",
421
+ line.kind === "removed" && "bg-rose-500/10 text-rose-200",
422
+ )}
423
+ key={index}
424
+ >
425
+ <span aria-hidden="true" className="select-none opacity-60">
426
+ {line.kind === "added" ? "+" : line.kind === "removed" ? "−" : ""}
427
+ </span>
428
+ <span>
429
+ {line.kind === "same" ? null : (
430
+ <span className="sr-only">
431
+ {line.kind === "added" ? "Added: " : "Removed: "}
432
+ </span>
433
+ )}
434
+ {line.text || " "}
435
+ </span>
436
+ </div>
437
+ ))}
438
+ </div>
439
+ );
440
+ }
441
+
442
+ type DiffLine = { kind: "added" | "removed" | "same"; text: string };
443
+
444
+ /** Line diff from the longest common subsequence. Instructions stay short. */
445
+ function diffLines(before: string, after: string): DiffLine[] {
446
+ const a = before.split("\n");
447
+ const b = after.split("\n");
448
+ const lengths = Array.from({ length: a.length + 1 }, () =>
449
+ Array.from({ length: b.length + 1 }, () => 0),
450
+ );
451
+ for (let i = a.length - 1; i >= 0; i--) {
452
+ for (let j = b.length - 1; j >= 0; j--) {
453
+ lengths[i]![j] =
454
+ a[i] === b[j]
455
+ ? lengths[i + 1]![j + 1]! + 1
456
+ : Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!);
457
+ }
458
+ }
459
+ const lines: DiffLine[] = [];
460
+ let i = 0;
461
+ let j = 0;
462
+ while (i < a.length && j < b.length) {
463
+ if (a[i] === b[j]) {
464
+ lines.push({ kind: "same", text: a[i]! });
465
+ i++;
466
+ j++;
467
+ } else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) {
468
+ lines.push({ kind: "removed", text: a[i++]! });
469
+ } else {
470
+ lines.push({ kind: "added", text: b[j++]! });
471
+ }
472
+ }
473
+ while (i < a.length) lines.push({ kind: "removed", text: a[i++]! });
474
+ while (j < b.length) lines.push({ kind: "added", text: b[j++]! });
475
+ return lines;
476
+ }
477
+
478
+ function versionEditor(version: ListedAutomationVersion): string {
479
+ return version.editedBy
480
+ ? (version.editedBy.fullName ??
481
+ version.editedBy.userName ??
482
+ version.editedBy.slackUserId)
483
+ : "Editor unknown";
484
+ }
485
+
486
+ function versionChangeText(
487
+ version: ListedAutomationVersion,
488
+ previous: ListedAutomationVersion | undefined,
489
+ truncated: boolean,
490
+ ): string | undefined {
491
+ if (!previous) {
492
+ return version.version === 1 || !truncated
493
+ ? "First saved version"
494
+ : undefined;
495
+ }
496
+ const changes = changedFields(previous, version);
497
+ if (changes.length === 0) return "No setting changes";
498
+ const labels = VERSION_FIELDS.filter(({ key }) => changes.includes(key)).map(
499
+ ({ label }) => label.toLowerCase(),
500
+ );
501
+ return `Changed ${labels.join(", ")}`;
502
+ }
503
+
504
+ /** Name the fields that differ from the previous version. */
505
+ function changedFields(
506
+ previous: ListedAutomationVersion,
507
+ version: ListedAutomationVersion,
508
+ ): VersionField[] {
509
+ const before: Record<string, unknown> = previous.definition;
510
+ const after: Record<string, unknown> = version.definition;
511
+ const differs = (key: string) =>
512
+ JSON.stringify(before[key]) !== JSON.stringify(after[key]);
513
+ return VERSION_FIELDS.flatMap(({ key }) =>
514
+ (key === "when" ? differs("schedule") || differs("trigger") : differs(key))
515
+ ? [key]
516
+ : [],
517
+ );
518
+ }
@@ -1,16 +1,11 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import {
3
- useLocation,
4
- useNavigate,
5
- useParams,
6
- useSearchParams,
7
- } from "react-router";
1
+ import { useEffect } from "react";
2
+ import { useLocation, useNavigate, useSearchParams } from "react-router";
8
3
  import {
9
4
  automationListQuerySchema,
10
5
  type AutomationSummary,
11
6
  } from "@sentry/junior/api/schema";
12
7
  import { Globe2, ListChecks, LockKeyhole, UserRound } from "lucide-react";
13
- import { useAutomationsData, useAutomationData } from "../../api";
8
+ import { useAutomationsData } from "../../api";
14
9
  import { Button } from "../../components/ui/button";
15
10
  import { AutomationFilters, AutomationSort } from "./AutomationFilters";
16
11
  import { InlineError } from "../../components/InlineError";
@@ -31,12 +26,8 @@ import {
31
26
  useDebouncedSearchParam,
32
27
  useSearchParamEnum,
33
28
  } from "../../searchParams";
34
- import { AutomationDeleteDialog } from "./AutomationDeleteDialog";
35
29
  import { AutomationRow } from "./AutomationRow";
36
30
  import { AutomationCostChart } from "./AutomationCostChart";
37
- import { AutomationDetails } from "./AutomationDetails";
38
- import { PageLayout } from "../../components/layout/PageLayout";
39
- import { cn } from "../../styles";
40
31
  import { AutomationExecutionChart } from "./AutomationExecutionChart";
41
32
 
42
33
  const AUTOMATION_RANGE_OPTIONS = ["1", "7", "30", "90"] as const;
@@ -55,12 +46,8 @@ export function AutomationsPage(props: {
55
46
  enabled: boolean;
56
47
  view: "list" | "overview";
57
48
  }) {
58
- const [deleteTarget, setDeleteTarget] = useState<AutomationSummary>();
59
- const deleteSource = useRef<HTMLButtonElement | null>(null);
60
- const listRef = useRef<HTMLDivElement>(null);
61
49
  const navigate = useNavigate();
62
50
  const location = useLocation();
63
- const { automationId } = useParams();
64
51
  const [rangeParam, setRangeParam] = useSearchParamEnum(
65
52
  "range",
66
53
  "30",
@@ -95,7 +82,6 @@ export function AutomationsPage(props: {
95
82
  props.enabled,
96
83
  props.view === "list" ? request.toString() : "",
97
84
  );
98
- const detail = useAutomationData(props.enabled, automationId);
99
85
  const setFilter = (key: string, value: string) => {
100
86
  // Consecutive control events can precede the router's next render.
101
87
  const next = new URLSearchParams(window.location.search);
@@ -111,10 +97,9 @@ export function AutomationsPage(props: {
111
97
  else next.set("page", String(page));
112
98
  return next;
113
99
  });
114
- const listPath = "/automations/list";
115
- const tasksPath = (pathname: string) =>
116
- pathWithSearch(pathname, location.search);
117
- const selectedTaskPath = (id: string) => tasksPath(automationPath(id));
100
+ // The Automation page keeps the list query, so its back link restores it.
101
+ const automationPagePath = (id: string) =>
102
+ pathWithSearch(automationPath(id), location.search);
118
103
  const automations = query.data?.automations ?? EMPTY_TASKS;
119
104
  const mineCount = query.data?.counts.mine ?? 0;
120
105
  const publicCount = query.data?.counts.public ?? 0;
@@ -122,22 +107,8 @@ export function AutomationsPage(props: {
122
107
  const visibleTaskCount = query.data?.total ?? 0;
123
108
  const totalPages = pageCount(visibleTaskCount, filters.pageSize);
124
109
  const page = query.data?.page ?? filters.page;
125
- const selectedTask = detail.data;
126
- const closeDetails = () => {
127
- const trigger = automationId
128
- ? listRef.current?.querySelector<HTMLButtonElement>(
129
- `[data-automation-id="${CSS.escape(automationId)}"] button`,
130
- )
131
- : undefined;
132
- navigate(tasksPath(listPath));
133
- (trigger ?? listRef.current)?.focus();
134
- };
135
110
  // Overview names the tab through PageHeader; the list has a plain heading.
136
- useDocumentTitle(
137
- props.view === "list"
138
- ? (selectedTask?.title ?? "All automations")
139
- : undefined,
140
- );
111
+ useDocumentTitle(props.view === "list" ? "All automations" : undefined);
141
112
  // A deletion or a shared URL can leave the requested page past the last page.
142
113
  useEffect(() => {
143
114
  if (
@@ -182,7 +153,7 @@ export function AutomationsPage(props: {
182
153
  : [];
183
154
  const showExecutionCharts = executionSeries.length > 0;
184
155
 
185
- const content = (
156
+ return (
186
157
  <>
187
158
  {props.view === "overview" ? (
188
159
  <PageHeader
@@ -291,15 +262,9 @@ export function AutomationsPage(props: {
291
262
  return (
292
263
  <AutomationRow
293
264
  key={key}
294
- onSelect={() =>
295
- navigate(
296
- automationId === automation.id
297
- ? tasksPath(listPath)
298
- : selectedTaskPath(automation.id),
299
- )
300
- }
301
- selected={automationId === automation.id}
265
+ onSelect={() => navigate(automationPagePath(automation.id))}
302
266
  automation={automation}
267
+ to={automationPagePath(automation.id)}
303
268
  />
304
269
  );
305
270
  })}
@@ -316,57 +281,4 @@ export function AutomationsPage(props: {
316
281
  ) : null}
317
282
  </>
318
283
  );
319
- if (props.view === "overview") return content;
320
-
321
- return (
322
- <div
323
- className={cn(
324
- "grid h-full min-h-0 min-w-0",
325
- automationId && "xl:grid-cols-[minmax(0,1fr)_25rem]",
326
- )}
327
- >
328
- <div
329
- aria-label="Automations list"
330
- className="min-h-0 overflow-y-auto overscroll-contain outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
331
- ref={listRef}
332
- role="region"
333
- tabIndex={0}
334
- >
335
- <PageLayout>{content}</PageLayout>
336
- </div>
337
- {automationId ? (
338
- <AutomationDetails
339
- key={automationId}
340
- onClose={closeDetails}
341
- onDelete={(source) => {
342
- if (!selectedTask?.ownedByViewer) return;
343
- deleteSource.current = source;
344
- setDeleteTarget(selectedTask);
345
- }}
346
- onRetry={() => void detail.refetch()}
347
- error={Boolean(detail.error)}
348
- range={range}
349
- automation={selectedTask}
350
- />
351
- ) : null}
352
- {deleteTarget ? (
353
- <AutomationDeleteDialog
354
- automation={deleteTarget}
355
- onClose={() => setDeleteTarget(undefined)}
356
- onDeleted={() => {
357
- if (automationId !== deleteTarget.id) return;
358
- // The details unmount after the route change, which can finish
359
- // after the dialog closes. Do not return focus to their button.
360
- deleteSource.current = null;
361
- navigate(tasksPath(listPath));
362
- }}
363
- returnFocus={() =>
364
- deleteSource.current?.isConnected
365
- ? deleteSource.current
366
- : (listRef.current ?? false)
367
- }
368
- />
369
- ) : null}
370
- </div>
371
- );
372
284
  }