@sentry/junior-dashboard 0.268.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 (81) hide show
  1. package/README.md +7 -105
  2. package/dist/app.js +12 -9
  3. package/dist/client.js +81 -81
  4. package/dist/index.js +12 -9
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +2 -1
  8. package/src/app.ts +11 -7
  9. package/src/client/App.tsx +15 -9
  10. package/src/client/api.ts +4 -19
  11. package/src/client/components/AGENTS.md +65 -0
  12. package/src/client/components/ActorAvatar.tsx +1 -1
  13. package/src/client/components/AutomationCard.tsx +2 -1
  14. package/src/client/components/ParticipantAvatarStack.tsx +1 -0
  15. package/src/client/components/ProfileMenu.tsx +88 -29
  16. package/src/client/components/UserInputForm.tsx +110 -37
  17. package/src/client/components/charts/AGENTS.md +15 -0
  18. package/src/client/conversations/AGENTS.md +38 -0
  19. package/src/client/conversations/ConversationBrief.tsx +55 -36
  20. package/src/client/conversations/ConversationComposer.tsx +27 -8
  21. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  22. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  23. package/src/client/conversations/ConversationPage.tsx +90 -56
  24. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  25. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  27. package/src/client/conversations/ConversationWatches.tsx +4 -1
  28. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  29. package/src/client/conversations/EventDetails.tsx +6 -9
  30. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  31. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  32. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  33. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  34. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  35. package/src/client/conversations/TranscriptRows.tsx +6 -1
  36. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  37. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  38. package/src/client/conversations/TranscriptView.tsx +6 -6
  39. package/src/client/conversations/conversationOutbox.ts +13 -3
  40. package/src/client/conversations/conversationSections.ts +36 -8
  41. package/src/client/conversations/eventTranscript.ts +1 -0
  42. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  43. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  44. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  45. package/src/client/display-text/DisplayText.tsx +68 -0
  46. package/src/client/display-text/emoji.ts +54 -0
  47. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  48. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  49. package/src/client/display-text/mentions.tsx +148 -0
  50. package/src/client/display-text/plainText.ts +29 -0
  51. package/src/client/format.ts +7 -23
  52. package/src/client/pages/automations/AGENTS.md +34 -0
  53. package/src/client/pages/automations/AutomationActions.tsx +18 -20
  54. package/src/client/pages/automations/AutomationEditor.tsx +3 -4
  55. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  56. package/src/client/pages/automations/AutomationOverview.tsx +5 -5
  57. package/src/client/pages/automations/AutomationStatus.tsx +2 -2
  58. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  59. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  60. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  61. package/src/client/pages/system/AGENTS.md +12 -0
  62. package/src/client/review-desk/ConversationReviewBar.tsx +14 -9
  63. package/src/client/review-desk/ReviewDeskPage.tsx +99 -79
  64. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  65. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  66. package/src/client/review-desk/ReviewItemDetail.tsx +17 -9
  67. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  68. package/src/client/review-desk/queries.ts +25 -16
  69. package/src/client/review-desk/reviewItemFormat.ts +29 -1
  70. package/src/mock-reporting/routes.ts +1 -1
  71. package/src/tailwind.css +1 -1
  72. package/src/client/components/README.md +0 -219
  73. package/src/client/components/charts/README.md +0 -38
  74. package/src/client/conversations/README.md +0 -159
  75. package/src/client/conversations/TranscriptText.tsx +0 -47
  76. package/src/client/conversations/UserInputRequestCard.tsx +0 -39
  77. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  78. package/src/client/conversations/transcriptMentions.tsx +0 -134
  79. package/src/client/conversations/userInputRequest.ts +0 -56
  80. package/src/client/pages/automations/README.md +0 -97
  81. package/src/client/pages/system/README.md +0 -23
@@ -29,6 +29,7 @@ import { InlineError } from "../../components/InlineError";
29
29
  import { PageContentSkeleton } from "../../components/PageContentSkeleton";
30
30
  import { StatusChip } from "../../components/StatusChip";
31
31
  import { Card } from "../../components/layout/Card";
32
+ import { DisplayText } from "../../display-text/DisplayText";
32
33
  import { formatRelativeTime } from "../../format";
33
34
  import { DashboardApiError, fetchDashboardJson, post } from "../../http";
34
35
  import { cn } from "../../styles";
@@ -364,7 +365,10 @@ function fieldValue(
364
365
  return definition.title ?? "First line of the instruction";
365
366
  case "instruction":
366
367
  return (
367
- <span className="whitespace-pre-wrap">{definition.instruction}</span>
368
+ <DisplayText
369
+ mentions={automation.mentions}
370
+ text={definition.instruction}
371
+ />
368
372
  );
369
373
  case "when":
370
374
  if (version.kind === "scheduled") {
@@ -431,7 +435,7 @@ function LineDiff(props: { after: string; before: string }) {
431
435
  {line.kind === "added" ? "Added: " : "Removed: "}
432
436
  </span>
433
437
  )}
434
- {line.text || " "}
438
+ {line.value || " "}
435
439
  </span>
436
440
  </div>
437
441
  ))}
@@ -439,7 +443,7 @@ function LineDiff(props: { after: string; before: string }) {
439
443
  );
440
444
  }
441
445
 
442
- type DiffLine = { kind: "added" | "removed" | "same"; text: string };
446
+ type DiffLine = { kind: "added" | "removed" | "same"; value: string };
443
447
 
444
448
  /** Line diff from the longest common subsequence. Instructions stay short. */
445
449
  function diffLines(before: string, after: string): DiffLine[] {
@@ -461,17 +465,17 @@ function diffLines(before: string, after: string): DiffLine[] {
461
465
  let j = 0;
462
466
  while (i < a.length && j < b.length) {
463
467
  if (a[i] === b[j]) {
464
- lines.push({ kind: "same", text: a[i]! });
468
+ lines.push({ kind: "same", value: a[i]! });
465
469
  i++;
466
470
  j++;
467
471
  } else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) {
468
- lines.push({ kind: "removed", text: a[i++]! });
472
+ lines.push({ kind: "removed", value: a[i++]! });
469
473
  } else {
470
- lines.push({ kind: "added", text: b[j++]! });
474
+ lines.push({ kind: "added", value: b[j++]! });
471
475
  }
472
476
  }
473
- while (i < a.length) lines.push({ kind: "removed", text: a[i++]! });
474
- while (j < b.length) lines.push({ kind: "added", text: b[j++]! });
477
+ while (i < a.length) lines.push({ kind: "removed", value: a[i++]! });
478
+ while (j < b.length) lines.push({ kind: "added", value: b[j++]! });
475
479
  return lines;
476
480
  }
477
481
 
@@ -16,6 +16,7 @@ import {
16
16
  } from "../../components/controls/TimeRangeSelector";
17
17
  import { Card } from "../../components/layout/Card";
18
18
  import { ConversationHomeList } from "../../conversations/ConversationHomeList";
19
+ import { attentionFirst } from "../../conversations/conversationSections";
19
20
  import { buildConversations, getDashboardTimeZone } from "../../format";
20
21
  import { useDebouncedValue } from "../../debouncedValue";
21
22
  import { useWaitingConversationIds } from "../../review-desk/queries";
@@ -52,7 +53,11 @@ export function RepositoryConversationsTab(props: { repositoryId: string }) {
52
53
  </EmptyTelemetry>
53
54
  ) : (
54
55
  <ConversationHomeList
55
- conversations={pageItems(conversations, page, PAGE_SIZE)}
56
+ conversations={pageItems(
57
+ attentionFirst(conversations, waitingConversationIds),
58
+ page,
59
+ PAGE_SIZE,
60
+ )}
56
61
  emptyLabel={
57
62
  search
58
63
  ? "No conversations match this search."
@@ -18,16 +18,12 @@ import {
18
18
  TooltipContent,
19
19
  TooltipTrigger,
20
20
  } from "../../components/ui/tooltip";
21
- import { TranscriptText } from "../../conversations/TranscriptText";
21
+ import { DisplayText } from "../../display-text/DisplayText";
22
22
  import type {
23
23
  PluginUserPageRecord,
24
24
  PluginUserPageRecordAction,
25
25
  } from "../user/pluginUserPageData";
26
26
 
27
- // The memory plugin API does not resolve mentions yet, so memory text shows
28
- // Slack ids instead of names.
29
- const NO_MEMORY_MENTIONS: readonly [] = [];
30
-
31
27
  // The heading, origin, and scope rows already show these values.
32
28
  const SUMMARIZED_METADATA = new Set([
33
29
  "Learned",
@@ -81,15 +77,11 @@ export function MemoryDetails(props: {
81
77
  {record ? (
82
78
  <>
83
79
  <div className="grid gap-3 text-sm leading-relaxed">
84
- <TranscriptText
85
- mentions={NO_MEMORY_MENTIONS}
86
- text={record.title}
87
- />
80
+ {/* The memory plugin API does not resolve mentions yet, so
81
+ memory text shows Slack ids instead of names. */}
82
+ <DisplayText text={record.title} />
88
83
  {record.description ? (
89
- <TranscriptText
90
- mentions={NO_MEMORY_MENTIONS}
91
- text={record.description}
92
- />
84
+ <DisplayText text={record.description} />
93
85
  ) : null}
94
86
  </div>
95
87
  <MemoryFacts record={record} />
@@ -0,0 +1,12 @@
1
+ # System pages
2
+
3
+ - The URL selects a Workspace recipe: `/system/workspaces/:id` opens
4
+ `WorkspaceDetails` beside the list.
5
+ - The list report has no snapshots. The detail read adds the current
6
+ snapshot. Keep snapshot loading and errors inside the Snapshot row.
7
+ - Edit and Delete live in the panel header. List rows have no actions.
8
+ - `WorkspaceDeleteDialog` keeps errors in the dialog and blocks dismissal
9
+ while the request runs.
10
+ - Close and Escape return focus to the selected row.
11
+
12
+ Browser coverage: `e2e/system.spec.ts`.
@@ -2,10 +2,13 @@ import { ChevronRight } from "lucide-react";
2
2
  import { useState } from "react";
3
3
 
4
4
  import { Drawer } from "../components/Drawer";
5
+ import { LoadMorePagination } from "../components/Pagination";
5
6
  import { ReviewItemCard } from "./ReviewItemCard";
6
7
  import { useReviewItemsData } from "./queries";
7
8
 
8
- function itemCountLabel(count: number): string {
9
+ /** `more` marks a count of the loaded pages only, such as "50+". */
10
+ function itemCountLabel(count: number, more: boolean): string {
11
+ if (more) return `${count}+ open review items`;
9
12
  return count === 1 ? "1 open review item" : `${count} open review items`;
10
13
  }
11
14
 
@@ -15,14 +18,12 @@ function itemCountLabel(count: number): string {
15
18
  */
16
19
  export function ConversationReviewBar(props: { conversationId: string }) {
17
20
  const [open, setOpen] = useState(false);
18
- const items =
19
- useReviewItemsData({
20
- archived: false,
21
- conversationId: props.conversationId,
22
- view: "all",
23
- }).data?.items ?? [];
21
+ const { items, query } = useReviewItemsData({
22
+ conversationId: props.conversationId,
23
+ });
24
24
  if (items.length === 0) return null;
25
- const label = itemCountLabel(items.length);
25
+ const more = Boolean(query.hasNextPage);
26
+ const label = itemCountLabel(items.length, more);
26
27
  return (
27
28
  <>
28
29
  <button
@@ -48,12 +49,16 @@ export function ConversationReviewBar(props: { conversationId: string }) {
48
49
  label="Review items"
49
50
  onClose={() => setOpen(false)}
50
51
  openKey={props.conversationId}
51
- width="narrow"
52
52
  >
53
53
  <div className="grid gap-3">
54
54
  {items.map((item) => (
55
55
  <ReviewItemCard item={item} key={item.id} />
56
56
  ))}
57
+ <LoadMorePagination
58
+ hasMore={more}
59
+ loading={query.isFetchingNextPage}
60
+ onLoadMore={() => void query.fetchNextPage()}
61
+ />
57
62
  </div>
58
63
  </Drawer>
59
64
  ) : null}
@@ -1,12 +1,13 @@
1
- import type { ReactNode } from "react";
1
+ import { Archive } from "lucide-react";
2
+ import { useId, type ReactNode } from "react";
2
3
  import { Link, useSearchParams } from "react-router";
3
4
  import type {
4
5
  AutomationSummary,
5
6
  ReviewItemPayload,
6
7
  } from "@sentry/junior/api/schema";
7
8
 
8
- import { FilterTabList } from "../components/FilterBar";
9
9
  import { InlineError } from "../components/InlineError";
10
+ import { LoadMorePagination } from "../components/Pagination";
10
11
  import { SearchInput } from "../components/SearchInput";
11
12
  import { StatusChip } from "../components/StatusChip";
12
13
  import { Card } from "../components/layout/Card";
@@ -14,33 +15,19 @@ import { PageHeader } from "../components/layout/PageHeader";
14
15
  import { PageLayout } from "../components/layout/PageLayout";
15
16
  import { Skeleton } from "../components/ui/skeleton";
16
17
  import { automationPath, formatRelativeTime } from "../format";
17
- import {
18
- parseSearchParamEnum,
19
- pathWithSearch,
20
- useDebouncedSearchParam,
21
- } from "../searchParams";
18
+ import { pathWithSearch, useDebouncedSearchParam } from "../searchParams";
22
19
  import { cn } from "../styles";
23
20
  import { ReviewItemDetail } from "./ReviewItemDetail";
24
21
  import {
25
- REVIEW_DESK_VIEWS,
26
22
  useBlockedAutomationsData,
27
23
  useReviewItemData,
28
24
  useReviewItemsData,
29
25
  } from "./queries";
30
- import { assigneeLabel, reviewItemPath } from "./reviewItemFormat";
31
-
32
- const VIEW_TABS = [
33
- { label: "Mine", value: "mine" },
34
- { label: "Everyone", value: "everyone" },
35
- { label: "All", value: "all" },
36
- ] as const;
37
-
38
- const STATE_TABS = [
39
- { label: "Open", value: "open" },
40
- { label: "Archived", value: "archived" },
41
- ] as const;
42
-
43
- const ITEM_STATES = ["open", "archived"] as const;
26
+ import {
27
+ assigneeLabel,
28
+ reviewItemPath,
29
+ reviewItemStatusLabel,
30
+ } from "./reviewItemFormat";
44
31
 
45
32
  const rowClass =
46
33
  "grid gap-0.5 rounded-md px-3 py-2 text-inherit no-underline transition-colors";
@@ -55,10 +42,23 @@ function EmptyMessage(props: { children: ReactNode }) {
55
42
  );
56
43
  }
57
44
 
58
- function ListGroup(props: { children: ReactNode; title: string }) {
45
+ function ListGroup(props: {
46
+ children: ReactNode;
47
+ className?: string;
48
+ icon?: ReactNode;
49
+ title: string;
50
+ }) {
51
+ const headingId = useId();
59
52
  return (
60
- <section className="grid gap-1">
61
- <h2 className="m-0 px-3 font-sans text-xs font-medium text-dashboard-text-muted">
53
+ <section
54
+ aria-labelledby={headingId}
55
+ className={cn("grid gap-1", props.className)}
56
+ >
57
+ <h2
58
+ id={headingId}
59
+ className="m-0 flex items-center gap-1.5 px-3 font-sans text-xs font-medium text-dashboard-text-muted"
60
+ >
61
+ {props.icon}
62
62
  {props.title}
63
63
  </h2>
64
64
  <ul className="m-0 grid list-none gap-0.5 p-0">{props.children}</ul>
@@ -72,6 +72,7 @@ function ItemRow(props: {
72
72
  selected: boolean;
73
73
  }) {
74
74
  const { item } = props;
75
+ const closed = item.status !== "open";
75
76
  const unassigned = !item.assignedToEveryone && item.assignees.length === 0;
76
77
  return (
77
78
  <li>
@@ -86,9 +87,20 @@ function ItemRow(props: {
86
87
  replace
87
88
  to={pathWithSearch(reviewItemPath(item.id), props.searchParams)}
88
89
  >
89
- <span className="truncate text-sm font-medium">{item.title}</span>
90
+ <span
91
+ className={cn(
92
+ "truncate text-sm font-medium",
93
+ closed && "text-dashboard-text-muted",
94
+ )}
95
+ >
96
+ {item.title}
97
+ </span>
90
98
  <span className="flex min-w-0 items-center gap-2 text-xs text-dashboard-text-muted">
91
- {unassigned ? (
99
+ {closed ? (
100
+ <StatusChip className="shrink-0" size="compact">
101
+ {reviewItemStatusLabel(item)}
102
+ </StatusChip>
103
+ ) : unassigned ? (
92
104
  <StatusChip size="compact" tone="warning">
93
105
  Unassigned
94
106
  </StatusChip>
@@ -140,42 +152,25 @@ function ReviewListLoading() {
140
152
  }
141
153
 
142
154
  /**
143
- * Render the review desk: open or archived Review items in the Mine,
144
- * Everyone, and All views, with the selected item beside the list.
155
+ * Render the review desk: the open Review items that the viewer can see,
156
+ * with search and the selected item beside the list. A search also finds
157
+ * archived items, which show in their own group.
145
158
  */
146
159
  export function ReviewDeskPage() {
147
- const [searchParams, setSearchParams] = useSearchParams();
148
- const view =
149
- parseSearchParamEnum(searchParams.get("view"), REVIEW_DESK_VIEWS) ?? "mine";
150
- const state =
151
- parseSearchParamEnum(searchParams.get("state"), ITEM_STATES) ?? "open";
152
- // The selected item can be missing from the new list, so a tab change
153
- // drops it and the first item of the new list shows.
154
- function selectTab(key: "state" | "view", value: string, initial: string) {
155
- setSearchParams(
156
- (current) => {
157
- const next = new URLSearchParams(current);
158
- next.delete("item");
159
- if (value === initial) next.delete(key);
160
- else next.set(key, value);
161
- return next;
162
- },
163
- { replace: true },
164
- );
165
- }
160
+ const [searchParams] = useSearchParams();
166
161
  const [search, setSearch, q] = useDebouncedSearchParam();
167
- const archived = state === "archived";
168
- const items = useReviewItemsData({ archived, q, view });
169
- const showBlocked = !archived && view !== "everyone" && !q;
162
+ const { items: list, moreArchived, query: items } = useReviewItemsData({ q });
163
+ const showBlocked = !q;
170
164
  const blocked = useBlockedAutomationsData(showBlocked);
171
- const list = items.data?.items ?? [];
172
165
  const selectedId = searchParams.get("item") ?? list[0]?.id;
173
166
  const listed = list.find((item) => item.id === selectedId);
174
167
  // A link can select an item that is not in the current view.
175
168
  const direct = useReviewItemData(listed ? undefined : selectedId);
176
169
  const selected = listed ?? direct.data;
177
- const actions = list.filter((item) => item.kind === "action");
178
- const notices = list.filter((item) => item.kind === "notice");
170
+ const open = list.filter((item) => item.status === "open");
171
+ const archived = list.filter((item) => item.status !== "open");
172
+ const actions = open.filter((item) => item.kind === "action");
173
+ const notices = open.filter((item) => item.kind === "notice");
179
174
  const blockedAutomations = showBlocked
180
175
  ? (blocked.data?.automations ?? [])
181
176
  : [];
@@ -188,30 +183,18 @@ export function ReviewDeskPage() {
188
183
  description="Items that need a person, and who can act on them."
189
184
  title="Review desk"
190
185
  />
191
- <div className="flex min-w-0 flex-wrap items-center gap-3">
192
- <FilterTabList
193
- ariaLabel="Assignee view"
194
- items={VIEW_TABS}
195
- onChange={(value) => selectTab("view", value, "mine")}
196
- value={view}
197
- />
198
- <FilterTabList
199
- ariaLabel="Item state"
200
- items={STATE_TABS}
201
- onChange={(value) => selectTab("state", value, "open")}
202
- value={state}
203
- />
204
- <SearchInput
205
- className="ml-auto w-full sm:w-64"
206
- label="Search review items"
207
- onChange={setSearch}
208
- placeholder="Search items"
209
- value={search}
210
- />
211
- </div>
186
+ <SearchInput
187
+ label="Search review items"
188
+ onChange={setSearch}
189
+ placeholder="Search open and archived items"
190
+ value={search}
191
+ />
212
192
  <div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
213
- <Card className="grid content-start gap-4 py-3" padding="none">
214
- {items.error ? (
193
+ <Card
194
+ className="grid content-start gap-4 py-3 lg:self-start"
195
+ padding="none"
196
+ >
197
+ {items.error && !items.data ? (
215
198
  <InlineError className="px-3">
216
199
  Could not load review items.
217
200
  </InlineError>
@@ -219,12 +202,12 @@ export function ReviewDeskPage() {
219
202
  <ReviewListLoading />
220
203
  ) : empty ? (
221
204
  <EmptyMessage>
222
- {archived ? "No archived items." : "Nothing needs you right now."}
205
+ {q ? `No items match "${q}".` : "No open items."}
223
206
  </EmptyMessage>
224
207
  ) : (
225
208
  <>
226
209
  {actions.length > 0 ? (
227
- <ListGroup title={archived ? "Questions" : "Needs an answer"}>
210
+ <ListGroup title="Needs an answer">
228
211
  {actions.map((item) => (
229
212
  <ItemRow
230
213
  item={item}
@@ -247,6 +230,43 @@ export function ReviewDeskPage() {
247
230
  ))}
248
231
  </ListGroup>
249
232
  ) : null}
233
+ <LoadMorePagination
234
+ className="px-3"
235
+ hasMore={!items.isPlaceholderData && Boolean(items.hasNextPage)}
236
+ loading={items.isFetchingNextPage}
237
+ onLoadMore={() => void items.fetchNextPage()}
238
+ />
239
+ {items.isFetchNextPageError ? (
240
+ <InlineError className="px-3 text-center">
241
+ Could not load more items. Try again.
242
+ </InlineError>
243
+ ) : null}
244
+ {archived.length > 0 ? (
245
+ <ListGroup
246
+ className={
247
+ open.length > 0
248
+ ? "border-t border-dashboard-border-subtle pt-3"
249
+ : undefined
250
+ }
251
+ icon={<Archive aria-hidden="true" size={13} />}
252
+ title="Archived"
253
+ >
254
+ {archived.map((item) => (
255
+ <ItemRow
256
+ item={item}
257
+ key={item.id}
258
+ searchParams={searchParams}
259
+ selected={item.id === selected?.id}
260
+ />
261
+ ))}
262
+ {moreArchived ? (
263
+ <li className="px-3 py-1 text-xs text-dashboard-text-muted">
264
+ Only the newest archived matches show. Add words to the
265
+ search to find older items.
266
+ </li>
267
+ ) : null}
268
+ </ListGroup>
269
+ ) : null}
250
270
  {blockedAutomations.length > 0 ? (
251
271
  <ListGroup title="Blocked Automations">
252
272
  {blockedAutomations.map((automation) => (
@@ -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
  }
@@ -1,9 +1,10 @@
1
+ import { Archive } from "lucide-react";
1
2
  import type { ReactNode } from "react";
2
3
  import { Link } from "react-router";
3
4
  import type { ReviewItemPayload } from "@sentry/junior/api/schema";
4
5
 
5
6
  import { StatusChip } from "../components/StatusChip";
6
- import { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
7
+ import { DisplayText } from "../display-text/DisplayText";
7
8
  import { conversationPath } from "../conversations/conversationRoutes";
8
9
  import { automationPath, formatRelativeTime } from "../format";
9
10
  import { ReviewItemActions } from "./ReviewItemActions";
@@ -26,27 +27,34 @@ export function ReviewItemDetail(props: { item: ReviewItemPayload }) {
26
27
  const { item } = props;
27
28
  const subject = item.subject;
28
29
  const conversationTitles = useLinkedConversationTitles(item.conversationIds);
30
+ const closed = item.status !== "open";
29
31
  return (
30
32
  <article className="grid min-w-0 content-start gap-5">
33
+ {closed ? (
34
+ // Search mixes open and archived items, so a closed item must be
35
+ // easy to tell apart at the top of the detail.
36
+ <p className="m-0 flex items-center gap-2 rounded-md bg-dashboard-fill-soft px-3 py-2 text-xs font-medium text-dashboard-text-muted">
37
+ <Archive aria-hidden="true" size={14} />
38
+ Archived · {reviewItemStatusLabel(item)}
39
+ </p>
40
+ ) : null}
31
41
  <header className="grid gap-1.5">
32
42
  <div className="flex min-w-0 items-start justify-between gap-3">
33
43
  <h2 className="m-0 min-w-0 break-words font-display text-xl font-medium leading-snug">
34
44
  {item.title}
35
45
  </h2>
36
- <StatusChip
37
- className="mt-1 shrink-0"
38
- size="compact"
39
- tone={item.status === "open" ? "accent" : "neutral"}
40
- >
41
- {reviewItemStatusLabel(item)}
42
- </StatusChip>
46
+ {closed ? null : (
47
+ <StatusChip className="mt-1 shrink-0" size="compact" tone="accent">
48
+ {reviewItemStatusLabel(item)}
49
+ </StatusChip>
50
+ )}
43
51
  </div>
44
52
  <p className="m-0 text-xs text-dashboard-text-muted">
45
53
  Added {formatRelativeTime(item.createdAt)}
46
54
  {item.visibility === "private" ? " · Private" : null}
47
55
  </p>
48
56
  </header>
49
- <TranscriptMarkdown text={item.body} />
57
+ <DisplayText text={item.body} />
50
58
  <section
51
59
  aria-label={item.status === "open" ? "Actions" : "Outcome"}
52
60
  className="border-t border-dashboard-border-subtle pt-5"