@sentry/junior-dashboard 0.273.0 → 0.275.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 (36) hide show
  1. package/dist/app.js +3 -3
  2. package/dist/client.js +76 -76
  3. package/dist/index.js +3 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/client/App.tsx +11 -0
  7. package/src/client/components/Metric.tsx +0 -32
  8. package/src/client/components/ProfileMenu.tsx +56 -4
  9. package/src/client/components/layout/DashboardChrome.tsx +3 -3
  10. package/src/client/components/layout/DashboardHeader.tsx +41 -4
  11. package/src/client/components/layout/NavRail.tsx +43 -40
  12. package/src/client/conversations/ConversationComposer.tsx +7 -4
  13. package/src/client/conversations/ConversationDetails.tsx +117 -162
  14. package/src/client/conversations/ConversationHeader.tsx +119 -133
  15. package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
  16. package/src/client/conversations/ConversationMeta.tsx +49 -44
  17. package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
  18. package/src/client/conversations/ConversationPage.tsx +33 -368
  19. package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
  20. package/src/client/conversations/ConversationSidebar.tsx +119 -60
  21. package/src/client/conversations/ConversationWorkspace.tsx +8 -3
  22. package/src/client/conversations/TelemetryMetrics.tsx +0 -14
  23. package/src/client/conversations/TranscriptView.tsx +15 -32
  24. package/src/client/conversations/UsageCard.tsx +1 -15
  25. package/src/client/conversations/composerAutocorrect.ts +12 -0
  26. package/src/client/conversations/conversationAge.ts +24 -0
  27. package/src/client/conversations/conversationRoutes.ts +14 -5
  28. package/src/client/conversations/conversationSections.ts +66 -48
  29. package/src/client/conversations/useConversationFinishedIndicators.ts +40 -8
  30. package/src/client/pages/settings/SettingsPage.tsx +28 -0
  31. package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
  32. package/src/client/review-desk/queries.ts +30 -6
  33. package/src/mock-reporting/routes.ts +1 -1
  34. package/src/tailwind.css +4 -0
  35. package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
  36. package/src/client/review-desk/ConversationReviewBar.tsx +0 -67
@@ -1,5 +1,4 @@
1
1
  import {
2
- memo,
3
2
  useCallback,
4
3
  useEffect,
5
4
  useMemo,
@@ -11,38 +10,26 @@ 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";
47
34
  import { ConversationEventDetails } from "./ConversationEventDetails";
48
35
  import { ConversationEventLogLoading } from "./ConversationEventLog";
@@ -60,21 +47,19 @@ import {
60
47
  import { cn } from "../styles";
61
48
  import { ConversationForkLinks } from "./ConversationForkLinks";
62
49
  import { ConversationAgentTree } from "./ConversationAgentTree";
63
- import { ConversationHeaderMeta } from "./ConversationHeaderMeta";
64
50
  import { conversationLinks } from "./ConversationLinkedWork";
65
51
  import {
66
52
  ConversationAnnotations,
53
+ conversationLocation,
67
54
  ConversationPrivacyChip,
68
55
  ConversationStats,
69
56
  hasConversationAnnotations,
70
57
  hasConversationStats,
71
- PendingAuthorization,
72
58
  } from "./ConversationMeta";
73
59
  import {
74
60
  ConversationUsageBreakdown,
75
61
  hasConversationUsageBreakdown,
76
62
  } from "./ConversationUsageBreakdown";
77
- import { PendingMailboxStack } from "./PendingMailboxStack";
78
63
  import {
79
64
  buildConversations,
80
65
  conversationDetailTitle,
@@ -89,14 +74,7 @@ import {
89
74
  ParticipantAvatarStack,
90
75
  } from "../components/ParticipantAvatarStack";
91
76
  import { ChatLayout } from "./ChatLayout";
92
- import { ComposerDock } from "./ComposerDock";
93
- import { ConversationReviewBar } from "../review-desk/ConversationReviewBar";
94
- import { useReviewItemData } from "../review-desk/queries";
95
- import {
96
- latestReviewItemId,
97
- reviewItemWaitsForAnswer,
98
- } from "../review-desk/reviewItemFormat";
99
- import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
77
+ import { latestReviewItemId } from "../review-desk/reviewItemFormat";
100
78
  import { Transcript } from "./TranscriptView";
101
79
  import {
102
80
  readConversationStartPosition,
@@ -156,7 +134,26 @@ export function ConversationPage(props: {
156
134
  const [detailsChoice, setDetailsChoice] = useState<boolean | undefined>(() =>
157
135
  eventOpen ? false : undefined,
158
136
  );
159
- const detailsOpen = detailsChoice ?? detailsDefaultOpen;
137
+ // A sidebar review chip links here with the review param. It opens
138
+ // details at Needs you, even below the width where details open alone.
139
+ const reviewOpen =
140
+ searchParams.get(CONVERSATION_REVIEW_PARAM) === CONVERSATION_REVIEW_OPEN;
141
+ useEffect(() => {
142
+ if (reviewOpen) setDetailsChoice(true);
143
+ }, [reviewOpen]);
144
+ const detailsOpen = detailsChoice ?? (reviewOpen || detailsDefaultOpen);
145
+ const closeDetails = useCallback(() => {
146
+ setDetailsChoice(false);
147
+ setSearchParams(
148
+ (current) => {
149
+ if (!current.has(CONVERSATION_REVIEW_PARAM)) return current;
150
+ const next = new URLSearchParams(current);
151
+ next.delete(CONVERSATION_REVIEW_PARAM);
152
+ return next;
153
+ },
154
+ { replace: true },
155
+ );
156
+ }, [setSearchParams]);
160
157
  const [turnContext, setTurnContext] = useState<TurnContextSelection>();
161
158
  const showDetails = detailsOpen && !eventOpen && !turnContext;
162
159
  const [viewState, setView] = useState<TranscriptViewMode>(() =>
@@ -380,7 +377,8 @@ export function ConversationPage(props: {
380
377
  onDetailsClick={() => {
381
378
  if (eventOpen) setSelectedEventSeq(undefined);
382
379
  setTurnContext(undefined);
383
- setDetailsChoice(!showDetails);
380
+ if (showDetails) closeDetails();
381
+ else setDetailsChoice(true);
384
382
  }}
385
383
  forks={
386
384
  detail.data?.forkedFromConversationId ||
@@ -399,6 +397,7 @@ export function ConversationPage(props: {
399
397
  label="Copy as Markdown"
400
398
  copyingLabel="Preparing Markdown"
401
399
  key={conversationDetail?.conversationId ?? "loading"}
400
+ layout="menu"
402
401
  getText={
403
402
  conversationDetail
404
403
  ? async () =>
@@ -427,30 +426,14 @@ export function ConversationPage(props: {
427
426
  pending: archive.isPending,
428
427
  }}
429
428
  live={live}
430
- meta={
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
- }
429
+ location={conversationLocation(conversation, detail.data)}
448
430
  onSearchChange={setSearch}
449
431
  onViewChange={onViewChange}
450
432
  privacy={
451
433
  <ConversationPrivacyChip visibility={conversation?.visibility} />
452
434
  }
453
435
  search={search}
436
+ sentryConversationUrl={detail.data?.sentryConversationUrl}
454
437
  title={title}
455
438
  view={view}
456
439
  />
@@ -550,17 +533,13 @@ export function ConversationPage(props: {
550
533
  />
551
534
  {showDetails ? (
552
535
  <ConversationDetails
536
+ brief={detail.data?.brief}
553
537
  conversationId={conversationId}
538
+ highlightReview={reviewOpen}
554
539
  identity={identity}
555
- sentryConversationUrl={detail.data?.sentryConversationUrl}
556
- onClose={() => setDetailsChoice(false)}
540
+ onClose={closeDetails}
557
541
  onOpenSubagent={setSubagentTarget}
558
542
  links={conversationLinks(detail.data)}
559
- brief={
560
- detail.data?.brief ? (
561
- <ConversationBrief brief={detail.data.brief} variant="summary" />
562
- ) : null
563
- }
564
543
  overview={
565
544
  hasConversationStats({
566
545
  conversation,
@@ -613,320 +592,6 @@ export function ConversationPage(props: {
613
592
  );
614
593
  }
615
594
 
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
595
  function applyPendingArchiveUpdate(
931
596
  conversation: Conversation | undefined,
932
597
  update: PendingArchiveConversationUpdate | undefined,