@sentry/junior-dashboard 0.275.0 → 0.277.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 (68) hide show
  1. package/dist/app.js +194 -40
  2. package/dist/client.js +120 -92
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +189 -38
  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 +88 -15
  9. package/src/auth.ts +5 -1
  10. package/src/client/components/FormNotice.tsx +11 -14
  11. package/src/client/components/ProfileMenu.tsx +5 -6
  12. package/src/client/conversations/AutoArchiveToggle.tsx +35 -0
  13. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  14. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  15. package/src/client/conversations/ConversationComposer.tsx +42 -26
  16. package/src/client/conversations/ConversationDetails.tsx +66 -35
  17. package/src/client/conversations/ConversationHeader.tsx +19 -2
  18. package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
  19. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  20. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  21. package/src/client/conversations/ConversationMemories.tsx +114 -77
  22. package/src/client/conversations/ConversationMeta.tsx +47 -42
  23. package/src/client/conversations/ConversationPage.tsx +183 -60
  24. package/src/client/conversations/ConversationSidebar.tsx +80 -24
  25. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  26. package/src/client/conversations/ConversationWatches.tsx +42 -26
  27. package/src/client/conversations/ConversationWorkspace.tsx +4 -4
  28. package/src/client/conversations/DetailsList.tsx +8 -14
  29. package/src/client/conversations/MemoryIcons.tsx +23 -0
  30. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  31. package/src/client/conversations/QuickQuestion.tsx +476 -0
  32. package/src/client/conversations/TelemetryMetrics.tsx +2 -1
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  34. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +174 -0
  36. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  37. package/src/client/conversations/TranscriptRows.tsx +9 -2
  38. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  39. package/src/client/conversations/UsageCard.tsx +3 -3
  40. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  41. package/src/client/conversations/conversationArchive.ts +255 -0
  42. package/src/client/conversations/conversationMemory.ts +139 -0
  43. package/src/client/conversations/conversationSections.ts +24 -18
  44. package/src/client/conversations/eventTranscript.ts +8 -0
  45. package/src/client/conversations/queries.ts +48 -238
  46. package/src/client/conversations/usePinConversation.ts +88 -0
  47. package/src/client/display-text/DisplayText.tsx +7 -2
  48. package/src/client/display-text/codeBlocks.ts +92 -0
  49. package/src/client/format.ts +3 -67
  50. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  51. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  52. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  53. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  54. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  55. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  56. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  57. package/src/client/review-desk/queries.ts +2 -1
  58. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  59. package/src/client/sentry.ts +77 -0
  60. package/src/client/types.ts +7 -0
  61. package/src/client.tsx +3 -0
  62. package/src/conversation-cache.ts +1 -1
  63. package/src/mock-reporting/fixtures.ts +103 -27
  64. package/src/mock-reporting/routes.ts +47 -0
  65. package/src/mock-reporting/watches.ts +2 -0
  66. package/src/shell.ts +27 -2
  67. package/src/tailwind.css +8 -19
  68. package/src/client/components/ActiveIndicator.tsx +0 -14
@@ -0,0 +1,139 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { createContext, useContext } from "react";
3
+ import { z } from "zod";
4
+
5
+ import { fetchDashboardJson } from "../http";
6
+ import { conversationSideReadQueryKey } from "./queries";
7
+
8
+ const conversationMemoryListSchema = z
9
+ .object({
10
+ events: z.array(
11
+ z
12
+ .object({
13
+ memoryIds: z.array(z.string().min(1)).min(1),
14
+ source: z.enum(["captured", "recalled"]),
15
+ turnId: z.string().min(1).optional(),
16
+ })
17
+ .strict(),
18
+ ),
19
+ memories: z.array(
20
+ z
21
+ .object({
22
+ content: z.string().min(1),
23
+ id: z.string().min(1),
24
+ visibility: z.enum(["private", "public"]),
25
+ })
26
+ .strict(),
27
+ ),
28
+ })
29
+ .strict();
30
+
31
+ export type ConversationMemoryList = z.output<
32
+ typeof conversationMemoryListSchema
33
+ >;
34
+ export type ConversationMemory = ConversationMemoryList["memories"][number];
35
+ type MemorySource = ConversationMemoryList["events"][number]["source"];
36
+
37
+ /** One transcript spot that recalled or captured memories. */
38
+ export type MemoryAnchor = { source: MemorySource; turnId: string };
39
+
40
+ /** Load what one Conversation recalled and captured. */
41
+ export function useConversationMemories(conversationId: string) {
42
+ return useQuery({
43
+ queryFn: ({ signal }) =>
44
+ fetchDashboardJson(
45
+ conversationMemoryListSchema,
46
+ `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
47
+ signal,
48
+ ),
49
+ queryKey: conversationSideReadQueryKey(conversationId, "memories"),
50
+ retry: false,
51
+ });
52
+ }
53
+
54
+ /** Name the transcript element that opens one anchor's popover. */
55
+ export function memoryAnchorKey(anchor: MemoryAnchor): string {
56
+ return `${anchor.source}:${anchor.turnId}`;
57
+ }
58
+
59
+ /** List the memories that one transcript spot recalled or captured. */
60
+ export function anchorMemories(
61
+ list: ConversationMemoryList | undefined,
62
+ anchor: MemoryAnchor,
63
+ ): ConversationMemory[] {
64
+ const event = list?.events.find(
65
+ (candidate) =>
66
+ candidate.source === anchor.source && candidate.turnId === anchor.turnId,
67
+ );
68
+ if (!event || !list) return [];
69
+ const byId = new Map(list.memories.map((memory) => [memory.id, memory]));
70
+ return event.memoryIds.flatMap((id) => {
71
+ const memory = byId.get(id);
72
+ return memory ? [memory] : [];
73
+ });
74
+ }
75
+
76
+ /** One row of the Conversation memory list. */
77
+ export type ConversationMemoryEntry = {
78
+ memory: ConversationMemory;
79
+ source: MemorySource;
80
+ turnId?: string;
81
+ };
82
+
83
+ /**
84
+ * Flatten recalls and captures into one list in Conversation order. A memory
85
+ * shows once per source, at the first Turn that recalled or captured it.
86
+ */
87
+ export function conversationMemoryEntries(
88
+ list: ConversationMemoryList | undefined,
89
+ ): ConversationMemoryEntry[] {
90
+ if (!list) return [];
91
+ const byId = new Map(list.memories.map((memory) => [memory.id, memory]));
92
+ const seen = new Set<string>();
93
+ const entries: ConversationMemoryEntry[] = [];
94
+ for (const event of list.events) {
95
+ for (const id of event.memoryIds) {
96
+ const key = `${event.source}:${id}`;
97
+ const memory = byId.get(id);
98
+ if (seen.has(key) || !memory) continue;
99
+ seen.add(key);
100
+ entries.push({
101
+ memory,
102
+ source: event.source,
103
+ ...(event.turnId ? { turnId: event.turnId } : undefined),
104
+ });
105
+ }
106
+ }
107
+ return entries;
108
+ }
109
+
110
+ /** The open memory popover, and the memory it highlights. */
111
+ export type OpenMemoryPopover = MemoryAnchor & { memoryId?: string };
112
+
113
+ /** Shared memory popover state for one Conversation page. */
114
+ export type ConversationMemoryState = {
115
+ list?: ConversationMemoryList;
116
+ open?: OpenMemoryPopover;
117
+ setOpen(open: OpenMemoryPopover | undefined): void;
118
+ };
119
+
120
+ /**
121
+ * Let the transcript and the details panel share memory popovers. Only the
122
+ * Conversation page sets it, so nested transcripts show plain events.
123
+ */
124
+ export const ConversationMemoryContext = createContext<
125
+ ConversationMemoryState | undefined
126
+ >(undefined);
127
+
128
+ /** Read the memory popover state of the current Conversation page. */
129
+ export function useConversationMemoryState() {
130
+ return useContext(ConversationMemoryContext);
131
+ }
132
+
133
+ /** True for the memory plugin event that a Turn's capture emits. */
134
+ export function isMemoryCaptureEvent(part: {
135
+ name: string;
136
+ namespace: string;
137
+ }): boolean {
138
+ return part.namespace === "memory" && part.name === "memories_captured";
139
+ }
@@ -7,18 +7,22 @@ export type ConversationSection = {
7
7
  };
8
8
 
9
9
  const DAY_MS = 24 * 60 * 60 * 1000;
10
- // Sorts before every time section.
10
+ // Pinned sorts first, then Needs attention, then every time section.
11
+ const PINNED_SECTION = { key: "pinned", label: "Pinned", order: -2 };
11
12
  const ATTENTION_SECTION = {
12
13
  key: "attention",
13
14
  label: "Needs attention",
14
- order: Number.NEGATIVE_INFINITY,
15
+ order: -1,
15
16
  };
16
17
 
17
18
  /**
18
- * Group conversations into Needs attention, then by last activity: Today,
19
- * Yesterday, Previous 7 days, Previous 30 days, then one section per month.
19
+ * Group conversations into Pinned, then Needs attention, then by last
20
+ * activity: Today, Yesterday, Previous 7 days, Previous 30 days, then one
21
+ * section per month.
20
22
  *
21
- * Conversations in `attentionIds` go to Needs attention, newest first.
23
+ * A pinned conversation shows only in Pinned, even when it needs attention.
24
+ * Conversations in `attentionIds` go to Needs attention. Each section lists
25
+ * the newest first.
22
26
  */
23
27
  export function buildConversationSections(
24
28
  conversations: Conversation[],
@@ -35,14 +39,16 @@ export function buildConversationSections(
35
39
  );
36
40
 
37
41
  for (const conversation of sorted) {
38
- const section = options.attentionIds?.has(conversation.id)
39
- ? ATTENTION_SECTION
40
- : conversationSection(
41
- activityTime(conversation),
42
- nowDay,
43
- options.nowMs,
44
- options.timeZone,
45
- );
42
+ const section = conversation.pinnedAt
43
+ ? PINNED_SECTION
44
+ : options.attentionIds?.has(conversation.id)
45
+ ? ATTENTION_SECTION
46
+ : conversationSection(
47
+ activityTime(conversation),
48
+ nowDay,
49
+ options.nowMs,
50
+ options.timeZone,
51
+ );
46
52
  const existing = sections.get(section.key);
47
53
  if (existing) {
48
54
  existing.conversations.push(conversation);
@@ -65,16 +71,16 @@ export function attentionConversationIds(
65
71
  }
66
72
 
67
73
  /**
68
- * Sort attention conversations to the front. The sort is stable, so each group
69
- * keeps the feed order. Paginated lists call this before paging, so page 1
70
- * holds every attention conversation.
74
+ * Sort pinned, then attention conversations to the front. The sort is stable,
75
+ * so each group keeps the feed order. Paginated lists call this before paging,
76
+ * so page 1 holds every pinned and attention conversation.
71
77
  */
72
- export function attentionFirst(
78
+ export function pinnedAndAttentionFirst(
73
79
  conversations: Conversation[],
74
80
  attentionIds: ReadonlySet<string>,
75
81
  ): Conversation[] {
76
82
  const rank = (conversation: Conversation) =>
77
- attentionIds.has(conversation.id) ? 0 : 1;
83
+ conversation.pinnedAt ? 0 : attentionIds.has(conversation.id) ? 1 : 2;
78
84
  return [...conversations].sort((left, right) => rank(left) - rank(right));
79
85
  }
80
86
 
@@ -97,6 +97,9 @@ export function pendingTranscriptMessage(
97
97
  ? { actorIdentityId: message.actorIdentityId }
98
98
  : undefined),
99
99
  ...(message.mentions ? { mentions: message.mentions } : undefined),
100
+ ...(message.reviewItemChange
101
+ ? { reviewItemChanges: [message.reviewItemChange] }
102
+ : undefined),
100
103
  };
101
104
  }
102
105
 
@@ -240,6 +243,9 @@ export function transcriptMessagesFromEvents(
240
243
  ...(data.explicitMention !== undefined
241
244
  ? { explicitMention: data.explicitMention }
242
245
  : undefined),
246
+ ...(data.reviewItemChanges
247
+ ? { reviewItemChanges: data.reviewItemChanges }
248
+ : undefined),
243
249
  ...(data.source ? { source: data.source } : undefined),
244
250
  };
245
251
  messages.push(message);
@@ -327,6 +333,7 @@ export function transcriptMessagesFromEvents(
327
333
  kind: data.kind,
328
334
  loadedAt: event.createdAt,
329
335
  pluginName: data.pluginName,
336
+ turnId: data.turnId,
330
337
  version: data.version,
331
338
  });
332
339
  }
@@ -411,6 +418,7 @@ export function transcriptMessagesFromEvents(
411
418
  name: data.name,
412
419
  version: data.version,
413
420
  presentation: data.presentation,
421
+ ...(data.turnId ? { turnId: data.turnId } : undefined),
414
422
  },
415
423
  ]),
416
424
  );
@@ -1,9 +1,9 @@
1
1
  import { useDeferredValue, useEffect, useMemo, useRef } 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,11 @@ 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,
20
19
  webMessageId,
21
- archiveConversationResponseSchema,
22
20
  cancelConversationPendingMessagesResponseSchema,
23
21
  conversationDetailReportSchema,
24
22
  conversationEventPageSchema,
@@ -28,11 +26,11 @@ import {
28
26
  stopConversationTurnResponseSchema,
29
27
  } from "@sentry/junior/api/schema";
30
28
 
29
+ import { REVIEW_ITEMS_QUERY_KEY } from "../review-desk/queries";
31
30
  import {
32
31
  DashboardApiError,
33
32
  del,
34
33
  fetchDashboardJson,
35
- patch,
36
34
  post,
37
35
  readDashboardResponse,
38
36
  } from "../http";
@@ -66,78 +64,13 @@ export function conversationDetailQueryKey(conversationId: string | undefined) {
66
64
  return ["conversation", conversationId, "detail"] as const;
67
65
  }
68
66
 
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
67
  // Right after a send, Junior usually reads the message within a few seconds.
136
68
  // Poll fast for that window only, then fall back to the normal live interval.
137
69
  const FAST_POLL_INTERVAL_MS = 500;
138
70
  const FAST_POLL_WINDOW_MS = 6_000;
139
71
 
140
- type ConversationSnapshot = ConversationDetailReport & {
72
+ /** The cached detail report with its mailbox and revalidation ETag. */
73
+ export type ConversationSnapshot = ConversationDetailReport & {
141
74
  mailbox?: ConversationPendingMessagesReport;
142
75
  detailEtag?: string;
143
76
  };
@@ -153,6 +86,32 @@ async function readConversationSnapshot(
153
86
  return { ...detail, mailbox };
154
87
  }
155
88
 
89
+ const CONVERSATION_SIDE_READS = ["agents", "memories", "watches"] as const;
90
+
91
+ /**
92
+ * Key a read that shows beside the detail report, such as in the details
93
+ * panel. A detail change refreshes it.
94
+ */
95
+ export function conversationSideReadQueryKey(
96
+ conversationId: string,
97
+ read: (typeof CONVERSATION_SIDE_READS)[number],
98
+ ) {
99
+ return ["conversation", conversationId, read] as const;
100
+ }
101
+
102
+ /** Refresh the side reads and Review items. Only mounted reads refetch. */
103
+ function refreshConversationSideReads(
104
+ client: QueryClient,
105
+ conversationId: string,
106
+ ) {
107
+ for (const read of CONVERSATION_SIDE_READS) {
108
+ void client.invalidateQueries({
109
+ queryKey: conversationSideReadQueryKey(conversationId, read),
110
+ });
111
+ }
112
+ void client.invalidateQueries({ queryKey: REVIEW_ITEMS_QUERY_KEY });
113
+ }
114
+
156
115
  /** Refresh history and mailbox as one visible snapshot, including idle input. */
157
116
  export function conversationDetailQueryOptions(
158
117
  conversationId: string | undefined,
@@ -165,14 +124,26 @@ export function conversationDetailQueryOptions(
165
124
  return queryOptions({
166
125
  enabled: Boolean(conversationId),
167
126
  queryKey: conversationDetailQueryKey(conversationId),
168
- queryFn: ({ signal, client }) =>
169
- readConversationSnapshot(
127
+ queryFn: async ({ signal, client }) => {
128
+ const previous = client.getQueryData<ConversationSnapshot>(
129
+ conversationDetailQueryKey(conversationId),
130
+ );
131
+ const next = await readConversationSnapshot(
170
132
  conversationId!,
171
133
  signal,
172
- client.getQueryData<ConversationSnapshot>(
173
- conversationDetailQueryKey(conversationId),
174
- ),
175
- ),
134
+ previous,
135
+ );
136
+ // Side reads do not poll. A new ETag means the Conversation changed,
137
+ // so the details panel may be stale too.
138
+ if (
139
+ previous?.detailEtag &&
140
+ next.detailEtag &&
141
+ previous.detailEtag !== next.detailEtag
142
+ ) {
143
+ refreshConversationSideReads(client, conversationId!);
144
+ }
145
+ return next;
146
+ },
176
147
  structuralSharing: (previous, next) => {
177
148
  if (!next || typeof next !== "object") return next;
178
149
  if (!previous || typeof previous !== "object") return next;
@@ -510,167 +481,6 @@ export function useStopConversationTurn(conversationId: string) {
510
481
  });
511
482
  }
512
483
 
513
- /** Archive or restore one conversation with an immediate reversible cache update. */
514
- export function useArchiveConversation(
515
- conversationId: string,
516
- options?: {
517
- onError?(): void;
518
- onSuccess?(archivedAt: string | null): void;
519
- },
520
- ) {
521
- 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));
670
- }
671
- return conversation ? { conversation, feedQueryHashes } : undefined;
672
- }
673
-
674
484
  /** Fetch a bounded conversation snapshot and older pages on demand. */
675
485
  export function useConversationData(conversationId: string | undefined) {
676
486
  const queryClient = useQueryClient();
@@ -0,0 +1,88 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import type {
3
+ ConversationDetailReport,
4
+ ConversationFeed,
5
+ } from "@sentry/junior/api/schema";
6
+ import { pinConversationResponseSchema } from "@sentry/junior/api/schema";
7
+
8
+ import { patch } from "../http";
9
+ import { conversationDetailQueryKey } from "./queries";
10
+
11
+ type DetailCache = ConversationDetailReport & { detailEtag?: string };
12
+
13
+ /**
14
+ * Pin or unpin one conversation for the viewer. The feed and detail caches
15
+ * change at once, so the row moves to or from the Pinned section before the
16
+ * request returns. A failed request puts the old caches back.
17
+ */
18
+ export function usePinConversation(
19
+ conversationId: string,
20
+ options?: { onError?(pinned: boolean): void; onSuccess?(): void },
21
+ ) {
22
+ const queryClient = useQueryClient();
23
+ const feeds = { queryKey: ["dashboard", "conversations"] };
24
+ const detailQueryKey = conversationDetailQueryKey(conversationId);
25
+ const setPinnedAt = (pinnedAt: string | null) => {
26
+ // Match the API: a pin also restores an archived conversation.
27
+ const viewerMarks = pinnedAt
28
+ ? { archivedAt: null, pinnedAt }
29
+ : { pinnedAt };
30
+ queryClient.setQueriesData<ConversationFeed>(feeds, (feed) =>
31
+ feed
32
+ ? {
33
+ ...feed,
34
+ conversations: feed.conversations.map((conversation) =>
35
+ conversation.conversationId === conversationId
36
+ ? { ...conversation, ...viewerMarks }
37
+ : conversation,
38
+ ),
39
+ }
40
+ : feed,
41
+ );
42
+ // Clear the etag so the next read gets a full body, not a 304 that keeps
43
+ // this local value.
44
+ queryClient.setQueryData<DetailCache>(detailQueryKey, (detail) =>
45
+ detail ? { ...detail, ...viewerMarks, detailEtag: undefined } : detail,
46
+ );
47
+ };
48
+ return useMutation({
49
+ mutationFn: (pinned: boolean) =>
50
+ patch(
51
+ pinConversationResponseSchema,
52
+ `/api/conversations/${encodeURIComponent(conversationId)}/pin`,
53
+ { pinned },
54
+ ),
55
+ onMutate: async (pinned) => {
56
+ await Promise.all([
57
+ queryClient.cancelQueries(feeds),
58
+ queryClient.cancelQueries({ queryKey: detailQueryKey }),
59
+ ]);
60
+ const previous = {
61
+ detail: queryClient.getQueryData<DetailCache>(detailQueryKey),
62
+ feeds: queryClient.getQueriesData<ConversationFeed>(feeds),
63
+ };
64
+ setPinnedAt(pinned ? new Date().toISOString() : null);
65
+ return previous;
66
+ },
67
+ onError: (_error, pinned, previous) => {
68
+ previous?.feeds.forEach(([queryKey, feed]) => {
69
+ queryClient.setQueryData(queryKey, feed);
70
+ });
71
+ queryClient.setQueryData(detailQueryKey, previous?.detail);
72
+ options?.onError?.(pinned);
73
+ },
74
+ onSuccess: (result) => {
75
+ setPinnedAt(result.pinnedAt);
76
+ options?.onSuccess?.();
77
+ },
78
+ // A reader's first pin adds the conversation to their feed.
79
+ onSettled: () =>
80
+ Promise.all([
81
+ queryClient.invalidateQueries(feeds),
82
+ queryClient.invalidateQueries({
83
+ exact: true,
84
+ queryKey: detailQueryKey,
85
+ }),
86
+ ]),
87
+ });
88
+ }