@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.
- package/dist/app.js +194 -40
- package/dist/client.js +120 -92
- package/dist/index.d.ts +1 -1
- package/dist/index.js +189 -38
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +20 -2
- package/src/app.ts +88 -15
- package/src/auth.ts +5 -1
- package/src/client/components/FormNotice.tsx +11 -14
- package/src/client/components/ProfileMenu.tsx +5 -6
- package/src/client/conversations/AutoArchiveToggle.tsx +35 -0
- package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
- package/src/client/conversations/ConversationChildAgents.tsx +2 -1
- package/src/client/conversations/ConversationComposer.tsx +42 -26
- package/src/client/conversations/ConversationDetails.tsx +66 -35
- package/src/client/conversations/ConversationHeader.tsx +19 -2
- package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
- package/src/client/conversations/ConversationHomeList.tsx +1 -1
- package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
- package/src/client/conversations/ConversationMemories.tsx +114 -77
- package/src/client/conversations/ConversationMeta.tsx +47 -42
- package/src/client/conversations/ConversationPage.tsx +183 -60
- package/src/client/conversations/ConversationSidebar.tsx +80 -24
- package/src/client/conversations/ConversationTranscript.tsx +37 -8
- package/src/client/conversations/ConversationWatches.tsx +42 -26
- package/src/client/conversations/ConversationWorkspace.tsx +4 -4
- package/src/client/conversations/DetailsList.tsx +8 -14
- package/src/client/conversations/MemoryIcons.tsx +23 -0
- package/src/client/conversations/PendingMailboxStack.tsx +11 -1
- package/src/client/conversations/QuickQuestion.tsx +476 -0
- package/src/client/conversations/TelemetryMetrics.tsx +2 -1
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptEventView.tsx +25 -2
- package/src/client/conversations/TranscriptMemoryPopover.tsx +174 -0
- package/src/client/conversations/TranscriptMessageView.tsx +46 -16
- package/src/client/conversations/TranscriptRows.tsx +9 -2
- package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
- package/src/client/conversations/UsageCard.tsx +3 -3
- package/src/client/conversations/autoArchiveQueries.ts +53 -0
- package/src/client/conversations/conversationArchive.ts +255 -0
- package/src/client/conversations/conversationMemory.ts +139 -0
- package/src/client/conversations/conversationSections.ts +24 -18
- package/src/client/conversations/eventTranscript.ts +8 -0
- package/src/client/conversations/queries.ts +48 -238
- 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/pages/code/RepositoryTabs.tsx +2 -2
- 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/ReviewItemActions.tsx +1 -1
- package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
- package/src/client/review-desk/ReviewItemContext.tsx +41 -23
- package/src/client/review-desk/queries.ts +2 -1
- package/src/client/review-desk/reviewItemFormat.ts +16 -0
- package/src/client/sentry.ts +77 -0
- package/src/client/types.ts +7 -0
- package/src/client.tsx +3 -0
- package/src/conversation-cache.ts +1 -1
- package/src/mock-reporting/fixtures.ts +103 -27
- package/src/mock-reporting/routes.ts +47 -0
- package/src/mock-reporting/watches.ts +2 -0
- package/src/shell.ts +27 -2
- package/src/tailwind.css +8 -19
- 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
|
-
//
|
|
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:
|
|
15
|
+
order: -1,
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* Group conversations into Needs attention, then by last
|
|
19
|
-
* Yesterday, Previous 7 days, Previous 30 days, then one
|
|
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
|
-
*
|
|
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 =
|
|
39
|
-
?
|
|
40
|
-
:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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,
|
|
69
|
-
* keeps the feed order. Paginated lists call this before paging,
|
|
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
|
|
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) ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
+
}
|