@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,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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
text={part.text ?? ""}
|
|
91
|
+
record={record}
|
|
70
92
|
/>
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
/**
|
|
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="
|
|
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
|
|
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
|
|
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="
|
|
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
|
-
<
|
|
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-
|
|
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
|
|
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
|
+
}
|