@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.
- package/dist/app.js +44 -3
- package/dist/client.js +69 -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/App.tsx +440 -430
- package/src/client/components/JuniorLogo.tsx +1 -1
- package/src/client/components/ProfileMenu.tsx +4 -41
- package/src/client/components/layout/DashboardChrome.tsx +9 -4
- package/src/client/components/layout/DashboardHeader.tsx +11 -51
- package/src/client/components/layout/NavRail.tsx +156 -0
- package/src/client/components/ui/sidebar.tsx +336 -0
- package/src/client/conversations/ConversationPage.tsx +14 -0
- package/src/client/conversations/ConversationTranscript.tsx +74 -5
- package/src/client/conversations/PendingMailboxStack.tsx +22 -9
- package/src/client/conversations/TranscriptMessageShell.tsx +19 -8
- package/src/client/conversations/TranscriptMessageView.tsx +8 -1
- package/src/client/conversations/TranscriptRows.tsx +23 -0
- package/src/client/conversations/TranscriptTurnContextView.tsx +1 -1
- package/src/client/conversations/TranscriptView.tsx +22 -0
- package/src/client/conversations/conversationOutbox.ts +19 -0
- package/src/client/conversations/eventTranscript.ts +6 -0
- package/src/client/conversations/queries.ts +29 -20
- package/src/client/conversations/transcript.ts +12 -2
- 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/format.ts +8 -1
- 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 -40
- 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 +6 -5
- package/src/client/review-desk/reviewItemFormat.ts +3 -5
- package/src/client/types.ts +2 -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 -36
- package/src/client/conversations/AGENTS.md +0 -17
- package/src/client/pages/automations/AGENTS.md +0 -15
|
@@ -40,6 +40,9 @@ export const TranscriptMessageView = memo(
|
|
|
40
40
|
<TranscriptMessageShell
|
|
41
41
|
role={props.message.role}
|
|
42
42
|
actor={transcriptMessageActorLabel(props.conversation, props.message)}
|
|
43
|
+
avatarUrl={
|
|
44
|
+
transcriptMessageActor(props.conversation, props.message)?.avatarUrl
|
|
45
|
+
}
|
|
43
46
|
onCopy={(event) => {
|
|
44
47
|
const selection = event.currentTarget.ownerDocument.getSelection();
|
|
45
48
|
if (
|
|
@@ -97,7 +100,8 @@ export const TranscriptMessageView = memo(
|
|
|
97
100
|
previous.conversation.conversationId === next.conversation.conversationId &&
|
|
98
101
|
previous.conversation.surface === next.conversation.surface &&
|
|
99
102
|
previous.conversation.canFork === next.conversation.canFork &&
|
|
100
|
-
previous.conversation.actorIdentity === next.conversation.actorIdentity
|
|
103
|
+
previous.conversation.actorIdentity === next.conversation.actorIdentity &&
|
|
104
|
+
previous.conversation.authors === next.conversation.authors,
|
|
101
105
|
);
|
|
102
106
|
|
|
103
107
|
/** Render a redacted primary chat message without exposing body content. */
|
|
@@ -109,6 +113,9 @@ export function RedactedMessageView(props: {
|
|
|
109
113
|
<TranscriptMessageShell
|
|
110
114
|
role={props.message.role}
|
|
111
115
|
actor={transcriptMessageActorLabel(props.conversation, props.message)}
|
|
116
|
+
avatarUrl={
|
|
117
|
+
transcriptMessageActor(props.conversation, props.message)?.avatarUrl
|
|
118
|
+
}
|
|
112
119
|
>
|
|
113
120
|
<TranscriptMessageHeader
|
|
114
121
|
message={props.message}
|
|
@@ -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>
|
|
@@ -203,7 +203,7 @@ function TurnContextActor(props: { actor: ActorIdentity; timestamp?: number }) {
|
|
|
203
203
|
aria-label="Speaker"
|
|
204
204
|
className="flex min-w-0 items-center gap-3 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
|
|
205
205
|
>
|
|
206
|
-
<ActorAvatar name={name} size="detail" />
|
|
206
|
+
<ActorAvatar imageUrl={props.actor.avatarUrl} name={name} size="detail" />
|
|
207
207
|
<div className="min-w-0 flex-1">
|
|
208
208
|
<div className="flex min-w-0 items-baseline justify-between gap-3">
|
|
209
209
|
{email ? (
|
|
@@ -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,8 +1,10 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
1
2
|
import type {
|
|
2
3
|
ActorIdentity,
|
|
3
4
|
ConversationPendingMessage,
|
|
4
5
|
InputImage,
|
|
5
6
|
} from "@sentry/junior/api/schema";
|
|
7
|
+
import type { DashboardCoreData } from "../types";
|
|
6
8
|
|
|
7
9
|
/** Client-owned mailbox row waiting on accept, server visibility, or retry. */
|
|
8
10
|
export type ConversationOutboxMessage = {
|
|
@@ -27,6 +29,22 @@ export type ConversationMailboxMessage = ConversationPendingMessage & {
|
|
|
27
29
|
idempotencyKey?: string;
|
|
28
30
|
};
|
|
29
31
|
|
|
32
|
+
/** Identity of the viewer, for the outbox rows that they send. */
|
|
33
|
+
export function outboxActorIdentity(
|
|
34
|
+
queryClient: QueryClient,
|
|
35
|
+
): ActorIdentity | undefined {
|
|
36
|
+
const user = queryClient.getQueryData<DashboardCoreData>([
|
|
37
|
+
"dashboard",
|
|
38
|
+
"core",
|
|
39
|
+
])?.me.user;
|
|
40
|
+
if (!user?.email) return undefined;
|
|
41
|
+
return {
|
|
42
|
+
...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : undefined),
|
|
43
|
+
email: user.email,
|
|
44
|
+
fullName: user.name || undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
/** Stable React Query key for one conversation's local send outbox. */
|
|
31
49
|
export function conversationOutboxQueryKey(conversationId: string | undefined) {
|
|
32
50
|
return ["conversation", conversationId, "outbox"] as const;
|
|
@@ -163,6 +181,7 @@ function sameActorIdentity(
|
|
|
163
181
|
if (left === right) return true;
|
|
164
182
|
if (!left || !right) return false;
|
|
165
183
|
return (
|
|
184
|
+
left.avatarUrl === right.avatarUrl &&
|
|
166
185
|
left.email === right.email &&
|
|
167
186
|
left.fullName === right.fullName &&
|
|
168
187
|
left.slackUserId === right.slackUserId &&
|
|
@@ -93,6 +93,9 @@ export function pendingTranscriptMessage(
|
|
|
93
93
|
...(message.actorIdentity
|
|
94
94
|
? { actorIdentity: message.actorIdentity }
|
|
95
95
|
: undefined),
|
|
96
|
+
...(message.actorIdentityId
|
|
97
|
+
? { actorIdentityId: message.actorIdentityId }
|
|
98
|
+
: undefined),
|
|
96
99
|
...(message.mentions ? { mentions: message.mentions } : undefined),
|
|
97
100
|
};
|
|
98
101
|
}
|
|
@@ -222,6 +225,9 @@ export function transcriptMessagesFromEvents(
|
|
|
222
225
|
...(data.actorIdentity
|
|
223
226
|
? { actorIdentity: data.actorIdentity }
|
|
224
227
|
: undefined),
|
|
228
|
+
...(data.actorIdentityId
|
|
229
|
+
? { actorIdentityId: data.actorIdentityId }
|
|
230
|
+
: undefined),
|
|
225
231
|
...(data.agentInvocationId
|
|
226
232
|
? { agentInvocationId: data.agentInvocationId }
|
|
227
233
|
: undefined),
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useDeferredValue, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import {
|
|
3
3
|
queryOptions,
|
|
4
|
-
type QueryClient,
|
|
5
4
|
useInfiniteQuery,
|
|
6
5
|
useMutation,
|
|
7
6
|
useMutationState,
|
|
@@ -9,7 +8,6 @@ import {
|
|
|
9
8
|
useQueryClient,
|
|
10
9
|
} from "@tanstack/react-query";
|
|
11
10
|
import type {
|
|
12
|
-
ActorIdentity,
|
|
13
11
|
ConversationDetailReport,
|
|
14
12
|
ConversationEventPage,
|
|
15
13
|
ConversationFeed,
|
|
@@ -30,7 +28,6 @@ import {
|
|
|
30
28
|
stopConversationTurnResponseSchema,
|
|
31
29
|
} from "@sentry/junior/api/schema";
|
|
32
30
|
|
|
33
|
-
import type { DashboardCoreData } from "../types";
|
|
34
31
|
import {
|
|
35
32
|
DashboardApiError,
|
|
36
33
|
del,
|
|
@@ -45,6 +42,7 @@ import {
|
|
|
45
42
|
failConversationOutboxMessage,
|
|
46
43
|
isInlineOutboxMessage,
|
|
47
44
|
mergeConversationMailboxMessages,
|
|
45
|
+
outboxActorIdentity,
|
|
48
46
|
acceptConversationOutboxMessage,
|
|
49
47
|
reuseConversationMailboxMessages,
|
|
50
48
|
splitInlineMailboxMessages,
|
|
@@ -178,9 +176,12 @@ export function conversationDetailQueryOptions(
|
|
|
178
176
|
structuralSharing: (previous, next) => {
|
|
179
177
|
if (!next || typeof next !== "object") return next;
|
|
180
178
|
if (!previous || typeof previous !== "object") return next;
|
|
181
|
-
return
|
|
179
|
+
return keepLatestReadPosition(
|
|
182
180
|
previous as ConversationSnapshot,
|
|
183
|
-
|
|
181
|
+
reuseConversationEventReferences(
|
|
182
|
+
previous as ConversationSnapshot,
|
|
183
|
+
next as ConversationSnapshot,
|
|
184
|
+
),
|
|
184
185
|
);
|
|
185
186
|
},
|
|
186
187
|
refetchInterval: (query) =>
|
|
@@ -196,18 +197,18 @@ export function conversationDetailQueryOptions(
|
|
|
196
197
|
});
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
};
|
|
200
|
+
/**
|
|
201
|
+
* Read positions only move forward. A poll that started before a read report
|
|
202
|
+
* finished must not put an older position back in the cache.
|
|
203
|
+
*/
|
|
204
|
+
function keepLatestReadPosition(
|
|
205
|
+
previous: ConversationSnapshot,
|
|
206
|
+
next: ConversationSnapshot,
|
|
207
|
+
): ConversationSnapshot {
|
|
208
|
+
const kept = previous.readPosition;
|
|
209
|
+
if (!kept) return next;
|
|
210
|
+
if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
|
|
211
|
+
return { ...next, readPosition: kept };
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
/** Create one dashboard conversation and refresh the personal feed. */
|
|
@@ -704,14 +705,17 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
704
705
|
0,
|
|
705
706
|
...awaitingRows.map((message) => Date.parse(message.createdAt)),
|
|
706
707
|
);
|
|
707
|
-
const detail = useQuery(
|
|
708
|
-
conversationDetailQueryOptions(conversationId, {
|
|
708
|
+
const detail = useQuery({
|
|
709
|
+
...conversationDetailQueryOptions(conversationId, {
|
|
709
710
|
awaitingMessage: awaitingRows.length > 0,
|
|
710
711
|
fastPollUntilMs: latestSendAtMs
|
|
711
712
|
? latestSendAtMs + FAST_POLL_WINDOW_MS
|
|
712
713
|
: undefined,
|
|
713
714
|
}),
|
|
714
|
-
|
|
715
|
+
// A reopened Conversation shows the cached copy at once, but it can miss
|
|
716
|
+
// messages that arrived while the reader was away. Fetch it each visit.
|
|
717
|
+
refetchOnMount: "always",
|
|
718
|
+
});
|
|
715
719
|
// Defer the whole server snapshot, not history alone. Queue removal and the
|
|
716
720
|
// matching transcript must paint together while composer input stays urgent.
|
|
717
721
|
const deferredDetail = useDeferredValue(detail.data);
|
|
@@ -893,6 +897,11 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
893
897
|
? history.hasNextPage
|
|
894
898
|
: Boolean(detailData?.previousCursor),
|
|
895
899
|
isPending: detail.isPending,
|
|
900
|
+
/** The latest detail after a fetch in this visit, before it is deferred. */
|
|
901
|
+
fetchedDetail:
|
|
902
|
+
detail.isFetchedAfterMount && !detail.isFetching
|
|
903
|
+
? detail.data
|
|
904
|
+
: undefined,
|
|
896
905
|
isLoadingPreviousPage,
|
|
897
906
|
pendingAuthorization: mailbox?.authorization,
|
|
898
907
|
pendingGeneratedAt: mailbox?.generatedAt,
|
|
@@ -223,8 +223,18 @@ export function reuseConversationEventReferences(
|
|
|
223
223
|
JSON.stringify(previous.annotations) === JSON.stringify(next.annotations)
|
|
224
224
|
? previous.annotations
|
|
225
225
|
: next.annotations;
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
const authors =
|
|
227
|
+
JSON.stringify(previous.authors) === JSON.stringify(next.authors)
|
|
228
|
+
? previous.authors
|
|
229
|
+
: next.authors;
|
|
230
|
+
if (
|
|
231
|
+
events === next.events &&
|
|
232
|
+
annotations === next.annotations &&
|
|
233
|
+
authors === next.authors
|
|
234
|
+
) {
|
|
235
|
+
return next;
|
|
236
|
+
}
|
|
237
|
+
return { ...next, events, annotations, authors };
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
function sameConversationEventVersion(
|
|
@@ -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;
|