@sentry/junior-dashboard 0.276.0 → 0.278.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/app.js +196 -72
  2. package/dist/client.js +94 -89
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +191 -70
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +80 -12
  9. package/src/auth.ts +5 -1
  10. package/src/client/App.tsx +9 -4
  11. package/src/client/components/DetailsPanel.tsx +1 -1
  12. package/src/client/components/FormNotice.tsx +11 -14
  13. package/src/client/components/ProfileMenu.tsx +5 -6
  14. package/src/client/components/ui/resizable.tsx +50 -0
  15. package/src/client/conversations/AutoArchiveToggle.tsx +71 -0
  16. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  17. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  18. package/src/client/conversations/ConversationDetails.tsx +6 -3
  19. package/src/client/conversations/ConversationHeader.tsx +15 -2
  20. package/src/client/conversations/ConversationHeaderActions.tsx +27 -0
  21. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  22. package/src/client/conversations/ConversationMemories.tsx +64 -67
  23. package/src/client/conversations/ConversationMeta.tsx +2 -7
  24. package/src/client/conversations/ConversationPage.tsx +175 -79
  25. package/src/client/conversations/ConversationSidebar.tsx +82 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +11 -8
  27. package/src/client/conversations/ConversationWatches.tsx +15 -21
  28. package/src/client/conversations/ConversationWorkspace.tsx +132 -44
  29. package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
  30. package/src/client/conversations/DetailsList.tsx +8 -14
  31. package/src/client/conversations/EventDetails.tsx +2 -62
  32. package/src/client/conversations/MemoryIcons.tsx +23 -0
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
  34. package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  36. package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
  37. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  38. package/src/client/conversations/TranscriptView.tsx +89 -20
  39. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  40. package/src/client/conversations/conversationArchive.ts +255 -0
  41. package/src/client/conversations/conversationMemory.ts +2 -1
  42. package/src/client/conversations/conversationSections.ts +24 -18
  43. package/src/client/conversations/conversationSidebarLayout.ts +61 -0
  44. package/src/client/conversations/eventLog.ts +2 -31
  45. package/src/client/conversations/eventTranscript.ts +9 -42
  46. package/src/client/conversations/queries.ts +121 -250
  47. package/src/client/conversations/transcript.ts +46 -16
  48. package/src/client/conversations/transcriptBottomPinning.ts +40 -24
  49. package/src/client/conversations/transcriptReadPosition.ts +31 -7
  50. package/src/client/conversations/transcriptRenderModel.ts +1 -14
  51. package/src/client/conversations/transcriptSearch.tsx +3 -11
  52. package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
  53. package/src/client/conversations/usePinConversation.ts +88 -0
  54. package/src/client/display-text/DisplayText.tsx +7 -2
  55. package/src/client/display-text/codeBlocks.ts +92 -0
  56. package/src/client/format.ts +3 -67
  57. package/src/client/http.ts +5 -5
  58. package/src/client/markdownExport.ts +32 -42
  59. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  60. package/src/client/pages/memory/MemoryDetails.tsx +6 -33
  61. package/src/client/pages/memory/MemoryPage.tsx +7 -92
  62. package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
  63. package/src/client/pages/memory/memoryDashboard.ts +2 -38
  64. package/src/client/pages/memory/memoryRecord.ts +1 -6
  65. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  66. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  67. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  68. package/src/client/review-desk/queries.ts +2 -1
  69. package/src/client/types.ts +19 -33
  70. package/src/conversation-cache.ts +7 -9
  71. package/src/mock-reporting/fixtures.ts +126 -60
  72. package/src/mock-reporting/routes.ts +50 -1
  73. package/src/tailwind.css +30 -0
@@ -16,24 +16,15 @@ export function TranscriptContextEventView(props: {
16
16
  timestamp?: number;
17
17
  }) {
18
18
  const event = props.part.event;
19
- const handoff = event.type === "handoff";
20
- const compactionDetail =
21
- event.type === "compaction" && event.details
22
- ? ` at approximately ${formatCompactNumber(event.details.estimatedInputTokens)} estimated tokens`
23
- : "";
24
- const compactionModel =
25
- event.type === "compaction" && event.modelProfile && event.modelId
26
- ? ` using the ${event.modelProfile} profile (${event.modelId})`
27
- : "";
19
+ const compactionDetail = event.details
20
+ ? ` at approximately ${formatCompactNumber(event.details.estimatedInputTokens)} estimated tokens`
21
+ : "";
22
+ const compactionModel = event.modelId ? ` using ${event.modelId}` : "";
28
23
  return (
29
24
  <article className="min-w-0 py-0.5">
30
25
  <div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1">
31
- <strong
32
- className={`font-sans text-xs font-semibold ${
33
- handoff ? "text-sky-100" : "text-amber-100"
34
- }`}
35
- >
36
- {handoff ? "Model handoff" : "Context compacted"}
26
+ <strong className="font-sans text-xs font-semibold text-amber-100">
27
+ Context compacted
37
28
  </strong>
38
29
  {typeof props.timestamp === "number" ? (
39
30
  <span className="text-xs text-dashboard-text-muted">
@@ -42,9 +33,7 @@ export function TranscriptContextEventView(props: {
42
33
  ) : null}
43
34
  </div>
44
35
  <div className="mt-1 text-xs leading-relaxed text-dashboard-text-muted">
45
- {handoff
46
- ? `Handed off to ${event.modelId} via the ${event.modelProfile} profile${event.reasoningLevel ? ` (${event.reasoningLevel})` : ""}.`
47
- : `Earlier context was summarized${compactionDetail}${compactionModel} before execution continued.`}
36
+ {`Earlier context was summarized${compactionDetail}${compactionModel} before execution continued.`}
48
37
  </div>
49
38
  {event.summary ? (
50
39
  <Collapsible className="mt-2">
@@ -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>
@@ -14,7 +14,6 @@ import {
14
14
  MessageSquareText,
15
15
  Minimize2,
16
16
  Paperclip,
17
- Send,
18
17
  Sparkles,
19
18
  TriangleAlert,
20
19
  Webhook,
@@ -27,7 +26,6 @@ import type { TranscriptViewStructuredEventPart } from "../types";
27
26
  type TranscriptRailEventKind =
28
27
  | "attachments_delivered"
29
28
  | "compaction"
30
- | "handoff"
31
29
  | "message_context"
32
30
  | "event"
33
31
  | "structured_event"
@@ -119,12 +117,6 @@ function transcriptRailMarker(kind: TranscriptRailEventKind): {
119
117
  icon: Bot,
120
118
  };
121
119
  }
122
- if (kind === "handoff") {
123
- return {
124
- className: "text-sky-200",
125
- icon: Send,
126
- };
127
- }
128
120
  return {
129
121
  className: "text-amber-200",
130
122
  icon: Minimize2,
@@ -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
  ))}
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useState, type ReactNode, type Ref } from "react";
2
2
  import type { ConversationMailboxMessage } from "./conversationOutbox";
3
3
  import { ArrowDown } from "lucide-react";
4
4
 
@@ -8,12 +8,13 @@ import type {
8
8
  } from "../types";
9
9
  import { EmptyTelemetry } from "../components/EmptyTelemetry";
10
10
  import { Button } from "../components/ui/button";
11
+ import { cn } from "../styles";
11
12
  import { ConversationTranscriptView } from "./ConversationTranscript";
12
13
  import { ConversationEventLog } from "./ConversationEventLog";
13
14
  import {
14
15
  transcriptBottomVersion,
15
16
  transcriptJuniorMessageVersion,
16
- useAutoLoadPreviousPage,
17
+ useAutoLoadHistoryPage,
17
18
  usePinnedTranscriptBottom,
18
19
  } from "./transcriptBottomPinning";
19
20
  import type { TranscriptViewMode } from "./transcriptRenderModel";
@@ -22,6 +23,7 @@ import {
22
23
  type TranscriptUnreadRange,
23
24
  } from "./transcriptReadPosition";
24
25
  import { TranscriptSearchProvider } from "./transcriptSearch";
26
+ import type { TranscriptHistory } from "./transcriptUnreadHistory";
25
27
  import {
26
28
  TurnContextPanelProvider,
27
29
  type TurnContextPanel,
@@ -36,6 +38,11 @@ export function Transcript(props: {
36
38
  /** The read position when the Conversation opened. */
37
39
  lastReadSeq?: number;
38
40
  loadingPreviousPage?: boolean;
41
+ /**
42
+ * Newer pages between the read position and the latest. They load on
43
+ * scroll. Jump to latest skips them.
44
+ */
45
+ newerHistory?: TranscriptHistory["newer"];
39
46
  pinRequestVersion?: number;
40
47
  onLoadPreviousPage?: () => void;
41
48
  /** Receive the last event sequence the reader saw in the transcript. */
@@ -68,6 +75,7 @@ export function Transcript(props: {
68
75
  const bottomPinning = usePinnedTranscriptBottom({
69
76
  conversationId: props.transcript?.conversationId,
70
77
  enabled: props.live ?? false,
78
+ endsAtLatest: !props.newerHistory,
71
79
  historyVersion: props.historyVersion ?? "empty",
72
80
  juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript),
73
81
  pinRequestVersion: props.pinRequestVersion,
@@ -88,7 +96,8 @@ export function Transcript(props: {
88
96
  paused: view !== "rich" || search.trim().length > 0,
89
97
  });
90
98
  const loadPreviousPage = () => props.onLoadPreviousPage?.();
91
- const previousPageRef = useAutoLoadPreviousPage({
99
+ const previousPageRef = useAutoLoadHistoryPage({
100
+ edge: "top",
92
101
  enabled: Boolean(
93
102
  props.onLoadPreviousPage &&
94
103
  props.hasPreviousPage &&
@@ -98,6 +107,13 @@ export function Transcript(props: {
98
107
  historyVersion: props.historyVersion ?? "empty",
99
108
  onLoad: loadPreviousPage,
100
109
  });
110
+ const newer = props.newerHistory;
111
+ const newerPageRef = useAutoLoadHistoryPage({
112
+ edge: "bottom",
113
+ enabled: Boolean(newer && !newer.loading && !newer.error),
114
+ historyVersion: props.historyVersion ?? "empty",
115
+ onLoad: () => newer?.load(),
116
+ });
101
117
 
102
118
  if (!props.transcript) {
103
119
  return (
@@ -120,22 +136,17 @@ export function Transcript(props: {
120
136
  </div>
121
137
  ) : null}
122
138
  {props.hasPreviousPage || props.loadingPreviousPage ? (
123
- <div className="mb-3 flex items-center gap-3" ref={previousPageRef}>
124
- <span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
125
- <button
126
- aria-label="Load earlier events"
127
- className="shrink-0 cursor-pointer border-0 bg-transparent px-1 py-0.5 font-sans text-xs font-medium text-dashboard-text-muted transition-colors hover:text-dashboard-text focus-visible:outline focus-visible:outline-1 focus-visible:outline-dashboard-focus/55 disabled:cursor-not-allowed disabled:opacity-50"
128
- disabled={props.loadingPreviousPage}
129
- onClick={loadPreviousPage}
130
- title="Load earlier events"
131
- type="button"
132
- >
133
- {props.loadingPreviousPage
134
- ? `Loading earlier ${historyLabel}…`
135
- : `Show earlier ${historyLabel}`}
136
- </button>
137
- <span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
138
- </div>
139
+ <HistoryPageControl
140
+ className="mb-3"
141
+ label="Load earlier events"
142
+ loading={props.loadingPreviousPage}
143
+ onClick={loadPreviousPage}
144
+ ref={previousPageRef}
145
+ >
146
+ {props.loadingPreviousPage
147
+ ? `Loading earlier ${historyLabel}…`
148
+ : `Show earlier ${historyLabel}`}
149
+ </HistoryPageControl>
139
150
  ) : null}
140
151
  {props.historyError ? (
141
152
  <div
@@ -164,6 +175,30 @@ export function Transcript(props: {
164
175
  />
165
176
  </TurnContextPanelProvider>
166
177
  )}
178
+ {newer ? (
179
+ <>
180
+ {newer.error ? (
181
+ <div
182
+ aria-live="polite"
183
+ className="mt-2 text-center font-sans text-xs text-amber-100/65"
184
+ >
185
+ Newer events could not be loaded.
186
+ </div>
187
+ ) : null}
188
+ {/* The bottom margin keeps the jump control off this row. */}
189
+ <HistoryPageControl
190
+ className="mb-12 mt-3"
191
+ label="Load newer events"
192
+ loading={newer.loading}
193
+ onClick={newer.load}
194
+ ref={newerPageRef}
195
+ >
196
+ {newer.loading
197
+ ? `Loading newer ${historyLabel}…`
198
+ : `Show newer ${historyLabel}`}
199
+ </HistoryPageControl>
200
+ </>
201
+ ) : null}
167
202
  <div
168
203
  aria-hidden="true"
169
204
  className="h-px"
@@ -171,7 +206,11 @@ export function Transcript(props: {
171
206
  />
172
207
  <JumpToLatestButton
173
208
  hasPendingUpdate={bottomPinning.hasPendingUpdate}
174
- onClick={bottomPinning.jumpToBottom}
209
+ onClick={() => {
210
+ // The latest page is in the detail, so the jump needs no request.
211
+ newer?.showLatest();
212
+ bottomPinning.jumpToBottom();
213
+ }}
175
214
  visible={bottomPinning.showJumpToLatest}
176
215
  />
177
216
  </div>
@@ -179,6 +218,36 @@ export function Transcript(props: {
179
218
  );
180
219
  }
181
220
 
221
+ /** A divider with one control that loads another page of history. */
222
+ function HistoryPageControl(props: {
223
+ children: ReactNode;
224
+ className: string;
225
+ label: string;
226
+ loading?: boolean;
227
+ onClick: () => void;
228
+ ref?: Ref<HTMLDivElement>;
229
+ }) {
230
+ return (
231
+ <div
232
+ className={cn("flex items-center gap-3", props.className)}
233
+ ref={props.ref}
234
+ >
235
+ <span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
236
+ <button
237
+ aria-label={props.label}
238
+ className="shrink-0 cursor-pointer border-0 bg-transparent px-1 py-0.5 font-sans text-xs font-medium text-dashboard-text-muted transition-colors hover:text-dashboard-text focus-visible:outline focus-visible:outline-1 focus-visible:outline-dashboard-focus/55 disabled:cursor-not-allowed disabled:opacity-50"
239
+ disabled={props.loading}
240
+ onClick={props.onClick}
241
+ title={props.label}
242
+ type="button"
243
+ >
244
+ {props.children}
245
+ </button>
246
+ <span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
247
+ </div>
248
+ );
249
+ }
250
+
182
251
  function JumpToLatestButton(props: {
183
252
  hasPendingUpdate: boolean;
184
253
  onClick: () => void;
@@ -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
  }