@sentry/junior-dashboard 0.269.0 → 0.270.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 (55) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +78 -75
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/client/App.tsx +14 -4
  7. package/src/client/components/AutomationCard.tsx +2 -1
  8. package/src/client/components/UserInputForm.tsx +109 -36
  9. package/src/client/conversations/AGENTS.md +0 -4
  10. package/src/client/conversations/ConversationBrief.tsx +55 -36
  11. package/src/client/conversations/ConversationComposer.tsx +27 -8
  12. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  13. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  14. package/src/client/conversations/ConversationPage.tsx +93 -32
  15. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  16. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  17. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  18. package/src/client/conversations/ConversationWatches.tsx +4 -1
  19. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  20. package/src/client/conversations/EventDetails.tsx +6 -9
  21. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  22. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  23. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  24. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  25. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  26. package/src/client/conversations/TranscriptRows.tsx +6 -1
  27. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  28. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  29. package/src/client/conversations/TranscriptView.tsx +6 -6
  30. package/src/client/conversations/conversationOutbox.ts +13 -3
  31. package/src/client/conversations/conversationSections.ts +36 -8
  32. package/src/client/conversations/eventTranscript.ts +1 -0
  33. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  34. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  35. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  36. package/src/client/display-text/DisplayText.tsx +68 -0
  37. package/src/client/display-text/emoji.ts +54 -0
  38. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  39. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  40. package/src/client/display-text/mentions.tsx +148 -0
  41. package/src/client/display-text/plainText.ts +29 -0
  42. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  43. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  44. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  45. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  46. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  47. package/src/client/review-desk/ConversationReviewBar.tsx +0 -1
  48. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  49. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  50. package/src/client/review-desk/ReviewItemDetail.tsx +2 -2
  51. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  52. package/src/client/review-desk/reviewItemFormat.ts +29 -1
  53. package/src/client/conversations/TranscriptText.tsx +0 -47
  54. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  55. package/src/client/conversations/transcriptMentions.tsx +0 -134
@@ -17,16 +17,18 @@ import {
17
17
  import { cn } from "../styles";
18
18
  import type { Conversation } from "../types";
19
19
  import { ConversationSidebarAnnotations } from "./ConversationMeta";
20
- import { formatConversationActivityPreview } from "./conversationActivityPreview";
21
20
  import { ConversationArchiveNotices } from "./ConversationArchiveNotices";
22
21
  import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
23
22
  import { conversationPath } from "./conversationRoutes";
24
23
  import { useArchiveConversation } from "./queries";
25
- import { MentionText } from "./transcriptMentions";
24
+ import { DisplayText } from "../display-text/DisplayText";
26
25
  import {
26
+ attentionConversationIds,
27
27
  buildConversationSections,
28
28
  type ConversationSection,
29
29
  } from "./conversationSections";
30
+ import { ConversationSectionHeading } from "./ConversationSectionHeading";
31
+ import { useCollapsedConversationSections } from "./useCollapsedConversationSections";
30
32
 
31
33
  /** Render grouped conversation cards on the dashboard home page. */
32
34
  export function ConversationHomeList(props: {
@@ -37,6 +39,8 @@ export function ConversationHomeList(props: {
37
39
  timeZone: string;
38
40
  waitingConversationIds: ReadonlySet<string>;
39
41
  }) {
42
+ const { collapsedKeys, toggle: toggleSection } =
43
+ useCollapsedConversationSections();
40
44
  const [archivedConversation, setArchivedConversation] =
41
45
  useState<Conversation>();
42
46
  const [archiveError, setArchiveError] = useState<{
@@ -83,6 +87,10 @@ export function ConversationHomeList(props: {
83
87
  }
84
88
 
85
89
  const sections = buildConversationSections(props.conversations, {
90
+ attentionIds: attentionConversationIds(
91
+ props.waitingConversationIds,
92
+ props.finishedConversationIds,
93
+ ),
86
94
  nowMs: Date.now(),
87
95
  timeZone: props.timeZone,
88
96
  });
@@ -91,10 +99,12 @@ export function ConversationHomeList(props: {
91
99
  <div aria-label="Your conversations" className="grid gap-5" role="list">
92
100
  {sections.map((section) => (
93
101
  <ConversationCardSection
102
+ collapsed={collapsedKeys.has(section.key)}
94
103
  finishedConversationIds={props.finishedConversationIds}
95
104
  key={section.key}
96
105
  onArchiveError={handleArchiveError}
97
106
  onArchived={handleArchived}
107
+ onToggle={() => toggleSection(section.key)}
98
108
  waitingConversationIds={props.waitingConversationIds}
99
109
  section={section}
100
110
  />
@@ -167,34 +177,42 @@ function ConversationCardLoading(props: { index: number }) {
167
177
  }
168
178
 
169
179
  function ConversationCardSection(props: {
180
+ collapsed: boolean;
170
181
  finishedConversationIds: ReadonlySet<string>;
171
182
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
172
183
  onArchived(conversation: Conversation): void;
184
+ onToggle(): void;
173
185
  section: ConversationSection;
174
186
  waitingConversationIds: ReadonlySet<string>;
175
187
  }) {
176
188
  return (
177
189
  <section aria-labelledby={`conversation-section-${props.section.key}`}>
178
- <h3
179
- className="mb-2 mt-0 px-1 font-sans text-xs font-medium text-dashboard-text-muted"
190
+ <ConversationSectionHeading
191
+ className={cn("px-1", !props.collapsed && "mb-2")}
192
+ collapsed={props.collapsed}
193
+ count={props.section.conversations.length}
180
194
  id={`conversation-section-${props.section.key}`}
181
- >
182
- {props.section.label}
183
- </h3>
184
- <div className="grid gap-2">
185
- {props.section.conversations.map((conversation) => (
186
- <ConversationCard
187
- conversation={conversation}
188
- finishedSinceSeen={props.finishedConversationIds.has(
189
- conversation.id,
190
- )}
191
- key={conversation.id}
192
- onArchiveError={props.onArchiveError}
193
- onArchived={props.onArchived}
194
- waitingOnViewer={props.waitingConversationIds.has(conversation.id)}
195
- />
196
- ))}
197
- </div>
195
+ label={props.section.label}
196
+ onToggle={props.onToggle}
197
+ />
198
+ {props.collapsed ? null : (
199
+ <div className="grid gap-2">
200
+ {props.section.conversations.map((conversation) => (
201
+ <ConversationCard
202
+ conversation={conversation}
203
+ finishedSinceSeen={props.finishedConversationIds.has(
204
+ conversation.id,
205
+ )}
206
+ key={conversation.id}
207
+ onArchiveError={props.onArchiveError}
208
+ onArchived={props.onArchived}
209
+ waitingOnViewer={props.waitingConversationIds.has(
210
+ conversation.id,
211
+ )}
212
+ />
213
+ ))}
214
+ </div>
215
+ )}
198
216
  </section>
199
217
  );
200
218
  }
@@ -272,11 +290,10 @@ function ConversationCard(props: {
272
290
  </div>
273
291
  {conversation.activityPreview ? (
274
292
  <p className="relative z-[1] m-0 line-clamp-2 font-sans text-sm leading-relaxed text-dashboard-text-subtle pointer-events-none">
275
- <MentionText
276
- mentions={conversation.activityPreview.mentions ?? []}
277
- text={formatConversationActivityPreview(
278
- conversation.activityPreview.text,
279
- )}
293
+ <DisplayText
294
+ inline
295
+ mentions={conversation.activityPreview.mentions}
296
+ text={conversation.activityPreview.text}
280
297
  />
281
298
  </p>
282
299
  ) : (
@@ -89,6 +89,12 @@ import {
89
89
  import { ChatLayout } from "./ChatLayout";
90
90
  import { ComposerDock } from "./ComposerDock";
91
91
  import { ConversationReviewBar } from "../review-desk/ConversationReviewBar";
92
+ import { useReviewItemData } from "../review-desk/queries";
93
+ import {
94
+ latestReviewItemId,
95
+ reviewItemWaitsForAnswer,
96
+ } from "../review-desk/reviewItemFormat";
97
+ import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
92
98
  import { Transcript } from "./TranscriptView";
93
99
  import { TranscriptLoading } from "./TranscriptLoading";
94
100
  import type { TranscriptViewMode } from "./transcriptRenderModel";
@@ -192,6 +198,10 @@ export function ConversationPage(props: {
192
198
  () => committedMessageIds(detail.data?.events),
193
199
  [detail.data?.events],
194
200
  );
201
+ const reviewItemId = useMemo(
202
+ () => latestReviewItemId(detail.data?.events),
203
+ [detail.data?.events],
204
+ );
195
205
  // Cancel needs the latest mailbox watermark, but not as a render prop. Live
196
206
  // polls refresh generatedAt every 2s even when the queue is unchanged.
197
207
  const pendingGeneratedAtRef = useRef(detail.pendingGeneratedAt);
@@ -475,6 +485,7 @@ export function ConversationPage(props: {
475
485
  // every 2s; a prop would bust footer memo while the reader types.
476
486
  pendingGeneratedAtRef={pendingGeneratedAtRef}
477
487
  pendingMessages={detail.pendingMessages}
488
+ reviewItemId={reviewItemId}
478
489
  // Without live history, an inline send would never leave the
479
490
  // transcript tail. A started Turn reads a new send only later.
480
491
  // Keep those sends in the queue. An earlier inline send that
@@ -568,6 +579,8 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
568
579
  pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
569
580
  pendingGeneratedAtRef: { current: string | undefined };
570
581
  pendingMessages: readonly ConversationMailboxMessage[];
582
+ /** The last Review item that Junior added in the transcript. */
583
+ reviewItemId?: string;
571
584
  /**
572
585
  * True when history is live and no Turn has started, so the next Turn will
573
586
  * read a new send at once.
@@ -577,6 +590,9 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
577
590
  stoppable: boolean;
578
591
  }) {
579
592
  const appendMessage = useAppendConversationMessage(props.conversationId);
593
+ // A message closes the review items that it answers. Ask before it drops
594
+ // answers that the reader did not send.
595
+ const unsentAnswers = useConfirmUnsentAnswers();
580
596
  const stopTurn = useStopConversationTurn(props.conversationId);
581
597
  const stopTurnRef = useRef(stopTurn);
582
598
  stopTurnRef.current = stopTurn;
@@ -586,6 +602,31 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
586
602
  const [editTargetInboundMessageId, setEditTargetInboundMessageId] =
587
603
  useState<string>();
588
604
  const [queueActionError, setQueueActionError] = useState<string>();
605
+ // While Junior waits for an answer, hide the message box, the review bar,
606
+ // and the queued messages, so the reader answers in the question and does
607
+ // not type the answer as a new message.
608
+ // Dismiss on the question closes the item and shows the message box again.
609
+ // The message box stays mounted, so its text and images stay, and it does
610
+ // not hide while it has focus.
611
+ const reviewItem = useReviewItemData(props.reviewItemId).data;
612
+ const [composerShownForItemId, setComposerShownForItemId] =
613
+ useState<string>();
614
+ const [composerFocused, setComposerFocused] = useState(false);
615
+ const question =
616
+ reviewItem &&
617
+ !props.stoppable &&
618
+ !composerFocused &&
619
+ reviewItemWaitsForAnswer(reviewItem) &&
620
+ composerShownForItemId !== reviewItem.id
621
+ ? reviewItem
622
+ : undefined;
623
+ // The message box can show only because it has focus. Using the review bar
624
+ // or queued messages moves focus out of it, so keep them shown for this item.
625
+ const keepComposerShown = () => {
626
+ if (composerFocused && reviewItem && reviewItemWaitsForAnswer(reviewItem)) {
627
+ setComposerShownForItemId(reviewItem.id);
628
+ }
629
+ };
589
630
  // Keep submit identity stable across mutation status flips so the memoized
590
631
  // composer does not re-render while the reader is still typing.
591
632
  const appendMessageRef = useRef(appendMessage);
@@ -777,41 +818,61 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
777
818
  {props.pendingAuthorization ? (
778
819
  <PendingAuthorization authorization={props.pendingAuthorization} />
779
820
  ) : null}
780
- <ConversationReviewBar conversationId={props.conversationId} />
781
- <PendingMailboxStack
782
- cancelError={cancelError}
783
- cancelPending={cancelPendingMessages.isPending}
784
- cancelTargetInboundMessageId={cancelTargetInboundMessageId}
785
- committedMessageIds={props.committedMessageIds}
786
- conversationId={props.conversationId}
787
- editTargetInboundMessageId={editTargetInboundMessageId}
788
- messages={props.pendingMessages}
789
- onCancelMessage={onCancelMessage}
790
- onEditMessage={onEditMessage}
791
- onLayoutChange={onMailboxLayoutChange}
792
- onRetry={onRetry}
793
- onSteerMessage={props.stoppable ? onSteerMessage : undefined}
794
- steerTargetInboundMessageId={steerTargetInboundMessageId}
795
- viewerEmail={viewerEmail}
796
- />
821
+ {/* The review bar and queued messages hide with the message box, so
822
+ the question is the last thing above the dock. */}
823
+ <div
824
+ hidden={Boolean(question)}
825
+ onFocus={keepComposerShown}
826
+ onPointerDown={keepComposerShown}
827
+ >
828
+ <ConversationReviewBar conversationId={props.conversationId} />
829
+ <PendingMailboxStack
830
+ cancelError={cancelError}
831
+ cancelPending={cancelPendingMessages.isPending}
832
+ cancelTargetInboundMessageId={cancelTargetInboundMessageId}
833
+ committedMessageIds={props.committedMessageIds}
834
+ conversationId={props.conversationId}
835
+ editTargetInboundMessageId={editTargetInboundMessageId}
836
+ messages={props.pendingMessages}
837
+ onCancelMessage={onCancelMessage}
838
+ onEditMessage={onEditMessage}
839
+ onLayoutChange={onMailboxLayoutChange}
840
+ onRetry={onRetry}
841
+ onSteerMessage={props.stoppable ? onSteerMessage : undefined}
842
+ steerTargetInboundMessageId={steerTargetInboundMessageId}
843
+ viewerEmail={viewerEmail}
844
+ />
845
+ </div>
846
+ {unsentAnswers.dialog}
797
847
  </>
798
848
  }
799
849
  >
800
- <ConversationComposer
801
- draftId={props.conversationId}
802
- error={
803
- queueActionError ??
804
- (stopTurn.error ? "Could not stop Junior. Try again." : undefined)
805
- }
806
- ref={composerRef}
807
- label="Continue this conversation"
808
- submitLabel="Send"
809
- onFocus={onComposerFocus}
810
- onStop={props.stoppable ? onStop : undefined}
811
- stopPending={stopTurn.isPending}
812
- onSubmitStart={onSubmitStart}
813
- onSubmit={onSubmit}
814
- />
850
+ <div
851
+ hidden={Boolean(question)}
852
+ onBlur={(event) => {
853
+ if (!event.currentTarget.contains(event.relatedTarget)) {
854
+ setComposerFocused(false);
855
+ }
856
+ }}
857
+ onFocus={() => setComposerFocused(true)}
858
+ >
859
+ <ConversationComposer
860
+ confirmSubmit={unsentAnswers.confirmSubmit}
861
+ draftId={props.conversationId}
862
+ error={
863
+ queueActionError ??
864
+ (stopTurn.error ? "Could not stop Junior. Try again." : undefined)
865
+ }
866
+ ref={composerRef}
867
+ label="Continue this conversation"
868
+ submitLabel="Send"
869
+ onFocus={onComposerFocus}
870
+ onStop={props.stoppable ? onStop : undefined}
871
+ stopPending={stopTurn.isPending}
872
+ onSubmitStart={onSubmitStart}
873
+ onSubmit={onSubmit}
874
+ />
875
+ </div>
815
876
  </ComposerDock>
816
877
  );
817
878
  });
@@ -0,0 +1,44 @@
1
+ import { ChevronRight } from "lucide-react";
2
+
3
+ import { cn } from "../styles";
4
+
5
+ /** Render a conversation section label that collapses its rows. */
6
+ export function ConversationSectionHeading(props: {
7
+ className?: string;
8
+ collapsed: boolean;
9
+ count: number;
10
+ id?: string;
11
+ label: string;
12
+ onToggle(): void;
13
+ }) {
14
+ return (
15
+ <h3
16
+ className={cn(
17
+ "m-0 font-sans text-xs font-medium text-dashboard-text-muted",
18
+ props.className,
19
+ )}
20
+ id={props.id}
21
+ >
22
+ <button
23
+ aria-expanded={!props.collapsed}
24
+ className="flex cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent p-0 font-[inherit] text-inherit transition-colors hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus"
25
+ onClick={props.onToggle}
26
+ type="button"
27
+ >
28
+ <ChevronRight
29
+ aria-hidden="true"
30
+ className={cn(
31
+ "size-3 shrink-0 transition-transform motion-reduce:transition-none",
32
+ !props.collapsed && "rotate-90",
33
+ )}
34
+ />
35
+ {props.label}
36
+ {props.collapsed ? (
37
+ <span className="tabular-nums text-dashboard-text-faint">
38
+ {props.count}
39
+ </span>
40
+ ) : null}
41
+ </button>
42
+ </h3>
43
+ );
44
+ }
@@ -14,9 +14,12 @@ import type { Conversation } from "../types";
14
14
  import { Notice, NoticeAction } from "../components/Notice";
15
15
  import { AnimatedList } from "./AnimatedList";
16
16
  import {
17
+ attentionConversationIds,
17
18
  buildConversationSections,
18
19
  type ConversationSection,
19
20
  } from "./conversationSections";
21
+ import { ConversationSectionHeading } from "./ConversationSectionHeading";
22
+ import { useCollapsedConversationSections } from "./useCollapsedConversationSections";
20
23
  import { EmptyTelemetry } from "../components/EmptyTelemetry";
21
24
  import { SearchInput } from "../components/SearchInput";
22
25
  import { Button } from "../components/ui/button";
@@ -30,7 +33,15 @@ import { ConversationSidebarAnnotations } from "./ConversationMeta";
30
33
  import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
31
34
 
32
35
  type ConversationSidebarEntry =
33
- | { first: boolean; key: string; kind: "section"; label: string }
36
+ | {
37
+ collapsed: boolean;
38
+ count: number;
39
+ first: boolean;
40
+ key: string;
41
+ kind: "section";
42
+ label: string;
43
+ sectionKey: string;
44
+ }
34
45
  | { conversation: Conversation; key: string; kind: "conversation" };
35
46
 
36
47
  // Tracks the attempted direction alongside the conversation so a failed
@@ -78,17 +89,30 @@ export function ConversationSidebar(props: {
78
89
  );
79
90
  setArchivedConversation(conversation);
80
91
  }, []);
81
- // Rebuild section rows only when the feed or timezone changes. Avoid fresh
82
- // Date.now() arrays on unrelated parent renders while the reader is scrolling.
92
+ const { collapsedKeys, toggle: toggleSection } =
93
+ useCollapsedConversationSections();
94
+ // Rebuild section rows only when their inputs change. Avoid fresh Date.now()
95
+ // arrays on unrelated parent renders while the reader is scrolling.
83
96
  const entries = useMemo(
84
97
  () =>
85
98
  conversationSidebarEntries(
86
99
  buildConversationSections(props.conversations, {
100
+ attentionIds: attentionConversationIds(
101
+ props.waitingConversationIds,
102
+ props.finishedConversationIds,
103
+ ),
87
104
  nowMs: Date.now(),
88
105
  timeZone: props.timeZone,
89
106
  }),
107
+ collapsedKeys,
90
108
  ),
91
- [props.conversations, props.timeZone],
109
+ [
110
+ collapsedKeys,
111
+ props.conversations,
112
+ props.finishedConversationIds,
113
+ props.timeZone,
114
+ props.waitingConversationIds,
115
+ ],
92
116
  );
93
117
  return (
94
118
  <aside
@@ -170,14 +194,13 @@ export function ConversationSidebar(props: {
170
194
  items={entries}
171
195
  renderItem={(entry) =>
172
196
  entry.kind === "section" ? (
173
- <h3
174
- className={cn(
175
- "m-0 px-3 pb-1 font-sans text-xs font-medium text-dashboard-text-muted",
176
- entry.first ? "pt-1" : "pt-3",
177
- )}
178
- >
179
- {entry.label}
180
- </h3>
197
+ <ConversationSectionHeading
198
+ className={cn("px-3 pb-1", entry.first ? "pt-1" : "pt-3")}
199
+ collapsed={entry.collapsed}
200
+ count={entry.count}
201
+ label={entry.label}
202
+ onToggle={() => toggleSection(entry.sectionKey)}
203
+ />
181
204
  ) : (
182
205
  <ConversationSidebarRow
183
206
  conversation={entry.conversation}
@@ -253,20 +276,27 @@ function ConversationRowsLoading() {
253
276
 
254
277
  function conversationSidebarEntries(
255
278
  sections: ConversationSection[],
279
+ collapsedKeys: ReadonlySet<string>,
256
280
  ): ConversationSidebarEntry[] {
257
- return sections.flatMap((section, index) => [
258
- {
259
- first: index === 0,
260
- key: `section-${section.key}`,
261
- kind: "section" as const,
262
- label: section.label,
263
- },
264
- ...section.conversations.map((conversation) => ({
265
- conversation,
266
- key: `conversation-${conversation.id}`,
267
- kind: "conversation" as const,
268
- })),
269
- ]);
281
+ return sections.flatMap((section, index) => {
282
+ const collapsed = collapsedKeys.has(section.key);
283
+ return [
284
+ {
285
+ collapsed,
286
+ count: section.conversations.length,
287
+ first: index === 0,
288
+ key: `section-${section.key}`,
289
+ kind: "section" as const,
290
+ label: section.label,
291
+ sectionKey: section.key,
292
+ },
293
+ ...(collapsed ? [] : section.conversations).map((conversation) => ({
294
+ conversation,
295
+ key: `conversation-${conversation.id}`,
296
+ kind: "conversation" as const,
297
+ })),
298
+ ];
299
+ });
270
300
  }
271
301
 
272
302
  const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
@@ -360,7 +360,7 @@ function TranscriptEntryList(props: {
360
360
  knownKeys: activityKeys.current,
361
361
  });
362
362
  rows.push(
363
- <TranscriptRow indent key={activityKey}>
363
+ <TranscriptRow anchorKey={activityKey} indent key={activityKey}>
364
364
  <TranscriptActivityGroup
365
365
  entries={visibleEntries}
366
366
  renderEntry={renderEntry}
@@ -372,8 +372,9 @@ function TranscriptEntryList(props: {
372
372
  }
373
373
 
374
374
  if (!search.active || entryMatchesSearch(entry, search.normalizedQuery)) {
375
+ const rowKey = `${props.keyPrefix}:${entry.key}`;
375
376
  rows.push(
376
- <TranscriptRow key={`${props.keyPrefix}:${entry.key}`}>
377
+ <TranscriptRow anchorKey={rowKey} key={rowKey}>
377
378
  {renderEntry(entry)}
378
379
  </TranscriptRow>,
379
380
  );
@@ -21,6 +21,7 @@ import {
21
21
  type ConversationWatch,
22
22
  } from "@sentry/junior/api/schema";
23
23
 
24
+ import { DisplayText } from "../display-text/DisplayText";
24
25
  import { formatTime } from "../format";
25
26
  import { fetchDashboardJson } from "../http";
26
27
  import { dashboardDisclosureChevronClass } from "../styles";
@@ -106,7 +107,9 @@ function WatchRow(props: { watch: ConversationWatch }) {
106
107
  {/* Indent under the title: icon column, column gap, and row padding. */}
107
108
  <div className="grid min-w-0 gap-1.5 pb-2 pl-9 pr-1.5 pt-0.5 text-xs leading-4 text-dashboard-text-muted">
108
109
  {watch.intent ? (
109
- <p className="m-0 break-words">{watch.intent}</p>
110
+ <p className="m-0 break-words">
111
+ <DisplayText inline text={watch.intent} />
112
+ </p>
110
113
  ) : null}
111
114
  {watch.firesAt ? (
112
115
  <p className="m-0">
@@ -16,11 +16,16 @@ import {
16
16
  type PendingArchiveConversationUpdate,
17
17
  } from "./queries";
18
18
  import { conversationPath, NEW_CONVERSATION_PATH } from "./conversationRoutes";
19
+ import {
20
+ attentionConversationIds,
21
+ attentionFirst,
22
+ } from "./conversationSections";
19
23
  import { buildConversations, getDashboardTimeZone } from "../format";
20
24
  import { useDebouncedValue } from "../debouncedValue";
21
25
  import type { Conversation } from "../types";
22
26
  import { ConversationPage } from "./ConversationPage";
23
27
  import { useWaitingConversationIds } from "../review-desk/queries";
28
+ import { UnsentAnswersProvider } from "../review-desk/UnsentAnswers";
24
29
  import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
25
30
 
26
31
  const CONVERSATION_PAGE_SIZE = 20;
@@ -51,10 +56,30 @@ export function ConversationWorkspace() {
51
56
  if (selectedId) navigate(NEW_CONVERSATION_PATH);
52
57
  };
53
58
 
59
+ const waitingConversationIds = useWaitingConversationIds();
60
+ const { finishedConversationIds, markRead } =
61
+ useConversationFinishedIndicators(
62
+ conversations,
63
+ selectedId,
64
+ Boolean(feed.data),
65
+ !search,
66
+ );
67
+
54
68
  const totalPages = pageCount(conversations.length, CONVERSATION_PAGE_SIZE);
55
69
  const pagedConversations = useMemo(
56
- () => pageItems(conversations, page, CONVERSATION_PAGE_SIZE),
57
- [conversations, page],
70
+ () =>
71
+ pageItems(
72
+ attentionFirst(
73
+ conversations,
74
+ attentionConversationIds(
75
+ waitingConversationIds,
76
+ finishedConversationIds,
77
+ ),
78
+ ),
79
+ page,
80
+ CONVERSATION_PAGE_SIZE,
81
+ ),
82
+ [conversations, finishedConversationIds, page, waitingConversationIds],
58
83
  );
59
84
  useEffect(() => {
60
85
  setPage(1);
@@ -62,14 +87,6 @@ export function ConversationWorkspace() {
62
87
  useEffect(() => {
63
88
  if (page > totalPages) setPage(totalPages);
64
89
  }, [page, totalPages]);
65
- const waitingConversationIds = useWaitingConversationIds();
66
- const { finishedConversationIds, markRead } =
67
- useConversationFinishedIndicators(
68
- conversations,
69
- selectedId,
70
- Boolean(feed.data),
71
- !search,
72
- );
73
90
 
74
91
  const createView = (
75
92
  <NewConversationView
@@ -145,21 +162,22 @@ export function ConversationWorkspace() {
145
162
  aria-label="Selected conversation"
146
163
  className="grid min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-dashboard-bg"
147
164
  >
148
- <ConversationPage
149
- key={selectedId}
150
- conversationId={selectedId}
151
- data={
152
- feed.data
153
- ? {
154
- conversations: feed.data,
155
- }
156
- : undefined
157
- }
158
- onRead={markRead}
159
- pendingArchiveUpdate={pendingArchiveUpdates.find(
160
- (update) => update.conversationId === selectedId,
161
- )}
162
- />
165
+ <UnsentAnswersProvider conversationId={selectedId} key={selectedId}>
166
+ <ConversationPage
167
+ conversationId={selectedId}
168
+ data={
169
+ feed.data
170
+ ? {
171
+ conversations: feed.data,
172
+ }
173
+ : undefined
174
+ }
175
+ onRead={markRead}
176
+ pendingArchiveUpdate={pendingArchiveUpdates.find(
177
+ (update) => update.conversationId === selectedId,
178
+ )}
179
+ />
180
+ </UnsentAnswersProvider>
163
181
  </section>
164
182
  </div>
165
183
  );