@sentry/junior-dashboard 0.276.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 (45) hide show
  1. package/dist/app.js +168 -33
  2. package/dist/client.js +88 -83
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +163 -31
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +4 -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/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/ConversationDetails.tsx +6 -3
  16. package/src/client/conversations/ConversationHeader.tsx +9 -0
  17. package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
  18. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  19. package/src/client/conversations/ConversationMemories.tsx +64 -67
  20. package/src/client/conversations/ConversationPage.tsx +21 -8
  21. package/src/client/conversations/ConversationSidebar.tsx +80 -24
  22. package/src/client/conversations/ConversationWatches.tsx +15 -21
  23. package/src/client/conversations/ConversationWorkspace.tsx +4 -4
  24. package/src/client/conversations/DetailsList.tsx +8 -14
  25. package/src/client/conversations/MemoryIcons.tsx +23 -0
  26. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  27. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  28. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  29. package/src/client/conversations/conversationArchive.ts +255 -0
  30. package/src/client/conversations/conversationMemory.ts +2 -1
  31. package/src/client/conversations/conversationSections.ts +24 -18
  32. package/src/client/conversations/queries.ts +48 -238
  33. package/src/client/conversations/usePinConversation.ts +88 -0
  34. package/src/client/display-text/DisplayText.tsx +7 -2
  35. package/src/client/display-text/codeBlocks.ts +92 -0
  36. package/src/client/format.ts +3 -67
  37. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  38. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  39. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  40. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  41. package/src/client/review-desk/queries.ts +2 -1
  42. package/src/client/types.ts +1 -0
  43. package/src/conversation-cache.ts +1 -1
  44. package/src/mock-reporting/fixtures.ts +103 -27
  45. package/src/mock-reporting/routes.ts +47 -0
@@ -18,6 +18,7 @@ import {
18
18
  type ConversationMemoryState,
19
19
  type MemoryAnchor,
20
20
  } from "./conversationMemory";
21
+ import { MemoryLinkIcons } from "./MemoryIcons";
21
22
  import { memoryRecallContent } from "./turnContext";
22
23
 
23
24
  function memoryCount(count: number): string {
@@ -54,32 +55,26 @@ function MemoryPopover(props: {
54
55
  aria-label={props.label}
55
56
  className="max-h-[min(28rem,var(--available-height))] w-96 p-1.5"
56
57
  >
57
- <ul className="m-0 grid list-none gap-0.5 p-0">
58
+ <ul className="m-0 grid list-none divide-y divide-dashboard-border-subtle p-0">
58
59
  {props.memories.map((memory) => (
59
- <li
60
- className={cn(
61
- "grid gap-1.5 rounded-md px-2.5 py-2",
62
- memory.id === highlightId && "bg-dashboard-fill-strong",
63
- )}
64
- key={memory.id}
65
- ref={
66
- memory.id === highlightId
67
- ? (node) => node?.scrollIntoView({ block: "nearest" })
68
- : undefined
69
- }
70
- >
71
- <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
72
- {memory.content}
73
- </p>
74
- <div className="flex items-center gap-3 text-xs text-dashboard-text-muted">
75
- {memory.visibility === "private" ? <span>Private</span> : null}
76
- <Link
77
- className="text-dashboard-text-muted no-underline hover:text-dashboard-text hover:underline"
78
- to={memoryPath(memory.id)}
79
- >
80
- Open
81
- </Link>
82
- </div>
60
+ <li className="py-1 first:pt-0 last:pb-0" key={memory.id}>
61
+ <Link
62
+ className={cn(
63
+ "group grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-2.5 py-2 no-underline transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus",
64
+ memory.id === highlightId && "bg-dashboard-fill-strong",
65
+ )}
66
+ ref={
67
+ memory.id === highlightId
68
+ ? (node) => node?.scrollIntoView({ block: "nearest" })
69
+ : undefined
70
+ }
71
+ to={memoryPath(memory.id)}
72
+ >
73
+ <p className="m-0 line-clamp-4 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
74
+ {memory.content}
75
+ </p>
76
+ <MemoryLinkIcons private={memory.visibility === "private"} />
77
+ </Link>
83
78
  </li>
84
79
  ))}
85
80
  </ul>
@@ -29,6 +29,7 @@ import {
29
29
  } from "./turnContext";
30
30
  import { cn } from "../styles";
31
31
  import { DetailsCard } from "./DetailsList";
32
+ import { MemoryLinkIcons } from "./MemoryIcons";
32
33
  import { HighlightText } from "./transcriptSearch";
33
34
 
34
35
  /** What one turn context view shows: who spoke, when, and the context. */
@@ -270,17 +271,20 @@ function TurnContextCard(props: { context: TranscriptViewTurnContext }) {
270
271
  );
271
272
  }
272
273
 
273
- // Each row links to the memory's page, which shows its details.
274
+ // Each row shows the full memory and links to the memory's page.
274
275
  function MemoryRecall(props: { memories: MemoryRecallContent["memories"] }) {
275
276
  return (
276
- <ul className="-mx-1.5 -mb-1.5 -mt-1 grid list-none gap-0.5 p-0">
277
+ <ul className="-mx-1.5 -mb-1.5 -mt-1 grid list-none divide-y divide-dashboard-border-subtle p-0">
277
278
  {props.memories.map((memory) => (
278
- <li key={memory.id}>
279
+ <li className="py-1 first:pt-0 last:pb-0" key={memory.id}>
279
280
  <Link
280
- className="line-clamp-2 rounded-md px-1.5 py-1.5 text-sm leading-5 text-dashboard-text no-underline transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
281
+ className="group grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1.5 no-underline transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
281
282
  to={memoryPath(memory.id)}
282
283
  >
283
- <HighlightText text={memory.content} />
284
+ <span className="whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
285
+ <HighlightText text={memory.content} />
286
+ </span>
287
+ <MemoryLinkIcons private={memory.scope === "personal"} />
284
288
  </Link>
285
289
  </li>
286
290
  ))}
@@ -0,0 +1,53 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import {
3
+ archiveDoneConversationsResponseSchema,
4
+ conversationAutoArchiveSchema,
5
+ type ConversationDetailReport,
6
+ } from "@sentry/junior/api/schema";
7
+
8
+ import { patch, post } from "../http";
9
+ import { conversationDetailQueryKey } from "./queries";
10
+
11
+ /** Turn auto-archive on or off for one conversation and the viewer. */
12
+ export function useSetConversationAutoArchive(conversationId: string) {
13
+ const queryClient = useQueryClient();
14
+ return useMutation({
15
+ mutationFn: (autoArchive: boolean) =>
16
+ patch(
17
+ conversationAutoArchiveSchema,
18
+ `/api/conversations/${encodeURIComponent(conversationId)}/auto-archive`,
19
+ { autoArchive },
20
+ ),
21
+ onSuccess: (result) => {
22
+ queryClient.setQueryData<
23
+ ConversationDetailReport & { detailEtag?: string }
24
+ >(conversationDetailQueryKey(conversationId), (detail) =>
25
+ detail
26
+ ? {
27
+ ...detail,
28
+ autoArchive: result.autoArchive,
29
+ detailEtag: undefined,
30
+ }
31
+ : detail,
32
+ );
33
+ },
34
+ });
35
+ }
36
+
37
+ /** Archive all of the viewer's done conversations once. */
38
+ export function useArchiveDoneConversations() {
39
+ const queryClient = useQueryClient();
40
+ return useMutation({
41
+ mutationFn: () =>
42
+ post(
43
+ archiveDoneConversationsResponseSchema,
44
+ "/api/conversations/archive-done",
45
+ {},
46
+ ),
47
+ onSuccess: async () => {
48
+ await queryClient.invalidateQueries({
49
+ queryKey: ["dashboard", "conversations"],
50
+ });
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,255 @@
1
+ import {
2
+ useMutation,
3
+ useMutationState,
4
+ useQueryClient,
5
+ } from "@tanstack/react-query";
6
+ import type {
7
+ ConversationDetailReport,
8
+ ConversationFeed,
9
+ ConversationSummaryReport,
10
+ } from "@sentry/junior/api/schema";
11
+ import { archiveConversationResponseSchema } from "@sentry/junior/api/schema";
12
+
13
+ import { patch } from "../http";
14
+ import {
15
+ conversationDetailQueryKey,
16
+ type ConversationSnapshot,
17
+ } from "./queries";
18
+
19
+ function archivedConversationQueryKey(conversationId: string) {
20
+ return ["dashboard", "archived-conversation", conversationId] as const;
21
+ }
22
+
23
+ /** Read active/archived status from a dashboard conversations query key. */
24
+ function conversationFeedStatus(
25
+ queryKey: readonly unknown[],
26
+ ): "active" | "archived" | undefined {
27
+ const status = queryKey[queryKey.length - 1];
28
+ return status === "active" || status === "archived" ? status : undefined;
29
+ }
30
+
31
+ type ArchivedConversationSnapshot = {
32
+ conversation: ConversationSummaryReport;
33
+ feedQueryHashes: string[];
34
+ };
35
+
36
+ const archiveConversationMutationKey = [
37
+ "dashboard",
38
+ "archive-conversation",
39
+ ] as const;
40
+
41
+ type ArchiveConversationVariables = {
42
+ archived: boolean;
43
+ lastSeenAt: string;
44
+ };
45
+
46
+ type ArchiveConversationMutationContext = {
47
+ archivedQueryKey: ReturnType<typeof archivedConversationQueryKey>;
48
+ archivedSnapshot?: ArchivedConversationSnapshot;
49
+ detailQueryKey: ReturnType<typeof conversationDetailQueryKey>;
50
+ previousArchivedSnapshot?: ArchivedConversationSnapshot;
51
+ previousDetail?: ConversationDetailReport;
52
+ previousFeeds: Array<[readonly unknown[], ConversationFeed | undefined]>;
53
+ };
54
+
55
+ export type PendingArchiveConversationUpdate = {
56
+ archived: boolean;
57
+ conversation?: ConversationSummaryReport;
58
+ conversationId: string;
59
+ };
60
+
61
+ /** Read pending archive state so refetches cannot visually replace optimistic UI. */
62
+ export function usePendingArchiveConversationUpdates() {
63
+ return useMutationState({
64
+ filters: {
65
+ mutationKey: archiveConversationMutationKey,
66
+ status: "pending",
67
+ },
68
+ select: (mutation) => {
69
+ const variables = mutation.state
70
+ .variables as ArchiveConversationVariables;
71
+ const context = mutation.state.context as
72
+ | ArchiveConversationMutationContext
73
+ | undefined;
74
+ const conversationId = mutation.options.mutationKey?.[2];
75
+ return {
76
+ archived: variables.archived,
77
+ conversation: context?.archivedSnapshot?.conversation,
78
+ conversationId:
79
+ typeof conversationId === "string" ? conversationId : "",
80
+ } satisfies PendingArchiveConversationUpdate;
81
+ },
82
+ });
83
+ }
84
+
85
+ /** Archive or restore one conversation with an immediate reversible cache update. */
86
+ export function useArchiveConversation(
87
+ conversationId: string,
88
+ options?: {
89
+ onError?(): void;
90
+ onSuccess?(archivedAt: string | null): void;
91
+ },
92
+ ) {
93
+ const queryClient = useQueryClient();
94
+ return useMutation({
95
+ mutationKey: [...archiveConversationMutationKey, conversationId],
96
+ mutationFn: (args: ArchiveConversationVariables) =>
97
+ patch(
98
+ archiveConversationResponseSchema,
99
+ `/api/conversations/${encodeURIComponent(conversationId)}/archive`,
100
+ args,
101
+ ),
102
+ onMutate: async (args) => {
103
+ const conversationQueries = { queryKey: ["dashboard", "conversations"] };
104
+ const detailQueryKey = conversationDetailQueryKey(conversationId);
105
+ const archivedQueryKey = archivedConversationQueryKey(conversationId);
106
+ await Promise.all([
107
+ queryClient.cancelQueries(conversationQueries),
108
+ queryClient.cancelQueries({ queryKey: detailQueryKey }),
109
+ ]);
110
+ const previousFeeds =
111
+ queryClient.getQueriesData<ConversationFeed>(conversationQueries);
112
+ const previousDetail =
113
+ queryClient.getQueryData<ConversationDetailReport>(detailQueryKey);
114
+ const previousArchivedSnapshot =
115
+ queryClient.getQueryData<ArchivedConversationSnapshot>(
116
+ archivedQueryKey,
117
+ );
118
+ const archivedAt = args.archived ? new Date().toISOString() : null;
119
+ // Match the API: archive also unpins.
120
+ const viewerMarks = args.archived
121
+ ? { archivedAt, pinnedAt: null }
122
+ : { archivedAt };
123
+ // Snapshot from any loaded feed, including archived-only fixtures that
124
+ // were never archived through this client session.
125
+ const archivedSnapshot =
126
+ buildArchivedConversationSnapshot(previousFeeds, conversationId) ??
127
+ previousArchivedSnapshot;
128
+
129
+ previousFeeds.forEach(([queryKey, feed]) => {
130
+ if (!feed) return;
131
+ const conversationExists = feed.conversations.some(
132
+ (conversation) => conversation.conversationId === conversationId,
133
+ );
134
+ const feedStatus = conversationFeedStatus(queryKey);
135
+ const shouldRestore =
136
+ !args.archived &&
137
+ !conversationExists &&
138
+ Boolean(archivedSnapshot) &&
139
+ feedStatus !== "archived";
140
+ const conversations = shouldRestore
141
+ ? [
142
+ ...feed.conversations,
143
+ // The archive cleared the pin, so the restored row is unpinned.
144
+ {
145
+ ...archivedSnapshot!.conversation,
146
+ archivedAt: null,
147
+ pinnedAt: null,
148
+ },
149
+ ].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
150
+ : feed.conversations.map((conversation) =>
151
+ conversation.conversationId === conversationId
152
+ ? { ...conversation, ...viewerMarks }
153
+ : conversation,
154
+ );
155
+ queryClient.setQueryData<ConversationFeed>(queryKey, {
156
+ ...feed,
157
+ conversations,
158
+ });
159
+ });
160
+ queryClient.setQueryData<ConversationSnapshot>(
161
+ detailQueryKey,
162
+ (detail) =>
163
+ detail
164
+ ? { ...detail, ...viewerMarks, detailEtag: undefined }
165
+ : detail,
166
+ );
167
+ if (archivedSnapshot) {
168
+ queryClient.setQueryData(archivedQueryKey, archivedSnapshot);
169
+ }
170
+ return {
171
+ archivedQueryKey,
172
+ archivedSnapshot,
173
+ detailQueryKey,
174
+ previousArchivedSnapshot,
175
+ previousDetail,
176
+ previousFeeds,
177
+ } satisfies ArchiveConversationMutationContext;
178
+ },
179
+ onError: (_error, _args, context) => {
180
+ context?.previousFeeds.forEach(([queryKey, feed]) => {
181
+ queryClient.setQueryData(queryKey, feed);
182
+ });
183
+ if (context) {
184
+ queryClient.setQueryData(
185
+ context.detailQueryKey,
186
+ context.previousDetail,
187
+ );
188
+ if (context.previousArchivedSnapshot) {
189
+ queryClient.setQueryData(
190
+ context.archivedQueryKey,
191
+ context.previousArchivedSnapshot,
192
+ );
193
+ } else {
194
+ queryClient.removeQueries({
195
+ exact: true,
196
+ queryKey: context.archivedQueryKey,
197
+ });
198
+ }
199
+ }
200
+ options?.onError?.();
201
+ },
202
+ onSuccess: (result, args) => {
203
+ queryClient.setQueryData<ConversationSnapshot>(
204
+ conversationDetailQueryKey(conversationId),
205
+ (detail) =>
206
+ detail
207
+ ? {
208
+ ...detail,
209
+ archivedAt: result.archivedAt,
210
+ detailEtag: undefined,
211
+ }
212
+ : detail,
213
+ );
214
+ if (!args.archived) {
215
+ queryClient.removeQueries({
216
+ exact: true,
217
+ queryKey: archivedConversationQueryKey(conversationId),
218
+ });
219
+ }
220
+ options?.onSuccess?.(result.archivedAt);
221
+ },
222
+ onSettled: async () => {
223
+ await Promise.all([
224
+ queryClient.invalidateQueries({
225
+ queryKey: ["dashboard", "conversations"],
226
+ }),
227
+ queryClient.invalidateQueries({
228
+ queryKey: ["dashboard", "locations"],
229
+ }),
230
+ queryClient.invalidateQueries({ queryKey: ["dashboard", "people"] }),
231
+ queryClient.invalidateQueries({
232
+ exact: true,
233
+ queryKey: conversationDetailQueryKey(conversationId),
234
+ }),
235
+ ]);
236
+ },
237
+ });
238
+ }
239
+
240
+ function buildArchivedConversationSnapshot(
241
+ feeds: Array<[readonly unknown[], ConversationFeed | undefined]>,
242
+ conversationId: string,
243
+ ): ArchivedConversationSnapshot | undefined {
244
+ let conversation: ConversationSummaryReport | undefined;
245
+ const feedQueryHashes: string[] = [];
246
+ for (const [queryKey, feed] of feeds) {
247
+ const feedConversation = feed?.conversations.find(
248
+ (item) => item.conversationId === conversationId,
249
+ );
250
+ if (!feedConversation) continue;
251
+ conversation ??= feedConversation;
252
+ feedQueryHashes.push(JSON.stringify(queryKey));
253
+ }
254
+ return conversation ? { conversation, feedQueryHashes } : undefined;
255
+ }
@@ -3,6 +3,7 @@ import { createContext, useContext } from "react";
3
3
  import { z } from "zod";
4
4
 
5
5
  import { fetchDashboardJson } from "../http";
6
+ import { conversationSideReadQueryKey } from "./queries";
6
7
 
7
8
  const conversationMemoryListSchema = z
8
9
  .object({
@@ -45,7 +46,7 @@ export function useConversationMemories(conversationId: string) {
45
46
  `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
46
47
  signal,
47
48
  ),
48
- queryKey: ["conversation", conversationId, "memories"],
49
+ queryKey: conversationSideReadQueryKey(conversationId, "memories"),
49
50
  retry: false,
50
51
  });
51
52
  }
@@ -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