@sentry/junior-dashboard 0.271.0 → 0.272.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 (30) hide show
  1. package/dist/app.js +44 -3
  2. package/dist/client.js +67 -67
  3. package/dist/index.js +40 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/app.ts +2 -0
  7. package/src/client/conversations/ConversationPage.tsx +11 -0
  8. package/src/client/conversations/ConversationTranscript.tsx +74 -5
  9. package/src/client/conversations/TranscriptRows.tsx +23 -0
  10. package/src/client/conversations/TranscriptView.tsx +22 -0
  11. package/src/client/conversations/queries.ts +30 -5
  12. package/src/client/conversations/transcriptBottomPinning.ts +84 -13
  13. package/src/client/conversations/transcriptReadPosition.ts +267 -0
  14. package/src/client/conversations/transcriptRenderModel.ts +16 -2
  15. package/src/client/pages/automations/AutomationActions.tsx +2 -3
  16. package/src/client/pages/automations/AutomationEditor.tsx +18 -30
  17. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  18. package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
  19. package/src/client/pages/settings/SettingsPage.tsx +62 -0
  20. package/src/client/review-desk/ReviewDeskPage.tsx +26 -44
  21. package/src/client/review-desk/ReviewDeskSidebar.tsx +29 -23
  22. package/src/client/review-desk/ReviewItemActions.tsx +1 -10
  23. package/src/client/review-desk/queries.ts +3 -5
  24. package/src/client/review-desk/reviewItemFormat.ts +3 -5
  25. package/src/mock-reporting/read-positions.ts +39 -0
  26. package/src/mock-reporting/routes.ts +26 -1
  27. package/src/tailwind.css +1 -1
  28. package/src/client/components/AGENTS.md +0 -36
  29. package/src/client/conversations/AGENTS.md +0 -17
  30. package/src/client/pages/automations/AGENTS.md +0 -15
@@ -5,7 +5,6 @@ import type { ReviewItemPayload } from "@sentry/junior/api/schema";
5
5
  import { InlineError } from "../components/InlineError";
6
6
  import { LoadMorePagination } from "../components/Pagination";
7
7
  import { useDetailsPanel } from "../components/DetailsPanel";
8
- import { Button } from "../components/ui/button";
9
8
  import { Skeleton } from "../components/ui/skeleton";
10
9
  import { useDocumentTitle } from "../documentTitle";
11
10
  import { pathWithSearch, useDebouncedSearchParam } from "../searchParams";
@@ -24,10 +23,7 @@ import { inboxGroups, nextInboxItem, reviewItemPath } from "./reviewItemFormat";
24
23
  const shellClass =
25
24
  "grid h-full min-h-0 w-full overflow-hidden bg-dashboard-bg md:grid-cols-[20rem_minmax(0,1fr)]";
26
25
 
27
- function InboxEmpty(props: {
28
- className?: string;
29
- onShowArchived?: () => void;
30
- }) {
26
+ function InboxEmpty(props: { className?: string }) {
31
27
  return (
32
28
  <div
33
29
  className={cn(
@@ -38,11 +34,6 @@ function InboxEmpty(props: {
38
34
  <p className="m-0 font-display text-lg font-medium text-dashboard-text">
39
35
  Nothing needs you
40
36
  </p>
41
- {props.onShowArchived ? (
42
- <Button onClick={props.onShowArchived} variant="quiet">
43
- See archived items
44
- </Button>
45
- ) : null}
46
37
  </div>
47
38
  );
48
39
  }
@@ -78,22 +69,29 @@ export function ReviewDeskPage() {
78
69
  useDocumentTitle("Inbox");
79
70
  const [searchParams] = useSearchParams();
80
71
  const [search, setSearch, q] = useDebouncedSearchParam();
81
- const {
82
- items: list,
83
- moreArchived,
84
- query: items,
85
- } = useReviewItemsData({ includeArchived: true, q });
72
+ const { items: list, moreArchived, query: items } = useReviewItemsData({ q });
86
73
  const showBlocked = !q;
87
74
  const blocked = useBlockedAutomationsData(showBlocked);
88
75
  const blockedAutomations = showBlocked
89
76
  ? (blocked.data?.automations ?? [])
90
77
  : [];
91
78
  const groups = useMemo(() => inboxGroups(list), [list]);
79
+ // A search lists every match in one list. Later pages add open items, so
80
+ // keep open matches before archived ones.
81
+ const results = useMemo(
82
+ () =>
83
+ q
84
+ ? [
85
+ ...list.filter((item) => item.status === "open"),
86
+ ...list.filter((item) => item.status !== "open"),
87
+ ]
88
+ : undefined,
89
+ [list, q],
90
+ );
92
91
  const open = [...groups.needsYou, ...groups.fyi];
93
92
 
94
- // Archived items stay out of the way until a search or the reader asks.
95
93
  const [collapsed, setCollapsed] = useState<ReadonlySet<InboxGroupKey>>(
96
- () => new Set(["archived"]),
94
+ () => new Set(),
97
95
  );
98
96
  const toggle = (key: InboxGroupKey) =>
99
97
  setCollapsed((current) => {
@@ -102,16 +100,6 @@ export function ReviewDeskPage() {
102
100
  else next.add(key);
103
101
  return next;
104
102
  });
105
- const shownCollapsed = useMemo(() => {
106
- if (!q || !collapsed.has("archived")) return collapsed;
107
- const next = new Set(collapsed);
108
- next.delete("archived");
109
- return next;
110
- }, [collapsed, q]);
111
- const showArchived =
112
- groups.archived.length > 0 && shownCollapsed.has("archived")
113
- ? () => toggle("archived")
114
- : undefined;
115
103
 
116
104
  const itemParam = searchParams.get("item") ?? undefined;
117
105
  // Wide screens select the item that waited longest. Small screens show
@@ -125,12 +113,11 @@ export function ReviewDeskPage() {
125
113
 
126
114
  // Read source titles only for the rows on screen and the selected item.
127
115
  const sourceIds = useMemo(() => {
128
- const shown = [
129
- ...(shownCollapsed.has("needs-you") ? [] : groups.needsYou),
130
- ...(shownCollapsed.has("fyi") ? [] : groups.fyi),
131
- ...(shownCollapsed.has("archived") ? [] : groups.archived),
132
- ...(selected ? [selected] : []),
116
+ const rows = results ?? [
117
+ ...(collapsed.has("needs-you") ? [] : groups.needsYou),
118
+ ...(collapsed.has("fyi") ? [] : groups.fyi),
133
119
  ];
120
+ const shown = selected ? [...rows, selected] : rows;
134
121
  return [
135
122
  ...new Set(
136
123
  shown
@@ -138,7 +125,7 @@ export function ReviewDeskPage() {
138
125
  .map((item) => item.addedByConversationId),
139
126
  ),
140
127
  ];
141
- }, [groups, selected, shownCollapsed]);
128
+ }, [collapsed, groups, results, selected]);
142
129
  const sourceTitles = useLinkedConversationTitles(sourceIds);
143
130
  const sourceTitle = (item: ReviewItemPayload) =>
144
131
  sourceTitles[sourceIds.indexOf(item.addedByConversationId)];
@@ -162,9 +149,8 @@ export function ReviewDeskPage() {
162
149
  )}
163
150
  >
164
151
  <ReviewDeskSidebar
165
- archived={groups.archived}
166
152
  blocked={blockedAutomations}
167
- collapsed={shownCollapsed}
153
+ collapsed={collapsed}
168
154
  error={Boolean(items.error && !items.data)}
169
155
  footer={
170
156
  <>
@@ -179,9 +165,9 @@ export function ReviewDeskPage() {
179
165
  Could not load more items. Try again.
180
166
  </InlineError>
181
167
  ) : null}
182
- {moreArchived && !shownCollapsed.has("archived") ? (
168
+ {moreArchived ? (
183
169
  <p className="m-0 px-3 py-2 text-xs text-dashboard-text-muted">
184
- Search to find older archived items.
170
+ Narrow the search to find older archived items.
185
171
  </p>
186
172
  ) : null}
187
173
  {loaded && q && list.length === 0 ? (
@@ -189,12 +175,7 @@ export function ReviewDeskPage() {
189
175
  No items match "{q}".
190
176
  </p>
191
177
  ) : null}
192
- {empty ? (
193
- <InboxEmpty
194
- className="md:hidden"
195
- onShowArchived={showArchived}
196
- />
197
- ) : null}
178
+ {empty ? <InboxEmpty className="md:hidden" /> : null}
198
179
  </>
199
180
  }
200
181
  fyi={groups.fyi}
@@ -202,6 +183,7 @@ export function ReviewDeskPage() {
202
183
  needsYou={groups.needsYou}
203
184
  onSearchChange={setSearch}
204
185
  onToggle={toggle}
186
+ results={results}
205
187
  search={search}
206
188
  searchParams={listParams}
207
189
  selectedId={selected?.id}
@@ -250,7 +232,7 @@ export function ReviewDeskPage() {
250
232
  ) : direct.error ? (
251
233
  <InlineError className="p-7">Review item not found.</InlineError>
252
234
  ) : empty ? (
253
- <InboxEmpty onShowArchived={showArchived} />
235
+ <InboxEmpty />
254
236
  ) : null}
255
237
  </section>
256
238
  </div>
@@ -12,10 +12,10 @@ import { ConversationSectionHeading } from "../conversations/ConversationSection
12
12
  import { automationPath, formatRelativeTime } from "../format";
13
13
  import { pathWithSearch } from "../searchParams";
14
14
  import { cn } from "../styles";
15
- import { reviewItemPath } from "./reviewItemFormat";
15
+ import { reviewItemPath, reviewItemStatusLabel } from "./reviewItemFormat";
16
16
 
17
17
  /** A sidebar group key. The page owns which groups are collapsed. */
18
- export type InboxGroupKey = "archived" | "blocked" | "fyi" | "needs-you";
18
+ export type InboxGroupKey = "blocked" | "fyi" | "needs-you";
19
19
 
20
20
  type Dot = "accent" | "muted" | "warning" | "closed";
21
21
 
@@ -120,11 +120,11 @@ function InboxRowsLoading() {
120
120
  }
121
121
 
122
122
  /**
123
- * List the inbox: open items that need the viewer, notices, blocked
124
- * Automations, and archived items, each in a group that collapses.
123
+ * List the inbox: open items that need the viewer, notices, and blocked
124
+ * Automations, each in a group that collapses. A search shows one list of
125
+ * open and archived matches instead.
125
126
  */
126
127
  export function ReviewDeskSidebar(props: {
127
- archived: ReviewItemPayload[];
128
128
  blocked: AutomationSummary[];
129
129
  collapsed: ReadonlySet<InboxGroupKey>;
130
130
  /** Shown under the groups, such as an empty state or load more. */
@@ -135,6 +135,8 @@ export function ReviewDeskSidebar(props: {
135
135
  needsYou: ReviewItemPayload[];
136
136
  onSearchChange(value: string): void;
137
137
  onToggle(key: InboxGroupKey): void;
138
+ /** Search matches in API order. Set while a search is active. */
139
+ results?: ReviewItemPayload[];
138
140
  search: string;
139
141
  searchParams: URLSearchParams;
140
142
  selectedId?: string;
@@ -153,16 +155,19 @@ export function ReviewDeskSidebar(props: {
153
155
  ];
154
156
  const firstKey = (index: number) =>
155
157
  shown.slice(0, index).every((count) => count === 0);
156
- const itemRow = (item: ReviewItemPayload, dot: Dot) => {
158
+ const itemRow = (item: ReviewItemPayload) => {
159
+ const open = item.status === "open";
157
160
  const source = item.automationId ? "Automation" : props.sourceTitle(item);
158
- const age = formatRelativeTime(
159
- item.status === "open" ? item.createdAt : item.closedAt,
160
- );
161
+ const meta = [
162
+ open ? undefined : reviewItemStatusLabel(item),
163
+ source,
164
+ formatRelativeTime(open ? item.createdAt : item.closedAt),
165
+ ];
161
166
  return (
162
167
  <InboxRow
163
- dot={dot}
168
+ dot={open ? (item.kind === "action" ? "accent" : "muted") : "closed"}
164
169
  key={item.id}
165
- meta={source ? `${source} · ${age}` : age}
170
+ meta={meta.filter(Boolean).join(" · ")}
166
171
  selected={
167
172
  item.id !== props.selectedId
168
173
  ? undefined
@@ -202,6 +207,18 @@ export function ReviewDeskSidebar(props: {
202
207
  </InlineError>
203
208
  ) : props.loading ? (
204
209
  <InboxRowsLoading />
210
+ ) : props.results ? (
211
+ <>
212
+ {props.results.length > 0 ? (
213
+ <ul
214
+ aria-label="Search results"
215
+ className="m-0 grid list-none gap-0.5 p-0 pt-1"
216
+ >
217
+ {props.results.map(itemRow)}
218
+ </ul>
219
+ ) : null}
220
+ {props.footer}
221
+ </>
205
222
  ) : (
206
223
  <>
207
224
  {groups.map((group, index) => (
@@ -213,9 +230,7 @@ export function ReviewDeskSidebar(props: {
213
230
  label={group.label}
214
231
  onToggle={() => props.onToggle(group.key)}
215
232
  >
216
- {group.items.map((item) =>
217
- itemRow(item, item.kind === "action" ? "accent" : "muted"),
218
- )}
233
+ {group.items.map(itemRow)}
219
234
  </InboxGroup>
220
235
  ))}
221
236
  <InboxGroup
@@ -235,15 +250,6 @@ export function ReviewDeskSidebar(props: {
235
250
  />
236
251
  ))}
237
252
  </InboxGroup>
238
- <InboxGroup
239
- collapsed={props.collapsed.has("archived")}
240
- count={props.archived.length}
241
- first={firstKey(3)}
242
- label="Archived"
243
- onToggle={() => props.onToggle("archived")}
244
- >
245
- {props.archived.map((item) => itemRow(item, "closed"))}
246
- </InboxGroup>
247
253
  {props.footer}
248
254
  </>
249
255
  )}
@@ -102,7 +102,7 @@ export function ReviewItemActions(props: { item: ReviewItemPayload }) {
102
102
  }
103
103
  }
104
104
 
105
- // Take and Dismiss are quiet, so the answer or Done stays the main action.
105
+ // Take and Dismiss are quiet, so the answer stays the main action.
106
106
  const secondaryActions = (
107
107
  <>
108
108
  {item.canTake ? (
@@ -149,15 +149,6 @@ export function ReviewItemActions(props: { item: ReviewItemPayload }) {
149
149
  </div>
150
150
  ) : (
151
151
  <div className="flex flex-wrap items-center gap-2">
152
- {item.kind === "notice" ? (
153
- <Button
154
- disabled={action.isPending}
155
- onClick={() => void run({ action: "complete" }).catch(() => {})}
156
- variant="primary"
157
- >
158
- Done
159
- </Button>
160
- ) : null}
161
152
  <div className="flex flex-wrap gap-1 sm:ml-auto">
162
153
  {secondaryActions}
163
154
  </div>
@@ -25,19 +25,17 @@ const COUNT_REFRESH_MS = 60_000;
25
25
 
26
26
  /**
27
27
  * List the open Review items that the viewer can see, one page at a time.
28
- * A search or `includeArchived` also finds archived items, with the first
29
- * page. `items` holds the items of every loaded page, and `query` loads more.
28
+ * A search also finds archived items, with the first page. `items` holds the
29
+ * items of every loaded page, and `query` loads more.
30
30
  */
31
31
  export function useReviewItemsData(filters: {
32
32
  conversationId?: string;
33
- includeArchived?: boolean;
34
33
  q?: string;
35
34
  }) {
36
35
  const params = new URLSearchParams();
37
36
  if (filters.conversationId) {
38
37
  params.set("conversationId", filters.conversationId);
39
38
  }
40
- if (filters.includeArchived) params.set("include", "archived");
41
39
  if (filters.q) params.set("q", filters.q);
42
40
  const search = params.toString();
43
41
  const query = useInfiniteQuery({
@@ -137,7 +135,7 @@ export function useBlockedAutomationsData(enabled: boolean) {
137
135
  });
138
136
  }
139
137
 
140
- /** Take, submit, complete, or dismiss one Review item. */
138
+ /** Take, submit, or dismiss one Review item. */
141
139
  export function useReviewItemAction() {
142
140
  const queryClient = useQueryClient();
143
141
  return useMutation({
@@ -101,9 +101,8 @@ export function reviewItemWaitsForAnswer(item: ReviewItemPayload): boolean {
101
101
  );
102
102
  }
103
103
 
104
- /** The Review items of the inbox, in the groups and order the list shows. */
104
+ /** The open Review items of the inbox, in the groups and order it shows. */
105
105
  export type InboxGroups = {
106
- archived: ReviewItemPayload[];
107
106
  fyi: ReviewItemPayload[];
108
107
  needsYou: ReviewItemPayload[];
109
108
  };
@@ -117,13 +116,12 @@ function inboxOrder(a: ReviewItemPayload, b: ReviewItemPayload): number {
117
116
  }
118
117
 
119
118
  /**
120
- * Split items into inbox groups. Open items show oldest first, so the item
121
- * that waited longest is at the top. Archived items keep the API order.
119
+ * Split open items into inbox groups, oldest first, so the item that waited
120
+ * longest is at the top. Archived items are in no group.
122
121
  */
123
122
  export function inboxGroups(items: readonly ReviewItemPayload[]): InboxGroups {
124
123
  const open = items.filter((item) => item.status === "open");
125
124
  return {
126
- archived: items.filter((item) => item.status !== "open"),
127
125
  fyi: open.filter((item) => item.kind === "notice").sort(inboxOrder),
128
126
  needsYou: open.filter((item) => item.kind === "action").sort(inboxOrder),
129
127
  };
@@ -0,0 +1,39 @@
1
+ import type {
2
+ ConversationDetailReport,
3
+ ConversationReadPosition,
4
+ } from "@sentry/junior/api/schema";
5
+ import { readMockConversationDetail } from "./fixtures";
6
+
7
+ /** Process-local read positions so mock QA can reopen at unread rows. */
8
+ const mockReadPositions = new Map<string, number>();
9
+
10
+ /** Clear mock read positions so tests sharing a worker do not leak state. */
11
+ export function resetMockReadPositions(): void {
12
+ mockReadPositions.clear();
13
+ }
14
+
15
+ /** Move a mock read position forward, like the API. */
16
+ export function markMockConversationRead(
17
+ conversationId: string,
18
+ lastReadSeq: number,
19
+ ): ConversationReadPosition | undefined {
20
+ const detail = readMockConversationDetail(conversationId);
21
+ if (!detail) return undefined;
22
+ const lastSeq = detail.events.at(-1)?.seq ?? 0;
23
+ const stored = Math.max(
24
+ mockReadPositions.get(conversationId) ?? -1,
25
+ Math.min(lastReadSeq, lastSeq),
26
+ );
27
+ mockReadPositions.set(conversationId, stored);
28
+ return { lastReadSeq: stored };
29
+ }
30
+
31
+ /** Add the mock read position to a detail report. */
32
+ export function withMockReadPosition(
33
+ report: ConversationDetailReport,
34
+ ): ConversationDetailReport {
35
+ const lastReadSeq = mockReadPositions.get(report.conversationId);
36
+ return lastReadSeq === undefined
37
+ ? report
38
+ : { ...report, readPosition: { lastReadSeq } };
39
+ }
@@ -15,6 +15,7 @@ import {
15
15
  conversationFeedQuerySchema,
16
16
  conversationFeedSchema,
17
17
  conversationParamsSchema,
18
+ conversationReadPositionSchema,
18
19
  conversationPendingMessagesReportSchema,
19
20
  conversationStatsReportSchema,
20
21
  conversationWatchesReportSchema,
@@ -38,6 +39,10 @@ import {
38
39
  automationRunListSchema,
39
40
  } from "@sentry/junior/api/schema";
40
41
  import { mockChartPng } from "./chart-png";
42
+ import {
43
+ markMockConversationRead,
44
+ withMockReadPosition,
45
+ } from "./read-positions";
41
46
  import {
42
47
  filterMockConversationsByRepository,
43
48
  readMockCodeRepository,
@@ -244,7 +249,27 @@ export function createMockReportingApi(): Hono<{
244
249
  const { conversationId } = params.data;
245
250
  const report = readMockConversationDetail(conversationId, query.data.limit);
246
251
  return report
247
- ? jsonResponse(conversationDetailReportSchema, report)
252
+ ? jsonResponse(
253
+ conversationDetailReportSchema,
254
+ withMockReadPosition(report),
255
+ )
256
+ : errorResponse("Conversation not found.", 404);
257
+ });
258
+ app.put("/conversations/:conversationId/read-position", async (c) => {
259
+ const params = conversationParamsSchema.safeParse(c.req.param());
260
+ if (!params.success) {
261
+ return errorResponse("Invalid route parameters.", 400);
262
+ }
263
+ const body = conversationReadPositionSchema.safeParse(await c.req.json());
264
+ if (!body.success) {
265
+ return errorResponse("Invalid request body.", 400);
266
+ }
267
+ const result = markMockConversationRead(
268
+ params.data.conversationId,
269
+ body.data.lastReadSeq,
270
+ );
271
+ return result
272
+ ? jsonResponse(conversationReadPositionSchema, result)
248
273
  : errorResponse("Conversation not found.", 404);
249
274
  });
250
275
  app.patch("/conversations/:conversationId/archive", async (c) => {
package/src/tailwind.css CHANGED
@@ -4,7 +4,7 @@
4
4
  @import "shadcn/tailwind.css";
5
5
 
6
6
  /*
7
- * Dashboard design tokens. components/AGENTS.md has the token rules.
7
+ * Dashboard design tokens. `pnpm dashboard-style:check` enforces their use.
8
8
  * Keep structural UI colors here so a second theme can redefine them.
9
9
  * Prefer roles over opacity ladders. Status accents stay as Tailwind palette utilities.
10
10
  */
@@ -1,36 +0,0 @@
1
- # Dashboard UI rules
2
-
3
- These apply to all dashboard UI. `pnpm dashboard-style:check` enforces the
4
- token rules.
5
-
6
- - Add shadcn components with
7
- `pnpm --filter @sentry/junior-dashboard exec shadcn add <component>`.
8
- - Change a `ui/` file only when every caller needs the same style. Do not
9
- wrap a `ui/` component just to rename it or its props.
10
- - Do not apply the shadcn CLI's global border or body resets. Keep the
11
- Tailwind radius scale.
12
- - Confirmations use `ui/alert-dialog`, not `window.confirm`.
13
- - Short task forms use `ui/dialog`, never a `Drawer` or `DetailsPanel`. Mount
14
- each dialog through `useSheet`, only while open.
15
- - Put only inline elements in a `ui/trace` row button.
16
- - Label icon-only controls with `TooltipContent size="compact"`, not the
17
- native `title` attribute.
18
- - Set `focusableWhenDisabled` on a tooltip trigger `ui/button` that can be
19
- disabled.
20
- - Field ids are `<control id>-error` and `<control id>-help`.
21
- - Put a checkbox or radio inside its `<label>`. Give each `RadioGroup` an
22
- `aria-label`.
23
- - A form page has one `Button variant="primary"`.
24
- - Do not replace `ObjectIcon` with a provider logo.
25
- - Use `light:` only for values that no token covers. Chart series use
26
- `var(--color-*)`, not hex.
27
- - Use `outline-dashboard-focus` for keyboard focus only.
28
- - Labels use sentence case, with no uppercase or letter spacing.
29
- - App navigation uses pills, page links use `SegmentedNav`, and panel
30
- switches use `ui/tabs`.
31
- - Do not add viewport, offset, or safe-area math in pages.
32
- `layout/VisualViewportShell`, `conversations/ChatLayout`, and
33
- `conversations/ComposerDock` own it. Home and create do not use
34
- `ComposerDock`.
35
- - Keep a feature component under `conversations/` or `pages/` until a second
36
- real caller needs it.
@@ -1,17 +0,0 @@
1
- # Transcript components
2
-
3
- - Keep horizontal padding inside each transcript row, so focus outlines are
4
- not clipped. Do not add negative margins at `TranscriptSummaryTrigger` call
5
- sites.
6
- - The Automation trigger card shows the credentials from the run's dispatch
7
- record, not the current setting. Keep the run input collapsed, because it
8
- is not from a person.
9
- - A nested `Transcript` never uses the panel of the page behind it.
10
- - The page shows one right panel at a time. Closing a panel returns focus to
11
- the control that opened it.
12
- - The URL owns the selected event in the main Conversation. Standalone
13
- transcripts keep it in `ConversationEventLog`.
14
- - A pending exit must not close a new panel or another Conversation. A
15
- pending fork blocks dismissal of its dialog.
16
- - Raw JSON keeps the original ISO values. Persist the UTC/local mode, not the
17
- timezone.
@@ -1,15 +0,0 @@
1
- # Automation pages
2
-
3
- - Core owns validation, edit access, creator-only rules, revision checks,
4
- Schedule previews, and the Event catalog. Show core errors. Do not repeat
5
- these rules in the page.
6
- - Preview requests never save or run work.
7
- - Send only changed fields. Keep unsupported triggers and stored Destinations
8
- unless the user replaces them. Keep outcome order.
9
- - Warn that an instruction or event selector change switches to system
10
- credentials.
11
- - Query refreshes must not replace an unsaved draft. After a stale save, let
12
- the user keep their changes or load the latest version.
13
- - Lifecycle actions and "Make active" read the latest revision first.
14
- - `AutomationRecentRuns` shows terminal runs only. Do not infer queued or
15
- running work from terminal records.