@sentry/junior-dashboard 0.269.0 → 0.271.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 (76) 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 +103 -65
  7. package/src/client/components/AGENTS.md +30 -59
  8. package/src/client/components/AutomationCard.tsx +2 -1
  9. package/src/client/components/ProfileMenu.tsx +0 -24
  10. package/src/client/components/UserInputForm.tsx +143 -39
  11. package/src/client/components/layout/PageHeader.tsx +25 -10
  12. package/src/client/conversations/AGENTS.md +12 -37
  13. package/src/client/conversations/ConversationBrief.tsx +55 -36
  14. package/src/client/conversations/ConversationComposer.tsx +27 -8
  15. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  16. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  17. package/src/client/conversations/ConversationPage.tsx +137 -40
  18. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  19. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  20. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  21. package/src/client/conversations/ConversationWatches.tsx +4 -1
  22. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  23. package/src/client/conversations/EventDetails.tsx +6 -9
  24. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  25. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  26. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  27. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  28. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  29. package/src/client/conversations/TranscriptRows.tsx +6 -1
  30. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  31. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  32. package/src/client/conversations/TranscriptView.tsx +6 -6
  33. package/src/client/conversations/conversationOutbox.ts +13 -3
  34. package/src/client/conversations/conversationRoutes.ts +30 -3
  35. package/src/client/conversations/conversationSections.ts +36 -8
  36. package/src/client/conversations/eventTranscript.ts +1 -0
  37. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  38. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  39. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  40. package/src/client/display-text/DisplayText.tsx +68 -0
  41. package/src/client/display-text/emoji.ts +54 -0
  42. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  43. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  44. package/src/client/display-text/mentions.tsx +148 -0
  45. package/src/client/display-text/plainText.ts +29 -0
  46. package/src/client/pages/automations/AGENTS.md +6 -25
  47. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  48. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  49. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  50. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  51. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  52. package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
  53. package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
  54. package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
  55. package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +54 -62
  56. package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
  57. package/src/client/primaryNav.ts +1 -1
  58. package/src/client/review-desk/ConversationReviewBar.tsx +0 -1
  59. package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
  60. package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
  61. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  62. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  63. package/src/client/review-desk/ReviewItemContext.tsx +108 -0
  64. package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
  65. package/src/client/review-desk/ReviewItemView.tsx +159 -0
  66. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  67. package/src/client/review-desk/queries.ts +4 -2
  68. package/src/client/review-desk/reviewItemFormat.ts +83 -1
  69. package/src/client/components/charts/AGENTS.md +0 -15
  70. package/src/client/conversations/TranscriptText.tsx +0 -47
  71. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  72. package/src/client/conversations/transcriptMentions.tsx +0 -134
  73. package/src/client/pages/ConnectionsPage.tsx +0 -207
  74. package/src/client/pages/PersonalTokensPage.tsx +0 -205
  75. package/src/client/pages/system/AGENTS.md +0 -12
  76. package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
@@ -0,0 +1,253 @@
1
+ import type { ReactNode } from "react";
2
+ import { Link } from "react-router";
3
+ import type {
4
+ AutomationSummary,
5
+ ReviewItemPayload,
6
+ } from "@sentry/junior/api/schema";
7
+
8
+ import { InlineError } from "../components/InlineError";
9
+ import { SearchInput } from "../components/SearchInput";
10
+ import { Skeleton } from "../components/ui/skeleton";
11
+ import { ConversationSectionHeading } from "../conversations/ConversationSectionHeading";
12
+ import { automationPath, formatRelativeTime } from "../format";
13
+ import { pathWithSearch } from "../searchParams";
14
+ import { cn } from "../styles";
15
+ import { reviewItemPath } from "./reviewItemFormat";
16
+
17
+ /** A sidebar group key. The page owns which groups are collapsed. */
18
+ export type InboxGroupKey = "archived" | "blocked" | "fyi" | "needs-you";
19
+
20
+ type Dot = "accent" | "muted" | "warning" | "closed";
21
+
22
+ const dotClass: Record<Dot, string> = {
23
+ accent: "bg-dashboard-accent",
24
+ closed: "border border-dashboard-text-faint",
25
+ muted: "bg-dashboard-text-faint",
26
+ warning: "bg-amber-300",
27
+ };
28
+
29
+ const rowClass =
30
+ "block min-w-0 rounded-lg px-3 py-2 text-inherit no-underline transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus";
31
+
32
+ function InboxRow(props: {
33
+ dot: Dot;
34
+ meta: ReactNode;
35
+ selected?: "always" | "wide";
36
+ title: string;
37
+ to: string;
38
+ }) {
39
+ return (
40
+ <li>
41
+ <Link
42
+ aria-current={props.selected ? "page" : undefined}
43
+ className={cn(
44
+ rowClass,
45
+ props.selected === "always" && "bg-dashboard-surface-active",
46
+ props.selected === "wide" && "md:bg-dashboard-surface-active",
47
+ )}
48
+ to={props.to}
49
+ >
50
+ <div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2">
51
+ <span
52
+ aria-hidden="true"
53
+ className={cn(
54
+ "mt-[0.4rem] size-2 shrink-0 rounded-full",
55
+ dotClass[props.dot],
56
+ )}
57
+ />
58
+ <span
59
+ className={cn(
60
+ "truncate font-sans text-sm font-medium leading-snug",
61
+ props.dot === "closed"
62
+ ? "text-dashboard-text-muted"
63
+ : "text-dashboard-text",
64
+ )}
65
+ >
66
+ {props.title}
67
+ </span>
68
+ <span className="col-start-2 mt-1 truncate font-sans text-xs leading-tight text-dashboard-text-muted">
69
+ {props.meta}
70
+ </span>
71
+ </div>
72
+ </Link>
73
+ </li>
74
+ );
75
+ }
76
+
77
+ function InboxGroup(props: {
78
+ children: ReactNode;
79
+ collapsed: boolean;
80
+ count: number;
81
+ first: boolean;
82
+ label: string;
83
+ onToggle(): void;
84
+ }) {
85
+ if (props.count === 0) return null;
86
+ return (
87
+ <section aria-label={props.label}>
88
+ <ConversationSectionHeading
89
+ className={cn("px-3 pb-1", props.first ? "pt-1" : "pt-3")}
90
+ collapsed={props.collapsed}
91
+ count={props.count}
92
+ label={props.label}
93
+ onToggle={props.onToggle}
94
+ />
95
+ {props.collapsed ? null : (
96
+ <ul className="m-0 grid list-none gap-0.5 p-0">{props.children}</ul>
97
+ )}
98
+ </section>
99
+ );
100
+ }
101
+
102
+ function InboxRowsLoading() {
103
+ return (
104
+ <div
105
+ aria-busy="true"
106
+ aria-live="polite"
107
+ className="grid gap-0.5"
108
+ role="status"
109
+ >
110
+ <span className="sr-only">Loading review items</span>
111
+ <Skeleton className="mb-1 ml-3 mt-1.5 h-2.5 w-16" />
112
+ {[0, 1, 2, 3, 4].map((row) => (
113
+ <div className="grid gap-1 rounded-lg px-3 py-2" key={row}>
114
+ <Skeleton className={cn("h-4", row % 2 ? "w-3/5" : "w-4/5")} />
115
+ <Skeleton className="h-3 w-2/5 opacity-70" />
116
+ </div>
117
+ ))}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * List the inbox: open items that need the viewer, notices, blocked
124
+ * Automations, and archived items, each in a group that collapses.
125
+ */
126
+ export function ReviewDeskSidebar(props: {
127
+ archived: ReviewItemPayload[];
128
+ blocked: AutomationSummary[];
129
+ collapsed: ReadonlySet<InboxGroupKey>;
130
+ /** Shown under the groups, such as an empty state or load more. */
131
+ footer?: ReactNode;
132
+ error: boolean;
133
+ fyi: ReviewItemPayload[];
134
+ loading: boolean;
135
+ needsYou: ReviewItemPayload[];
136
+ onSearchChange(value: string): void;
137
+ onToggle(key: InboxGroupKey): void;
138
+ search: string;
139
+ searchParams: URLSearchParams;
140
+ selectedId?: string;
141
+ /** False while small screens show the list instead of the selected item. */
142
+ selectedShown: boolean;
143
+ /** The title of the Conversation that added an item, when it is known. */
144
+ sourceTitle(item: ReviewItemPayload): string | undefined;
145
+ }) {
146
+ const groups = [
147
+ { items: props.needsYou, key: "needs-you", label: "Needs you" },
148
+ { items: props.fyi, key: "fyi", label: "FYI" },
149
+ ] as const;
150
+ const shown = [
151
+ ...groups.map((group) => group.items.length),
152
+ props.blocked.length,
153
+ ];
154
+ const firstKey = (index: number) =>
155
+ shown.slice(0, index).every((count) => count === 0);
156
+ const itemRow = (item: ReviewItemPayload, dot: Dot) => {
157
+ const source = item.automationId ? "Automation" : props.sourceTitle(item);
158
+ const age = formatRelativeTime(
159
+ item.status === "open" ? item.createdAt : item.closedAt,
160
+ );
161
+ return (
162
+ <InboxRow
163
+ dot={dot}
164
+ key={item.id}
165
+ meta={source ? `${source} · ${age}` : age}
166
+ selected={
167
+ item.id !== props.selectedId
168
+ ? undefined
169
+ : props.selectedShown
170
+ ? "always"
171
+ : "wide"
172
+ }
173
+ title={item.title}
174
+ to={pathWithSearch(reviewItemPath(item.id), props.searchParams)}
175
+ />
176
+ );
177
+ };
178
+
179
+ return (
180
+ <aside className="grid h-full min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden border-r border-dashboard-border bg-dashboard-surface-panel">
181
+ <div className="px-5 py-3">
182
+ <h2 className="m-0 font-display text-lg font-medium leading-tight text-dashboard-text">
183
+ Inbox
184
+ </h2>
185
+ </div>
186
+ <div className="px-4 pb-2">
187
+ <SearchInput
188
+ label="Search review items"
189
+ onChange={props.onSearchChange}
190
+ placeholder="Search items…"
191
+ size="compact"
192
+ value={props.search}
193
+ />
194
+ </div>
195
+ <nav
196
+ aria-label="Review items"
197
+ className="min-h-0 overflow-y-auto overscroll-contain px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))]"
198
+ >
199
+ {props.error ? (
200
+ <InlineError className="px-3 py-2">
201
+ Could not load review items.
202
+ </InlineError>
203
+ ) : props.loading ? (
204
+ <InboxRowsLoading />
205
+ ) : (
206
+ <>
207
+ {groups.map((group, index) => (
208
+ <InboxGroup
209
+ collapsed={props.collapsed.has(group.key)}
210
+ count={group.items.length}
211
+ first={firstKey(index)}
212
+ key={group.key}
213
+ label={group.label}
214
+ onToggle={() => props.onToggle(group.key)}
215
+ >
216
+ {group.items.map((item) =>
217
+ itemRow(item, item.kind === "action" ? "accent" : "muted"),
218
+ )}
219
+ </InboxGroup>
220
+ ))}
221
+ <InboxGroup
222
+ collapsed={props.collapsed.has("blocked")}
223
+ count={props.blocked.length}
224
+ first={firstKey(2)}
225
+ label="Blocked automations"
226
+ onToggle={() => props.onToggle("blocked")}
227
+ >
228
+ {props.blocked.map((automation) => (
229
+ <InboxRow
230
+ dot="warning"
231
+ key={automation.id}
232
+ meta={automation.statusReason ?? "Blocked"}
233
+ title={automation.title}
234
+ to={automationPath(automation.id)}
235
+ />
236
+ ))}
237
+ </InboxGroup>
238
+ <InboxGroup
239
+ collapsed={props.collapsed.has("archived")}
240
+ count={props.archived.length}
241
+ first={firstKey(3)}
242
+ label="Archived"
243
+ onToggle={() => props.onToggle("archived")}
244
+ >
245
+ {props.archived.map((item) => itemRow(item, "closed"))}
246
+ </InboxGroup>
247
+ {props.footer}
248
+ </>
249
+ )}
250
+ </nav>
251
+ </aside>
252
+ );
253
+ }
@@ -8,10 +8,11 @@ import {
8
8
  import { InlineError } from "../components/InlineError";
9
9
  import { UserInputForm } from "../components/UserInputForm";
10
10
  import { Button } from "../components/ui/button";
11
- import { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
11
+ import { DisplayText } from "../display-text/DisplayText";
12
12
  import { formatRelativeTime } from "../format";
13
13
  import { DashboardApiError } from "../http";
14
14
  import { useReviewItemAction } from "./queries";
15
+ import { useReportUnsentAnswer } from "./UnsentAnswers";
15
16
 
16
17
  function personLabel(person: { displayName?: string; email: string }) {
17
18
  return person.displayName ?? person.email;
@@ -79,6 +80,8 @@ export function ReviewItemActions(props: { item: ReviewItemPayload }) {
79
80
  const { item } = props;
80
81
  const action = useReviewItemAction();
81
82
  const [error, setError] = useState<string>();
83
+ const [unsent, setUnsent] = useState(false);
84
+ useReportUnsentAnswer(item, unsent);
82
85
 
83
86
  if (item.status !== "open") return <ClosedSummary item={item} />;
84
87
  if (!item.canAct) {
@@ -126,9 +129,10 @@ export function ReviewItemActions(props: { item: ReviewItemPayload }) {
126
129
  {item.kind === "action" && item.request ? (
127
130
  <div className="text-sm">
128
131
  <div className="font-medium">
129
- <TranscriptMarkdown text={item.request.question} />
132
+ <DisplayText text={item.request.question} />
130
133
  </div>
131
134
  <UserInputForm
135
+ onUnsentChange={setUnsent}
132
136
  onSubmit={(answer) =>
133
137
  run({
134
138
  action: "submit",
@@ -1,38 +1,79 @@
1
+ import { ArrowRight, Inbox } from "lucide-react";
1
2
  import { Link } from "react-router";
2
3
  import type { ReviewItemPayload } from "@sentry/junior/api/schema";
3
4
 
4
5
  import { StatusChip } from "../components/StatusChip";
5
- import { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
6
+ import { DisplayText } from "../display-text/DisplayText";
7
+ import { cn } from "../styles";
6
8
  import { ReviewItemActions } from "./ReviewItemActions";
7
9
  import { useReviewItemData } from "./queries";
8
- import { reviewItemPath, reviewItemStatusLabel } from "./reviewItemFormat";
10
+ import {
11
+ assigneeLabel,
12
+ reviewItemPath,
13
+ reviewItemStatusLabel,
14
+ } from "./reviewItemFormat";
9
15
 
10
16
  /**
11
17
  * Show one Review item as a card: its title, status, description, and the
12
- * viewer's actions, with a link to the review desk.
18
+ * viewer's actions, with a link to the review desk. The card uses the same
19
+ * frame as transcript object cards, so it reads as one record, not as prose.
13
20
  */
14
21
  export function ReviewItemCard(props: { item: ReviewItemPayload }) {
15
22
  const { item } = props;
23
+ const open = item.status === "open";
24
+ const assigned = item.assignees.length > 0 || item.assignedToEveryone;
16
25
  return (
17
26
  <section
18
27
  aria-label={`Review item: ${item.title}`}
19
- className="grid gap-3 rounded-lg border border-violet-300/25 bg-violet-300/[0.06] p-3 font-sans text-sm text-dashboard-text"
28
+ className="my-1 w-full min-w-0 max-w-[37.5rem] overflow-hidden rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-panel font-sans text-sm text-dashboard-text"
20
29
  >
21
- <header className="flex min-w-0 items-baseline justify-between gap-3">
22
- <span className="min-w-0 truncate font-medium">{item.title}</span>
23
- <StatusChip
24
- className="shrink-0"
25
- size="compact"
26
- tone={item.status === "open" ? "accent" : "neutral"}
30
+ <header className="flex items-start gap-3 px-4 pb-3 pt-4 md:px-5">
31
+ {/* The accent tile marks an item that still needs a person. */}
32
+ <span
33
+ aria-hidden="true"
34
+ className={cn(
35
+ "grid size-9 shrink-0 place-items-center rounded-lg border",
36
+ open
37
+ ? "border-dashboard-accent/30 bg-dashboard-accent/10 text-dashboard-accent"
38
+ : "border-dashboard-border bg-dashboard-fill-soft text-dashboard-text-muted",
39
+ )}
27
40
  >
28
- {reviewItemStatusLabel(item)}
29
- </StatusChip>
41
+ <Inbox size={18} />
42
+ </span>
43
+ <div className="grid min-w-0 flex-1 gap-1">
44
+ <div className="flex min-w-0 items-center justify-between gap-3">
45
+ <p className="m-0 text-xs text-dashboard-text-muted">Review item</p>
46
+ <StatusChip
47
+ className="shrink-0"
48
+ size="compact"
49
+ tone={open ? "accent" : "neutral"}
50
+ >
51
+ {reviewItemStatusLabel(item)}
52
+ </StatusChip>
53
+ </div>
54
+ <h3 className="m-0 break-words text-base font-semibold leading-snug">
55
+ {item.title}
56
+ </h3>
57
+ </div>
30
58
  </header>
31
- <TranscriptMarkdown text={item.body} />
32
- <ReviewItemActions item={item} />
33
- <Link className="w-fit text-xs" to={reviewItemPath(item.id)}>
34
- Open on the review desk
35
- </Link>
59
+ <div className="grid min-w-0 gap-4 px-4 pb-4 md:px-5">
60
+ <DisplayText text={item.body} />
61
+ <div className="border-t border-dashboard-border pt-4">
62
+ <ReviewItemActions item={item} />
63
+ </div>
64
+ </div>
65
+ <footer className="flex flex-wrap items-center justify-between gap-3 border-t border-dashboard-border bg-dashboard-fill-faint px-4 py-3 text-xs md:px-5">
66
+ <Link
67
+ className="inline-flex items-center gap-2 font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus"
68
+ to={reviewItemPath(item.id)}
69
+ >
70
+ Open on the review desk
71
+ <ArrowRight aria-hidden="true" size={14} />
72
+ </Link>
73
+ <span className="min-w-0 truncate text-dashboard-text-muted">
74
+ {assigned ? `Assigned to ${assigneeLabel(item)}` : "Unassigned"}
75
+ </span>
76
+ </footer>
36
77
  </section>
37
78
  );
38
79
  }
@@ -0,0 +1,108 @@
1
+ import { useEffect, useMemo, useRef, type ReactNode } from "react";
2
+ import type {
3
+ ConversationReportEvent,
4
+ ReviewItemPayload,
5
+ } from "@sentry/junior/api/schema";
6
+
7
+ import { getDashboardAgentName } from "../agentName";
8
+ import { ConversationTranscriptView } from "../conversations/ConversationTranscript";
9
+ import { TranscriptLoading } from "../conversations/TranscriptLoading";
10
+ import { useConversationData } from "../conversations/queries";
11
+ import type { ConversationTranscript } from "../types";
12
+
13
+ /** Messages before the item that the excerpt shows. */
14
+ const BEFORE_COUNT = 3;
15
+ /** Messages after the item that the excerpt shows, newest last. */
16
+ const AFTER_COUNT = 8;
17
+
18
+ /**
19
+ * Split the messages of a Conversation at the time an item was added. Only
20
+ * messages show: the tool call that added the item would repeat its card.
21
+ */
22
+ function conversationExcerpt(
23
+ events: readonly ConversationReportEvent[],
24
+ itemCreatedAt: string,
25
+ ): { after: ConversationReportEvent[]; before: ConversationReportEvent[] } {
26
+ const askedAt = Date.parse(itemCreatedAt);
27
+ const messages = events.filter(
28
+ (event) => event.data.type === "message" && event.data.role !== "system",
29
+ );
30
+ return {
31
+ after: messages
32
+ .filter((event) => Date.parse(event.createdAt) > askedAt)
33
+ .slice(-AFTER_COUNT),
34
+ before: messages
35
+ .filter((event) => Date.parse(event.createdAt) <= askedAt)
36
+ .slice(-BEFORE_COUNT),
37
+ };
38
+ }
39
+
40
+ function Excerpt(props: {
41
+ conversation: ConversationTranscript;
42
+ events: ConversationReportEvent[];
43
+ }) {
44
+ const conversation = useMemo(
45
+ () => ({ ...props.conversation, events: props.events }),
46
+ [props.conversation, props.events],
47
+ );
48
+ return <ConversationTranscriptView conversation={conversation} />;
49
+ }
50
+
51
+ /**
52
+ * Show the end of the Conversation that added an item, split where the agent
53
+ * asked. Returns `fallback` when the viewer cannot read its messages.
54
+ */
55
+ export function ReviewItemContext(props: {
56
+ fallback: ReactNode;
57
+ item: ReviewItemPayload;
58
+ }) {
59
+ const detail = useConversationData(props.item.addedByConversationId);
60
+ const data = detail.data;
61
+ const events = data?.events;
62
+ const excerpt = useMemo(
63
+ () =>
64
+ events ? conversationExcerpt(events, props.item.createdAt) : undefined,
65
+ [events, props.item.createdAt],
66
+ );
67
+ // The first page holds the newest events. Load earlier pages until the
68
+ // page reaches the time the item was added, or history ends.
69
+ const loadPreviousPageRef = useRef(detail.loadPreviousPage);
70
+ loadPreviousPageRef.current = detail.loadPreviousPage;
71
+ const oldest = events?.[0]?.createdAt;
72
+ const loadEarlier =
73
+ oldest !== undefined &&
74
+ Date.parse(oldest) > Date.parse(props.item.createdAt) &&
75
+ detail.hasPreviousPage &&
76
+ !detail.isLoadingPreviousPage &&
77
+ !detail.historyError;
78
+ useEffect(() => {
79
+ if (loadEarlier) loadPreviousPageRef.current();
80
+ }, [loadEarlier, oldest]);
81
+
82
+ if (detail.isPending) return <TranscriptLoading />;
83
+ if (
84
+ !data ||
85
+ !excerpt ||
86
+ data.eventHistory.status !== "available" ||
87
+ excerpt.before.length + excerpt.after.length === 0
88
+ ) {
89
+ return props.fallback;
90
+ }
91
+ return (
92
+ <div className="grid min-w-0 gap-2">
93
+ {excerpt.before.length > 0 ? (
94
+ <Excerpt conversation={data} events={excerpt.before} />
95
+ ) : null}
96
+ {excerpt.after.length > 0 ? (
97
+ <>
98
+ <div className="flex items-center gap-3 py-1 font-sans text-xs font-medium text-dashboard-accent-text">
99
+ <span className="h-px flex-1 bg-dashboard-accent/30" />
100
+ New since {getDashboardAgentName()} asked
101
+ <span className="h-px flex-1 bg-dashboard-accent/30" />
102
+ </div>
103
+ <Excerpt conversation={data} events={excerpt.after} />
104
+ </>
105
+ ) : null}
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,153 @@
1
+ import type { ReactNode } from "react";
2
+ import { Link } from "react-router";
3
+ import type { ReviewItemPayload } from "@sentry/junior/api/schema";
4
+
5
+ import { DetailsPanel } from "../components/DetailsPanel";
6
+ import { StatusChip } from "../components/StatusChip";
7
+ import { DetailsCard } from "../conversations/DetailsList";
8
+ import { conversationPath } from "../conversations/conversationRoutes";
9
+ import { DisplayText } from "../display-text/DisplayText";
10
+ import { automationPath, formatRelativeTime } from "../format";
11
+ import { dashboardInlineLinkClass } from "../styles";
12
+ import { useLinkedConversationTitles } from "./queries";
13
+ import {
14
+ assigneeLabel,
15
+ reviewItemStatusLabel,
16
+ reviewItemSubjectHref,
17
+ } from "./reviewItemFormat";
18
+
19
+ function Fact(props: { children: ReactNode; label: string }) {
20
+ return (
21
+ <>
22
+ <dt className="text-dashboard-text-muted">{props.label}</dt>
23
+ <dd className="m-0 min-w-0 break-words text-dashboard-text">
24
+ {props.children}
25
+ </dd>
26
+ </>
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Show the facts of one Review item beside it: its text, assignees, where
32
+ * it came from, and the Conversations linked to it.
33
+ */
34
+ export function ReviewItemDetails(props: {
35
+ item: ReviewItemPayload;
36
+ onClose(): void;
37
+ }) {
38
+ const { item } = props;
39
+ const subject = item.subject;
40
+ const subjectHref = subject?.url
41
+ ? reviewItemSubjectHref(subject.url)
42
+ : undefined;
43
+ const [addedByTitle, ...linkedTitles] = useLinkedConversationTitles([
44
+ item.addedByConversationId,
45
+ ...item.conversationIds,
46
+ ]);
47
+ const assigned = item.assignees.length > 0 || item.assignedToEveryone;
48
+ return (
49
+ <DetailsPanel
50
+ label="Review item details"
51
+ onClose={props.onClose}
52
+ title={
53
+ <h2 className="m-0 text-base font-semibold text-dashboard-text">
54
+ Details
55
+ </h2>
56
+ }
57
+ >
58
+ <div className="grid min-w-0 gap-3 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
59
+ <div className="min-w-0 break-words px-1 pb-1 font-sans text-sm leading-relaxed text-dashboard-text-muted">
60
+ <DisplayText text={item.body} />
61
+ </div>
62
+ <DetailsCard title="Assignees">
63
+ {assigned ? (
64
+ <span className="text-sm text-dashboard-text">
65
+ {assigneeLabel(item)}
66
+ </span>
67
+ ) : (
68
+ <StatusChip size="compact" tone="warning">
69
+ Unassigned
70
+ </StatusChip>
71
+ )}
72
+ </DetailsCard>
73
+ <DetailsCard>
74
+ <dl className="m-0 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5">
75
+ <Fact label="Status">{reviewItemStatusLabel(item)}</Fact>
76
+ <Fact label="Added">{formatRelativeTime(item.createdAt)}</Fact>
77
+ <Fact label="Added by">
78
+ <Link
79
+ className={dashboardInlineLinkClass}
80
+ title={item.addedByConversationId}
81
+ to={conversationPath(item.addedByConversationId, {
82
+ turnId: item.addedByTurnId,
83
+ })}
84
+ >
85
+ {addedByTitle ?? "Conversation"}
86
+ </Link>
87
+ {item.automationId ? (
88
+ <>
89
+ {" · "}
90
+ <Link
91
+ className={dashboardInlineLinkClass}
92
+ to={automationPath(item.automationId)}
93
+ >
94
+ Automation
95
+ </Link>
96
+ </>
97
+ ) : null}
98
+ </Fact>
99
+ <Fact label="Visibility">
100
+ {item.visibility === "private" ? "Private" : "Public"}
101
+ </Fact>
102
+ </dl>
103
+ </DetailsCard>
104
+ {subject ? (
105
+ <DetailsCard title="Subject">
106
+ <span className="text-sm">
107
+ {subjectHref ? (
108
+ <a
109
+ className={dashboardInlineLinkClass}
110
+ href={subjectHref}
111
+ rel="noreferrer"
112
+ target="_blank"
113
+ >
114
+ {subject.title ?? subjectHref}
115
+ </a>
116
+ ) : (
117
+ (subject.title ?? subject.id)
118
+ )}
119
+ </span>
120
+ </DetailsCard>
121
+ ) : null}
122
+ {item.conversationIds.length > 0 ? (
123
+ <DetailsCard
124
+ count={item.conversationIds.length}
125
+ title="Linked conversations"
126
+ >
127
+ <ul className="m-0 grid list-none gap-1 p-0 text-sm">
128
+ {item.conversationIds.map((conversationId, index) => {
129
+ const title = linkedTitles[index];
130
+ return (
131
+ <li
132
+ className={
133
+ title ? "truncate" : "truncate font-mono text-xs"
134
+ }
135
+ key={conversationId}
136
+ >
137
+ <Link
138
+ className={dashboardInlineLinkClass}
139
+ title={conversationId}
140
+ to={conversationPath(conversationId)}
141
+ >
142
+ {title ?? conversationId}
143
+ </Link>
144
+ </li>
145
+ );
146
+ })}
147
+ </ul>
148
+ </DetailsCard>
149
+ ) : null}
150
+ </div>
151
+ </DetailsPanel>
152
+ );
153
+ }