@sentry/junior-dashboard 0.270.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 +77 -77
- 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/App.tsx +89 -61
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +38 -7
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/ConversationPage.tsx +55 -8
- 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/conversationRoutes.ts +30 -3
- 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/ConnectionsPage.tsx +198 -0
- package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
- package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
- package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +115 -61
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +185 -242
- package/src/client/review-desk/ReviewDeskSidebar.tsx +259 -0
- package/src/client/review-desk/ReviewItemActions.tsx +1 -10
- package/src/client/review-desk/ReviewItemContext.tsx +108 -0
- package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
- package/src/client/review-desk/ReviewItemView.tsx +159 -0
- package/src/client/review-desk/queries.ts +3 -3
- package/src/client/review-desk/reviewItemFormat.ts +52 -0
- 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 -65
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/conversations/AGENTS.md +0 -38
- package/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/automations/AGENTS.md +0 -34
- package/src/client/pages/system/AGENTS.md +0 -12
- package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
|
@@ -49,8 +49,13 @@ import {
|
|
|
49
49
|
type RenderedTranscriptEntry,
|
|
50
50
|
} from "./transcriptRenderModel";
|
|
51
51
|
import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch";
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
TranscriptRow,
|
|
54
|
+
TranscriptRows,
|
|
55
|
+
TranscriptUnreadMarker,
|
|
56
|
+
} from "./TranscriptRows";
|
|
53
57
|
import { TranscriptTimestampProvider } from "./TranscriptTimestamp";
|
|
58
|
+
import type { TranscriptUnreadRange } from "./transcriptReadPosition";
|
|
54
59
|
|
|
55
60
|
type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
|
|
56
61
|
type TranscriptContextEntry = Extract<TranscriptEntry, { kind: "context" }>;
|
|
@@ -85,6 +90,8 @@ export const ConversationTranscriptView = memo(
|
|
|
85
90
|
/** Sends that history does not have yet. History wins on message id. */
|
|
86
91
|
pendingMessages?: readonly ConversationMailboxMessage[];
|
|
87
92
|
responding?: boolean;
|
|
93
|
+
/** Show the "New" marker before the first row in this range. */
|
|
94
|
+
unread?: TranscriptUnreadRange;
|
|
88
95
|
}) {
|
|
89
96
|
// Cards can change while their Message events stay unchanged.
|
|
90
97
|
const { events, annotations } = props.conversation;
|
|
@@ -106,6 +113,7 @@ export const ConversationTranscriptView = memo(
|
|
|
106
113
|
conversation={props.conversation}
|
|
107
114
|
messages={messages}
|
|
108
115
|
responding={props.responding}
|
|
116
|
+
unread={props.unread}
|
|
109
117
|
/>
|
|
110
118
|
</section>
|
|
111
119
|
</TranscriptTimestampProvider>
|
|
@@ -121,6 +129,7 @@ function SegmentEvents(props: {
|
|
|
121
129
|
conversation: ConversationTranscript;
|
|
122
130
|
messages: TranscriptViewMessage[];
|
|
123
131
|
responding?: boolean;
|
|
132
|
+
unread?: TranscriptUnreadRange;
|
|
124
133
|
}) {
|
|
125
134
|
return (
|
|
126
135
|
<TranscriptRows>
|
|
@@ -134,6 +143,7 @@ function SegmentEvents(props: {
|
|
|
134
143
|
onOpenSubagentTranscript={props.onOpenSubagentTranscript}
|
|
135
144
|
transcript={props.messages}
|
|
136
145
|
conversation={props.conversation}
|
|
146
|
+
unread={props.unread}
|
|
137
147
|
/>
|
|
138
148
|
) : props.conversation.eventHistory.status === "redacted" &&
|
|
139
149
|
props.messages.length > 0 ? (
|
|
@@ -205,6 +215,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
|
|
|
205
215
|
}) => void;
|
|
206
216
|
transcript: TranscriptViewMessage[];
|
|
207
217
|
conversation: ConversationTranscript;
|
|
218
|
+
unread?: TranscriptUnreadRange;
|
|
208
219
|
}) {
|
|
209
220
|
const entries = useMemo(
|
|
210
221
|
() => groupTranscriptMessages(props.transcript),
|
|
@@ -214,6 +225,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
|
|
|
214
225
|
<TranscriptEntryList
|
|
215
226
|
entries={entries}
|
|
216
227
|
keyPrefix={props.conversation.conversationId}
|
|
228
|
+
unread={props.unread}
|
|
217
229
|
renderContext={(entry) => (
|
|
218
230
|
<TranscriptRailEvent
|
|
219
231
|
kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
|
|
@@ -314,11 +326,26 @@ function TranscriptEntryList(props: {
|
|
|
314
326
|
renderReasoning: (entry: TranscriptReasoningEntry) => ReactNode;
|
|
315
327
|
renderSubagent: (entry: TranscriptSubagentEntry) => ReactNode;
|
|
316
328
|
renderTool: (entry: TranscriptToolEntry) => ReactNode;
|
|
329
|
+
unread?: TranscriptUnreadRange;
|
|
317
330
|
}) {
|
|
318
331
|
const search = useTranscriptSearch();
|
|
319
332
|
const activityKeys = useRef(new Map<string, string>());
|
|
320
333
|
const claimedActivityKeys = new Set<string>();
|
|
321
334
|
const rows: ReactNode[] = [];
|
|
335
|
+
// Search results are not a reading order, so they get no marker.
|
|
336
|
+
const markerSeq = search.active
|
|
337
|
+
? undefined
|
|
338
|
+
: unreadMarkerSeq(props.entries, props.unread);
|
|
339
|
+
let unreadMarkerPending = markerSeq !== undefined;
|
|
340
|
+
// Entries are in seq order, so the first row that reaches the marker seq
|
|
341
|
+
// gets the marker.
|
|
342
|
+
const pushRow = (seq: number | undefined, row: ReactNode) => {
|
|
343
|
+
if (unreadMarkerPending && seq !== undefined && seq >= markerSeq!) {
|
|
344
|
+
unreadMarkerPending = false;
|
|
345
|
+
rows.push(<TranscriptUnreadMarker key="unread-marker" />);
|
|
346
|
+
}
|
|
347
|
+
rows.push(row);
|
|
348
|
+
};
|
|
322
349
|
|
|
323
350
|
const renderEntry = (entry: TranscriptEntry): ReactNode => {
|
|
324
351
|
if (entry.kind === "subagent") return props.renderSubagent(entry);
|
|
@@ -359,8 +386,16 @@ function TranscriptEntryList(props: {
|
|
|
359
386
|
keyPrefix: props.keyPrefix,
|
|
360
387
|
knownKeys: activityKeys.current,
|
|
361
388
|
});
|
|
362
|
-
|
|
363
|
-
|
|
389
|
+
const lastSeq = lastEntrySeq(activityEntries);
|
|
390
|
+
// The marker goes before the group when any of it is unread.
|
|
391
|
+
pushRow(
|
|
392
|
+
lastSeq,
|
|
393
|
+
<TranscriptRow
|
|
394
|
+
anchorKey={activityKey}
|
|
395
|
+
indent
|
|
396
|
+
key={activityKey}
|
|
397
|
+
seq={lastSeq}
|
|
398
|
+
>
|
|
364
399
|
<TranscriptActivityGroup
|
|
365
400
|
entries={visibleEntries}
|
|
366
401
|
renderEntry={renderEntry}
|
|
@@ -373,8 +408,9 @@ function TranscriptEntryList(props: {
|
|
|
373
408
|
|
|
374
409
|
if (!search.active || entryMatchesSearch(entry, search.normalizedQuery)) {
|
|
375
410
|
const rowKey = `${props.keyPrefix}:${entry.key}`;
|
|
376
|
-
|
|
377
|
-
|
|
411
|
+
pushRow(
|
|
412
|
+
entry.seq,
|
|
413
|
+
<TranscriptRow anchorKey={rowKey} key={rowKey} seq={entry.seq}>
|
|
378
414
|
{renderEntry(entry)}
|
|
379
415
|
</TranscriptRow>,
|
|
380
416
|
);
|
|
@@ -391,6 +427,39 @@ function TranscriptEntryList(props: {
|
|
|
391
427
|
return rows;
|
|
392
428
|
}
|
|
393
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Find the seq of the first unread row. Entries are in seq order, and pending
|
|
432
|
+
* sends without a seq come last, so a binary search finds the first seq after
|
|
433
|
+
* the read position. Rows after the range arrived after open, so they get no
|
|
434
|
+
* marker.
|
|
435
|
+
*/
|
|
436
|
+
function unreadMarkerSeq(
|
|
437
|
+
entries: readonly TranscriptEntry[],
|
|
438
|
+
unread: TranscriptUnreadRange | undefined,
|
|
439
|
+
): number | undefined {
|
|
440
|
+
if (!unread) return undefined;
|
|
441
|
+
let low = 0;
|
|
442
|
+
let high = entries.length;
|
|
443
|
+
while (low < high) {
|
|
444
|
+
const middle = (low + high) >> 1;
|
|
445
|
+
const seq = entries[middle]!.seq;
|
|
446
|
+
if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1;
|
|
447
|
+
else high = middle;
|
|
448
|
+
}
|
|
449
|
+
const seq = entries[low]?.seq;
|
|
450
|
+
return seq !== undefined && seq <= unread.throughSeq ? seq : undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function lastEntrySeq(
|
|
454
|
+
entries: readonly RenderedTranscriptEntry[],
|
|
455
|
+
): number | undefined {
|
|
456
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
457
|
+
const seq = entries[index]!.seq;
|
|
458
|
+
if (seq !== undefined) return seq;
|
|
459
|
+
}
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
394
463
|
/** Keep one activity group mounted while new or historical events extend either edge. */
|
|
395
464
|
function stableActivityGroupKey(args: {
|
|
396
465
|
claimedKeys: Set<string>;
|
|
@@ -15,17 +15,40 @@ export function TranscriptRows({
|
|
|
15
15
|
);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Mark where unread rows start. The pinning hook can open the transcript here.
|
|
20
|
+
*/
|
|
21
|
+
export function TranscriptUnreadMarker() {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className="flex items-center gap-3 py-1"
|
|
25
|
+
data-transcript-unread-marker=""
|
|
26
|
+
role="separator"
|
|
27
|
+
aria-label="New messages"
|
|
28
|
+
>
|
|
29
|
+
<span className="h-px flex-1 bg-dashboard-accent/50" />
|
|
30
|
+
<span className="shrink-0 font-sans text-xs font-semibold text-dashboard-accent-text">
|
|
31
|
+
New
|
|
32
|
+
</span>
|
|
33
|
+
<span className="h-px flex-1 bg-dashboard-accent/50" />
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
/** Leave room for control outlines inside the mobile row's paint bounds. */
|
|
19
39
|
export function TranscriptRow(props: {
|
|
20
40
|
/** Stable row id that holds the reader's place while layout changes. */
|
|
21
41
|
anchorKey?: string;
|
|
22
42
|
children: ReactNode;
|
|
23
43
|
indent?: boolean;
|
|
44
|
+
/** Last event sequence in the row. Read positions use it. */
|
|
45
|
+
seq?: number;
|
|
24
46
|
}) {
|
|
25
47
|
return (
|
|
26
48
|
<div
|
|
27
49
|
className="mobile-transcript-row -mx-2 px-2 py-1.5"
|
|
28
50
|
data-scroll-anchor={props.anchorKey}
|
|
51
|
+
data-transcript-seq={props.seq}
|
|
29
52
|
>
|
|
30
53
|
{props.indent ? (
|
|
31
54
|
<div className="pl-11">{props.children}</div>
|
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
usePinnedTranscriptBottom,
|
|
23
23
|
} from "./transcriptBottomPinning";
|
|
24
24
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
25
|
+
import {
|
|
26
|
+
useTranscriptReadReporter,
|
|
27
|
+
type TranscriptUnreadRange,
|
|
28
|
+
} from "./transcriptReadPosition";
|
|
25
29
|
import { TranscriptSearchProvider } from "./transcriptSearch";
|
|
26
30
|
import {
|
|
27
31
|
TurnContextPanelProvider,
|
|
@@ -34,9 +38,13 @@ export function Transcript(props: {
|
|
|
34
38
|
historyError?: Error | null;
|
|
35
39
|
historyVersion?: string;
|
|
36
40
|
live?: boolean;
|
|
41
|
+
/** The read position when the Conversation opened. */
|
|
42
|
+
lastReadSeq?: number;
|
|
37
43
|
loadingPreviousPage?: boolean;
|
|
38
44
|
pinRequestVersion?: number;
|
|
39
45
|
onLoadPreviousPage?: () => void;
|
|
46
|
+
/** Receive the last event sequence the reader saw in the transcript. */
|
|
47
|
+
onRead?: (seq: number) => void;
|
|
40
48
|
onSelectedEventSeqChange?(seq: number | undefined): void;
|
|
41
49
|
/** The reader's own sends to show at the end until history has them. */
|
|
42
50
|
pendingMessages?: readonly ConversationMailboxMessage[];
|
|
@@ -51,6 +59,10 @@ export function Transcript(props: {
|
|
|
51
59
|
transcript?: ConversationTranscript;
|
|
52
60
|
/** Show turn context in the owner's panel. Without it, a drawer opens. */
|
|
53
61
|
turnContextPanel?: TurnContextPanel;
|
|
62
|
+
/** Open at the "New" marker instead of the latest message. */
|
|
63
|
+
startAtUnread?: boolean;
|
|
64
|
+
/** Rows that were unread when the Conversation opened get a marker. */
|
|
65
|
+
unread?: TranscriptUnreadRange;
|
|
54
66
|
view?: TranscriptViewMode;
|
|
55
67
|
}) {
|
|
56
68
|
const view = props.view ?? "rich";
|
|
@@ -64,6 +76,7 @@ export function Transcript(props: {
|
|
|
64
76
|
historyVersion: props.historyVersion ?? "empty",
|
|
65
77
|
juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript),
|
|
66
78
|
pinRequestVersion: props.pinRequestVersion,
|
|
79
|
+
startAtUnread: view === "rich" && props.startAtUnread,
|
|
67
80
|
view,
|
|
68
81
|
versions: {
|
|
69
82
|
rich: transcriptBottomVersion(props.transcript, "rich"),
|
|
@@ -71,6 +84,14 @@ export function Transcript(props: {
|
|
|
71
84
|
},
|
|
72
85
|
});
|
|
73
86
|
|
|
87
|
+
// Read positions follow the transcript rows, not the event log. Search
|
|
88
|
+
// results skip rows, so they do not move the read position.
|
|
89
|
+
useTranscriptReadReporter({
|
|
90
|
+
content: bottomPinning.contentElement,
|
|
91
|
+
lastReadSeq: props.lastReadSeq,
|
|
92
|
+
onRead: props.onRead,
|
|
93
|
+
paused: view !== "rich" || search.trim().length > 0,
|
|
94
|
+
});
|
|
74
95
|
const loadPreviousPage = () => props.onLoadPreviousPage?.();
|
|
75
96
|
const previousPageRef = useAutoLoadPreviousPage({
|
|
76
97
|
enabled: Boolean(
|
|
@@ -144,6 +165,7 @@ export function Transcript(props: {
|
|
|
144
165
|
conversation={props.transcript}
|
|
145
166
|
pendingMessages={props.pendingMessages}
|
|
146
167
|
responding={props.responding ?? props.live ?? false}
|
|
168
|
+
unread={props.unread}
|
|
147
169
|
/>
|
|
148
170
|
</TurnContextPanelProvider>
|
|
149
171
|
)}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type { ConversationReportEvent } from "@sentry/junior/api/schema";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the dashboard route for a conversation id. A `turnId` opens the
|
|
5
|
+
* start of that Turn in the event log.
|
|
6
|
+
*/
|
|
7
|
+
export function conversationPath(
|
|
8
|
+
conversationId: string,
|
|
9
|
+
options?: { turnId?: string },
|
|
10
|
+
): string {
|
|
11
|
+
const path = `/conversations/${encodeURIComponent(conversationId)}`;
|
|
12
|
+
return options?.turnId
|
|
13
|
+
? `${path}?${CONVERSATION_TURN_PARAM}=${encodeURIComponent(options.turnId)}`
|
|
14
|
+
: path;
|
|
4
15
|
}
|
|
5
16
|
|
|
6
17
|
/** Dashboard home route. Legacy `/conversations/new` redirects here. */
|
|
@@ -20,3 +31,19 @@ export function parseConversationEventSeq(
|
|
|
20
31
|
): number | undefined {
|
|
21
32
|
return value && /^\d+$/.test(value) ? Number(value) : undefined;
|
|
22
33
|
}
|
|
34
|
+
|
|
35
|
+
/** Query param that opens the start of one Turn in the event log. */
|
|
36
|
+
export const CONVERSATION_TURN_PARAM = "turn";
|
|
37
|
+
|
|
38
|
+
/** Find the sequence number of the event that started a Turn. */
|
|
39
|
+
export function turnStartedEventSeq(
|
|
40
|
+
events: readonly ConversationReportEvent[] | undefined,
|
|
41
|
+
turnId: string,
|
|
42
|
+
): number | undefined {
|
|
43
|
+
return events?.find(
|
|
44
|
+
({ data }) =>
|
|
45
|
+
data.type === "turn_lifecycle" &&
|
|
46
|
+
data.state === "started" &&
|
|
47
|
+
data.turnId === turnId,
|
|
48
|
+
)?.seq;
|
|
49
|
+
}
|
|
@@ -178,9 +178,12 @@ export function conversationDetailQueryOptions(
|
|
|
178
178
|
structuralSharing: (previous, next) => {
|
|
179
179
|
if (!next || typeof next !== "object") return next;
|
|
180
180
|
if (!previous || typeof previous !== "object") return next;
|
|
181
|
-
return
|
|
181
|
+
return keepLatestReadPosition(
|
|
182
182
|
previous as ConversationSnapshot,
|
|
183
|
-
|
|
183
|
+
reuseConversationEventReferences(
|
|
184
|
+
previous as ConversationSnapshot,
|
|
185
|
+
next as ConversationSnapshot,
|
|
186
|
+
),
|
|
184
187
|
);
|
|
185
188
|
},
|
|
186
189
|
refetchInterval: (query) =>
|
|
@@ -196,6 +199,20 @@ export function conversationDetailQueryOptions(
|
|
|
196
199
|
});
|
|
197
200
|
}
|
|
198
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Read positions only move forward. A poll that started before a read report
|
|
204
|
+
* finished must not put an older position back in the cache.
|
|
205
|
+
*/
|
|
206
|
+
function keepLatestReadPosition(
|
|
207
|
+
previous: ConversationSnapshot,
|
|
208
|
+
next: ConversationSnapshot,
|
|
209
|
+
): ConversationSnapshot {
|
|
210
|
+
const kept = previous.readPosition;
|
|
211
|
+
if (!kept) return next;
|
|
212
|
+
if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
|
|
213
|
+
return { ...next, readPosition: kept };
|
|
214
|
+
}
|
|
215
|
+
|
|
199
216
|
function outboxActorIdentity(
|
|
200
217
|
queryClient: QueryClient,
|
|
201
218
|
): ActorIdentity | undefined {
|
|
@@ -704,14 +721,17 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
704
721
|
0,
|
|
705
722
|
...awaitingRows.map((message) => Date.parse(message.createdAt)),
|
|
706
723
|
);
|
|
707
|
-
const detail = useQuery(
|
|
708
|
-
conversationDetailQueryOptions(conversationId, {
|
|
724
|
+
const detail = useQuery({
|
|
725
|
+
...conversationDetailQueryOptions(conversationId, {
|
|
709
726
|
awaitingMessage: awaitingRows.length > 0,
|
|
710
727
|
fastPollUntilMs: latestSendAtMs
|
|
711
728
|
? latestSendAtMs + FAST_POLL_WINDOW_MS
|
|
712
729
|
: undefined,
|
|
713
730
|
}),
|
|
714
|
-
|
|
731
|
+
// A reopened Conversation shows the cached copy at once, but it can miss
|
|
732
|
+
// messages that arrived while the reader was away. Fetch it each visit.
|
|
733
|
+
refetchOnMount: "always",
|
|
734
|
+
});
|
|
715
735
|
// Defer the whole server snapshot, not history alone. Queue removal and the
|
|
716
736
|
// matching transcript must paint together while composer input stays urgent.
|
|
717
737
|
const deferredDetail = useDeferredValue(detail.data);
|
|
@@ -893,6 +913,11 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
893
913
|
? history.hasNextPage
|
|
894
914
|
: Boolean(detailData?.previousCursor),
|
|
895
915
|
isPending: detail.isPending,
|
|
916
|
+
/** The latest detail after a fetch in this visit, before it is deferred. */
|
|
917
|
+
fetchedDetail:
|
|
918
|
+
detail.isFetchedAfterMount && !detail.isFetching
|
|
919
|
+
? detail.data
|
|
920
|
+
: undefined,
|
|
896
921
|
isLoadingPreviousPage,
|
|
897
922
|
pendingAuthorization: mailbox?.authorization,
|
|
898
923
|
pendingGeneratedAt: mailbox?.generatedAt,
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
const BOTTOM_PROXIMITY_PX = 96;
|
|
23
23
|
const PREVIOUS_PAGE_PROXIMITY_PX = 600;
|
|
24
24
|
const USER_SCROLL_DELTA_PX = 2;
|
|
25
|
+
// Leave a little earlier context above the "New" marker.
|
|
26
|
+
const UNREAD_START_OFFSET_PX = 48;
|
|
25
27
|
const PIN_START_ATTRIBUTE = "data-transcript-pin-start";
|
|
26
28
|
const PIN_START_MARGIN_PX = 16;
|
|
27
29
|
|
|
@@ -46,6 +48,7 @@ export type ScrollSnapshot = {
|
|
|
46
48
|
|
|
47
49
|
type BottomPinResult = {
|
|
48
50
|
anchorRef: RefObject<HTMLDivElement | null>;
|
|
51
|
+
contentElement: HTMLDivElement | null;
|
|
49
52
|
contentRef: RefCallback<HTMLDivElement>;
|
|
50
53
|
hasPendingUpdate: boolean;
|
|
51
54
|
jumpToBottom: () => void;
|
|
@@ -259,14 +262,19 @@ export function terminalReplyPinState(input: {
|
|
|
259
262
|
|
|
260
263
|
/**
|
|
261
264
|
* Show the jump control only when the reader left the bottom and a newer tail
|
|
262
|
-
* arrived
|
|
265
|
+
* arrived, or when the transcript opened at unread rows. Staying pinned, or
|
|
266
|
+
* intentionally following, must not flash it.
|
|
263
267
|
*/
|
|
264
268
|
export function shouldShowJumpToLatest(input: {
|
|
265
269
|
enabled: boolean;
|
|
266
270
|
following: boolean;
|
|
267
271
|
hasPendingUpdate: boolean;
|
|
272
|
+
openedAtUnread?: boolean;
|
|
268
273
|
}): boolean {
|
|
269
|
-
return
|
|
274
|
+
return (
|
|
275
|
+
!input.following &&
|
|
276
|
+
(Boolean(input.openedAtUnread) || (input.enabled && input.hasPendingUpdate))
|
|
277
|
+
);
|
|
270
278
|
}
|
|
271
279
|
|
|
272
280
|
/** Resolve scroll intent with user upward movement taking precedence over bottom slack. */
|
|
@@ -314,6 +322,8 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
314
322
|
historyVersion: string;
|
|
315
323
|
juniorMessageVersion: string;
|
|
316
324
|
pinRequestVersion?: number;
|
|
325
|
+
/** Open at the "New" marker when the transcript has one. */
|
|
326
|
+
startAtUnread?: boolean;
|
|
317
327
|
view: TranscriptViewMode;
|
|
318
328
|
versions: Record<TranscriptViewMode, string>;
|
|
319
329
|
}): BottomPinResult {
|
|
@@ -337,6 +347,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
337
347
|
const seenPinStartsRef = useRef(new Set<string>());
|
|
338
348
|
const [following, setFollowing] = useState(false);
|
|
339
349
|
const [hasPendingUpdate, setHasPendingUpdate] = useState(false);
|
|
350
|
+
const [openedAtUnread, setOpenedAtUnread] = useState(false);
|
|
340
351
|
const [contentElement, setContentElement] = useState<HTMLDivElement | null>(
|
|
341
352
|
null,
|
|
342
353
|
);
|
|
@@ -351,9 +362,14 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
351
362
|
if (!input.enabled) setHasPendingUpdate(false);
|
|
352
363
|
}, [input.enabled]);
|
|
353
364
|
|
|
365
|
+
// The transcript opened at the latest message, but the reader prefers the
|
|
366
|
+
// "New" marker. The marker can appear when a fetch replaces a cached detail.
|
|
367
|
+
const awaitingUnreadMarkerRef = useRef(false);
|
|
354
368
|
const setFollowingIntent = useCallback((value: boolean) => {
|
|
355
369
|
followingRef.current = value;
|
|
356
370
|
setFollowing(value);
|
|
371
|
+
if (value) setOpenedAtUnread(false);
|
|
372
|
+
else awaitingUnreadMarkerRef.current = false;
|
|
357
373
|
}, []);
|
|
358
374
|
|
|
359
375
|
const rememberPlace = useCallback(() => {
|
|
@@ -502,6 +518,32 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
502
518
|
[setFollowingIntent],
|
|
503
519
|
);
|
|
504
520
|
|
|
521
|
+
/** Scroll to the "New" marker, if the transcript has one. */
|
|
522
|
+
const openAtUnreadMarker = useCallback((): boolean => {
|
|
523
|
+
const content = contentElementRef.current;
|
|
524
|
+
const root = scrollRootFor(content);
|
|
525
|
+
const marker = content?.querySelector("[data-transcript-unread-marker]");
|
|
526
|
+
if (!root || !marker) return false;
|
|
527
|
+
awaitingUnreadMarkerRef.current = false;
|
|
528
|
+
const offset =
|
|
529
|
+
marker.getBoundingClientRect().top - viewportBounds(root).top;
|
|
530
|
+
setScrollTop(
|
|
531
|
+
root,
|
|
532
|
+
Math.max(
|
|
533
|
+
0,
|
|
534
|
+
scrollSnapshot(root).scrollTop + offset - UNREAD_START_OFFSET_PX,
|
|
535
|
+
),
|
|
536
|
+
);
|
|
537
|
+
const snapshot = scrollSnapshot(root);
|
|
538
|
+
previousScrollTopRef.current = snapshot.scrollTop;
|
|
539
|
+
// Unread rows that fit above the bottom leave the reader at the latest.
|
|
540
|
+
const atBottom = isNearScrollBottom(snapshot);
|
|
541
|
+
setFollowingIntent(atBottom);
|
|
542
|
+
setOpenedAtUnread(!atBottom);
|
|
543
|
+
rememberPlace();
|
|
544
|
+
return true;
|
|
545
|
+
}, [rememberPlace, setFollowingIntent]);
|
|
546
|
+
|
|
505
547
|
useBrowserLayoutEffect(() => {
|
|
506
548
|
if (!contentElement || !input.conversationId) return;
|
|
507
549
|
if (initializedConversationRef.current === input.conversationId) return;
|
|
@@ -510,24 +552,40 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
510
552
|
if (!root) return;
|
|
511
553
|
|
|
512
554
|
initializedConversationRef.current = input.conversationId;
|
|
555
|
+
setHasPendingUpdate(false);
|
|
556
|
+
if (input.startAtUnread && openAtUnreadMarker()) return;
|
|
557
|
+
|
|
513
558
|
setScrollTop(root, scrollSnapshot(root).scrollHeight);
|
|
514
559
|
previousScrollTopRef.current = scrollSnapshot(root).scrollTop;
|
|
515
|
-
//
|
|
516
|
-
// reader scrolls up so late content and footer layout cannot expose
|
|
517
|
-
// space below the transcript.
|
|
560
|
+
// Without unread rows, start at the latest message. Keep following until
|
|
561
|
+
// the reader scrolls up so late content and footer layout cannot expose
|
|
562
|
+
// stale space below the transcript.
|
|
518
563
|
setFollowingIntent(true);
|
|
519
|
-
|
|
520
|
-
}, [
|
|
564
|
+
awaitingUnreadMarkerRef.current = Boolean(input.startAtUnread);
|
|
565
|
+
}, [
|
|
566
|
+
contentElement,
|
|
567
|
+
input.conversationId,
|
|
568
|
+
input.startAtUnread,
|
|
569
|
+
openAtUnreadMarker,
|
|
570
|
+
setFollowingIntent,
|
|
571
|
+
]);
|
|
572
|
+
|
|
573
|
+
// Follow the latest message, unless the "New" marker appeared before the
|
|
574
|
+
// reader moved. Then open at the marker, as if it was there at the start.
|
|
575
|
+
const followLatest = useCallback(() => {
|
|
576
|
+
if (awaitingUnreadMarkerRef.current && openAtUnreadMarker()) return;
|
|
577
|
+
scrollToBottom("auto");
|
|
578
|
+
}, [openAtUnreadMarker, scrollToBottom]);
|
|
521
579
|
|
|
522
580
|
const syncAfterLayoutChange = useCallback(() => {
|
|
523
581
|
if (followingRef.current) {
|
|
524
|
-
|
|
582
|
+
followLatest();
|
|
525
583
|
return;
|
|
526
584
|
}
|
|
527
585
|
|
|
528
586
|
restorePlace();
|
|
529
587
|
measurePosition("measure");
|
|
530
|
-
}, [measurePosition, restorePlace
|
|
588
|
+
}, [followLatest, measurePosition, restorePlace]);
|
|
531
589
|
|
|
532
590
|
// An earlier page renders above the reader. Hold their place before paint;
|
|
533
591
|
// a reader at the bottom stays at the latest message.
|
|
@@ -568,7 +626,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
568
626
|
following: followingRef.current,
|
|
569
627
|
})
|
|
570
628
|
) {
|
|
571
|
-
|
|
629
|
+
followLatest();
|
|
572
630
|
setHasPendingUpdate(false);
|
|
573
631
|
return;
|
|
574
632
|
}
|
|
@@ -581,7 +639,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
581
639
|
input.view,
|
|
582
640
|
input.versions.rich,
|
|
583
641
|
input.versions.raw,
|
|
584
|
-
|
|
642
|
+
followLatest,
|
|
585
643
|
setFollowingIntent,
|
|
586
644
|
]);
|
|
587
645
|
|
|
@@ -655,6 +713,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
655
713
|
}, [contentElement, syncAfterLayoutChange]);
|
|
656
714
|
|
|
657
715
|
const jumpToBottom = useCallback(() => {
|
|
716
|
+
awaitingUnreadMarkerRef.current = false;
|
|
658
717
|
setFollowingIntent(true);
|
|
659
718
|
setHasPendingUpdate(false);
|
|
660
719
|
scrollToBottom(preferredExplicitScrollBehavior());
|
|
@@ -698,6 +757,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
698
757
|
return useMemo(
|
|
699
758
|
() => ({
|
|
700
759
|
anchorRef,
|
|
760
|
+
contentElement,
|
|
701
761
|
contentRef,
|
|
702
762
|
hasPendingUpdate,
|
|
703
763
|
jumpToBottom,
|
|
@@ -705,9 +765,18 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
705
765
|
enabled: input.enabled,
|
|
706
766
|
following,
|
|
707
767
|
hasPendingUpdate,
|
|
768
|
+
openedAtUnread,
|
|
708
769
|
}),
|
|
709
770
|
}),
|
|
710
|
-
[
|
|
771
|
+
[
|
|
772
|
+
contentElement,
|
|
773
|
+
contentRef,
|
|
774
|
+
following,
|
|
775
|
+
hasPendingUpdate,
|
|
776
|
+
input.enabled,
|
|
777
|
+
jumpToBottom,
|
|
778
|
+
openedAtUnread,
|
|
779
|
+
],
|
|
711
780
|
);
|
|
712
781
|
}
|
|
713
782
|
|
|
@@ -744,7 +813,8 @@ export function useAutoLoadPreviousPage(input: {
|
|
|
744
813
|
return setSentinel;
|
|
745
814
|
}
|
|
746
815
|
|
|
747
|
-
|
|
816
|
+
/** Find the nearest element that scrolls the transcript, or the window. */
|
|
817
|
+
export function scrollRootFor(element: HTMLElement | null): ScrollRoot | null {
|
|
748
818
|
if (typeof window === "undefined") return null;
|
|
749
819
|
if (!element) return window;
|
|
750
820
|
|
|
@@ -819,6 +889,7 @@ function setScrollTop(
|
|
|
819
889
|
root.scrollTo({ behavior, top: scrollTop });
|
|
820
890
|
}
|
|
821
891
|
|
|
892
|
+
/** Read the visible band of a scroll root in client coordinates. */
|
|
822
893
|
function viewportBounds(root: ScrollRoot): { bottom: number; top: number } {
|
|
823
894
|
if (isWindowRoot(root)) return { bottom: window.innerHeight, top: 0 };
|
|
824
895
|
const top = root.getBoundingClientRect().top;
|