@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.
- package/dist/app.js +44 -3
- package/dist/client.js +67 -67
- package/dist/index.js +40 -3
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/app.ts +2 -0
- package/src/client/conversations/ConversationPage.tsx +11 -0
- package/src/client/conversations/ConversationTranscript.tsx +74 -5
- package/src/client/conversations/TranscriptRows.tsx +23 -0
- package/src/client/conversations/TranscriptView.tsx +22 -0
- package/src/client/conversations/queries.ts +30 -5
- package/src/client/conversations/transcriptBottomPinning.ts +84 -13
- package/src/client/conversations/transcriptReadPosition.ts +267 -0
- package/src/client/conversations/transcriptRenderModel.ts +16 -2
- package/src/client/pages/automations/AutomationActions.tsx +2 -3
- package/src/client/pages/automations/AutomationEditor.tsx +18 -30
- package/src/client/pages/automations/AutomationOverview.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
- package/src/client/pages/settings/SettingsPage.tsx +62 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +26 -44
- package/src/client/review-desk/ReviewDeskSidebar.tsx +29 -23
- package/src/client/review-desk/ReviewItemActions.tsx +1 -10
- package/src/client/review-desk/queries.ts +3 -5
- package/src/client/review-desk/reviewItemFormat.ts +3 -5
- package/src/mock-reporting/read-positions.ts +39 -0
- package/src/mock-reporting/routes.ts +26 -1
- package/src/tailwind.css +1 -1
- package/src/client/components/AGENTS.md +0 -36
- package/src/client/conversations/AGENTS.md +0 -17
- package/src/client/pages/automations/AGENTS.md +0 -15
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track how far the reader has read a Conversation transcript.
|
|
3
|
+
*
|
|
4
|
+
* Rows from stored events carry `data-transcript-seq`. While the page is
|
|
5
|
+
* visible, the reporter sends the sequence of the last row that has reached
|
|
6
|
+
* the viewport. The server keeps the highest value, so every client of the
|
|
7
|
+
* same user shares one position. Detail polls bring it back to other clients.
|
|
8
|
+
*
|
|
9
|
+
* The page reads the position once when a Conversation opens. That value
|
|
10
|
+
* places the "New" marker and, by the reader's preference, where the
|
|
11
|
+
* transcript starts. It does not move during the visit, like Slack.
|
|
12
|
+
*/
|
|
13
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
14
|
+
import { useLocalStorage } from "@uidotdev/usehooks";
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
conversationReadPositionSchema,
|
|
19
|
+
type ConversationDetailReport,
|
|
20
|
+
type ConversationReadPosition,
|
|
21
|
+
} from "@sentry/junior/api/schema";
|
|
22
|
+
import { put } from "../http";
|
|
23
|
+
import { conversationDetailQueryKey } from "./queries";
|
|
24
|
+
import { scrollRootFor } from "./transcriptBottomPinning";
|
|
25
|
+
|
|
26
|
+
const READ_REPORT_DELAY_MS = 750;
|
|
27
|
+
const SEQ_ATTRIBUTE = "data-transcript-seq";
|
|
28
|
+
const ROW_SELECTOR = `[${SEQ_ATTRIBUTE}]`;
|
|
29
|
+
const START_POSITION_STORAGE_KEY = "junior:conversation:start-position";
|
|
30
|
+
|
|
31
|
+
/** Where a Conversation opens: at the first unread row or at the latest. */
|
|
32
|
+
export type ConversationStartPosition = "unread" | "latest";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Event sequences that were unread when the Conversation opened. Rows that
|
|
36
|
+
* arrive later are not in it, so a reader who follows new replies gets no
|
|
37
|
+
* marker for them.
|
|
38
|
+
*/
|
|
39
|
+
export type TranscriptUnreadRange = { afterSeq: number; throughSeq: number };
|
|
40
|
+
|
|
41
|
+
/** The read state when the Conversation opened. */
|
|
42
|
+
export type ReadPositionAtOpen = {
|
|
43
|
+
lastReadSeq?: number;
|
|
44
|
+
unread?: TranscriptUnreadRange;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Edit the browser-only preference for where Conversations open. */
|
|
48
|
+
export function useConversationStartPosition() {
|
|
49
|
+
return useLocalStorage<ConversationStartPosition>(
|
|
50
|
+
START_POSITION_STORAGE_KEY,
|
|
51
|
+
"unread",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the start preference without subscribing. A Conversation reads it once
|
|
57
|
+
* when it opens, and this works where `useLocalStorage` cannot, such as
|
|
58
|
+
* server rendering.
|
|
59
|
+
*/
|
|
60
|
+
export function readConversationStartPosition(): ConversationStartPosition {
|
|
61
|
+
if (typeof window === "undefined") return "unread";
|
|
62
|
+
try {
|
|
63
|
+
const stored = window.localStorage.getItem(START_POSITION_STORAGE_KEY);
|
|
64
|
+
return stored && JSON.parse(stored) === "latest" ? "latest" : "unread";
|
|
65
|
+
} catch {
|
|
66
|
+
return "unread";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Keep the read state from when the reader opened the Conversation. The page
|
|
72
|
+
* is keyed by Conversation, so this runs once for each visit.
|
|
73
|
+
*
|
|
74
|
+
* The read position comes from the first detail, which can be a cached copy.
|
|
75
|
+
* The last unread row comes from the first detail fetched in this visit, so a
|
|
76
|
+
* stale cache cannot hide messages that arrived while the reader was away,
|
|
77
|
+
* and later polls cannot add rows that arrived after open.
|
|
78
|
+
*/
|
|
79
|
+
export function useReadPositionAtOpen(
|
|
80
|
+
detail: DetailAtOpen | undefined,
|
|
81
|
+
/** The first detail fetched in this visit, not only read from the cache. */
|
|
82
|
+
fetched: DetailAtOpen | undefined,
|
|
83
|
+
): ReadPositionAtOpen {
|
|
84
|
+
// Store derived state during render so the first transcript render has it.
|
|
85
|
+
const [opened, setOpened] = useState<{ lastReadSeq?: number }>();
|
|
86
|
+
const [fetchedEnd, setFetchedEnd] = useState<{ throughSeq?: number }>();
|
|
87
|
+
const read =
|
|
88
|
+
opened ??
|
|
89
|
+
(detail ? { lastReadSeq: detail.readPosition?.lastReadSeq } : undefined);
|
|
90
|
+
if (!opened && read) setOpened(read);
|
|
91
|
+
const end =
|
|
92
|
+
fetchedEnd ??
|
|
93
|
+
(fetched ? { throughSeq: fetched.events.at(-1)?.seq } : undefined);
|
|
94
|
+
if (!fetchedEnd && end) setFetchedEnd(end);
|
|
95
|
+
return readPositionAtOpen(
|
|
96
|
+
read?.lastReadSeq,
|
|
97
|
+
end ? end.throughSeq : detail?.events.at(-1)?.seq,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type DetailAtOpen = {
|
|
102
|
+
events: readonly { seq: number }[];
|
|
103
|
+
readPosition?: ConversationReadPosition;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Find the rows after the read position, through the last row at open. */
|
|
107
|
+
function readPositionAtOpen(
|
|
108
|
+
lastReadSeq: number | undefined,
|
|
109
|
+
throughSeq: number | undefined,
|
|
110
|
+
): ReadPositionAtOpen {
|
|
111
|
+
if (lastReadSeq === undefined) return {};
|
|
112
|
+
if (throughSeq === undefined || throughSeq <= lastReadSeq) {
|
|
113
|
+
return { lastReadSeq };
|
|
114
|
+
}
|
|
115
|
+
return { lastReadSeq, unread: { afterSeq: lastReadSeq, throughSeq } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Send read positions for one Conversation. A position at or before the one
|
|
120
|
+
* in the cached detail is not sent, because the server already has it. The
|
|
121
|
+
* cached detail keeps the stored value, so the next visit in this session
|
|
122
|
+
* opens at the right place.
|
|
123
|
+
*/
|
|
124
|
+
export function useMarkConversationRead(conversationId: string) {
|
|
125
|
+
const queryClient = useQueryClient();
|
|
126
|
+
const { mutate } = useMutation({
|
|
127
|
+
mutationFn: (lastReadSeq: number) =>
|
|
128
|
+
put(
|
|
129
|
+
conversationReadPositionSchema,
|
|
130
|
+
`/api/conversations/${encodeURIComponent(conversationId)}/read-position`,
|
|
131
|
+
{ lastReadSeq },
|
|
132
|
+
),
|
|
133
|
+
onSuccess: (readPosition) => {
|
|
134
|
+
queryClient.setQueryData<ConversationDetailReport>(
|
|
135
|
+
conversationDetailQueryKey(conversationId),
|
|
136
|
+
(detail) => (detail ? { ...detail, readPosition } : detail),
|
|
137
|
+
);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
return useCallback(
|
|
141
|
+
(lastReadSeq: number) => {
|
|
142
|
+
const stored = queryClient.getQueryData<ConversationDetailReport>(
|
|
143
|
+
conversationDetailQueryKey(conversationId),
|
|
144
|
+
)?.readPosition?.lastReadSeq;
|
|
145
|
+
if (stored !== undefined && lastReadSeq <= stored) return;
|
|
146
|
+
mutate(lastReadSeq);
|
|
147
|
+
},
|
|
148
|
+
[conversationId, mutate, queryClient],
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Report the last transcript row that the reader saw.
|
|
154
|
+
*
|
|
155
|
+
* An IntersectionObserver keeps the set of rows in the viewport, so a scroll
|
|
156
|
+
* does not measure rows. Reports send the highest seq in that set. They wait
|
|
157
|
+
* a short time and only move forward. A hidden or paused page does not
|
|
158
|
+
* report. Leaving the page or hiding it sends the pending row at once, so a
|
|
159
|
+
* quick exit keeps progress.
|
|
160
|
+
*/
|
|
161
|
+
export function useTranscriptReadReporter(input: {
|
|
162
|
+
content: HTMLElement | null;
|
|
163
|
+
/** The stored position when the Conversation opened. */
|
|
164
|
+
lastReadSeq?: number;
|
|
165
|
+
onRead?: (seq: number) => void;
|
|
166
|
+
/**
|
|
167
|
+
* Stop reporting while the rows are not in reading order, such as search
|
|
168
|
+
* results. This is read when a report is scheduled, so the render that
|
|
169
|
+
* changes the rows also stops their report.
|
|
170
|
+
*/
|
|
171
|
+
paused?: boolean;
|
|
172
|
+
}): void {
|
|
173
|
+
const onReadRef = useRef(input.onRead);
|
|
174
|
+
onReadRef.current = input.onRead;
|
|
175
|
+
const pausedRef = useRef(input.paused);
|
|
176
|
+
pausedRef.current = input.paused;
|
|
177
|
+
const reportedRef = useRef(input.lastReadSeq ?? -1);
|
|
178
|
+
const enabled = Boolean(input.onRead);
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const content = input.content;
|
|
182
|
+
const root = scrollRootFor(content);
|
|
183
|
+
if (!enabled || !content || !root) return;
|
|
184
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
185
|
+
|
|
186
|
+
const visibleRows = new Set<HTMLElement>();
|
|
187
|
+
let timer: number | undefined;
|
|
188
|
+
let seenSeq: number | null = null;
|
|
189
|
+
const send = () => {
|
|
190
|
+
if (timer !== undefined) window.clearTimeout(timer);
|
|
191
|
+
timer = undefined;
|
|
192
|
+
const seq = seenSeq;
|
|
193
|
+
seenSeq = null;
|
|
194
|
+
if (seq === null || seq <= reportedRef.current) return;
|
|
195
|
+
reportedRef.current = seq;
|
|
196
|
+
onReadRef.current?.(seq);
|
|
197
|
+
};
|
|
198
|
+
const schedule = () => {
|
|
199
|
+
if (pausedRef.current || document.visibilityState !== "visible") return;
|
|
200
|
+
// Read the seq now: an activity group row moves its seq as it grows.
|
|
201
|
+
for (const row of visibleRows) {
|
|
202
|
+
const seq = Number(row.dataset.transcriptSeq);
|
|
203
|
+
if (Number.isSafeInteger(seq)) seenSeq = Math.max(seenSeq ?? -1, seq);
|
|
204
|
+
}
|
|
205
|
+
if (seenSeq === null) return;
|
|
206
|
+
timer ??= window.setTimeout(send, READ_REPORT_DELAY_MS);
|
|
207
|
+
};
|
|
208
|
+
const onVisibilityChange = () => {
|
|
209
|
+
if (document.visibilityState === "visible") schedule();
|
|
210
|
+
else send();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const intersections = new IntersectionObserver(
|
|
214
|
+
(entries) => {
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
const row = entry.target as HTMLElement;
|
|
217
|
+
if (entry.isIntersecting && row.isConnected) visibleRows.add(row);
|
|
218
|
+
else visibleRows.delete(row);
|
|
219
|
+
}
|
|
220
|
+
schedule();
|
|
221
|
+
},
|
|
222
|
+
{ root: root === window ? null : (root as HTMLElement) },
|
|
223
|
+
);
|
|
224
|
+
const rowsIn = (node: Node): HTMLElement[] => {
|
|
225
|
+
if (!(node instanceof HTMLElement)) return [];
|
|
226
|
+
const rows = [...node.querySelectorAll<HTMLElement>(ROW_SELECTOR)];
|
|
227
|
+
return node.matches(ROW_SELECTOR) ? [node, ...rows] : rows;
|
|
228
|
+
};
|
|
229
|
+
const observeRows = (node: Node) => {
|
|
230
|
+
for (const row of rowsIn(node)) intersections.observe(row);
|
|
231
|
+
};
|
|
232
|
+
const forgetRows = (node: Node) => {
|
|
233
|
+
for (const row of rowsIn(node)) {
|
|
234
|
+
intersections.unobserve(row);
|
|
235
|
+
visibleRows.delete(row);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
// Rows mount and unmount as history loads and replies arrive. An
|
|
239
|
+
// activity group row keeps its node and raises its seq as tools arrive.
|
|
240
|
+
const mutations = new MutationObserver((records) => {
|
|
241
|
+
let seqChanged = false;
|
|
242
|
+
for (const record of records) {
|
|
243
|
+
record.addedNodes.forEach(observeRows);
|
|
244
|
+
record.removedNodes.forEach(forgetRows);
|
|
245
|
+
if (record.type === "attributes") {
|
|
246
|
+
const row = record.target as HTMLElement;
|
|
247
|
+
intersections.observe(row);
|
|
248
|
+
seqChanged ||= visibleRows.has(row);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (seqChanged) schedule();
|
|
252
|
+
});
|
|
253
|
+
mutations.observe(content, {
|
|
254
|
+
attributeFilter: [SEQ_ATTRIBUTE],
|
|
255
|
+
childList: true,
|
|
256
|
+
subtree: true,
|
|
257
|
+
});
|
|
258
|
+
observeRows(content);
|
|
259
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
260
|
+
return () => {
|
|
261
|
+
send();
|
|
262
|
+
intersections.disconnect();
|
|
263
|
+
mutations.disconnect();
|
|
264
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
265
|
+
};
|
|
266
|
+
}, [enabled, input.content]);
|
|
267
|
+
}
|
|
@@ -75,7 +75,7 @@ export type RenderedMessageEntry = {
|
|
|
75
75
|
};
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
-
export type RenderedTranscriptEntry =
|
|
78
|
+
export type RenderedTranscriptEntry = (
|
|
79
79
|
| RenderedAttachmentsDeliveredEntry
|
|
80
80
|
| RenderedContextEventEntry
|
|
81
81
|
| RenderedFailureEntry
|
|
@@ -83,7 +83,11 @@ export type RenderedTranscriptEntry =
|
|
|
83
83
|
| RenderedStructuredEventEntry
|
|
84
84
|
| RenderedReasoningEntry
|
|
85
85
|
| RenderedSubagentEntry
|
|
86
|
-
| RenderedToolEntry
|
|
86
|
+
| RenderedToolEntry
|
|
87
|
+
) & {
|
|
88
|
+
/** Event sequence of the source message. Pending sends have none. */
|
|
89
|
+
seq?: number;
|
|
90
|
+
};
|
|
87
91
|
|
|
88
92
|
export type TranscriptViewMode = "raw" | "rich";
|
|
89
93
|
|
|
@@ -94,6 +98,8 @@ export function groupTranscriptMessages(
|
|
|
94
98
|
const entries: RenderedTranscriptEntry[] = [];
|
|
95
99
|
|
|
96
100
|
for (const message of messages) {
|
|
101
|
+
const seq = message.pending ? undefined : message.sourceSeq;
|
|
102
|
+
const source = seq === undefined ? {} : { seq };
|
|
97
103
|
let textParts: TranscriptViewTextPart[] = [];
|
|
98
104
|
let textGroup = 0;
|
|
99
105
|
const flushMessage = () => {
|
|
@@ -108,6 +114,7 @@ export function groupTranscriptMessages(
|
|
|
108
114
|
key: `${message.sourceSeq}:message:${textGroup}`,
|
|
109
115
|
kind: "message",
|
|
110
116
|
message: nextMessage as RenderedMessageEntry["message"],
|
|
117
|
+
...source,
|
|
111
118
|
});
|
|
112
119
|
textParts = [];
|
|
113
120
|
textGroup += 1;
|
|
@@ -124,6 +131,7 @@ export function groupTranscriptMessages(
|
|
|
124
131
|
entries.push({
|
|
125
132
|
key: `tool:${part.id}`,
|
|
126
133
|
kind: "tool",
|
|
134
|
+
...source,
|
|
127
135
|
part,
|
|
128
136
|
timestamp: part.startedTimestamp ?? message.timestamp,
|
|
129
137
|
});
|
|
@@ -131,6 +139,7 @@ export function groupTranscriptMessages(
|
|
|
131
139
|
entries.push({
|
|
132
140
|
key: `${message.sourceSeq}:reasoning:${partIndex}`,
|
|
133
141
|
kind: "reasoning",
|
|
142
|
+
...source,
|
|
134
143
|
part,
|
|
135
144
|
timestamp: message.timestamp,
|
|
136
145
|
});
|
|
@@ -138,6 +147,7 @@ export function groupTranscriptMessages(
|
|
|
138
147
|
entries.push({
|
|
139
148
|
key: `subagent:${part.id}`,
|
|
140
149
|
kind: "subagent",
|
|
150
|
+
...source,
|
|
141
151
|
part,
|
|
142
152
|
timestamp: message.timestamp,
|
|
143
153
|
});
|
|
@@ -145,6 +155,7 @@ export function groupTranscriptMessages(
|
|
|
145
155
|
entries.push({
|
|
146
156
|
key: `${message.sourceSeq}:structured-event:${part.namespace}:${part.name}`,
|
|
147
157
|
kind: "structured_event",
|
|
158
|
+
...source,
|
|
148
159
|
part,
|
|
149
160
|
timestamp: message.timestamp,
|
|
150
161
|
});
|
|
@@ -152,6 +163,7 @@ export function groupTranscriptMessages(
|
|
|
152
163
|
entries.push({
|
|
153
164
|
key: `${message.sourceSeq}:attachments-delivered`,
|
|
154
165
|
kind: "attachments_delivered",
|
|
166
|
+
...source,
|
|
155
167
|
part,
|
|
156
168
|
timestamp: message.timestamp,
|
|
157
169
|
});
|
|
@@ -159,6 +171,7 @@ export function groupTranscriptMessages(
|
|
|
159
171
|
entries.push({
|
|
160
172
|
key: `${message.sourceSeq}:context:${partIndex}`,
|
|
161
173
|
kind: "context",
|
|
174
|
+
...source,
|
|
162
175
|
part,
|
|
163
176
|
timestamp: message.timestamp,
|
|
164
177
|
});
|
|
@@ -170,6 +183,7 @@ export function groupTranscriptMessages(
|
|
|
170
183
|
entries.push({
|
|
171
184
|
key: `${message.sourceSeq}:failure`,
|
|
172
185
|
kind: "failure",
|
|
186
|
+
...source,
|
|
173
187
|
failureCode: message.failureCode,
|
|
174
188
|
...(message.failureReason
|
|
175
189
|
? { failureReason: message.failureReason }
|
|
@@ -22,9 +22,8 @@ import { FormNotice } from "../../components/FormNotice";
|
|
|
22
22
|
import { DashboardApiError, fetchDashboardJson, post } from "../../http";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Header actions for one Automation: Edit,
|
|
26
|
-
*
|
|
27
|
-
* lifecycle. Lifecycle actions re-read the revision before they apply.
|
|
25
|
+
* Header actions for one Automation: Edit, Pause or Resume, and Delete.
|
|
26
|
+
* Lifecycle actions re-read the revision before they apply.
|
|
28
27
|
*/
|
|
29
28
|
export function AutomationActions({
|
|
30
29
|
automation,
|
|
@@ -20,10 +20,7 @@ import { AutomationDraftChanges } from "./AutomationDraftChanges";
|
|
|
20
20
|
import { AutomationFormSection } from "./AutomationFormSection";
|
|
21
21
|
import { AutomationScheduleFields } from "./AutomationScheduleFields";
|
|
22
22
|
import { AutomationEventFields } from "./AutomationEventFields";
|
|
23
|
-
import {
|
|
24
|
-
AutomationOutcomeFields,
|
|
25
|
-
AutomationOutcomeList,
|
|
26
|
-
} from "./AutomationOutcomeFields";
|
|
23
|
+
import { AutomationOutcomeFields } from "./AutomationOutcomeFields";
|
|
27
24
|
import {
|
|
28
25
|
automationDraftChanges,
|
|
29
26
|
createAutomationDraft,
|
|
@@ -131,11 +128,14 @@ export function AutomationEditor(props: {
|
|
|
131
128
|
const triggerField = original.kind === "scheduled" ? "schedule" : "trigger";
|
|
132
129
|
const owned = original.ownedByViewer;
|
|
133
130
|
const creator = props.summary.createdBy;
|
|
134
|
-
// Core switches a non-creator's execution change to system
|
|
131
|
+
// Core switches a non-creator's execution or outcome change to system
|
|
132
|
+
// credentials.
|
|
135
133
|
const dropsCreatorCredentials =
|
|
136
134
|
!owned &&
|
|
137
135
|
original.credentialMode === "creator" &&
|
|
138
|
-
(changes.instruction !== undefined ||
|
|
136
|
+
(changes.instruction !== undefined ||
|
|
137
|
+
changes[triggerField] !== undefined ||
|
|
138
|
+
changes.outcomes !== undefined);
|
|
139
139
|
const triggerError = fieldError(triggerField);
|
|
140
140
|
const inputState = (key: string) => ({
|
|
141
141
|
"aria-invalid": Boolean(fieldError(key)) || undefined,
|
|
@@ -252,8 +252,8 @@ export function AutomationEditor(props: {
|
|
|
252
252
|
{owned ? null : (
|
|
253
253
|
<p className="mt-0 mb-6 max-w-2xl text-sm leading-relaxed text-dashboard-text-muted">
|
|
254
254
|
{creator} created this automation. You can edit it because it is in a
|
|
255
|
-
public channel. Only {creator} can
|
|
256
|
-
|
|
255
|
+
public channel. Only {creator} can turn on their connected accounts.
|
|
256
|
+
Version history keeps every saved change.
|
|
257
257
|
</p>
|
|
258
258
|
)}
|
|
259
259
|
<div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:gap-10">
|
|
@@ -345,25 +345,13 @@ export function AutomationEditor(props: {
|
|
|
345
345
|
{fieldError("outcomes")}
|
|
346
346
|
</p>
|
|
347
347
|
) : null}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
/>
|
|
356
|
-
) : (
|
|
357
|
-
<>
|
|
358
|
-
<AutomationOutcomeList
|
|
359
|
-
outcomes={original.outcomes}
|
|
360
|
-
destination={props.summary.destination}
|
|
361
|
-
/>
|
|
362
|
-
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
363
|
-
Only {creator} can change where results go.
|
|
364
|
-
</p>
|
|
365
|
-
</>
|
|
366
|
-
)}
|
|
348
|
+
<AutomationOutcomeFields
|
|
349
|
+
value={draft.outcomes}
|
|
350
|
+
original={original.outcomes}
|
|
351
|
+
destination={props.summary.destination}
|
|
352
|
+
disabled={save.isPending}
|
|
353
|
+
onChange={(outcomes) => change({ outcomes })}
|
|
354
|
+
/>
|
|
367
355
|
</AutomationFormSection>
|
|
368
356
|
<AutomationFormSection
|
|
369
357
|
changed={changes.credentialMode !== undefined}
|
|
@@ -424,9 +412,9 @@ export function AutomationEditor(props: {
|
|
|
424
412
|
</RadioGroup>
|
|
425
413
|
{dropsCreatorCredentials ? (
|
|
426
414
|
<FormNotice title="Saving switches this automation to system credentials.">
|
|
427
|
-
Only {creator} can let a changed instruction
|
|
428
|
-
{original.kind === "event" ? "
|
|
429
|
-
their connected accounts.
|
|
415
|
+
Only {creator} can let a changed instruction,
|
|
416
|
+
{original.kind === "event" ? " trigger," : " schedule,"} or
|
|
417
|
+
result destination use their connected accounts.
|
|
430
418
|
</FormNotice>
|
|
431
419
|
) : null}
|
|
432
420
|
{fieldError("credentialMode") ? (
|
|
@@ -198,8 +198,8 @@ function AutomationAbout(props: { automation: AutomationSummary }) {
|
|
|
198
198
|
{automation.destination.visibility === "private"
|
|
199
199
|
? null
|
|
200
200
|
: "You can edit, pause, or delete it because it is in a public channel. "}
|
|
201
|
-
Only {automation.createdBy} can
|
|
202
|
-
|
|
201
|
+
Only {automation.createdBy} can turn on their connected accounts, or
|
|
202
|
+
resume or run it while it uses them.
|
|
203
203
|
</p>
|
|
204
204
|
)}
|
|
205
205
|
</RailCard>
|
|
@@ -223,7 +223,7 @@ function AutomationVersionsView(props: {
|
|
|
223
223
|
Junior saves these settings as a new version. Future runs use them.
|
|
224
224
|
{automation.ownedByViewer
|
|
225
225
|
? null
|
|
226
|
-
: ` Only ${automation.createdBy} can turn on their connected accounts
|
|
226
|
+
: ` Only ${automation.createdBy} can turn on their connected accounts.`}
|
|
227
227
|
</AlertDialogDescription>
|
|
228
228
|
<AlertDialogFooter>
|
|
229
229
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
@@ -13,6 +13,11 @@ import { Button } from "../../components/ui/button";
|
|
|
13
13
|
import { Input } from "../../components/ui/input";
|
|
14
14
|
import { Textarea } from "../../components/ui/textarea";
|
|
15
15
|
import { Field, FieldDescription, FieldLabel } from "../../components/ui/field";
|
|
16
|
+
import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
|
|
17
|
+
import {
|
|
18
|
+
useConversationStartPosition,
|
|
19
|
+
type ConversationStartPosition,
|
|
20
|
+
} from "../../conversations/transcriptReadPosition";
|
|
16
21
|
import { InlineError } from "../../components/InlineError";
|
|
17
22
|
import { Card } from "../../components/layout/Card";
|
|
18
23
|
import { DashboardApiError, fetchDashboardJson, patch, put } from "../../http";
|
|
@@ -99,11 +104,68 @@ export function SettingsPage({ identity }: SettingsPageProps) {
|
|
|
99
104
|
</div>
|
|
100
105
|
</Card>
|
|
101
106
|
</form>
|
|
107
|
+
<ConversationStartSetting />
|
|
102
108
|
<CustomizationForm />
|
|
103
109
|
</SettingsPageLayout>
|
|
104
110
|
);
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
const startPositionOptions: {
|
|
114
|
+
description: string;
|
|
115
|
+
label: string;
|
|
116
|
+
value: ConversationStartPosition;
|
|
117
|
+
}[] = [
|
|
118
|
+
{
|
|
119
|
+
description: "Open at the first new message, below a “New” line.",
|
|
120
|
+
label: "Where I left off",
|
|
121
|
+
value: "unread",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
description: "Always open at the newest message.",
|
|
125
|
+
label: "Latest message",
|
|
126
|
+
value: "latest",
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/** Let the user choose where a Conversation opens on this browser. */
|
|
131
|
+
function ConversationStartSetting() {
|
|
132
|
+
const [startPosition, setStartPosition] = useConversationStartPosition();
|
|
133
|
+
return (
|
|
134
|
+
<Card className="mt-6 mb-0" padding="md" variant="raised">
|
|
135
|
+
<fieldset className="m-0 min-w-0 border-0 p-0">
|
|
136
|
+
<legend className="m-0 p-0 text-lg font-bold">
|
|
137
|
+
Open conversations at
|
|
138
|
+
</legend>
|
|
139
|
+
<p className="mt-1 mb-4 text-sm text-dashboard-text-muted">
|
|
140
|
+
Junior keeps your place in each conversation on all your devices. This
|
|
141
|
+
choice applies to this browser.
|
|
142
|
+
</p>
|
|
143
|
+
<RadioGroup<ConversationStartPosition>
|
|
144
|
+
aria-label="Open conversations at"
|
|
145
|
+
className="grid gap-3"
|
|
146
|
+
onValueChange={setStartPosition}
|
|
147
|
+
value={startPosition}
|
|
148
|
+
>
|
|
149
|
+
{startPositionOptions.map((option) => (
|
|
150
|
+
<label
|
|
151
|
+
className="flex cursor-pointer items-start gap-3"
|
|
152
|
+
key={option.value}
|
|
153
|
+
>
|
|
154
|
+
<RadioGroupItem className="mt-0.5" value={option.value} />
|
|
155
|
+
<span className="grid gap-0.5">
|
|
156
|
+
<span className="text-sm font-medium">{option.label}</span>
|
|
157
|
+
<span className="text-xs text-dashboard-text-muted">
|
|
158
|
+
{option.description}
|
|
159
|
+
</span>
|
|
160
|
+
</span>
|
|
161
|
+
</label>
|
|
162
|
+
))}
|
|
163
|
+
</RadioGroup>
|
|
164
|
+
</fieldset>
|
|
165
|
+
</Card>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
107
169
|
/**
|
|
108
170
|
* Let the user save text that changes how the agent replies to them. The
|
|
109
171
|
* server returns 404 when the app does not enable this experiment.
|