@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.
Files changed (73) hide show
  1. package/dist/app.js +196 -72
  2. package/dist/client.js +94 -89
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +191 -70
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +80 -12
  9. package/src/auth.ts +5 -1
  10. package/src/client/App.tsx +9 -4
  11. package/src/client/components/DetailsPanel.tsx +1 -1
  12. package/src/client/components/FormNotice.tsx +11 -14
  13. package/src/client/components/ProfileMenu.tsx +5 -6
  14. package/src/client/components/ui/resizable.tsx +50 -0
  15. package/src/client/conversations/AutoArchiveToggle.tsx +71 -0
  16. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  17. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  18. package/src/client/conversations/ConversationDetails.tsx +6 -3
  19. package/src/client/conversations/ConversationHeader.tsx +15 -2
  20. package/src/client/conversations/ConversationHeaderActions.tsx +27 -0
  21. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  22. package/src/client/conversations/ConversationMemories.tsx +64 -67
  23. package/src/client/conversations/ConversationMeta.tsx +2 -7
  24. package/src/client/conversations/ConversationPage.tsx +175 -79
  25. package/src/client/conversations/ConversationSidebar.tsx +82 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +11 -8
  27. package/src/client/conversations/ConversationWatches.tsx +15 -21
  28. package/src/client/conversations/ConversationWorkspace.tsx +132 -44
  29. package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
  30. package/src/client/conversations/DetailsList.tsx +8 -14
  31. package/src/client/conversations/EventDetails.tsx +2 -62
  32. package/src/client/conversations/MemoryIcons.tsx +23 -0
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
  34. package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  36. package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
  37. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  38. package/src/client/conversations/TranscriptView.tsx +89 -20
  39. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  40. package/src/client/conversations/conversationArchive.ts +255 -0
  41. package/src/client/conversations/conversationMemory.ts +2 -1
  42. package/src/client/conversations/conversationSections.ts +24 -18
  43. package/src/client/conversations/conversationSidebarLayout.ts +61 -0
  44. package/src/client/conversations/eventLog.ts +2 -31
  45. package/src/client/conversations/eventTranscript.ts +9 -42
  46. package/src/client/conversations/queries.ts +121 -250
  47. package/src/client/conversations/transcript.ts +46 -16
  48. package/src/client/conversations/transcriptBottomPinning.ts +40 -24
  49. package/src/client/conversations/transcriptReadPosition.ts +31 -7
  50. package/src/client/conversations/transcriptRenderModel.ts +1 -14
  51. package/src/client/conversations/transcriptSearch.tsx +3 -11
  52. package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
  53. package/src/client/conversations/usePinConversation.ts +88 -0
  54. package/src/client/display-text/DisplayText.tsx +7 -2
  55. package/src/client/display-text/codeBlocks.ts +92 -0
  56. package/src/client/format.ts +3 -67
  57. package/src/client/http.ts +5 -5
  58. package/src/client/markdownExport.ts +32 -42
  59. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  60. package/src/client/pages/memory/MemoryDetails.tsx +6 -33
  61. package/src/client/pages/memory/MemoryPage.tsx +7 -92
  62. package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
  63. package/src/client/pages/memory/memoryDashboard.ts +2 -38
  64. package/src/client/pages/memory/memoryRecord.ts +1 -6
  65. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  66. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  67. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  68. package/src/client/review-desk/queries.ts +2 -1
  69. package/src/client/types.ts +19 -33
  70. package/src/conversation-cache.ts +7 -9
  71. package/src/mock-reporting/fixtures.ts +126 -60
  72. package/src/mock-reporting/routes.ts +50 -1
  73. 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
- type ConversationSnapshot = ConversationDetailReport & {
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(conversationId, signal, previous);
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
- readConversationSnapshot(
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
- client.getQueryData<ConversationSnapshot>(
173
- conversationDetailQueryKey(conversationId),
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
- return { ...next, readPosition: kept };
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
- /** Archive or restore one conversation with an immediate reversible cache update. */
514
- export function useArchiveConversation(
515
- conversationId: string,
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
- onError?(): void;
518
- onSuccess?(archivedAt: string | null): void;
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
- return useMutation({
523
- mutationKey: [...archiveConversationMutationKey, conversationId],
524
- mutationFn: (args: ArchiveConversationVariables) =>
525
- patch(
526
- archiveConversationResponseSchema,
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
- return conversation ? { conversation, feedQueryHashes } : undefined;
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) void fetchNextHistoryPage();
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 && !history.isFetchingNextPage) {
926
- void history.fetchNextPage();
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 { ...report, detailEtag: response.headers.get("etag") ?? undefined };
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
- if (detail.eventHistory.status !== "available") {
17
- return withoutModelUsage(detail);
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 {