@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,51 @@
1
+ import { GitFork } from "lucide-react";
2
+ import { Link } from "react-router";
3
+ import { conversationPath } from "./conversationRoutes";
4
+
5
+ const linkClassName =
6
+ "font-medium text-dashboard-text no-underline underline-offset-2 hover:underline";
7
+
8
+ /** Link a conversation to its fork source and to its forks. */
9
+ export function ConversationForkLinks(props: {
10
+ forkedFromConversationId?: string;
11
+ forkedFromTitle?: string;
12
+ forks?: string[];
13
+ }) {
14
+ return (
15
+ <nav
16
+ aria-label="Conversation forks"
17
+ className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-snug text-dashboard-text-muted"
18
+ >
19
+ <GitFork aria-hidden="true" size={12} className="shrink-0" />
20
+ {props.forkedFromConversationId ? (
21
+ <span>
22
+ Forked from{" "}
23
+ <Link
24
+ className={linkClassName}
25
+ to={conversationPath(props.forkedFromConversationId)}
26
+ >
27
+ {props.forkedFromTitle ?? "source conversation"}
28
+ </Link>
29
+ </span>
30
+ ) : null}
31
+ {props.forkedFromConversationId && props.forks?.length ? (
32
+ <span>·</span>
33
+ ) : null}
34
+ {props.forks?.length ? (
35
+ <span className="inline-flex flex-wrap items-center gap-x-1.5">
36
+ Forks:
37
+ {props.forks.map((id, index) => (
38
+ <Link
39
+ key={id}
40
+ aria-label={`Fork ${index + 1}`}
41
+ className={linkClassName}
42
+ to={conversationPath(id)}
43
+ >
44
+ {index + 1}
45
+ </Link>
46
+ ))}
47
+ </span>
48
+ ) : null}
49
+ </nav>
50
+ );
51
+ }
@@ -32,6 +32,8 @@ export function ConversationHeader(props: {
32
32
  archive: ConversationArchiveAction;
33
33
  brief?: ReactNode;
34
34
  conversationId: string;
35
+ /** Links to the fork source and to forks of this conversation. */
36
+ forks?: ReactNode;
35
37
  identity: ReactNode;
36
38
  linkedWork?: ReactNode;
37
39
  lastActivityAt?: string;
@@ -149,7 +151,10 @@ export function ConversationHeader(props: {
149
151
  );
150
152
 
151
153
  const showMobileHeader =
152
- searchOpenVisible || props.archive.error || Boolean(props.linkedWork);
154
+ searchOpenVisible ||
155
+ props.archive.error ||
156
+ Boolean(props.linkedWork) ||
157
+ Boolean(props.forks);
153
158
 
154
159
  const liveIndicator = props.live ? (
155
160
  <span
@@ -183,6 +188,9 @@ export function ConversationHeader(props: {
183
188
  {props.meta}
184
189
  </div>
185
190
  ) : null}
191
+ {props.forks ? (
192
+ <div className="mt-1.5 min-w-0">{props.forks}</div>
193
+ ) : null}
186
194
  </div>
187
195
  <ConversationHeaderActions
188
196
  archive={props.archive}
@@ -196,6 +204,9 @@ export function ConversationHeader(props: {
196
204
  view={props.view}
197
205
  />
198
206
  </div>
207
+ {props.forks ? (
208
+ <div className="px-4 py-2 md:hidden">{props.forks}</div>
209
+ ) : null}
199
210
  {props.linkedWork ? (
200
211
  <div
201
212
  aria-label="Linked work"
@@ -1,4 +1,5 @@
1
1
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useSearchParams } from "react-router";
2
3
  import type {
3
4
  ConversationDetailReport,
4
5
  ConversationFeed,
@@ -18,8 +19,13 @@ import type { ConversationMailboxMessage } from "./conversationOutbox";
18
19
  import { buildConversationMarkdown } from "../markdownExport";
19
20
  import { CopyMarkdownButton } from "./CopyMarkdownButton";
20
21
  import { ConversationBrief } from "./ConversationBrief";
22
+ import {
23
+ CONVERSATION_EVENT_PARAM,
24
+ parseConversationEventSeq,
25
+ } from "./conversationRoutes";
21
26
  import { ConversationComposer } from "./ConversationComposer";
22
27
  import { ConversationHeader } from "./ConversationHeader";
28
+ import { ConversationForkLinks } from "./ConversationForkLinks";
23
29
  import { ConversationHeaderMeta } from "./ConversationHeaderMeta";
24
30
  import {
25
31
  ConversationAnnotations,
@@ -67,7 +73,15 @@ export function ConversationPage(props: {
67
73
  }) {
68
74
  const [subagentTarget, setSubagentTarget] =
69
75
  useState<SubagentTranscriptTarget>();
70
- const [view, setView] = useState<TranscriptViewMode>("rich");
76
+ const [searchParams, setSearchParams] = useSearchParams();
77
+ // The URL owns the open event log entry so readers can share that link.
78
+ const selectedEventSeq = parseConversationEventSeq(
79
+ searchParams.get(CONVERSATION_EVENT_PARAM),
80
+ );
81
+ const [viewState, setView] = useState<TranscriptViewMode>(() =>
82
+ selectedEventSeq === undefined ? "rich" : "raw",
83
+ );
84
+ const view = selectedEventSeq === undefined ? viewState : "raw";
71
85
  const [search, setSearch] = useState("");
72
86
  const [pinRequestVersion, setPinRequestVersion] = useState(0);
73
87
  const conversationId = props.conversationId;
@@ -109,6 +123,48 @@ export function ConversationPage(props: {
109
123
  // polls refresh generatedAt every 2s even when the queue is unchanged.
110
124
  const pendingGeneratedAtRef = useRef(detail.pendingGeneratedAt);
111
125
  pendingGeneratedAtRef.current = detail.pendingGeneratedAt;
126
+ const setSelectedEventSeq = useCallback(
127
+ (seq: number | undefined) => {
128
+ // Keep the event log after close, even when a link opened the page.
129
+ setView("raw");
130
+ setSearchParams(
131
+ (current) => {
132
+ const next = new URLSearchParams(current);
133
+ if (seq === undefined) next.delete(CONVERSATION_EVENT_PARAM);
134
+ else next.set(CONVERSATION_EVENT_PARAM, String(seq));
135
+ return next;
136
+ },
137
+ { replace: true },
138
+ );
139
+ },
140
+ [setSearchParams],
141
+ );
142
+ const onViewChange = useCallback(
143
+ (value: TranscriptViewMode) => {
144
+ if (value !== "raw" && selectedEventSeq !== undefined) {
145
+ setSelectedEventSeq(undefined);
146
+ }
147
+ setView(value);
148
+ },
149
+ [selectedEventSeq, setSelectedEventSeq],
150
+ );
151
+ // A linked event can be older than the first page. Load earlier pages until
152
+ // it is present or history ends.
153
+ const loadPreviousPageRef = useRef(detail.loadPreviousPage);
154
+ loadPreviousPageRef.current = detail.loadPreviousPage;
155
+ const oldestLoadedSeq = transcript?.events[0]?.seq;
156
+ const linkedEventMissing =
157
+ selectedEventSeq !== undefined &&
158
+ oldestLoadedSeq !== undefined &&
159
+ selectedEventSeq < oldestLoadedSeq;
160
+ const loadLinkedEvent =
161
+ linkedEventMissing &&
162
+ detail.hasPreviousPage &&
163
+ !detail.isLoadingPreviousPage &&
164
+ !detail.historyError;
165
+ useEffect(() => {
166
+ if (loadLinkedEvent) loadPreviousPageRef.current();
167
+ }, [loadLinkedEvent, oldestLoadedSeq]);
112
168
  const requestPin = useCallback(() => {
113
169
  setPinRequestVersion((version) => version + 1);
114
170
  }, []);
@@ -137,6 +193,18 @@ export function ConversationPage(props: {
137
193
  <section className="min-w-0">
138
194
  <ConversationHeader
139
195
  conversationId={conversationId}
196
+ forks={
197
+ detail.data?.forkedFromConversationId ||
198
+ detail.data?.forks?.length ? (
199
+ <ConversationForkLinks
200
+ forkedFromConversationId={
201
+ detail.data.forkedFromConversationId
202
+ }
203
+ forkedFromTitle={detail.data.forkedFromTitle}
204
+ forks={detail.data.forks}
205
+ />
206
+ ) : null
207
+ }
140
208
  lastActivityAt={conversation?.lastSeenAt}
141
209
  sentryConversationUrl={detail.data?.sentryConversationUrl}
142
210
  copyAction={
@@ -206,7 +274,7 @@ export function ConversationPage(props: {
206
274
  />
207
275
  }
208
276
  onSearchChange={setSearch}
209
- onViewChange={setView}
277
+ onViewChange={onViewChange}
210
278
  privacy={
211
279
  <ConversationPrivacyChip
212
280
  visibility={conversation?.visibility}
@@ -256,12 +324,14 @@ export function ConversationPage(props: {
256
324
  live={live}
257
325
  loadingPreviousPage={detail.isLoadingPreviousPage}
258
326
  onLoadPreviousPage={detail.loadPreviousPage}
327
+ onSelectedEventSeqChange={setSelectedEventSeq}
259
328
  pinRequestVersion={pinRequestVersion}
260
329
  responding={
261
330
  !detail.error && conversationIsResponding(transcript)
262
331
  }
263
332
  onOpenSubagentTranscript={onOpenSubagentTranscript}
264
333
  search={search}
334
+ selectedEventSeq={selectedEventSeq}
265
335
  transcript={transcript}
266
336
  view={view}
267
337
  />
@@ -0,0 +1,125 @@
1
+ import { GitFork } from "lucide-react";
2
+ import { useId, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { useNavigate } from "react-router";
5
+ import { Button } from "../components/Button";
6
+ import { Drawer } from "../components/Drawer";
7
+ import { Field } from "../components/Field";
8
+ import { TextArea } from "../components/TextInput";
9
+ import { DashboardApiError } from "../http";
10
+ import { conversationPath } from "./conversationRoutes";
11
+ import { useForkConversation } from "./queries";
12
+
13
+ type ForkTarget = { conversationId: string; messageId: string };
14
+
15
+ /** Open the fork dialog for one assistant reply. */
16
+ export function ForkConversationButton(props: ForkTarget) {
17
+ const [open, setOpen] = useState(false);
18
+ return (
19
+ <>
20
+ <Button
21
+ size="icon"
22
+ aria-label="Fork after this message"
23
+ title="Fork after this message"
24
+ onClick={() => setOpen(true)}
25
+ >
26
+ <GitFork size={14} aria-hidden="true" />
27
+ </Button>
28
+ {open
29
+ ? createPortal(
30
+ <ForkConversationDialog
31
+ {...props}
32
+ onClose={() => setOpen(false)}
33
+ />,
34
+ document.body,
35
+ )
36
+ : null}
37
+ </>
38
+ );
39
+ }
40
+
41
+ function ForkConversationDialog(props: ForkTarget & { onClose(): void }) {
42
+ const titleId = useId();
43
+ const inputId = useId();
44
+ const [idempotencyKey] = useState(() => crypto.randomUUID());
45
+ const [message, setMessage] = useState("");
46
+ const navigate = useNavigate();
47
+ const fork = useForkConversation(props.conversationId);
48
+ const submit = () => {
49
+ if (!message.trim() || fork.isPending) return;
50
+ fork.mutate(
51
+ { idempotencyKey, message, messageId: props.messageId },
52
+ {
53
+ onSuccess: (accepted) => {
54
+ props.onClose();
55
+ navigate(conversationPath(accepted.conversationId));
56
+ },
57
+ },
58
+ );
59
+ };
60
+ return (
61
+ <Drawer
62
+ closeLabel="Close fork dialog"
63
+ dismissLabel="Dismiss fork dialog"
64
+ header={
65
+ <h2 id={titleId} className="m-0 text-lg font-semibold">
66
+ Fork Conversation
67
+ </h2>
68
+ }
69
+ titleId={titleId}
70
+ openKey={idempotencyKey}
71
+ onClose={() => {
72
+ if (!fork.isPending) props.onClose();
73
+ }}
74
+ width="narrow"
75
+ >
76
+ <form
77
+ className="grid gap-4 text-sm"
78
+ onSubmit={(event) => {
79
+ event.preventDefault();
80
+ submit();
81
+ }}
82
+ >
83
+ <p className="m-0">
84
+ Creates a fork of the conversation from this point.
85
+ </p>
86
+ <Field htmlFor={inputId} label="Message">
87
+ <TextArea
88
+ id={inputId}
89
+ prose
90
+ autoFocus
91
+ className="min-h-28"
92
+ placeholder="Message Junior…"
93
+ value={message}
94
+ disabled={fork.isPending}
95
+ onChange={(event) => setMessage(event.target.value)}
96
+ onKeyDown={(event) => {
97
+ // Same keys as the composer: Enter sends, Shift+Enter adds a line.
98
+ if (
99
+ event.key === "Enter" &&
100
+ !event.shiftKey &&
101
+ !event.nativeEvent.isComposing
102
+ ) {
103
+ event.preventDefault();
104
+ submit();
105
+ }
106
+ }}
107
+ />
108
+ </Field>
109
+ {fork.error ? (
110
+ <p role="alert" className="m-0 text-red-300">
111
+ {(fork.error instanceof DashboardApiError && fork.error.apiError) ||
112
+ "Could not fork the conversation. Try again."}
113
+ </p>
114
+ ) : null}
115
+ <Button
116
+ tone="primary"
117
+ type="submit"
118
+ disabled={!message.trim() || fork.isPending}
119
+ >
120
+ {fork.isPending ? "Forking…" : "Fork and send"}
121
+ </Button>
122
+ </form>
123
+ </Drawer>
124
+ );
125
+ }
@@ -1,3 +1,4 @@
1
+ import { ForkConversationButton } from "./ForkConversationButton";
1
2
  import { TranscriptAttachments } from "./TranscriptAttachment";
2
3
  import { ObjectCard } from "./ObjectCard";
3
4
  import { AutomationCard } from "../components/AutomationCard";
@@ -46,11 +47,7 @@ export const TranscriptMessageView = memo(
46
47
  }}
47
48
  >
48
49
  <TranscriptMessageHeader
49
- contextAction={
50
- props.message.role === "user" && props.message.contexts?.length ? (
51
- <TranscriptTurnContextView contexts={props.message.contexts} />
52
- ) : undefined
53
- }
50
+ action={messageAction(props.message, props.conversation)}
54
51
  message={props.message}
55
52
  conversation={props.conversation}
56
53
  />
@@ -86,6 +83,7 @@ export const TranscriptMessageView = memo(
86
83
  previous.message === next.message &&
87
84
  previous.conversation.conversationId === next.conversation.conversationId &&
88
85
  previous.conversation.surface === next.conversation.surface &&
86
+ previous.conversation.canFork === next.conversation.canFork &&
89
87
  previous.conversation.actorIdentity === next.conversation.actorIdentity,
90
88
  );
91
89
 
@@ -114,8 +112,31 @@ export function RedactedMessageView(props: {
114
112
  );
115
113
  }
116
114
 
115
+ function messageAction(
116
+ message: TranscriptViewMessage,
117
+ conversation: ConversationTranscript,
118
+ ): ReactNode {
119
+ if (message.role === "user" && message.contexts?.length) {
120
+ return <TranscriptTurnContextView contexts={message.contexts} />;
121
+ }
122
+ if (
123
+ conversation.canFork &&
124
+ message.role === "assistant" &&
125
+ message.messageId &&
126
+ !message.pending
127
+ ) {
128
+ return (
129
+ <ForkConversationButton
130
+ conversationId={conversation.conversationId}
131
+ messageId={message.messageId}
132
+ />
133
+ );
134
+ }
135
+ return undefined;
136
+ }
137
+
117
138
  function TranscriptMessageHeader(props: {
118
- contextAction?: ReactNode;
139
+ action?: ReactNode;
119
140
  message: TranscriptViewMessage;
120
141
  conversation: ConversationTranscript;
121
142
  }) {
@@ -127,7 +148,7 @@ function TranscriptMessageHeader(props: {
127
148
  );
128
149
 
129
150
  return (
130
- <TranscriptMessageHeading action={props.contextAction}>
151
+ <TranscriptMessageHeading action={props.action}>
131
152
  <span className={transcriptRoleLabelClass(props.message.role)}>
132
153
  {roleLabel}
133
154
  </span>
@@ -25,8 +25,10 @@ export function Transcript(props: {
25
25
  loadingPreviousPage?: boolean;
26
26
  pinRequestVersion?: number;
27
27
  onLoadPreviousPage?: () => void;
28
+ onSelectedEventSeqChange?(seq: number | undefined): void;
28
29
  responding?: boolean;
29
30
  search?: string;
31
+ selectedEventSeq?: number;
30
32
  onOpenSubagentTranscript?: (args: {
31
33
  part: TranscriptViewSubagentPart;
32
34
  conversation: ConversationTranscript;
@@ -101,6 +103,8 @@ export function Transcript(props: {
101
103
  <ConversationEventLog
102
104
  key={props.transcript.conversationId}
103
105
  conversation={props.transcript}
106
+ onSelectedSeqChange={props.onSelectedEventSeqChange}
107
+ selectedSeq={props.selectedEventSeq}
104
108
  />
105
109
  ) : (
106
110
  <ConversationTranscriptView
@@ -10,3 +10,13 @@ export const NEW_CONVERSATION_PATH = "/";
10
10
  export function isNewConversationPath(pathname: string): boolean {
11
11
  return pathname === "/" || pathname === "/conversations/new";
12
12
  }
13
+
14
+ /** Query param that opens one event log entry by its sequence number. */
15
+ export const CONVERSATION_EVENT_PARAM = "event";
16
+
17
+ /** Read a linked event sequence number. Ignore values that are not one. */
18
+ export function parseConversationEventSeq(
19
+ value: string | null | undefined,
20
+ ): number | undefined {
21
+ return value && /^\d+$/.test(value) ? Number(value) : undefined;
22
+ }
@@ -25,6 +25,7 @@ import {
25
25
  conversationDetailReportSchema,
26
26
  conversationEventPageSchema,
27
27
  conversationPendingMessagesReportSchema,
28
+ forkConversationResponseSchema,
28
29
  } from "@sentry/junior/api/schema";
29
30
 
30
31
  import type { DashboardCoreData } from "../types";
@@ -227,6 +228,50 @@ export function useCreateConversation() {
227
228
  });
228
229
  }
229
230
 
231
+ /**
232
+ * Fork a conversation at one assistant reply and send the first message to
233
+ * the fork. A retry with the same key returns the same fork and message.
234
+ */
235
+ export function useForkConversation(sourceConversationId: string) {
236
+ const queryClient = useQueryClient();
237
+ return useMutation({
238
+ mutationFn: async (args: {
239
+ idempotencyKey: string;
240
+ message: string;
241
+ messageId: string;
242
+ }) => {
243
+ const fork = await post(
244
+ forkConversationResponseSchema,
245
+ `/api/conversations/${encodeURIComponent(sourceConversationId)}/forks`,
246
+ { messageId: args.messageId, idempotencyKey: args.idempotencyKey },
247
+ );
248
+ return post(
249
+ acceptedConversationMessageSchema,
250
+ `/api/conversations/${encodeURIComponent(fork.conversationId)}/messages`,
251
+ { idempotencyKey: args.idempotencyKey, message: args.message },
252
+ );
253
+ },
254
+ onSuccess: (accepted, args) => {
255
+ queryClient.setQueryData<ConversationOutboxMessage[]>(
256
+ conversationOutboxQueryKey(accepted.conversationId),
257
+ (current) =>
258
+ upsertConversationOutboxMessage(current, {
259
+ ...conversationOutboxMessageForSubmit({
260
+ idempotencyKey: args.idempotencyKey,
261
+ message: args.message,
262
+ actorIdentity: outboxActorIdentity(queryClient),
263
+ messageId: accepted.messageId,
264
+ }),
265
+ status: "accepted",
266
+ }),
267
+ );
268
+ void queryClient.invalidateQueries({
269
+ queryKey: ["dashboard", "conversations"],
270
+ });
271
+ },
272
+ });
273
+ }
274
+
230
275
  /** Add one dashboard message and refresh the shared transcript. */
231
276
  export function useAppendConversationMessage(conversationId: string) {
232
277
  const queryClient = useQueryClient();
@@ -1,5 +1,11 @@
1
1
  import { AutomationLifecycle } from "./AutomationLifecycle";
2
2
  import { automationOutcomeLabel } from "./automationOutcomes";
3
+ import {
4
+ automationEventConditions,
5
+ automationEventNames,
6
+ automationScheduleLabel,
7
+ formatAutomationRunTime,
8
+ } from "./automationFormat";
3
9
  import type { AutomationSummary } from "@sentry/junior/api/schema";
4
10
  import { Link, useLocation } from "react-router";
5
11
  import { Detail, DetailList } from "../../components/DetailList";
@@ -34,26 +40,24 @@ export function AutomationDetailsDrawer(props: {
34
40
  ) : (
35
41
  automation.createdBy
36
42
  );
43
+ const conditions =
44
+ automation.kind === "event" ? automationEventConditions(automation) : "";
37
45
  const details =
38
46
  automation.kind === "scheduled"
39
47
  ? [
40
- { label: "Schedule", value: automation.schedule },
48
+ { label: "Schedule", value: automationScheduleLabel(automation) },
41
49
  {
42
50
  label: "Next run",
43
51
  value:
44
52
  automation.status === "active" && automation.nextRunAt
45
- ? formatRunDate(automation.nextRunAt)
53
+ ? formatAutomationRunTime(automation.nextRunAt)
46
54
  : "None",
47
55
  },
48
56
  ]
49
57
  : [
50
58
  { label: "Watching", value: automation.resource },
51
- {
52
- label: "Events",
53
- value: automation.events
54
- .map((event) => event.replaceAll(/[._]/g, " "))
55
- .join(", "),
56
- },
59
+ { label: "Events", value: automationEventNames(automation) },
60
+ ...(conditions ? [{ label: "Conditions", value: conditions }] : []),
57
61
  ];
58
62
  const titleId = "automation-details-drawer-title";
59
63
 
@@ -110,16 +114,24 @@ export function AutomationDetailsDrawer(props: {
110
114
  )}
111
115
  </Detail>
112
116
  </DetailList>
113
- {automation.totalRuns > 0 &&
114
- automation.lastRunStatus !== "failed" &&
115
- automation.lastRunStatus !== "blocked" ? (
117
+ <div className="flex flex-wrap gap-x-5">
118
+ {automation.totalRuns > 0 &&
119
+ automation.lastRunStatus !== "failed" &&
120
+ automation.lastRunStatus !== "blocked" ? (
121
+ <Link
122
+ className="w-fit py-2 text-sm text-dashboard-text underline underline-offset-2"
123
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
124
+ >
125
+ View all runs
126
+ </Link>
127
+ ) : null}
116
128
  <Link
117
129
  className="w-fit py-2 text-sm text-dashboard-text underline underline-offset-2"
118
- to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
130
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/versions${location.search}`}
119
131
  >
120
- View all runs
132
+ Version history
121
133
  </Link>
122
- ) : null}
134
+ </div>
123
135
  <details>
124
136
  <summary className="cursor-pointer py-3 text-sm text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
125
137
  More details
@@ -192,10 +204,10 @@ function AutomationExecutionSummary(props: {
192
204
  className="text-dashboard-text underline decoration-white/20 underline-offset-2 hover:decoration-white/60"
193
205
  to={conversationPath(automation.lastConversationId)}
194
206
  >
195
- {formatRunDate(automation.lastRunAt)}
207
+ {formatAutomationRunTime(automation.lastRunAt)}
196
208
  </Link>
197
209
  ) : automation.lastRunAt ? (
198
- formatRunDate(automation.lastRunAt)
210
+ formatAutomationRunTime(automation.lastRunAt)
199
211
  ) : null}
200
212
  </span>
201
213
  </div>
@@ -208,14 +220,3 @@ function formatDate(value: string): string {
208
220
  timeStyle: "short",
209
221
  });
210
222
  }
211
-
212
- function formatRunDate(value: string): string {
213
- return formatTime(value, {
214
- day: "numeric",
215
- hour: "numeric",
216
- minute: "2-digit",
217
- month: "short",
218
- timeZoneName: "short",
219
- year: "numeric",
220
- });
221
- }