@sentry/junior-dashboard 0.276.0 → 0.278.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 +196 -72
- package/dist/client.js +94 -89
- package/dist/index.d.ts +1 -1
- package/dist/index.js +191 -70
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +20 -2
- package/src/app.ts +80 -12
- package/src/auth.ts +5 -1
- package/src/client/App.tsx +9 -4
- package/src/client/components/DetailsPanel.tsx +1 -1
- package/src/client/components/FormNotice.tsx +11 -14
- package/src/client/components/ProfileMenu.tsx +5 -6
- package/src/client/components/ui/resizable.tsx +50 -0
- package/src/client/conversations/AutoArchiveToggle.tsx +71 -0
- package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
- package/src/client/conversations/ConversationChildAgents.tsx +2 -1
- package/src/client/conversations/ConversationDetails.tsx +6 -3
- package/src/client/conversations/ConversationHeader.tsx +15 -2
- package/src/client/conversations/ConversationHeaderActions.tsx +27 -0
- package/src/client/conversations/ConversationHomeList.tsx +1 -1
- package/src/client/conversations/ConversationMemories.tsx +64 -67
- package/src/client/conversations/ConversationMeta.tsx +2 -7
- package/src/client/conversations/ConversationPage.tsx +175 -79
- package/src/client/conversations/ConversationSidebar.tsx +82 -25
- package/src/client/conversations/ConversationTranscript.tsx +11 -8
- package/src/client/conversations/ConversationWatches.tsx +15 -21
- package/src/client/conversations/ConversationWorkspace.tsx +132 -44
- package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
- package/src/client/conversations/DetailsList.tsx +8 -14
- package/src/client/conversations/EventDetails.tsx +2 -62
- package/src/client/conversations/MemoryIcons.tsx +23 -0
- package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
- package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
- package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
- package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
- package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
- package/src/client/conversations/TranscriptView.tsx +89 -20
- package/src/client/conversations/autoArchiveQueries.ts +53 -0
- package/src/client/conversations/conversationArchive.ts +255 -0
- package/src/client/conversations/conversationMemory.ts +2 -1
- package/src/client/conversations/conversationSections.ts +24 -18
- package/src/client/conversations/conversationSidebarLayout.ts +61 -0
- package/src/client/conversations/eventLog.ts +2 -31
- package/src/client/conversations/eventTranscript.ts +9 -42
- package/src/client/conversations/queries.ts +121 -250
- package/src/client/conversations/transcript.ts +46 -16
- package/src/client/conversations/transcriptBottomPinning.ts +40 -24
- package/src/client/conversations/transcriptReadPosition.ts +31 -7
- package/src/client/conversations/transcriptRenderModel.ts +1 -14
- package/src/client/conversations/transcriptSearch.tsx +3 -11
- package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
- package/src/client/conversations/usePinConversation.ts +88 -0
- package/src/client/display-text/DisplayText.tsx +7 -2
- package/src/client/display-text/codeBlocks.ts +92 -0
- package/src/client/format.ts +3 -67
- package/src/client/http.ts +5 -5
- package/src/client/markdownExport.ts +32 -42
- package/src/client/pages/code/RepositoryTabs.tsx +2 -2
- package/src/client/pages/memory/MemoryDetails.tsx +6 -33
- package/src/client/pages/memory/MemoryPage.tsx +7 -92
- package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
- package/src/client/pages/memory/memoryDashboard.ts +2 -38
- package/src/client/pages/memory/memoryRecord.ts +1 -6
- package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
- package/src/client/pages/settings/SettingsPage.tsx +2 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
- package/src/client/review-desk/queries.ts +2 -1
- package/src/client/types.ts +19 -33
- package/src/conversation-cache.ts +7 -9
- package/src/mock-reporting/fixtures.ts +126 -60
- package/src/mock-reporting/routes.ts +50 -1
- package/src/tailwind.css +30 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { useDeferredValue, useEffect, useMemo, useRef } from "react";
|
|
1
|
+
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
|
+
type QueryClient,
|
|
3
4
|
queryOptions,
|
|
4
5
|
useInfiniteQuery,
|
|
5
6
|
useMutation,
|
|
6
|
-
useMutationState,
|
|
7
7
|
useQuery,
|
|
8
8
|
useQueryClient,
|
|
9
9
|
} from "@tanstack/react-query";
|
|
@@ -12,13 +12,12 @@ import type {
|
|
|
12
12
|
ConversationEventPage,
|
|
13
13
|
ConversationFeed,
|
|
14
14
|
ConversationPendingMessagesReport,
|
|
15
|
-
ConversationSummaryReport,
|
|
16
15
|
InputImage,
|
|
17
16
|
} from "@sentry/junior/api/schema";
|
|
18
17
|
import {
|
|
19
18
|
acceptedConversationMessageSchema,
|
|
19
|
+
CONVERSATION_DETAIL_VERSION_HEADER,
|
|
20
20
|
webMessageId,
|
|
21
|
-
archiveConversationResponseSchema,
|
|
22
21
|
cancelConversationPendingMessagesResponseSchema,
|
|
23
22
|
conversationDetailReportSchema,
|
|
24
23
|
conversationEventPageSchema,
|
|
@@ -28,11 +27,11 @@ import {
|
|
|
28
27
|
stopConversationTurnResponseSchema,
|
|
29
28
|
} from "@sentry/junior/api/schema";
|
|
30
29
|
|
|
30
|
+
import { REVIEW_ITEMS_QUERY_KEY } from "../review-desk/queries";
|
|
31
31
|
import {
|
|
32
32
|
DashboardApiError,
|
|
33
33
|
del,
|
|
34
34
|
fetchDashboardJson,
|
|
35
|
-
patch,
|
|
36
35
|
post,
|
|
37
36
|
readDashboardResponse,
|
|
38
37
|
} from "../http";
|
|
@@ -66,78 +65,13 @@ export function conversationDetailQueryKey(conversationId: string | undefined) {
|
|
|
66
65
|
return ["conversation", conversationId, "detail"] as const;
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
function archivedConversationQueryKey(conversationId: string) {
|
|
70
|
-
return ["dashboard", "archived-conversation", conversationId] as const;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Read active/archived status from a dashboard conversations query key. */
|
|
74
|
-
function conversationFeedStatus(
|
|
75
|
-
queryKey: readonly unknown[],
|
|
76
|
-
): "active" | "archived" | undefined {
|
|
77
|
-
const status = queryKey[queryKey.length - 1];
|
|
78
|
-
return status === "active" || status === "archived" ? status : undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
type ArchivedConversationSnapshot = {
|
|
82
|
-
conversation: ConversationSummaryReport;
|
|
83
|
-
feedQueryHashes: string[];
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const archiveConversationMutationKey = [
|
|
87
|
-
"dashboard",
|
|
88
|
-
"archive-conversation",
|
|
89
|
-
] as const;
|
|
90
|
-
|
|
91
|
-
type ArchiveConversationVariables = {
|
|
92
|
-
archived: boolean;
|
|
93
|
-
lastSeenAt: string;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
type ArchiveConversationMutationContext = {
|
|
97
|
-
archivedQueryKey: ReturnType<typeof archivedConversationQueryKey>;
|
|
98
|
-
archivedSnapshot?: ArchivedConversationSnapshot;
|
|
99
|
-
detailQueryKey: ReturnType<typeof conversationDetailQueryKey>;
|
|
100
|
-
previousArchivedSnapshot?: ArchivedConversationSnapshot;
|
|
101
|
-
previousDetail?: ConversationDetailReport;
|
|
102
|
-
previousFeeds: Array<[readonly unknown[], ConversationFeed | undefined]>;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
export type PendingArchiveConversationUpdate = {
|
|
106
|
-
archived: boolean;
|
|
107
|
-
conversation?: ConversationSummaryReport;
|
|
108
|
-
conversationId: string;
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/** Read pending archive state so refetches cannot visually replace optimistic UI. */
|
|
112
|
-
export function usePendingArchiveConversationUpdates() {
|
|
113
|
-
return useMutationState({
|
|
114
|
-
filters: {
|
|
115
|
-
mutationKey: archiveConversationMutationKey,
|
|
116
|
-
status: "pending",
|
|
117
|
-
},
|
|
118
|
-
select: (mutation) => {
|
|
119
|
-
const variables = mutation.state
|
|
120
|
-
.variables as ArchiveConversationVariables;
|
|
121
|
-
const context = mutation.state.context as
|
|
122
|
-
| ArchiveConversationMutationContext
|
|
123
|
-
| undefined;
|
|
124
|
-
const conversationId = mutation.options.mutationKey?.[2];
|
|
125
|
-
return {
|
|
126
|
-
archived: variables.archived,
|
|
127
|
-
conversation: context?.archivedSnapshot?.conversation,
|
|
128
|
-
conversationId:
|
|
129
|
-
typeof conversationId === "string" ? conversationId : "",
|
|
130
|
-
} satisfies PendingArchiveConversationUpdate;
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
68
|
// Right after a send, Junior usually reads the message within a few seconds.
|
|
136
69
|
// Poll fast for that window only, then fall back to the normal live interval.
|
|
137
70
|
const FAST_POLL_INTERVAL_MS = 500;
|
|
138
71
|
const FAST_POLL_WINDOW_MS = 6_000;
|
|
139
72
|
|
|
140
|
-
|
|
73
|
+
/** The cached detail report with its mailbox and revalidation ETag. */
|
|
74
|
+
export type ConversationSnapshot = ConversationDetailReport & {
|
|
141
75
|
mailbox?: ConversationPendingMessagesReport;
|
|
142
76
|
detailEtag?: string;
|
|
143
77
|
};
|
|
@@ -147,12 +81,44 @@ async function readConversationSnapshot(
|
|
|
147
81
|
conversationId: string,
|
|
148
82
|
signal?: AbortSignal,
|
|
149
83
|
previous?: ConversationSnapshot,
|
|
84
|
+
unreadPage?: boolean,
|
|
150
85
|
): Promise<ConversationSnapshot> {
|
|
151
86
|
const mailbox = await readConversationPendingMessages(conversationId, signal);
|
|
152
|
-
const detail = await readConversationData(
|
|
87
|
+
const detail = await readConversationData(
|
|
88
|
+
conversationId,
|
|
89
|
+
signal,
|
|
90
|
+
previous,
|
|
91
|
+
unreadPage,
|
|
92
|
+
);
|
|
153
93
|
return { ...detail, mailbox };
|
|
154
94
|
}
|
|
155
95
|
|
|
96
|
+
const CONVERSATION_SIDE_READS = ["agents", "memories", "watches"] as const;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Key a read that shows beside the detail report, such as in the details
|
|
100
|
+
* panel. A detail change refreshes it.
|
|
101
|
+
*/
|
|
102
|
+
export function conversationSideReadQueryKey(
|
|
103
|
+
conversationId: string,
|
|
104
|
+
read: (typeof CONVERSATION_SIDE_READS)[number],
|
|
105
|
+
) {
|
|
106
|
+
return ["conversation", conversationId, read] as const;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Refresh the side reads and Review items. Only mounted reads refetch. */
|
|
110
|
+
function refreshConversationSideReads(
|
|
111
|
+
client: QueryClient,
|
|
112
|
+
conversationId: string,
|
|
113
|
+
) {
|
|
114
|
+
for (const read of CONVERSATION_SIDE_READS) {
|
|
115
|
+
void client.invalidateQueries({
|
|
116
|
+
queryKey: conversationSideReadQueryKey(conversationId, read),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
void client.invalidateQueries({ queryKey: REVIEW_ITEMS_QUERY_KEY });
|
|
120
|
+
}
|
|
121
|
+
|
|
156
122
|
/** Refresh history and mailbox as one visible snapshot, including idle input. */
|
|
157
123
|
export function conversationDetailQueryOptions(
|
|
158
124
|
conversationId: string | undefined,
|
|
@@ -160,19 +126,37 @@ export function conversationDetailQueryOptions(
|
|
|
160
126
|
awaitingMessage?: boolean;
|
|
161
127
|
/** Poll fast until this time so a new send reaches history quickly. */
|
|
162
128
|
fastPollUntilMs?: number;
|
|
129
|
+
/** Also read the page around the read position. */
|
|
130
|
+
unreadPage?: boolean;
|
|
163
131
|
},
|
|
164
132
|
) {
|
|
133
|
+
// `unreadPage` adds a page to one fetch. The detail in the cache is the
|
|
134
|
+
// same, so it stays out of the key.
|
|
135
|
+
// oxlint-disable-next-line @tanstack/query/exhaustive-deps
|
|
165
136
|
return queryOptions({
|
|
166
137
|
enabled: Boolean(conversationId),
|
|
167
138
|
queryKey: conversationDetailQueryKey(conversationId),
|
|
168
|
-
queryFn: ({ signal, client }) =>
|
|
169
|
-
|
|
139
|
+
queryFn: async ({ signal, client }) => {
|
|
140
|
+
const previous = client.getQueryData<ConversationSnapshot>(
|
|
141
|
+
conversationDetailQueryKey(conversationId),
|
|
142
|
+
);
|
|
143
|
+
const next = await readConversationSnapshot(
|
|
170
144
|
conversationId!,
|
|
171
145
|
signal,
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
146
|
+
previous,
|
|
147
|
+
options?.unreadPage,
|
|
148
|
+
);
|
|
149
|
+
// Side reads do not poll. A new ETag means the Conversation changed,
|
|
150
|
+
// so the details panel may be stale too.
|
|
151
|
+
if (
|
|
152
|
+
previous?.detailEtag &&
|
|
153
|
+
next.detailEtag &&
|
|
154
|
+
previous.detailEtag !== next.detailEtag
|
|
155
|
+
) {
|
|
156
|
+
refreshConversationSideReads(client, conversationId!);
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
},
|
|
176
160
|
structuralSharing: (previous, next) => {
|
|
177
161
|
if (!next || typeof next !== "object") return next;
|
|
178
162
|
if (!previous || typeof previous !== "object") return next;
|
|
@@ -199,7 +183,8 @@ export function conversationDetailQueryOptions(
|
|
|
199
183
|
|
|
200
184
|
/**
|
|
201
185
|
* 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
|
|
186
|
+
* finished must not put an older position back in the cache, nor the unread
|
|
187
|
+
* cursor of that older position.
|
|
203
188
|
*/
|
|
204
189
|
function keepLatestReadPosition(
|
|
205
190
|
previous: ConversationSnapshot,
|
|
@@ -208,7 +193,8 @@ function keepLatestReadPosition(
|
|
|
208
193
|
const kept = previous.readPosition;
|
|
209
194
|
if (!kept) return next;
|
|
210
195
|
if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
|
|
211
|
-
|
|
196
|
+
const { unreadCursor: _olderCursor, ...rest } = next;
|
|
197
|
+
return { ...rest, readPosition: kept };
|
|
212
198
|
}
|
|
213
199
|
|
|
214
200
|
/** Create one dashboard conversation and refresh the personal feed. */
|
|
@@ -510,170 +496,29 @@ export function useStopConversationTurn(conversationId: string) {
|
|
|
510
496
|
});
|
|
511
497
|
}
|
|
512
498
|
|
|
513
|
-
/**
|
|
514
|
-
export
|
|
515
|
-
|
|
499
|
+
/** The open Conversation's data, from `useConversationData`. */
|
|
500
|
+
export type ConversationData = ReturnType<typeof useConversationData>;
|
|
501
|
+
|
|
502
|
+
/** Fetch a bounded conversation snapshot and older pages on demand. */
|
|
503
|
+
export function useConversationData(
|
|
504
|
+
conversationId: string | undefined,
|
|
516
505
|
options?: {
|
|
517
|
-
|
|
518
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Ask the first fetch of this visit for the page around the read
|
|
508
|
+
* position. Polls do not ask, so the server reads that page once.
|
|
509
|
+
*/
|
|
510
|
+
unreadPageAtOpen?: boolean;
|
|
519
511
|
},
|
|
520
512
|
) {
|
|
521
513
|
const queryClient = useQueryClient();
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
`/api/conversations/${encodeURIComponent(conversationId)}/archive`,
|
|
528
|
-
args,
|
|
529
|
-
),
|
|
530
|
-
onMutate: async (args) => {
|
|
531
|
-
const conversationQueries = { queryKey: ["dashboard", "conversations"] };
|
|
532
|
-
const detailQueryKey = conversationDetailQueryKey(conversationId);
|
|
533
|
-
const archivedQueryKey = archivedConversationQueryKey(conversationId);
|
|
534
|
-
await Promise.all([
|
|
535
|
-
queryClient.cancelQueries(conversationQueries),
|
|
536
|
-
queryClient.cancelQueries({ queryKey: detailQueryKey }),
|
|
537
|
-
]);
|
|
538
|
-
const previousFeeds =
|
|
539
|
-
queryClient.getQueriesData<ConversationFeed>(conversationQueries);
|
|
540
|
-
const previousDetail =
|
|
541
|
-
queryClient.getQueryData<ConversationDetailReport>(detailQueryKey);
|
|
542
|
-
const previousArchivedSnapshot =
|
|
543
|
-
queryClient.getQueryData<ArchivedConversationSnapshot>(
|
|
544
|
-
archivedQueryKey,
|
|
545
|
-
);
|
|
546
|
-
const archivedAt = args.archived ? new Date().toISOString() : null;
|
|
547
|
-
// Snapshot from any loaded feed, including archived-only fixtures that
|
|
548
|
-
// were never archived through this client session.
|
|
549
|
-
const archivedSnapshot =
|
|
550
|
-
buildArchivedConversationSnapshot(previousFeeds, conversationId) ??
|
|
551
|
-
previousArchivedSnapshot;
|
|
552
|
-
|
|
553
|
-
previousFeeds.forEach(([queryKey, feed]) => {
|
|
554
|
-
if (!feed) return;
|
|
555
|
-
const conversationExists = feed.conversations.some(
|
|
556
|
-
(conversation) => conversation.conversationId === conversationId,
|
|
557
|
-
);
|
|
558
|
-
const feedStatus = conversationFeedStatus(queryKey);
|
|
559
|
-
const shouldRestore =
|
|
560
|
-
!args.archived &&
|
|
561
|
-
!conversationExists &&
|
|
562
|
-
Boolean(archivedSnapshot) &&
|
|
563
|
-
feedStatus !== "archived";
|
|
564
|
-
const conversations = shouldRestore
|
|
565
|
-
? [
|
|
566
|
-
...feed.conversations,
|
|
567
|
-
{ ...archivedSnapshot!.conversation, archivedAt: null },
|
|
568
|
-
].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
|
|
569
|
-
: feed.conversations.map((conversation) =>
|
|
570
|
-
conversation.conversationId === conversationId
|
|
571
|
-
? { ...conversation, archivedAt }
|
|
572
|
-
: conversation,
|
|
573
|
-
);
|
|
574
|
-
queryClient.setQueryData<ConversationFeed>(queryKey, {
|
|
575
|
-
...feed,
|
|
576
|
-
conversations,
|
|
577
|
-
});
|
|
578
|
-
});
|
|
579
|
-
queryClient.setQueryData<ConversationSnapshot>(
|
|
580
|
-
detailQueryKey,
|
|
581
|
-
(detail) =>
|
|
582
|
-
detail ? { ...detail, archivedAt, detailEtag: undefined } : detail,
|
|
583
|
-
);
|
|
584
|
-
if (archivedSnapshot) {
|
|
585
|
-
queryClient.setQueryData(archivedQueryKey, archivedSnapshot);
|
|
586
|
-
}
|
|
587
|
-
return {
|
|
588
|
-
archivedQueryKey,
|
|
589
|
-
archivedSnapshot,
|
|
590
|
-
detailQueryKey,
|
|
591
|
-
previousArchivedSnapshot,
|
|
592
|
-
previousDetail,
|
|
593
|
-
previousFeeds,
|
|
594
|
-
} satisfies ArchiveConversationMutationContext;
|
|
595
|
-
},
|
|
596
|
-
onError: (_error, _args, context) => {
|
|
597
|
-
context?.previousFeeds.forEach(([queryKey, feed]) => {
|
|
598
|
-
queryClient.setQueryData(queryKey, feed);
|
|
599
|
-
});
|
|
600
|
-
if (context) {
|
|
601
|
-
queryClient.setQueryData(
|
|
602
|
-
context.detailQueryKey,
|
|
603
|
-
context.previousDetail,
|
|
604
|
-
);
|
|
605
|
-
if (context.previousArchivedSnapshot) {
|
|
606
|
-
queryClient.setQueryData(
|
|
607
|
-
context.archivedQueryKey,
|
|
608
|
-
context.previousArchivedSnapshot,
|
|
609
|
-
);
|
|
610
|
-
} else {
|
|
611
|
-
queryClient.removeQueries({
|
|
612
|
-
exact: true,
|
|
613
|
-
queryKey: context.archivedQueryKey,
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
options?.onError?.();
|
|
618
|
-
},
|
|
619
|
-
onSuccess: (result, args) => {
|
|
620
|
-
queryClient.setQueryData<ConversationSnapshot>(
|
|
621
|
-
conversationDetailQueryKey(conversationId),
|
|
622
|
-
(detail) =>
|
|
623
|
-
detail
|
|
624
|
-
? {
|
|
625
|
-
...detail,
|
|
626
|
-
archivedAt: result.archivedAt,
|
|
627
|
-
detailEtag: undefined,
|
|
628
|
-
}
|
|
629
|
-
: detail,
|
|
630
|
-
);
|
|
631
|
-
if (!args.archived) {
|
|
632
|
-
queryClient.removeQueries({
|
|
633
|
-
exact: true,
|
|
634
|
-
queryKey: archivedConversationQueryKey(conversationId),
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
options?.onSuccess?.(result.archivedAt);
|
|
638
|
-
},
|
|
639
|
-
onSettled: async () => {
|
|
640
|
-
await Promise.all([
|
|
641
|
-
queryClient.invalidateQueries({
|
|
642
|
-
queryKey: ["dashboard", "conversations"],
|
|
643
|
-
}),
|
|
644
|
-
queryClient.invalidateQueries({
|
|
645
|
-
queryKey: ["dashboard", "locations"],
|
|
646
|
-
}),
|
|
647
|
-
queryClient.invalidateQueries({ queryKey: ["dashboard", "people"] }),
|
|
648
|
-
queryClient.invalidateQueries({
|
|
649
|
-
exact: true,
|
|
650
|
-
queryKey: conversationDetailQueryKey(conversationId),
|
|
651
|
-
}),
|
|
652
|
-
]);
|
|
653
|
-
},
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function buildArchivedConversationSnapshot(
|
|
658
|
-
feeds: Array<[readonly unknown[], ConversationFeed | undefined]>,
|
|
659
|
-
conversationId: string,
|
|
660
|
-
): ArchivedConversationSnapshot | undefined {
|
|
661
|
-
let conversation: ConversationSummaryReport | undefined;
|
|
662
|
-
const feedQueryHashes: string[] = [];
|
|
663
|
-
for (const [queryKey, feed] of feeds) {
|
|
664
|
-
const feedConversation = feed?.conversations.find(
|
|
665
|
-
(item) => item.conversationId === conversationId,
|
|
666
|
-
);
|
|
667
|
-
if (!feedConversation) continue;
|
|
668
|
-
conversation ??= feedConversation;
|
|
669
|
-
feedQueryHashes.push(JSON.stringify(queryKey));
|
|
514
|
+
// The shell keeps one hook across Conversations, so a new Conversation
|
|
515
|
+
// starts a new visit.
|
|
516
|
+
const [visit, setVisit] = useState({ conversationId, fetched: false });
|
|
517
|
+
if (visit.conversationId !== conversationId) {
|
|
518
|
+
setVisit({ conversationId, fetched: false });
|
|
670
519
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
/** Fetch a bounded conversation snapshot and older pages on demand. */
|
|
675
|
-
export function useConversationData(conversationId: string | undefined) {
|
|
676
|
-
const queryClient = useQueryClient();
|
|
520
|
+
const fetchedAtOpen =
|
|
521
|
+
visit.conversationId === conversationId && visit.fetched;
|
|
677
522
|
const outbox = useQuery({
|
|
678
523
|
enabled: Boolean(conversationId),
|
|
679
524
|
// Local-only cache. Never replace optimistic rows with an empty fetch result.
|
|
@@ -711,11 +556,20 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
711
556
|
fastPollUntilMs: latestSendAtMs
|
|
712
557
|
? latestSendAtMs + FAST_POLL_WINDOW_MS
|
|
713
558
|
: undefined,
|
|
559
|
+
unreadPage: Boolean(options?.unreadPageAtOpen) && !fetchedAtOpen,
|
|
714
560
|
}),
|
|
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
561
|
});
|
|
562
|
+
// A reopened Conversation shows the cached copy at once, but it can miss
|
|
563
|
+
// messages that arrived while the reader was away. Fetch it once at the
|
|
564
|
+
// start of each visit, even when the cached copy is still fresh. A fetch
|
|
565
|
+
// that is already running counts.
|
|
566
|
+
const refetchDetail = detail.refetch;
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
if (conversationId) void refetchDetail({ cancelRefetch: false });
|
|
569
|
+
}, [conversationId, refetchDetail]);
|
|
570
|
+
if (!fetchedAtOpen && detail.isFetchedAfterMount && !detail.isFetching) {
|
|
571
|
+
setVisit({ conversationId, fetched: true });
|
|
572
|
+
}
|
|
719
573
|
// Defer the whole server snapshot, not history alone. Queue removal and the
|
|
720
574
|
// matching transcript must paint together while composer input stays urgent.
|
|
721
575
|
const deferredDetail = useDeferredValue(detail.data);
|
|
@@ -869,7 +723,6 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
869
723
|
const isLoadingPreviousPage =
|
|
870
724
|
history.isFetchingNextPage || historyNeedsReconciliation;
|
|
871
725
|
|
|
872
|
-
const refetchDetail = detail.refetch;
|
|
873
726
|
useEffect(() => {
|
|
874
727
|
if (shouldRefreshDetail) void refetchDetail();
|
|
875
728
|
}, [refetchDetail, shouldRefreshDetail]);
|
|
@@ -885,11 +738,18 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
885
738
|
|
|
886
739
|
const fetchNextHistoryPage = history.fetchNextPage;
|
|
887
740
|
useEffect(() => {
|
|
888
|
-
if (historyNeedsReconciliation)
|
|
741
|
+
if (historyNeedsReconciliation) {
|
|
742
|
+
void fetchNextHistoryPage({ cancelRefetch: false });
|
|
743
|
+
}
|
|
889
744
|
}, [fetchNextHistoryPage, historyNeedsReconciliation]);
|
|
890
745
|
|
|
891
746
|
return {
|
|
892
747
|
data,
|
|
748
|
+
/**
|
|
749
|
+
* The latest page alone, without earlier pages. It is not deferred, so it
|
|
750
|
+
* matches `fetchedDetail` and the read position that came with it.
|
|
751
|
+
*/
|
|
752
|
+
latest: detail.data,
|
|
893
753
|
error: detail.error,
|
|
894
754
|
historyError,
|
|
895
755
|
historyVersion: conversationHistoryVersion(historyPages ?? []),
|
|
@@ -897,6 +757,8 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
897
757
|
? history.hasNextPage
|
|
898
758
|
: Boolean(detailData?.previousCursor),
|
|
899
759
|
isPending: detail.isPending,
|
|
760
|
+
/** The first fetch of this visit has ended. */
|
|
761
|
+
fetchedAtOpen,
|
|
900
762
|
/** The latest detail after a fetch in this visit, before it is deferred. */
|
|
901
763
|
fetchedDetail:
|
|
902
764
|
detail.isFetchedAfterMount && !detail.isFetching
|
|
@@ -922,8 +784,10 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
922
784
|
const hasPreviousPage = history.data
|
|
923
785
|
? history.hasNextPage
|
|
924
786
|
: Boolean(detailData?.previousCursor);
|
|
925
|
-
if (conversationId && hasPreviousPage
|
|
926
|
-
|
|
787
|
+
if (conversationId && hasPreviousPage) {
|
|
788
|
+
// A second call joins the read in flight. By default it cancels
|
|
789
|
+
// that read and starts the same read again.
|
|
790
|
+
void history.fetchNextPage({ cancelRefetch: false });
|
|
927
791
|
}
|
|
928
792
|
},
|
|
929
793
|
};
|
|
@@ -934,15 +798,22 @@ export async function readConversationData(
|
|
|
934
798
|
conversationId: string,
|
|
935
799
|
signal?: AbortSignal,
|
|
936
800
|
previous?: ConversationSnapshot,
|
|
801
|
+
unreadPage?: boolean,
|
|
937
802
|
): Promise<ConversationSnapshot> {
|
|
938
803
|
const response = await readDashboardResponse(
|
|
939
|
-
`/api/conversations/${encodeURIComponent(conversationId)}`,
|
|
804
|
+
`/api/conversations/${encodeURIComponent(conversationId)}${unreadPage ? "?unreadPage=true" : ""}`,
|
|
940
805
|
signal,
|
|
941
|
-
previous?.detailEtag
|
|
806
|
+
previous?.detailEtag
|
|
807
|
+
? { [CONVERSATION_DETAIL_VERSION_HEADER]: previous.detailEtag }
|
|
808
|
+
: undefined,
|
|
942
809
|
);
|
|
943
810
|
if (response.status === 304 && previous) return previous;
|
|
944
811
|
const report = conversationDetailReportSchema.parse(await response.json());
|
|
945
|
-
return {
|
|
812
|
+
return {
|
|
813
|
+
...report,
|
|
814
|
+
detailEtag:
|
|
815
|
+
response.headers.get(CONVERSATION_DETAIL_VERSION_HEADER) ?? undefined,
|
|
816
|
+
};
|
|
946
817
|
}
|
|
947
818
|
|
|
948
819
|
/** Read accepted mailbox messages that have not reached durable history yet. */
|
|
@@ -13,22 +13,8 @@ export function buildConversationTranscript(
|
|
|
13
13
|
detail: ConversationDetailReport,
|
|
14
14
|
historyPages: ConversationHistoryPage[],
|
|
15
15
|
): ConversationDetailReport {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const restrictedHistory = historyPages.find(
|
|
21
|
-
(page) => page.eventHistory.status !== "available",
|
|
22
|
-
);
|
|
23
|
-
if (restrictedHistory) {
|
|
24
|
-
return withoutModelUsage({
|
|
25
|
-
...detail,
|
|
26
|
-
eventHistory: restrictedHistory.eventHistory,
|
|
27
|
-
events: restrictedHistory.events,
|
|
28
|
-
previousCursor: restrictedHistory.previousCursor,
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
16
|
+
const restricted = restrictedTranscript(detail, historyPages);
|
|
17
|
+
if (restricted) return restricted;
|
|
32
18
|
if (historyPages.length === 0) return detail;
|
|
33
19
|
|
|
34
20
|
return {
|
|
@@ -45,6 +31,30 @@ export function buildConversationTranscript(
|
|
|
45
31
|
};
|
|
46
32
|
}
|
|
47
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Build a transcript that opens at the read position.
|
|
36
|
+
*
|
|
37
|
+
* `pages` hold one unbroken range of events around the read position. The
|
|
38
|
+
* caller supplies `latestEvents` only when `pages` reach them, so the
|
|
39
|
+
* transcript never shows a gap between the two.
|
|
40
|
+
*/
|
|
41
|
+
export function buildUnreadConversationTranscript(
|
|
42
|
+
detail: ConversationDetailReport,
|
|
43
|
+
pages: ConversationEventPage[],
|
|
44
|
+
latestEvents: ConversationReportEvent[],
|
|
45
|
+
): ConversationDetailReport {
|
|
46
|
+
const restricted = restrictedTranscript(detail, pages);
|
|
47
|
+
if (restricted) return restricted;
|
|
48
|
+
return {
|
|
49
|
+
...detail,
|
|
50
|
+
events: orderedEvents([
|
|
51
|
+
...pages.flatMap((page) => page.events),
|
|
52
|
+
...latestEvents,
|
|
53
|
+
]),
|
|
54
|
+
previousCursor: oldestPreviousCursor(pages),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
/** Materialize one immutable detail snapshot through its exact history cursor chain. */
|
|
49
59
|
export async function loadCompleteConversationTranscript(args: {
|
|
50
60
|
detail: ConversationDetailReport;
|
|
@@ -198,6 +208,26 @@ function orderedEvents(
|
|
|
198
208
|
);
|
|
199
209
|
}
|
|
200
210
|
|
|
211
|
+
/** Show only what restricted history allows, from detail or from a page. */
|
|
212
|
+
function restrictedTranscript(
|
|
213
|
+
detail: ConversationDetailReport,
|
|
214
|
+
pages: ConversationEventPage[],
|
|
215
|
+
): ConversationDetailReport | undefined {
|
|
216
|
+
if (detail.eventHistory.status !== "available") {
|
|
217
|
+
return withoutModelUsage(detail);
|
|
218
|
+
}
|
|
219
|
+
const restrictedPage = pages.find(
|
|
220
|
+
(page) => page.eventHistory.status !== "available",
|
|
221
|
+
);
|
|
222
|
+
if (!restrictedPage) return undefined;
|
|
223
|
+
return withoutModelUsage({
|
|
224
|
+
...detail,
|
|
225
|
+
eventHistory: restrictedPage.eventHistory,
|
|
226
|
+
events: restrictedPage.events,
|
|
227
|
+
previousCursor: restrictedPage.previousCursor,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
201
231
|
function withoutModelUsage(
|
|
202
232
|
detail: ConversationDetailReport,
|
|
203
233
|
): ConversationDetailReport {
|