@sentry/junior-dashboard 0.275.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.
Files changed (37) hide show
  1. package/dist/app.js +28 -9
  2. package/dist/client.js +115 -92
  3. package/dist/index.js +28 -9
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +8 -3
  7. package/src/client/conversations/ConversationComposer.tsx +42 -26
  8. package/src/client/conversations/ConversationDetails.tsx +63 -35
  9. package/src/client/conversations/ConversationHeader.tsx +10 -2
  10. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  11. package/src/client/conversations/ConversationMemories.tsx +118 -78
  12. package/src/client/conversations/ConversationMeta.tsx +47 -42
  13. package/src/client/conversations/ConversationPage.tsx +168 -58
  14. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  15. package/src/client/conversations/ConversationWatches.tsx +37 -15
  16. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  17. package/src/client/conversations/QuickQuestion.tsx +476 -0
  18. package/src/client/conversations/TelemetryMetrics.tsx +2 -1
  19. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  20. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  21. package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
  22. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  23. package/src/client/conversations/TranscriptRows.tsx +9 -2
  24. package/src/client/conversations/UsageCard.tsx +3 -3
  25. package/src/client/conversations/conversationMemory.ts +138 -0
  26. package/src/client/conversations/eventTranscript.ts +8 -0
  27. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  28. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  29. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  30. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  31. package/src/client/sentry.ts +77 -0
  32. package/src/client/types.ts +6 -0
  33. package/src/client.tsx +3 -0
  34. package/src/mock-reporting/watches.ts +2 -0
  35. package/src/shell.ts +27 -2
  36. package/src/tailwind.css +8 -19
  37. package/src/client/components/ActiveIndicator.tsx +0 -14
@@ -0,0 +1,179 @@
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 { memoryRecallContent } from "./turnContext";
22
+
23
+ function memoryCount(count: number): string {
24
+ return `${count} ${count === 1 ? "memory" : "memories"}`;
25
+ }
26
+
27
+ /**
28
+ * Open one transcript spot's memories in a popover, so the transcript keeps
29
+ * its length. The page owns which popover is open, so the details panel can
30
+ * open one too.
31
+ */
32
+ function MemoryPopover(props: {
33
+ anchor: MemoryAnchor;
34
+ label: string;
35
+ memories: ConversationMemory[];
36
+ state: ConversationMemoryState;
37
+ trigger: ReactElement;
38
+ }) {
39
+ const { anchor, state } = props;
40
+ const open =
41
+ state.open?.source === anchor.source && state.open.turnId === anchor.turnId;
42
+ const highlightId = open ? state.open?.memoryId : undefined;
43
+ return (
44
+ <Popover
45
+ onOpenChange={(next) => state.setOpen(next ? anchor : undefined)}
46
+ open={open}
47
+ >
48
+ <PopoverTrigger
49
+ data-memory-anchor={memoryAnchorKey(anchor)}
50
+ render={props.trigger}
51
+ />
52
+ <PopoverContent
53
+ align="start"
54
+ aria-label={props.label}
55
+ className="max-h-[min(28rem,var(--available-height))] w-96 p-1.5"
56
+ >
57
+ <ul className="m-0 grid list-none gap-0.5 p-0">
58
+ {props.memories.map((memory) => (
59
+ <li
60
+ className={cn(
61
+ "grid gap-1.5 rounded-md px-2.5 py-2",
62
+ memory.id === highlightId && "bg-dashboard-fill-strong",
63
+ )}
64
+ key={memory.id}
65
+ ref={
66
+ memory.id === highlightId
67
+ ? (node) => node?.scrollIntoView({ block: "nearest" })
68
+ : undefined
69
+ }
70
+ >
71
+ <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
72
+ {memory.content}
73
+ </p>
74
+ <div className="flex items-center gap-3 text-xs text-dashboard-text-muted">
75
+ {memory.visibility === "private" ? <span>Private</span> : null}
76
+ <Link
77
+ className="text-dashboard-text-muted no-underline hover:text-dashboard-text hover:underline"
78
+ to={memoryPath(memory.id)}
79
+ >
80
+ Open
81
+ </Link>
82
+ </div>
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ </PopoverContent>
87
+ </Popover>
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Show the memories that a prompt recalled, under that prompt. One prompt can
93
+ * start more than one Turn, and each Turn recalls, so each gets a chip.
94
+ */
95
+ export function TranscriptRecalledMemories(props: {
96
+ className?: string;
97
+ message: TranscriptViewMessage;
98
+ }) {
99
+ const state = useConversationMemoryState();
100
+ const anchors = [
101
+ ...new Set(
102
+ props.message.contexts
103
+ ?.filter((context) => memoryRecallContent(context))
104
+ .map((context) => context.turnId),
105
+ ),
106
+ ].flatMap((turnId) => {
107
+ const anchor = { source: "recalled", turnId } as const;
108
+ const memories = anchorMemories(state?.list, anchor);
109
+ return memories.length > 0 ? [{ anchor, memories }] : [];
110
+ });
111
+ if (!state || anchors.length === 0) return null;
112
+ return (
113
+ <div className={cn("flex flex-wrap gap-2", props.className)}>
114
+ {anchors.map(({ anchor, memories }) => {
115
+ const label = `Recalled ${memoryCount(memories.length)}`;
116
+ return (
117
+ <MemoryPopover
118
+ anchor={anchor}
119
+ key={anchor.turnId}
120
+ label={label}
121
+ memories={memories}
122
+ state={state}
123
+ trigger={
124
+ <button
125
+ 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"
126
+ type="button"
127
+ >
128
+ <Brain aria-hidden="true" size={13} strokeWidth={1.8} />
129
+ {label}
130
+ </button>
131
+ }
132
+ />
133
+ );
134
+ })}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Show the memories that one Turn captured. Without memory state or a list
141
+ * for the Turn, such as in a nested transcript, show `fallback`.
142
+ */
143
+ export function TranscriptCapturedMemories(props: {
144
+ fallback: ReactNode;
145
+ timestamp?: number;
146
+ turnId?: string;
147
+ }) {
148
+ const state = useConversationMemoryState();
149
+ const anchor = props.turnId
150
+ ? ({ source: "captured", turnId: props.turnId } as const)
151
+ : undefined;
152
+ const memories = anchor ? anchorMemories(state?.list, anchor) : [];
153
+ if (!state || !anchor || memories.length === 0) return props.fallback;
154
+ const label = `Captured ${memoryCount(memories.length)}`;
155
+ const timestamp = formatMessageTimestamp(props.timestamp);
156
+ return (
157
+ <MemoryPopover
158
+ anchor={anchor}
159
+ label={label}
160
+ memories={memories}
161
+ state={state}
162
+ trigger={
163
+ <button
164
+ 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)]"
165
+ type="button"
166
+ >
167
+ <span className="text-sm font-medium leading-6 text-dashboard-text">
168
+ {label}
169
+ </span>
170
+ {timestamp ? (
171
+ <span className="text-xs leading-6 tabular-nums text-dashboard-text-muted max-md:hidden">
172
+ {timestamp}
173
+ </span>
174
+ ) : null}
175
+ </button>
176
+ }
177
+ />
178
+ );
179
+ }
@@ -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
- <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2">
63
- {props.message.parts.map((part, index) =>
64
- part.type === "text" ? (
65
- <DisplayText
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
- mentions={props.message.mentions}
69
- text={part.text ?? ""}
91
+ record={record}
70
92
  />
71
- ) : null,
72
- )}
73
- </div>
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
- /** Leave room for control outlines inside the mobile row's paint bounds. */
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="mobile-transcript-row -mx-2 px-2 py-1.5"
56
+ className="py-1.5"
50
57
  data-scroll-anchor={props.anchorKey}
51
58
  data-transcript-seq={props.seq}
52
59
  >
@@ -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-lg font-semibold tabular-nums text-dashboard-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 text-dashboard-text",
127
+ row.code && "font-mono",
128
128
  )}
129
129
  >
130
130
  <span className="min-w-0 truncate" title={row.label}>
@@ -0,0 +1,138 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { createContext, useContext } from "react";
3
+ import { z } from "zod";
4
+
5
+ import { fetchDashboardJson } from "../http";
6
+
7
+ const conversationMemoryListSchema = z
8
+ .object({
9
+ events: z.array(
10
+ z
11
+ .object({
12
+ memoryIds: z.array(z.string().min(1)).min(1),
13
+ source: z.enum(["captured", "recalled"]),
14
+ turnId: z.string().min(1).optional(),
15
+ })
16
+ .strict(),
17
+ ),
18
+ memories: z.array(
19
+ z
20
+ .object({
21
+ content: z.string().min(1),
22
+ id: z.string().min(1),
23
+ visibility: z.enum(["private", "public"]),
24
+ })
25
+ .strict(),
26
+ ),
27
+ })
28
+ .strict();
29
+
30
+ export type ConversationMemoryList = z.output<
31
+ typeof conversationMemoryListSchema
32
+ >;
33
+ export type ConversationMemory = ConversationMemoryList["memories"][number];
34
+ type MemorySource = ConversationMemoryList["events"][number]["source"];
35
+
36
+ /** One transcript spot that recalled or captured memories. */
37
+ export type MemoryAnchor = { source: MemorySource; turnId: string };
38
+
39
+ /** Load what one Conversation recalled and captured. */
40
+ export function useConversationMemories(conversationId: string) {
41
+ return useQuery({
42
+ queryFn: ({ signal }) =>
43
+ fetchDashboardJson(
44
+ conversationMemoryListSchema,
45
+ `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
46
+ signal,
47
+ ),
48
+ queryKey: ["conversation", conversationId, "memories"],
49
+ retry: false,
50
+ });
51
+ }
52
+
53
+ /** Name the transcript element that opens one anchor's popover. */
54
+ export function memoryAnchorKey(anchor: MemoryAnchor): string {
55
+ return `${anchor.source}:${anchor.turnId}`;
56
+ }
57
+
58
+ /** List the memories that one transcript spot recalled or captured. */
59
+ export function anchorMemories(
60
+ list: ConversationMemoryList | undefined,
61
+ anchor: MemoryAnchor,
62
+ ): ConversationMemory[] {
63
+ const event = list?.events.find(
64
+ (candidate) =>
65
+ candidate.source === anchor.source && candidate.turnId === anchor.turnId,
66
+ );
67
+ if (!event || !list) return [];
68
+ const byId = new Map(list.memories.map((memory) => [memory.id, memory]));
69
+ return event.memoryIds.flatMap((id) => {
70
+ const memory = byId.get(id);
71
+ return memory ? [memory] : [];
72
+ });
73
+ }
74
+
75
+ /** One row of the Conversation memory list. */
76
+ export type ConversationMemoryEntry = {
77
+ memory: ConversationMemory;
78
+ source: MemorySource;
79
+ turnId?: string;
80
+ };
81
+
82
+ /**
83
+ * Flatten recalls and captures into one list in Conversation order. A memory
84
+ * shows once per source, at the first Turn that recalled or captured it.
85
+ */
86
+ export function conversationMemoryEntries(
87
+ list: ConversationMemoryList | undefined,
88
+ ): ConversationMemoryEntry[] {
89
+ if (!list) return [];
90
+ const byId = new Map(list.memories.map((memory) => [memory.id, memory]));
91
+ const seen = new Set<string>();
92
+ const entries: ConversationMemoryEntry[] = [];
93
+ for (const event of list.events) {
94
+ for (const id of event.memoryIds) {
95
+ const key = `${event.source}:${id}`;
96
+ const memory = byId.get(id);
97
+ if (seen.has(key) || !memory) continue;
98
+ seen.add(key);
99
+ entries.push({
100
+ memory,
101
+ source: event.source,
102
+ ...(event.turnId ? { turnId: event.turnId } : undefined),
103
+ });
104
+ }
105
+ }
106
+ return entries;
107
+ }
108
+
109
+ /** The open memory popover, and the memory it highlights. */
110
+ export type OpenMemoryPopover = MemoryAnchor & { memoryId?: string };
111
+
112
+ /** Shared memory popover state for one Conversation page. */
113
+ export type ConversationMemoryState = {
114
+ list?: ConversationMemoryList;
115
+ open?: OpenMemoryPopover;
116
+ setOpen(open: OpenMemoryPopover | undefined): void;
117
+ };
118
+
119
+ /**
120
+ * Let the transcript and the details panel share memory popovers. Only the
121
+ * Conversation page sets it, so nested transcripts show plain events.
122
+ */
123
+ export const ConversationMemoryContext = createContext<
124
+ ConversationMemoryState | undefined
125
+ >(undefined);
126
+
127
+ /** Read the memory popover state of the current Conversation page. */
128
+ export function useConversationMemoryState() {
129
+ return useContext(ConversationMemoryContext);
130
+ }
131
+
132
+ /** True for the memory plugin event that a Turn's capture emits. */
133
+ export function isMemoryCaptureEvent(part: {
134
+ name: string;
135
+ namespace: string;
136
+ }): boolean {
137
+ return part.namespace === "memory" && part.name === "memories_captured";
138
+ }
@@ -97,6 +97,9 @@ export function pendingTranscriptMessage(
97
97
  ? { actorIdentityId: message.actorIdentityId }
98
98
  : undefined),
99
99
  ...(message.mentions ? { mentions: message.mentions } : undefined),
100
+ ...(message.reviewItemChange
101
+ ? { reviewItemChanges: [message.reviewItemChange] }
102
+ : undefined),
100
103
  };
101
104
  }
102
105
 
@@ -240,6 +243,9 @@ export function transcriptMessagesFromEvents(
240
243
  ...(data.explicitMention !== undefined
241
244
  ? { explicitMention: data.explicitMention }
242
245
  : undefined),
246
+ ...(data.reviewItemChanges
247
+ ? { reviewItemChanges: data.reviewItemChanges }
248
+ : undefined),
243
249
  ...(data.source ? { source: data.source } : undefined),
244
250
  };
245
251
  messages.push(message);
@@ -327,6 +333,7 @@ export function transcriptMessagesFromEvents(
327
333
  kind: data.kind,
328
334
  loadedAt: event.createdAt,
329
335
  pluginName: data.pluginName,
336
+ turnId: data.turnId,
330
337
  version: data.version,
331
338
  });
332
339
  }
@@ -411,6 +418,7 @@ export function transcriptMessagesFromEvents(
411
418
  name: data.name,
412
419
  version: data.version,
413
420
  presentation: data.presentation,
421
+ ...(data.turnId ? { turnId: data.turnId } : undefined),
414
422
  },
415
423
  ]),
416
424
  );
@@ -26,7 +26,7 @@ function actionError(error: unknown): string {
26
26
  }
27
27
 
28
28
  /** Show the submitted answer as one line for each answered field. */
29
- function AnswerSummary(props: {
29
+ export function AnswerSummary(props: {
30
30
  answer: NonNullable<ReviewItemPayload["answer"]>;
31
31
  request: NonNullable<ReviewItemPayload["request"]>;
32
32
  }) {
@@ -0,0 +1,85 @@
1
+ import { Inbox } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { Link } from "react-router";
4
+ import type {
5
+ ReviewItemChangeRecord,
6
+ ReviewItemPayload,
7
+ } from "@sentry/junior/api/schema";
8
+
9
+ import { AnswerSummary } from "./ReviewItemActions";
10
+ import { useReviewItemData } from "./queries";
11
+ import { reviewItemPath } from "./reviewItemFormat";
12
+
13
+ const CHANGE_VERBS = {
14
+ dismissed: "Dismissed",
15
+ submitted: "Answered",
16
+ taken: "Took",
17
+ } as const satisfies Record<ReviewItemChangeRecord["kind"], string>;
18
+
19
+ function ChangeRow(props: {
20
+ item: ReviewItemPayload;
21
+ record: ReviewItemChangeRecord;
22
+ }) {
23
+ const { item, record } = props;
24
+ const verb = CHANGE_VERBS[record.kind];
25
+ return (
26
+ <section
27
+ aria-label={`${verb} ${item.title}`}
28
+ className="grid min-w-0 max-w-[37.5rem] gap-2 rounded-lg border border-dashboard-border bg-dashboard-surface-panel p-3 font-sans text-sm"
29
+ >
30
+ <div className="flex min-w-0 items-center gap-2">
31
+ <Inbox
32
+ aria-hidden="true"
33
+ className="shrink-0 text-dashboard-text-muted"
34
+ size={15}
35
+ />
36
+ <span className="shrink-0 text-dashboard-text-muted">{verb}</span>
37
+ <Link
38
+ className="min-w-0 flex-1 truncate font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus"
39
+ to={reviewItemPath(item.id)}
40
+ >
41
+ {item.title}
42
+ </Link>
43
+ </div>
44
+ {/* An answer is final, so the item's answer is this message's answer. */}
45
+ {record.kind === "submitted" && item.request && item.answer ? (
46
+ <AnswerSummary answer={item.answer} request={item.request} />
47
+ ) : null}
48
+ </section>
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Show the Review item change that a message records as one row: what the
54
+ * message did and the item it did it to. The row shows the change from the
55
+ * message, not the item's current state, so a take stays a take after a
56
+ * later answer. Shows `fallback` when the viewer cannot read the item.
57
+ */
58
+ export function ReviewItemChangeRow(props: {
59
+ fallback: ReactNode;
60
+ record: ReviewItemChangeRecord;
61
+ }) {
62
+ const { data, isError } = useReviewItemData(props.record.reviewItemId);
63
+ if (isError) return props.fallback;
64
+ // The row replaces the text, so wait for the item before showing it.
65
+ if (!data) return null;
66
+ return <ChangeRow item={data} record={props.record} />;
67
+ }
68
+
69
+ /**
70
+ * Name the Review item change that a queued message records, on one line.
71
+ * Shows `fallback` until the item loads or when the viewer cannot read it.
72
+ */
73
+ export function ReviewItemChangeLabel(props: {
74
+ fallback: ReactNode;
75
+ record: ReviewItemChangeRecord;
76
+ }) {
77
+ const { data } = useReviewItemData(props.record.reviewItemId);
78
+ if (!data) return props.fallback;
79
+ return (
80
+ <>
81
+ {CHANGE_VERBS[props.record.kind]}{" "}
82
+ <span className="font-medium text-dashboard-text">{data.title}</span>
83
+ </>
84
+ );
85
+ }