@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,174 @@
1
+ import { Brain } from "lucide-react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import { Link } from "react-router";
4
+
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ } from "../components/ui/popover";
10
+ import { formatMessageTimestamp, memoryPath } from "../format";
11
+ import { cn } from "../styles";
12
+ import type { TranscriptViewMessage } from "../types";
13
+ import {
14
+ anchorMemories,
15
+ memoryAnchorKey,
16
+ useConversationMemoryState,
17
+ type ConversationMemory,
18
+ type ConversationMemoryState,
19
+ type MemoryAnchor,
20
+ } from "./conversationMemory";
21
+ import { MemoryLinkIcons } from "./MemoryIcons";
22
+ import { memoryRecallContent } from "./turnContext";
23
+
24
+ function memoryCount(count: number): string {
25
+ return `${count} ${count === 1 ? "memory" : "memories"}`;
26
+ }
27
+
28
+ /**
29
+ * Open one transcript spot's memories in a popover, so the transcript keeps
30
+ * its length. The page owns which popover is open, so the details panel can
31
+ * open one too.
32
+ */
33
+ function MemoryPopover(props: {
34
+ anchor: MemoryAnchor;
35
+ label: string;
36
+ memories: ConversationMemory[];
37
+ state: ConversationMemoryState;
38
+ trigger: ReactElement;
39
+ }) {
40
+ const { anchor, state } = props;
41
+ const open =
42
+ state.open?.source === anchor.source && state.open.turnId === anchor.turnId;
43
+ const highlightId = open ? state.open?.memoryId : undefined;
44
+ return (
45
+ <Popover
46
+ onOpenChange={(next) => state.setOpen(next ? anchor : undefined)}
47
+ open={open}
48
+ >
49
+ <PopoverTrigger
50
+ data-memory-anchor={memoryAnchorKey(anchor)}
51
+ render={props.trigger}
52
+ />
53
+ <PopoverContent
54
+ align="start"
55
+ aria-label={props.label}
56
+ className="max-h-[min(28rem,var(--available-height))] w-96 p-1.5"
57
+ >
58
+ <ul className="m-0 grid list-none divide-y divide-dashboard-border-subtle p-0">
59
+ {props.memories.map((memory) => (
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>
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ </PopoverContent>
82
+ </Popover>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Show the memories that a prompt recalled, under that prompt. One prompt can
88
+ * start more than one Turn, and each Turn recalls, so each gets a chip.
89
+ */
90
+ export function TranscriptRecalledMemories(props: {
91
+ className?: string;
92
+ message: TranscriptViewMessage;
93
+ }) {
94
+ const state = useConversationMemoryState();
95
+ const anchors = [
96
+ ...new Set(
97
+ props.message.contexts
98
+ ?.filter((context) => memoryRecallContent(context))
99
+ .map((context) => context.turnId),
100
+ ),
101
+ ].flatMap((turnId) => {
102
+ const anchor = { source: "recalled", turnId } as const;
103
+ const memories = anchorMemories(state?.list, anchor);
104
+ return memories.length > 0 ? [{ anchor, memories }] : [];
105
+ });
106
+ if (!state || anchors.length === 0) return null;
107
+ return (
108
+ <div className={cn("flex flex-wrap gap-2", props.className)}>
109
+ {anchors.map(({ anchor, memories }) => {
110
+ const label = `Recalled ${memoryCount(memories.length)}`;
111
+ return (
112
+ <MemoryPopover
113
+ anchor={anchor}
114
+ key={anchor.turnId}
115
+ label={label}
116
+ memories={memories}
117
+ state={state}
118
+ trigger={
119
+ <button
120
+ className="inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-md border border-dashboard-border-subtle bg-transparent px-2 font-sans text-xs text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus aria-expanded:bg-dashboard-fill-strong aria-expanded:text-dashboard-text"
121
+ type="button"
122
+ >
123
+ <Brain aria-hidden="true" size={13} strokeWidth={1.8} />
124
+ {label}
125
+ </button>
126
+ }
127
+ />
128
+ );
129
+ })}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Show the memories that one Turn captured. Without memory state or a list
136
+ * for the Turn, such as in a nested transcript, show `fallback`.
137
+ */
138
+ export function TranscriptCapturedMemories(props: {
139
+ fallback: ReactNode;
140
+ timestamp?: number;
141
+ turnId?: string;
142
+ }) {
143
+ const state = useConversationMemoryState();
144
+ const anchor = props.turnId
145
+ ? ({ source: "captured", turnId: props.turnId } as const)
146
+ : undefined;
147
+ const memories = anchor ? anchorMemories(state?.list, anchor) : [];
148
+ if (!state || !anchor || memories.length === 0) return props.fallback;
149
+ const label = `Captured ${memoryCount(memories.length)}`;
150
+ const timestamp = formatMessageTimestamp(props.timestamp);
151
+ return (
152
+ <MemoryPopover
153
+ anchor={anchor}
154
+ label={label}
155
+ memories={memories}
156
+ state={state}
157
+ trigger={
158
+ <button
159
+ className="-mx-2 -my-1.5 grid w-[calc(100%+1rem)] cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-start gap-2 rounded-md border-0 bg-transparent px-2 py-1.5 text-left font-sans transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus aria-expanded:bg-dashboard-fill-hover max-md:grid-cols-[minmax(0,1fr)]"
160
+ type="button"
161
+ >
162
+ <span className="text-sm font-medium leading-6 text-dashboard-text">
163
+ {label}
164
+ </span>
165
+ {timestamp ? (
166
+ <span className="text-xs leading-6 tabular-nums text-dashboard-text-muted max-md:hidden">
167
+ {timestamp}
168
+ </span>
169
+ ) : null}
170
+ </button>
171
+ }
172
+ />
173
+ );
174
+ }
@@ -5,6 +5,7 @@ import {
5
5
  } from "./TranscriptAttachment";
6
6
  import { ObjectCard } from "./ObjectCard";
7
7
  import { AutomationCard } from "../components/AutomationCard";
8
+ import { ReviewItemChangeRow } from "../review-desk/ReviewItemChangeRow";
8
9
  import { memo, type ReactNode } from "react";
9
10
 
10
11
  import {
@@ -23,6 +24,7 @@ import { RedactedMarker } from "./TranscriptRedacted";
23
24
  import { SlackMark } from "./SlackMark";
24
25
  import { DisplayText } from "../display-text/DisplayText";
25
26
  import { TranscriptTurnContextView } from "./TranscriptTurnContextView";
27
+ import { TranscriptRecalledMemories } from "./TranscriptMemoryPopover";
26
28
  import { TranscriptTimestamp } from "./TranscriptTimestamp";
27
29
  import { showsSlackSourceIcon } from "./transcriptSource";
28
30
 
@@ -35,6 +37,28 @@ export const TranscriptMessageView = memo(
35
37
  const rawText = messageRawText(props.message);
36
38
  const roleKind = transcriptRoleKind(props.message.role);
37
39
  const chatText = roleKind === "assistant" || roleKind === "user";
40
+ const text = (
41
+ <div
42
+ className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2"
43
+ // `QuickQuestion` reads it: selections here can start a question.
44
+ data-quick-question-message-id={
45
+ canForkAfter(props.message, props.conversation)
46
+ ? props.message.messageId
47
+ : undefined
48
+ }
49
+ >
50
+ {props.message.parts.map((part, index) =>
51
+ part.type === "text" ? (
52
+ <DisplayText
53
+ chat={chatText}
54
+ key={index}
55
+ mentions={props.message.mentions}
56
+ text={part.text ?? ""}
57
+ />
58
+ ) : null,
59
+ )}
60
+ </div>
61
+ );
38
62
 
39
63
  return (
40
64
  <TranscriptMessageShell
@@ -59,18 +83,16 @@ export const TranscriptMessageView = memo(
59
83
  message={props.message}
60
84
  conversation={props.conversation}
61
85
  />
62
- <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2">
63
- {props.message.parts.map((part, index) =>
64
- part.type === "text" ? (
65
- <DisplayText
66
- chat={chatText}
86
+ {props.message.reviewItemChanges?.length
87
+ ? props.message.reviewItemChanges.map((record, index) => (
88
+ <ReviewItemChangeRow
89
+ fallback={text}
67
90
  key={index}
68
- mentions={props.message.mentions}
69
- text={part.text ?? ""}
91
+ record={record}
70
92
  />
71
- ) : null,
72
- )}
73
- </div>
93
+ ))
94
+ : text}
95
+ <TranscriptRecalledMemories message={props.message} />
74
96
  {props.message.attachments?.length ? (
75
97
  <TranscriptAttachments
76
98
  attachments={props.message.attachments}
@@ -146,12 +168,7 @@ function messageAction(
146
168
  />
147
169
  );
148
170
  }
149
- if (
150
- conversation.canFork &&
151
- message.role === "assistant" &&
152
- message.messageId &&
153
- !message.pending
154
- ) {
171
+ if (canForkAfter(message, conversation)) {
155
172
  return (
156
173
  <ForkConversationButton
157
174
  conversationId={conversation.conversationId}
@@ -162,6 +179,19 @@ function messageAction(
162
179
  return undefined;
163
180
  }
164
181
 
182
+ /** A delivered Junior reply ends the history of a fork or Quick question. */
183
+ function canForkAfter(
184
+ message: TranscriptViewMessage,
185
+ conversation: ConversationTranscript,
186
+ ): message is TranscriptViewMessage & { messageId: string } {
187
+ return Boolean(
188
+ conversation.canFork &&
189
+ message.role === "assistant" &&
190
+ message.messageId &&
191
+ !message.pending,
192
+ );
193
+ }
194
+
165
195
  function TranscriptMessageHeader(props: {
166
196
  action?: ReactNode;
167
197
  message: TranscriptViewMessage;
@@ -35,7 +35,14 @@ export function TranscriptUnreadMarker() {
35
35
  );
36
36
  }
37
37
 
38
- /** Leave room for control outlines inside the mobile row's paint bounds. */
38
+ /**
39
+ * Lay out one transcript row at its real size.
40
+ *
41
+ * Rows do not use `content-visibility: auto`. A skipped row has an estimated
42
+ * size, and it changes size when the reader scrolls near it. Above the
43
+ * reader, each change needs a scroll correction while they scroll, and on
44
+ * phones those corrections make the transcript jump.
45
+ */
39
46
  export function TranscriptRow(props: {
40
47
  /** Stable row id that holds the reader's place while layout changes. */
41
48
  anchorKey?: string;
@@ -46,7 +53,7 @@ export function TranscriptRow(props: {
46
53
  }) {
47
54
  return (
48
55
  <div
49
- className="mobile-transcript-row -mx-2 px-2 py-1.5"
56
+ className="py-1.5"
50
57
  data-scroll-anchor={props.anchorKey}
51
58
  data-transcript-seq={props.seq}
52
59
  >
@@ -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
  ))}
@@ -66,13 +66,13 @@ export function UsageCard(props: UsageCardProps) {
66
66
  )}
67
67
  >
68
68
  <span className="flex items-baseline justify-between gap-3">
69
- <span className="min-w-0 truncate">
69
+ <span className="min-w-0 truncate font-medium text-dashboard-text">
70
70
  {props.title}
71
71
  {props.note ? (
72
72
  <span className="text-dashboard-text-faint"> · {props.note}</span>
73
73
  ) : null}
74
74
  </span>
75
- <span className="whitespace-nowrap text-lg font-semibold tabular-nums text-dashboard-text">
75
+ <span className="whitespace-nowrap text-base font-semibold tabular-nums text-dashboard-text">
76
76
  {props.total}
77
77
  </span>
78
78
  </span>
@@ -124,7 +124,7 @@ export function UsageCard(props: UsageCardProps) {
124
124
  <span
125
125
  className={cn(
126
126
  "flex min-w-0 items-baseline gap-2",
127
- row.code && "font-mono text-dashboard-text",
127
+ row.code && "font-mono",
128
128
  )}
129
129
  >
130
130
  <span className="min-w-0 truncate" title={row.label}>
@@ -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
+ }