@sentry/junior-dashboard 0.274.0 → 0.276.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 +28 -9
- package/dist/client.js +115 -92
- package/dist/index.js +28 -9
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/app.ts +8 -3
- package/src/client/App.tsx +11 -0
- package/src/client/components/Metric.tsx +0 -32
- package/src/client/components/ProfileMenu.tsx +56 -4
- package/src/client/components/layout/DashboardChrome.tsx +3 -3
- package/src/client/components/layout/DashboardHeader.tsx +41 -4
- package/src/client/components/layout/NavRail.tsx +43 -40
- package/src/client/conversations/ConversationComposer.tsx +42 -26
- package/src/client/conversations/ConversationDetails.tsx +149 -166
- package/src/client/conversations/ConversationHeader.tsx +126 -132
- package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
- package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
- package/src/client/conversations/ConversationMemories.tsx +118 -78
- package/src/client/conversations/ConversationMeta.tsx +94 -84
- package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
- package/src/client/conversations/ConversationPage.tsx +197 -422
- package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
- package/src/client/conversations/ConversationSidebar.tsx +5 -7
- package/src/client/conversations/ConversationTranscript.tsx +37 -8
- package/src/client/conversations/ConversationWatches.tsx +37 -15
- package/src/client/conversations/PendingMailboxStack.tsx +11 -1
- package/src/client/conversations/QuickQuestion.tsx +476 -0
- package/src/client/conversations/TelemetryMetrics.tsx +2 -15
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptEventView.tsx +25 -2
- package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
- package/src/client/conversations/TranscriptMessageView.tsx +46 -16
- package/src/client/conversations/TranscriptRows.tsx +9 -2
- package/src/client/conversations/TranscriptView.tsx +15 -32
- package/src/client/conversations/UsageCard.tsx +4 -18
- package/src/client/conversations/conversationMemory.ts +138 -0
- package/src/client/conversations/conversationSections.ts +18 -14
- package/src/client/conversations/eventTranscript.ts +8 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
- 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/reviewItemFormat.ts +16 -0
- package/src/client/sentry.ts +77 -0
- package/src/client/types.ts +6 -0
- package/src/client.tsx +3 -0
- package/src/mock-reporting/watches.ts +2 -0
- package/src/shell.ts +27 -2
- package/src/tailwind.css +12 -19
- package/src/client/components/ActiveIndicator.tsx +0 -14
- package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
memo,
|
|
3
2
|
useCallback,
|
|
4
3
|
useEffect,
|
|
5
4
|
useMemo,
|
|
@@ -11,39 +10,35 @@ import { useSearchParams } from "react-router";
|
|
|
11
10
|
import type {
|
|
12
11
|
ConversationDetailReport,
|
|
13
12
|
ConversationFeed,
|
|
14
|
-
ConversationPendingMessagesReport,
|
|
15
|
-
InputImage,
|
|
16
13
|
} from "@sentry/junior/api/schema";
|
|
17
14
|
|
|
18
15
|
import {
|
|
19
|
-
useAppendConversationMessage,
|
|
20
16
|
useArchiveConversation,
|
|
21
|
-
useCancelConversationPendingMessages,
|
|
22
|
-
useSteerConversationPendingMessages,
|
|
23
17
|
useConversationData,
|
|
24
|
-
useStopConversationTurn,
|
|
25
18
|
type PendingArchiveConversationUpdate,
|
|
26
19
|
} from "./queries";
|
|
27
20
|
import { conversationIsResponding } from "./transcript";
|
|
28
|
-
import type { ConversationMailboxMessage } from "./conversationOutbox";
|
|
29
21
|
import { buildConversationMarkdown } from "../markdownExport";
|
|
30
22
|
import { CopyButton } from "../components/CopyButton";
|
|
31
|
-
import { ConversationBrief } from "./ConversationBrief";
|
|
32
23
|
import {
|
|
33
24
|
CONVERSATION_EVENT_PARAM,
|
|
25
|
+
CONVERSATION_REVIEW_OPEN,
|
|
26
|
+
CONVERSATION_REVIEW_PARAM,
|
|
34
27
|
CONVERSATION_TURN_PARAM,
|
|
35
28
|
parseConversationEventSeq,
|
|
36
29
|
turnStartedEventSeq,
|
|
37
30
|
} from "./conversationRoutes";
|
|
38
|
-
import {
|
|
39
|
-
ConversationComposer,
|
|
40
|
-
restoreStoredDraftText,
|
|
41
|
-
type ConversationComposerHandle,
|
|
42
|
-
} from "./ConversationComposer";
|
|
43
|
-
import { readStoredComposerImages } from "./ComposerImages";
|
|
44
|
-
import { useDashboardCoreData } from "../api";
|
|
45
31
|
import { ConversationHeader } from "./ConversationHeader";
|
|
32
|
+
import { ConversationReplyFooter } from "./ConversationReplyFooter";
|
|
46
33
|
import { ConversationDetails } from "./ConversationDetails";
|
|
34
|
+
import { turnPrompts } from "./ConversationMemories";
|
|
35
|
+
import {
|
|
36
|
+
ConversationMemoryContext,
|
|
37
|
+
memoryAnchorKey,
|
|
38
|
+
useConversationMemories,
|
|
39
|
+
type ConversationMemoryState,
|
|
40
|
+
type OpenMemoryPopover,
|
|
41
|
+
} from "./conversationMemory";
|
|
47
42
|
import { ConversationEventDetails } from "./ConversationEventDetails";
|
|
48
43
|
import { ConversationEventLogLoading } from "./ConversationEventLog";
|
|
49
44
|
import {
|
|
@@ -60,21 +55,20 @@ import {
|
|
|
60
55
|
import { cn } from "../styles";
|
|
61
56
|
import { ConversationForkLinks } from "./ConversationForkLinks";
|
|
62
57
|
import { ConversationAgentTree } from "./ConversationAgentTree";
|
|
63
|
-
import { ConversationHeaderMeta } from "./ConversationHeaderMeta";
|
|
64
58
|
import { conversationLinks } from "./ConversationLinkedWork";
|
|
65
59
|
import {
|
|
66
60
|
ConversationAnnotations,
|
|
61
|
+
conversationCost,
|
|
62
|
+
conversationLocation,
|
|
67
63
|
ConversationPrivacyChip,
|
|
68
64
|
ConversationStats,
|
|
69
65
|
hasConversationAnnotations,
|
|
70
66
|
hasConversationStats,
|
|
71
|
-
PendingAuthorization,
|
|
72
67
|
} from "./ConversationMeta";
|
|
73
68
|
import {
|
|
74
69
|
ConversationUsageBreakdown,
|
|
75
70
|
hasConversationUsageBreakdown,
|
|
76
71
|
} from "./ConversationUsageBreakdown";
|
|
77
|
-
import { PendingMailboxStack } from "./PendingMailboxStack";
|
|
78
72
|
import {
|
|
79
73
|
buildConversations,
|
|
80
74
|
conversationDetailTitle,
|
|
@@ -89,14 +83,12 @@ import {
|
|
|
89
83
|
ParticipantAvatarStack,
|
|
90
84
|
} from "../components/ParticipantAvatarStack";
|
|
91
85
|
import { ChatLayout } from "./ChatLayout";
|
|
92
|
-
import { ComposerDock } from "./ComposerDock";
|
|
93
|
-
import { ConversationReviewBar } from "../review-desk/ConversationReviewBar";
|
|
94
|
-
import { useReviewItemData } from "../review-desk/queries";
|
|
95
86
|
import {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
87
|
+
QuickQuestionPanel,
|
|
88
|
+
QuickQuestionSelectionButton,
|
|
89
|
+
useQuickQuestionThread,
|
|
90
|
+
} from "./QuickQuestion";
|
|
91
|
+
import { latestReviewItemId } from "../review-desk/reviewItemFormat";
|
|
100
92
|
import { Transcript } from "./TranscriptView";
|
|
101
93
|
import {
|
|
102
94
|
readConversationStartPosition,
|
|
@@ -156,9 +148,45 @@ export function ConversationPage(props: {
|
|
|
156
148
|
const [detailsChoice, setDetailsChoice] = useState<boolean | undefined>(() =>
|
|
157
149
|
eventOpen ? false : undefined,
|
|
158
150
|
);
|
|
159
|
-
|
|
151
|
+
// A sidebar review chip links here with the review param. It opens
|
|
152
|
+
// details at Needs you, even below the width where details open alone.
|
|
153
|
+
const reviewOpen =
|
|
154
|
+
searchParams.get(CONVERSATION_REVIEW_PARAM) === CONVERSATION_REVIEW_OPEN;
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (reviewOpen) setDetailsChoice(true);
|
|
157
|
+
}, [reviewOpen]);
|
|
158
|
+
const detailsOpen = detailsChoice ?? (reviewOpen || detailsDefaultOpen);
|
|
159
|
+
const closeDetails = useCallback(() => {
|
|
160
|
+
setDetailsChoice(false);
|
|
161
|
+
setSearchParams(
|
|
162
|
+
(current) => {
|
|
163
|
+
if (!current.has(CONVERSATION_REVIEW_PARAM)) return current;
|
|
164
|
+
const next = new URLSearchParams(current);
|
|
165
|
+
next.delete(CONVERSATION_REVIEW_PARAM);
|
|
166
|
+
return next;
|
|
167
|
+
},
|
|
168
|
+
{ replace: true },
|
|
169
|
+
);
|
|
170
|
+
}, [setSearchParams]);
|
|
160
171
|
const [turnContext, setTurnContext] = useState<TurnContextSelection>();
|
|
161
|
-
const
|
|
172
|
+
const [memoryPopover, setMemoryPopover] = useState<OpenMemoryPopover>();
|
|
173
|
+
// A memory chosen in details waits here until its transcript row renders.
|
|
174
|
+
const [memoryJump, setMemoryJump] = useState<OpenMemoryPopover>();
|
|
175
|
+
// The page stays mounted across Conversations. Memory state belongs to one.
|
|
176
|
+
const [memoryConversationId, setMemoryConversationId] = useState(
|
|
177
|
+
props.conversationId,
|
|
178
|
+
);
|
|
179
|
+
if (memoryConversationId !== props.conversationId) {
|
|
180
|
+
setMemoryConversationId(props.conversationId);
|
|
181
|
+
setMemoryPopover(undefined);
|
|
182
|
+
setMemoryJump(undefined);
|
|
183
|
+
}
|
|
184
|
+
const quickQuestion = useQuickQuestionThread(props.conversationId);
|
|
185
|
+
// Event details and turn context also show over a Quick question.
|
|
186
|
+
const showQuickQuestion =
|
|
187
|
+
Boolean(quickQuestion.thread) && !eventOpen && !turnContext;
|
|
188
|
+
const showDetails =
|
|
189
|
+
detailsOpen && !eventOpen && !turnContext && !quickQuestion.thread;
|
|
162
190
|
const [viewState, setView] = useState<TranscriptViewMode>(() =>
|
|
163
191
|
eventOpen ? "raw" : "rich",
|
|
164
192
|
);
|
|
@@ -170,6 +198,15 @@ export function ConversationPage(props: {
|
|
|
170
198
|
const summaries = props.data?.conversations.conversations ?? [];
|
|
171
199
|
const conversations = buildConversations(summaries);
|
|
172
200
|
const detail = useConversationData(conversationId);
|
|
201
|
+
const memoryList = useConversationMemories(conversationId).data;
|
|
202
|
+
const memoryState = useMemo<ConversationMemoryState>(
|
|
203
|
+
() => ({
|
|
204
|
+
list: memoryList,
|
|
205
|
+
open: memoryPopover,
|
|
206
|
+
setOpen: setMemoryPopover,
|
|
207
|
+
}),
|
|
208
|
+
[memoryList, memoryPopover],
|
|
209
|
+
);
|
|
173
210
|
const archive = useArchiveConversation(conversationId);
|
|
174
211
|
const markRead = useMarkConversationRead(conversationId);
|
|
175
212
|
const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
|
|
@@ -291,6 +328,38 @@ export function ConversationPage(props: {
|
|
|
291
328
|
if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
|
|
292
329
|
else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
|
|
293
330
|
}, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
|
|
331
|
+
// Show a chosen memory at its transcript row. Wait for the first page, then
|
|
332
|
+
// load earlier pages until the row renders, or stop when history ends.
|
|
333
|
+
const memoryPrompts = useMemo(
|
|
334
|
+
() => turnPrompts(transcript?.events),
|
|
335
|
+
[transcript?.events],
|
|
336
|
+
);
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (!memoryJump || !transcript) return;
|
|
339
|
+
const anchor = pageRef.current?.querySelector(
|
|
340
|
+
`[data-memory-anchor="${CSS.escape(memoryAnchorKey(memoryJump))}"]`,
|
|
341
|
+
);
|
|
342
|
+
if (anchor) {
|
|
343
|
+
anchor.scrollIntoView({ block: "center" });
|
|
344
|
+
setMemoryPopover(memoryJump);
|
|
345
|
+
setMemoryJump(undefined);
|
|
346
|
+
} else if (
|
|
347
|
+
detail.hasPreviousPage &&
|
|
348
|
+
!detail.isLoadingPreviousPage &&
|
|
349
|
+
!detail.historyError
|
|
350
|
+
) {
|
|
351
|
+
loadPreviousPageRef.current();
|
|
352
|
+
} else if (!detail.isLoadingPreviousPage) {
|
|
353
|
+
setMemoryJump(undefined);
|
|
354
|
+
}
|
|
355
|
+
}, [
|
|
356
|
+
detail.hasPreviousPage,
|
|
357
|
+
detail.historyError,
|
|
358
|
+
detail.isLoadingPreviousPage,
|
|
359
|
+
memoryJump,
|
|
360
|
+
transcript,
|
|
361
|
+
view,
|
|
362
|
+
]);
|
|
294
363
|
// Keep the panel while the transcript or a linked event loads, and for the
|
|
295
364
|
// render before a found Turn link becomes an event link.
|
|
296
365
|
const eventLoading =
|
|
@@ -365,7 +434,7 @@ export function ConversationPage(props: {
|
|
|
365
434
|
ref={pageRef}
|
|
366
435
|
className={cn(
|
|
367
436
|
"grid h-full min-h-0 min-w-0",
|
|
368
|
-
(showDetails || showEvent || turnContext) &&
|
|
437
|
+
(showDetails || showEvent || turnContext || showQuickQuestion) &&
|
|
369
438
|
"xl:grid-cols-[minmax(0,1fr)_25rem]",
|
|
370
439
|
)}
|
|
371
440
|
>
|
|
@@ -376,11 +445,14 @@ export function ConversationPage(props: {
|
|
|
376
445
|
agentTree ? <ConversationAgentTree nodes={agentTree} /> : null
|
|
377
446
|
}
|
|
378
447
|
conversationId={conversationId}
|
|
448
|
+
cost={conversationCost(conversation, detail.data, "below")}
|
|
379
449
|
detailsOpen={showDetails}
|
|
380
450
|
onDetailsClick={() => {
|
|
381
451
|
if (eventOpen) setSelectedEventSeq(undefined);
|
|
382
452
|
setTurnContext(undefined);
|
|
383
|
-
|
|
453
|
+
quickQuestion.close();
|
|
454
|
+
if (showDetails) closeDetails();
|
|
455
|
+
else setDetailsChoice(true);
|
|
384
456
|
}}
|
|
385
457
|
forks={
|
|
386
458
|
detail.data?.forkedFromConversationId ||
|
|
@@ -399,6 +471,7 @@ export function ConversationPage(props: {
|
|
|
399
471
|
label="Copy as Markdown"
|
|
400
472
|
copyingLabel="Preparing Markdown"
|
|
401
473
|
key={conversationDetail?.conversationId ?? "loading"}
|
|
474
|
+
layout="menu"
|
|
402
475
|
getText={
|
|
403
476
|
conversationDetail
|
|
404
477
|
? async () =>
|
|
@@ -427,30 +500,14 @@ export function ConversationPage(props: {
|
|
|
427
500
|
pending: archive.isPending,
|
|
428
501
|
}}
|
|
429
502
|
live={live}
|
|
430
|
-
|
|
431
|
-
<ConversationHeaderMeta
|
|
432
|
-
identity={identity}
|
|
433
|
-
stats={
|
|
434
|
-
hasConversationStats({
|
|
435
|
-
conversation,
|
|
436
|
-
detail: detail.data,
|
|
437
|
-
variant: "compact",
|
|
438
|
-
}) ? (
|
|
439
|
-
<ConversationStats
|
|
440
|
-
conversation={conversation}
|
|
441
|
-
detail={detail.data}
|
|
442
|
-
variant="compact"
|
|
443
|
-
/>
|
|
444
|
-
) : null
|
|
445
|
-
}
|
|
446
|
-
/>
|
|
447
|
-
}
|
|
503
|
+
location={conversationLocation(conversation, detail.data)}
|
|
448
504
|
onSearchChange={setSearch}
|
|
449
505
|
onViewChange={onViewChange}
|
|
450
506
|
privacy={
|
|
451
507
|
<ConversationPrivacyChip visibility={conversation?.visibility} />
|
|
452
508
|
}
|
|
453
509
|
search={search}
|
|
510
|
+
sentryConversationUrl={detail.data?.sentryConversationUrl}
|
|
454
511
|
title={title}
|
|
455
512
|
view={view}
|
|
456
513
|
/>
|
|
@@ -487,31 +544,35 @@ export function ConversationPage(props: {
|
|
|
487
544
|
data.
|
|
488
545
|
</div>
|
|
489
546
|
) : null}
|
|
490
|
-
<
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
547
|
+
<ConversationMemoryContext value={memoryState}>
|
|
548
|
+
<Transcript
|
|
549
|
+
hasPreviousPage={detail.hasPreviousPage}
|
|
550
|
+
historyError={detail.historyError}
|
|
551
|
+
historyVersion={detail.historyVersion}
|
|
552
|
+
lastReadSeq={readAtOpen.lastReadSeq}
|
|
553
|
+
live={live}
|
|
554
|
+
loadingPreviousPage={detail.isLoadingPreviousPage}
|
|
555
|
+
onLoadPreviousPage={detail.loadPreviousPage}
|
|
556
|
+
onRead={markRead}
|
|
557
|
+
onSelectedEventSeqChange={setSelectedEventSeq}
|
|
558
|
+
pendingMessages={detail.inlineMessages}
|
|
559
|
+
pinRequestVersion={pinRequestVersion}
|
|
560
|
+
responding={
|
|
561
|
+
!detail.error && conversationIsResponding(transcript)
|
|
562
|
+
}
|
|
563
|
+
onOpenSubagentTranscript={onOpenSubagentTranscript}
|
|
564
|
+
search={search}
|
|
565
|
+
onSearchChange={setSearch}
|
|
566
|
+
selectedEventSeq={selectedEventSeq}
|
|
567
|
+
startAtUnread={
|
|
568
|
+
readConversationStartPosition() === "unread"
|
|
569
|
+
}
|
|
570
|
+
transcript={transcript}
|
|
571
|
+
turnContextPanel={turnContextPanel}
|
|
572
|
+
unread={readAtOpen.unread}
|
|
573
|
+
view={view}
|
|
574
|
+
/>
|
|
575
|
+
</ConversationMemoryContext>
|
|
515
576
|
</>
|
|
516
577
|
)}
|
|
517
578
|
</div>
|
|
@@ -549,41 +610,51 @@ export function ConversationPage(props: {
|
|
|
549
610
|
}
|
|
550
611
|
/>
|
|
551
612
|
{showDetails ? (
|
|
552
|
-
<
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
613
|
+
<ConversationMemoryContext value={memoryState}>
|
|
614
|
+
<ConversationDetails
|
|
615
|
+
brief={detail.data?.brief}
|
|
616
|
+
conversationId={conversationId}
|
|
617
|
+
highlightReview={reviewOpen}
|
|
618
|
+
identity={identity}
|
|
619
|
+
onClose={closeDetails}
|
|
620
|
+
onOpenSubagent={setSubagentTarget}
|
|
621
|
+
memoryPrompts={memoryPrompts}
|
|
622
|
+
onSelectMemory={(entry) => {
|
|
623
|
+
// Memory rows show only in the Conversation view, and search
|
|
624
|
+
// can hide them.
|
|
625
|
+
if (view !== "rich") onViewChange("rich");
|
|
626
|
+
setSearch("");
|
|
627
|
+
setMemoryJump({
|
|
628
|
+
memoryId: entry.memory.id,
|
|
629
|
+
source: entry.source,
|
|
630
|
+
turnId: entry.turnId,
|
|
631
|
+
});
|
|
632
|
+
}}
|
|
633
|
+
links={conversationLinks(detail.data)}
|
|
634
|
+
overview={
|
|
635
|
+
hasConversationStats({
|
|
636
|
+
conversation,
|
|
637
|
+
detail: detail.data,
|
|
638
|
+
}) ? (
|
|
639
|
+
<ConversationStats
|
|
640
|
+
conversation={conversation}
|
|
641
|
+
detail={detail.data}
|
|
642
|
+
/>
|
|
643
|
+
) : null
|
|
644
|
+
}
|
|
645
|
+
usage={
|
|
646
|
+
hasConversationUsageBreakdown({
|
|
647
|
+
conversation,
|
|
648
|
+
detail: detail.data,
|
|
649
|
+
}) ? (
|
|
650
|
+
<ConversationUsageBreakdown
|
|
651
|
+
conversation={conversation}
|
|
652
|
+
detail={detail.data}
|
|
653
|
+
/>
|
|
654
|
+
) : null
|
|
655
|
+
}
|
|
656
|
+
/>
|
|
657
|
+
</ConversationMemoryContext>
|
|
587
658
|
) : null}
|
|
588
659
|
{showEvent ? (
|
|
589
660
|
<ConversationEventDetails
|
|
@@ -605,6 +676,24 @@ export function ConversationPage(props: {
|
|
|
605
676
|
/>
|
|
606
677
|
</TranscriptSearchProvider>
|
|
607
678
|
) : null}
|
|
679
|
+
{showQuickQuestion && quickQuestion.thread ? (
|
|
680
|
+
<QuickQuestionPanel
|
|
681
|
+
conversationId={conversationId}
|
|
682
|
+
error={quickQuestion.error}
|
|
683
|
+
onClose={quickQuestion.close}
|
|
684
|
+
onSubmit={quickQuestion.submit}
|
|
685
|
+
pending={quickQuestion.pending}
|
|
686
|
+
thread={quickQuestion.thread}
|
|
687
|
+
/>
|
|
688
|
+
) : null}
|
|
689
|
+
<QuickQuestionSelectionButton
|
|
690
|
+
onAsk={(selection) => {
|
|
691
|
+
if (eventOpen) setSelectedEventSeq(undefined);
|
|
692
|
+
setTurnContext(undefined);
|
|
693
|
+
quickQuestion.open(selection);
|
|
694
|
+
}}
|
|
695
|
+
rootRef={pageRef}
|
|
696
|
+
/>
|
|
608
697
|
<SubagentTranscriptDrawer
|
|
609
698
|
onClose={() => setSubagentTarget(undefined)}
|
|
610
699
|
target={subagentTarget}
|
|
@@ -613,320 +702,6 @@ export function ConversationPage(props: {
|
|
|
613
702
|
);
|
|
614
703
|
}
|
|
615
704
|
|
|
616
|
-
/**
|
|
617
|
-
* Own mutation state and mailbox chrome outside the page tree that re-renders
|
|
618
|
-
* on every live transcript poll. Keeps composer props stable while typing.
|
|
619
|
-
*
|
|
620
|
-
* Memoized so metadata-only polls that keep mailbox identity stable skip the
|
|
621
|
-
* footer tree. Fast chat UIs isolate the composer the same way.
|
|
622
|
-
*/
|
|
623
|
-
const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
624
|
-
authors?: ConversationDetailReport["authors"];
|
|
625
|
-
committedMessageIds: readonly string[];
|
|
626
|
-
conversationId: string;
|
|
627
|
-
onPinRequest: () => void;
|
|
628
|
-
pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
|
|
629
|
-
pendingGeneratedAtRef: { current: string | undefined };
|
|
630
|
-
pendingMessages: readonly ConversationMailboxMessage[];
|
|
631
|
-
/** The last Review item that Junior added in the transcript. */
|
|
632
|
-
reviewItemId?: string;
|
|
633
|
-
/**
|
|
634
|
-
* True when history is live and no Turn has started, so the next Turn will
|
|
635
|
-
* read a new send at once.
|
|
636
|
-
*/
|
|
637
|
-
sendsInline: boolean;
|
|
638
|
-
/** True while the conversation has a Turn that a stop can end. */
|
|
639
|
-
stoppable: boolean;
|
|
640
|
-
}) {
|
|
641
|
-
const appendMessage = useAppendConversationMessage(props.conversationId);
|
|
642
|
-
// A message closes the review items that it answers. Ask before it drops
|
|
643
|
-
// answers that the reader did not send.
|
|
644
|
-
const unsentAnswers = useConfirmUnsentAnswers();
|
|
645
|
-
const stopTurn = useStopConversationTurn(props.conversationId);
|
|
646
|
-
const stopTurnRef = useRef(stopTurn);
|
|
647
|
-
stopTurnRef.current = stopTurn;
|
|
648
|
-
const cancelPendingMessages = useCancelConversationPendingMessages(
|
|
649
|
-
props.conversationId,
|
|
650
|
-
);
|
|
651
|
-
const [editTargetInboundMessageId, setEditTargetInboundMessageId] =
|
|
652
|
-
useState<string>();
|
|
653
|
-
const [queueActionError, setQueueActionError] = useState<string>();
|
|
654
|
-
// While Junior waits for an answer, hide the message box, the review bar,
|
|
655
|
-
// and the queued messages, so the reader answers in the question and does
|
|
656
|
-
// not type the answer as a new message.
|
|
657
|
-
// Dismiss on the question closes the item and shows the message box again.
|
|
658
|
-
// The message box stays mounted, so its text and images stay, and it does
|
|
659
|
-
// not hide while it has focus.
|
|
660
|
-
const reviewItem = useReviewItemData(props.reviewItemId).data;
|
|
661
|
-
const [composerShownForItemId, setComposerShownForItemId] =
|
|
662
|
-
useState<string>();
|
|
663
|
-
const [composerFocused, setComposerFocused] = useState(false);
|
|
664
|
-
const question =
|
|
665
|
-
reviewItem &&
|
|
666
|
-
!props.stoppable &&
|
|
667
|
-
!composerFocused &&
|
|
668
|
-
reviewItemWaitsForAnswer(reviewItem) &&
|
|
669
|
-
composerShownForItemId !== reviewItem.id
|
|
670
|
-
? reviewItem
|
|
671
|
-
: undefined;
|
|
672
|
-
// The message box can show only because it has focus. Using the review bar
|
|
673
|
-
// or queued messages moves focus out of it, so keep them shown for this item.
|
|
674
|
-
const keepComposerShown = () => {
|
|
675
|
-
if (composerFocused && reviewItem && reviewItemWaitsForAnswer(reviewItem)) {
|
|
676
|
-
setComposerShownForItemId(reviewItem.id);
|
|
677
|
-
}
|
|
678
|
-
};
|
|
679
|
-
// Keep submit identity stable across mutation status flips so the memoized
|
|
680
|
-
// composer does not re-render while the reader is still typing.
|
|
681
|
-
const appendMessageRef = useRef(appendMessage);
|
|
682
|
-
appendMessageRef.current = appendMessage;
|
|
683
|
-
const cancelPendingMessagesRef = useRef(cancelPendingMessages);
|
|
684
|
-
cancelPendingMessagesRef.current = cancelPendingMessages;
|
|
685
|
-
const onPinRequestRef = useRef(props.onPinRequest);
|
|
686
|
-
onPinRequestRef.current = props.onPinRequest;
|
|
687
|
-
const pendingMessageVersion = props.pendingMessages
|
|
688
|
-
.map((message) =>
|
|
689
|
-
[
|
|
690
|
-
message.inboundMessageId,
|
|
691
|
-
message.messageId,
|
|
692
|
-
message.clientStatus,
|
|
693
|
-
message.delivery,
|
|
694
|
-
].join(":"),
|
|
695
|
-
)
|
|
696
|
-
.join("|");
|
|
697
|
-
const pendingMessageVersionRef = useRef(pendingMessageVersion);
|
|
698
|
-
useEffect(() => {
|
|
699
|
-
if (pendingMessageVersionRef.current === pendingMessageVersion) return;
|
|
700
|
-
pendingMessageVersionRef.current = pendingMessageVersion;
|
|
701
|
-
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
702
|
-
onPinRequestRef.current();
|
|
703
|
-
}, [pendingMessageVersion]);
|
|
704
|
-
// No Turn runs and nothing waits in the queue, so the next send would
|
|
705
|
-
// leave the queue almost at once. Show it in the transcript instead. A
|
|
706
|
-
// conversation can be active before its Turn starts; that still counts.
|
|
707
|
-
const committedIds = new Set(props.committedMessageIds);
|
|
708
|
-
const sendInlineRef = useRef(false);
|
|
709
|
-
sendInlineRef.current =
|
|
710
|
-
props.sendsInline &&
|
|
711
|
-
!props.pendingMessages.some(
|
|
712
|
-
(message) => !committedIds.has(message.messageId),
|
|
713
|
-
);
|
|
714
|
-
const onSubmit = useCallback(
|
|
715
|
-
async (message: string, idempotencyKey: string, images?: InputImage[]) => {
|
|
716
|
-
await appendMessageRef.current.mutateAsync({
|
|
717
|
-
idempotencyKey,
|
|
718
|
-
inline: sendInlineRef.current,
|
|
719
|
-
message,
|
|
720
|
-
images,
|
|
721
|
-
});
|
|
722
|
-
},
|
|
723
|
-
[],
|
|
724
|
-
);
|
|
725
|
-
const onRetry = useCallback((message: ConversationMailboxMessage) => {
|
|
726
|
-
if (!message.idempotencyKey || (!message.text && !message.images?.length))
|
|
727
|
-
return;
|
|
728
|
-
void appendMessageRef.current.mutateAsync({
|
|
729
|
-
idempotencyKey: message.idempotencyKey,
|
|
730
|
-
message: message.text ?? "",
|
|
731
|
-
images: message.images,
|
|
732
|
-
});
|
|
733
|
-
}, []);
|
|
734
|
-
const onSubmitStart = useCallback(() => {
|
|
735
|
-
// Keep an in-flight remove intact so optimistic cache rollback stays coherent.
|
|
736
|
-
if (!cancelPendingMessagesRef.current.isPending) {
|
|
737
|
-
cancelPendingMessagesRef.current.reset();
|
|
738
|
-
}
|
|
739
|
-
if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
|
|
740
|
-
setQueueActionError(undefined);
|
|
741
|
-
onPinRequestRef.current();
|
|
742
|
-
}, []);
|
|
743
|
-
const cancellableMessageIds = props.pendingMessages
|
|
744
|
-
.filter((message) => message.clientStatus === undefined)
|
|
745
|
-
.map((message) => message.inboundMessageId);
|
|
746
|
-
const onCancelMessage = useCallback(
|
|
747
|
-
(message: ConversationMailboxMessage) => {
|
|
748
|
-
const receivedBefore = props.pendingGeneratedAtRef.current;
|
|
749
|
-
if (!receivedBefore) return;
|
|
750
|
-
cancelPendingMessagesRef.current.mutate({
|
|
751
|
-
inboundMessageIds: [message.inboundMessageId],
|
|
752
|
-
receivedBefore,
|
|
753
|
-
});
|
|
754
|
-
},
|
|
755
|
-
[props.pendingGeneratedAtRef],
|
|
756
|
-
);
|
|
757
|
-
const cancelTargetInboundMessageId =
|
|
758
|
-
cancelPendingMessages.variables?.inboundMessageIds[0];
|
|
759
|
-
const cancelError = Boolean(
|
|
760
|
-
cancelPendingMessages.error &&
|
|
761
|
-
cancelPendingMessages.variables?.inboundMessageIds.some((id) =>
|
|
762
|
-
cancellableMessageIds.includes(id),
|
|
763
|
-
),
|
|
764
|
-
);
|
|
765
|
-
|
|
766
|
-
// Edit uses its own cancel mutation so remove errors stay on remove.
|
|
767
|
-
const editCancel = useCancelConversationPendingMessages(props.conversationId);
|
|
768
|
-
const editCancelRef = useRef(editCancel);
|
|
769
|
-
editCancelRef.current = editCancel;
|
|
770
|
-
const editingRef = useRef(false);
|
|
771
|
-
const composerRef = useRef<ConversationComposerHandle>(null);
|
|
772
|
-
const viewerEmail = useDashboardCoreData().data?.me.user.email;
|
|
773
|
-
const onEditMessage = useCallback(
|
|
774
|
-
async (message: ConversationMailboxMessage) => {
|
|
775
|
-
const receivedBefore = props.pendingGeneratedAtRef.current;
|
|
776
|
-
// The image read runs before the cancel starts, so isPending alone does
|
|
777
|
-
// not stop a second click.
|
|
778
|
-
if (!receivedBefore || editingRef.current) return;
|
|
779
|
-
editingRef.current = true;
|
|
780
|
-
const inboundMessageId = message.inboundMessageId;
|
|
781
|
-
setEditTargetInboundMessageId(inboundMessageId);
|
|
782
|
-
setQueueActionError(undefined);
|
|
783
|
-
try {
|
|
784
|
-
// Read images first. A failed read must leave the message queued.
|
|
785
|
-
const images = await readStoredComposerImages(
|
|
786
|
-
props.conversationId,
|
|
787
|
-
message.attachments,
|
|
788
|
-
);
|
|
789
|
-
// The reader left during the read. Keep the message in the queue.
|
|
790
|
-
if (!composerRef.current) return;
|
|
791
|
-
// Check before the cancel. After it, images that do not fit are lost.
|
|
792
|
-
if (!composerRef.current.canRestoreImages(images)) {
|
|
793
|
-
setQueueActionError("Remove some draft images to edit that message.");
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
const result = await editCancelRef.current.mutateAsync({
|
|
797
|
-
inboundMessageIds: [inboundMessageId],
|
|
798
|
-
receivedBefore,
|
|
799
|
-
});
|
|
800
|
-
// Junior can read the message before the cancel lands. Then it is
|
|
801
|
-
// already in the Conversation and there is nothing to edit.
|
|
802
|
-
if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) {
|
|
803
|
-
setQueueActionError("Junior already read that message.");
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
const restored = { images, text: message.text ?? "" };
|
|
807
|
-
if (composerRef.current) {
|
|
808
|
-
composerRef.current.restoreMessage(restored);
|
|
809
|
-
} else {
|
|
810
|
-
// The reader left during the cancel. Keep the text in the saved
|
|
811
|
-
// draft so it is there when they come back.
|
|
812
|
-
restoreStoredDraftText(props.conversationId, restored.text);
|
|
813
|
-
}
|
|
814
|
-
} catch {
|
|
815
|
-
setQueueActionError("Could not edit the queued message. Try again.");
|
|
816
|
-
} finally {
|
|
817
|
-
editingRef.current = false;
|
|
818
|
-
setEditTargetInboundMessageId(undefined);
|
|
819
|
-
}
|
|
820
|
-
},
|
|
821
|
-
[props.conversationId, props.pendingGeneratedAtRef],
|
|
822
|
-
);
|
|
823
|
-
|
|
824
|
-
const steerPendingMessages = useSteerConversationPendingMessages(
|
|
825
|
-
props.conversationId,
|
|
826
|
-
);
|
|
827
|
-
const steerPendingMessagesRef = useRef(steerPendingMessages);
|
|
828
|
-
steerPendingMessagesRef.current = steerPendingMessages;
|
|
829
|
-
const onSteerMessage = useCallback((message: ConversationMailboxMessage) => {
|
|
830
|
-
if (steerPendingMessagesRef.current.isPending) return;
|
|
831
|
-
setQueueActionError(undefined);
|
|
832
|
-
steerPendingMessagesRef.current.mutate(
|
|
833
|
-
{ inboundMessageIds: [message.inboundMessageId] },
|
|
834
|
-
{
|
|
835
|
-
onError: () =>
|
|
836
|
-
setQueueActionError(
|
|
837
|
-
"Could not send the queued message now. Try again.",
|
|
838
|
-
),
|
|
839
|
-
},
|
|
840
|
-
);
|
|
841
|
-
}, []);
|
|
842
|
-
const steerTargetInboundMessageId = steerPendingMessages.isPending
|
|
843
|
-
? steerPendingMessages.variables?.inboundMessageIds[0]
|
|
844
|
-
: undefined;
|
|
845
|
-
|
|
846
|
-
const onStop = useCallback(async () => {
|
|
847
|
-
const result = await stopTurnRef.current.mutateAsync();
|
|
848
|
-
const restored = result.stoppedMessages.map((message) => message.text);
|
|
849
|
-
return restored.length ? restored.join("\n") : undefined;
|
|
850
|
-
}, []);
|
|
851
|
-
|
|
852
|
-
const onMailboxLayoutChange = useCallback(() => {
|
|
853
|
-
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
854
|
-
onPinRequestRef.current();
|
|
855
|
-
}, []);
|
|
856
|
-
// Mobile keyboard open shrinks the transcript. Focus must re-pin the latest
|
|
857
|
-
// message so the reader does not stay stuck above the docked composer.
|
|
858
|
-
const onComposerFocus = useCallback(() => {
|
|
859
|
-
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
860
|
-
onPinRequestRef.current();
|
|
861
|
-
}, []);
|
|
862
|
-
|
|
863
|
-
return (
|
|
864
|
-
<ComposerDock
|
|
865
|
-
above={
|
|
866
|
-
<>
|
|
867
|
-
{props.pendingAuthorization ? (
|
|
868
|
-
<PendingAuthorization authorization={props.pendingAuthorization} />
|
|
869
|
-
) : null}
|
|
870
|
-
{/* The review bar and queued messages hide with the message box, so
|
|
871
|
-
the question is the last thing above the dock. */}
|
|
872
|
-
<div
|
|
873
|
-
hidden={Boolean(question)}
|
|
874
|
-
onFocus={keepComposerShown}
|
|
875
|
-
onPointerDown={keepComposerShown}
|
|
876
|
-
>
|
|
877
|
-
<ConversationReviewBar conversationId={props.conversationId} />
|
|
878
|
-
<PendingMailboxStack
|
|
879
|
-
authors={props.authors}
|
|
880
|
-
cancelError={cancelError}
|
|
881
|
-
cancelPending={cancelPendingMessages.isPending}
|
|
882
|
-
cancelTargetInboundMessageId={cancelTargetInboundMessageId}
|
|
883
|
-
committedMessageIds={props.committedMessageIds}
|
|
884
|
-
conversationId={props.conversationId}
|
|
885
|
-
editTargetInboundMessageId={editTargetInboundMessageId}
|
|
886
|
-
messages={props.pendingMessages}
|
|
887
|
-
onCancelMessage={onCancelMessage}
|
|
888
|
-
onEditMessage={onEditMessage}
|
|
889
|
-
onLayoutChange={onMailboxLayoutChange}
|
|
890
|
-
onRetry={onRetry}
|
|
891
|
-
onSteerMessage={props.stoppable ? onSteerMessage : undefined}
|
|
892
|
-
steerTargetInboundMessageId={steerTargetInboundMessageId}
|
|
893
|
-
viewerEmail={viewerEmail}
|
|
894
|
-
/>
|
|
895
|
-
</div>
|
|
896
|
-
{unsentAnswers.dialog}
|
|
897
|
-
</>
|
|
898
|
-
}
|
|
899
|
-
>
|
|
900
|
-
<div
|
|
901
|
-
hidden={Boolean(question)}
|
|
902
|
-
onBlur={(event) => {
|
|
903
|
-
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
904
|
-
setComposerFocused(false);
|
|
905
|
-
}
|
|
906
|
-
}}
|
|
907
|
-
onFocus={() => setComposerFocused(true)}
|
|
908
|
-
>
|
|
909
|
-
<ConversationComposer
|
|
910
|
-
confirmSubmit={unsentAnswers.confirmSubmit}
|
|
911
|
-
draftId={props.conversationId}
|
|
912
|
-
error={
|
|
913
|
-
queueActionError ??
|
|
914
|
-
(stopTurn.error ? "Could not stop Junior. Try again." : undefined)
|
|
915
|
-
}
|
|
916
|
-
ref={composerRef}
|
|
917
|
-
label="Continue this conversation"
|
|
918
|
-
submitLabel="Send"
|
|
919
|
-
onFocus={onComposerFocus}
|
|
920
|
-
onStop={props.stoppable ? onStop : undefined}
|
|
921
|
-
stopPending={stopTurn.isPending}
|
|
922
|
-
onSubmitStart={onSubmitStart}
|
|
923
|
-
onSubmit={onSubmit}
|
|
924
|
-
/>
|
|
925
|
-
</div>
|
|
926
|
-
</ComposerDock>
|
|
927
|
-
);
|
|
928
|
-
});
|
|
929
|
-
|
|
930
705
|
function applyPendingArchiveUpdate(
|
|
931
706
|
conversation: Conversation | undefined,
|
|
932
707
|
update: PendingArchiveConversationUpdate | undefined,
|