@sentry/junior-dashboard 0.271.0 → 0.273.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 (46) hide show
  1. package/dist/app.js +44 -3
  2. package/dist/client.js +69 -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/App.tsx +440 -430
  8. package/src/client/components/JuniorLogo.tsx +1 -1
  9. package/src/client/components/ProfileMenu.tsx +4 -41
  10. package/src/client/components/layout/DashboardChrome.tsx +9 -4
  11. package/src/client/components/layout/DashboardHeader.tsx +11 -51
  12. package/src/client/components/layout/NavRail.tsx +156 -0
  13. package/src/client/components/ui/sidebar.tsx +336 -0
  14. package/src/client/conversations/ConversationPage.tsx +14 -0
  15. package/src/client/conversations/ConversationTranscript.tsx +74 -5
  16. package/src/client/conversations/PendingMailboxStack.tsx +22 -9
  17. package/src/client/conversations/TranscriptMessageShell.tsx +19 -8
  18. package/src/client/conversations/TranscriptMessageView.tsx +8 -1
  19. package/src/client/conversations/TranscriptRows.tsx +23 -0
  20. package/src/client/conversations/TranscriptTurnContextView.tsx +1 -1
  21. package/src/client/conversations/TranscriptView.tsx +22 -0
  22. package/src/client/conversations/conversationOutbox.ts +19 -0
  23. package/src/client/conversations/eventTranscript.ts +6 -0
  24. package/src/client/conversations/queries.ts +29 -20
  25. package/src/client/conversations/transcript.ts +12 -2
  26. package/src/client/conversations/transcriptBottomPinning.ts +84 -13
  27. package/src/client/conversations/transcriptReadPosition.ts +267 -0
  28. package/src/client/conversations/transcriptRenderModel.ts +16 -2
  29. package/src/client/format.ts +8 -1
  30. package/src/client/pages/automations/AutomationActions.tsx +2 -3
  31. package/src/client/pages/automations/AutomationEditor.tsx +18 -30
  32. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  33. package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
  34. package/src/client/pages/settings/SettingsPage.tsx +62 -0
  35. package/src/client/review-desk/ReviewDeskPage.tsx +26 -40
  36. package/src/client/review-desk/ReviewDeskSidebar.tsx +29 -23
  37. package/src/client/review-desk/ReviewItemActions.tsx +1 -10
  38. package/src/client/review-desk/queries.ts +6 -5
  39. package/src/client/review-desk/reviewItemFormat.ts +3 -5
  40. package/src/client/types.ts +2 -0
  41. package/src/mock-reporting/read-positions.ts +39 -0
  42. package/src/mock-reporting/routes.ts +26 -1
  43. package/src/tailwind.css +1 -1
  44. package/src/client/components/AGENTS.md +0 -36
  45. package/src/client/conversations/AGENTS.md +0 -17
  46. 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 }
@@ -354,6 +354,10 @@ export function transcriptMessageActor(
354
354
  ): ActorIdentity | undefined {
355
355
  if (transcriptRoleKind(message.role) !== "user") return undefined;
356
356
  if (message.agentInvocationId) return undefined;
357
+ const author = message.actorIdentityId
358
+ ? conversation.authors?.[message.actorIdentityId]
359
+ : undefined;
360
+ if (author) return author;
357
361
  if (actorLabel(message.actorIdentity)) return message.actorIdentity;
358
362
  if (actorLabel(conversation.actorIdentity)) return conversation.actorIdentity;
359
363
  return undefined;
@@ -370,7 +374,10 @@ export function transcriptMessageActorLabel(
370
374
  // The conversation actor started the child agent but did not write its
371
375
  // input, so do not fall back to that actor here.
372
376
  if (message.agentInvocationId) return "Agent invocation";
373
- return actorLabel(transcriptMessageActor(conversation, message)) ?? "User";
377
+ // Show the person's name, not their email. A web send carries both, and
378
+ // its durable Message carries only the name.
379
+ const actor = transcriptMessageActor(conversation, message);
380
+ return actor?.fullName?.trim() || (actorLabel(actor) ?? "User");
374
381
  }
375
382
  if (kind === "system") return "System";
376
383
  if (kind === "tool") return "Tool";
@@ -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, then creator-only Pause or Resume
26
- * and Delete. Public readers can edit settings, but only the creator controls
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 credentials.
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 || changes[triggerField] !== 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 change where results go or turn on
256
- their connected accounts. Version history keeps every saved change.
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
- {owned ? (
349
- <AutomationOutcomeFields
350
- value={draft.outcomes}
351
- original={original.outcomes}
352
- destination={props.summary.destination}
353
- disabled={save.isPending}
354
- onChange={(outcomes) => change({ outcomes })}
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" ? " or trigger" : " or schedule"} use
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 change where results go, turn on their
202
- connected accounts, or resume or run it while it uses them.
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 or change where results go.`}
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.