@sentry/junior-dashboard 0.270.0 → 0.272.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 (48) hide show
  1. package/dist/app.js +44 -3
  2. package/dist/client.js +77 -77
  3. package/dist/index.js +40 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/app.ts +2 -0
  7. package/src/client/App.tsx +89 -61
  8. package/src/client/components/ProfileMenu.tsx +0 -24
  9. package/src/client/components/UserInputForm.tsx +38 -7
  10. package/src/client/components/layout/PageHeader.tsx +25 -10
  11. package/src/client/conversations/ConversationPage.tsx +55 -8
  12. package/src/client/conversations/ConversationTranscript.tsx +74 -5
  13. package/src/client/conversations/TranscriptRows.tsx +23 -0
  14. package/src/client/conversations/TranscriptView.tsx +22 -0
  15. package/src/client/conversations/conversationRoutes.ts +30 -3
  16. package/src/client/conversations/queries.ts +30 -5
  17. package/src/client/conversations/transcriptBottomPinning.ts +84 -13
  18. package/src/client/conversations/transcriptReadPosition.ts +267 -0
  19. package/src/client/conversations/transcriptRenderModel.ts +16 -2
  20. package/src/client/pages/automations/AutomationActions.tsx +2 -3
  21. package/src/client/pages/automations/AutomationEditor.tsx +18 -30
  22. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  23. package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
  24. package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
  25. package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
  26. package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
  27. package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +115 -61
  28. package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
  29. package/src/client/primaryNav.ts +1 -1
  30. package/src/client/review-desk/ReviewDeskPage.tsx +185 -242
  31. package/src/client/review-desk/ReviewDeskSidebar.tsx +259 -0
  32. package/src/client/review-desk/ReviewItemActions.tsx +1 -10
  33. package/src/client/review-desk/ReviewItemContext.tsx +108 -0
  34. package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
  35. package/src/client/review-desk/ReviewItemView.tsx +159 -0
  36. package/src/client/review-desk/queries.ts +3 -3
  37. package/src/client/review-desk/reviewItemFormat.ts +52 -0
  38. package/src/mock-reporting/read-positions.ts +39 -0
  39. package/src/mock-reporting/routes.ts +26 -1
  40. package/src/tailwind.css +1 -1
  41. package/src/client/components/AGENTS.md +0 -65
  42. package/src/client/components/charts/AGENTS.md +0 -15
  43. package/src/client/conversations/AGENTS.md +0 -38
  44. package/src/client/pages/ConnectionsPage.tsx +0 -207
  45. package/src/client/pages/PersonalTokensPage.tsx +0 -205
  46. package/src/client/pages/automations/AGENTS.md +0 -34
  47. package/src/client/pages/system/AGENTS.md +0 -12
  48. package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
@@ -1,297 +1,240 @@
1
- import { Archive } from "lucide-react";
2
- import { useId, type ReactNode } from "react";
3
- import { Link, useSearchParams } from "react-router";
4
- import type {
5
- AutomationSummary,
6
- ReviewItemPayload,
7
- } from "@sentry/junior/api/schema";
1
+ import { useMemo, useState } from "react";
2
+ import { useSearchParams } from "react-router";
3
+ import type { ReviewItemPayload } from "@sentry/junior/api/schema";
8
4
 
9
5
  import { InlineError } from "../components/InlineError";
10
6
  import { LoadMorePagination } from "../components/Pagination";
11
- import { SearchInput } from "../components/SearchInput";
12
- import { StatusChip } from "../components/StatusChip";
13
- import { Card } from "../components/layout/Card";
14
- import { PageHeader } from "../components/layout/PageHeader";
15
- import { PageLayout } from "../components/layout/PageLayout";
7
+ import { useDetailsPanel } from "../components/DetailsPanel";
16
8
  import { Skeleton } from "../components/ui/skeleton";
17
- import { automationPath, formatRelativeTime } from "../format";
9
+ import { useDocumentTitle } from "../documentTitle";
18
10
  import { pathWithSearch, useDebouncedSearchParam } from "../searchParams";
19
11
  import { cn } from "../styles";
20
- import { ReviewItemDetail } from "./ReviewItemDetail";
12
+ import { ReviewDeskSidebar, type InboxGroupKey } from "./ReviewDeskSidebar";
13
+ import { ReviewItemDetails } from "./ReviewItemDetails";
14
+ import { ReviewItemView } from "./ReviewItemView";
21
15
  import {
22
16
  useBlockedAutomationsData,
17
+ useLinkedConversationTitles,
23
18
  useReviewItemData,
24
19
  useReviewItemsData,
25
20
  } from "./queries";
26
- import {
27
- assigneeLabel,
28
- reviewItemPath,
29
- reviewItemStatusLabel,
30
- } from "./reviewItemFormat";
31
-
32
- const rowClass =
33
- "grid gap-0.5 rounded-md px-3 py-2 text-inherit no-underline transition-colors";
21
+ import { inboxGroups, nextInboxItem, reviewItemPath } from "./reviewItemFormat";
34
22
 
35
- // The list and the detail are already cards, so an empty state is plain
36
- // text, not a second box.
37
- function EmptyMessage(props: { children: ReactNode }) {
38
- return (
39
- <p className="m-0 px-4 py-8 text-center text-sm text-dashboard-text-muted">
40
- {props.children}
41
- </p>
42
- );
43
- }
23
+ const shellClass =
24
+ "grid h-full min-h-0 w-full overflow-hidden bg-dashboard-bg md:grid-cols-[20rem_minmax(0,1fr)]";
44
25
 
45
- function ListGroup(props: {
46
- children: ReactNode;
47
- className?: string;
48
- icon?: ReactNode;
49
- title: string;
50
- }) {
51
- const headingId = useId();
26
+ function InboxEmpty(props: { className?: string }) {
52
27
  return (
53
- <section
54
- aria-labelledby={headingId}
55
- className={cn("grid gap-1", props.className)}
28
+ <div
29
+ className={cn(
30
+ "grid content-center justify-items-center gap-3 px-4 py-12 text-center",
31
+ props.className,
32
+ )}
56
33
  >
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
- {props.title}
63
- </h2>
64
- <ul className="m-0 grid list-none gap-0.5 p-0">{props.children}</ul>
65
- </section>
66
- );
67
- }
68
-
69
- function ItemRow(props: {
70
- item: ReviewItemPayload;
71
- searchParams: URLSearchParams;
72
- selected: boolean;
73
- }) {
74
- const { item } = props;
75
- const closed = item.status !== "open";
76
- const unassigned = !item.assignedToEveryone && item.assignees.length === 0;
77
- return (
78
- <li>
79
- <Link
80
- aria-current={props.selected ? "true" : undefined}
81
- className={cn(
82
- rowClass,
83
- props.selected
84
- ? "bg-dashboard-accent/[0.08]"
85
- : "hover:bg-dashboard-fill-soft",
86
- )}
87
- replace
88
- to={pathWithSearch(reviewItemPath(item.id), props.searchParams)}
89
- >
90
- <span
91
- className={cn(
92
- "truncate text-sm font-medium",
93
- closed && "text-dashboard-text-muted",
94
- )}
95
- >
96
- {item.title}
97
- </span>
98
- <span className="flex min-w-0 items-center gap-2 text-xs text-dashboard-text-muted">
99
- {closed ? (
100
- <StatusChip className="shrink-0" size="compact">
101
- {reviewItemStatusLabel(item)}
102
- </StatusChip>
103
- ) : unassigned ? (
104
- <StatusChip size="compact" tone="warning">
105
- Unassigned
106
- </StatusChip>
107
- ) : (
108
- <span className="truncate">{assigneeLabel(item)}</span>
109
- )}
110
- <span className="shrink-0">
111
- {formatRelativeTime(item.closedAt ?? item.updatedAt)}
112
- </span>
113
- </span>
114
- </Link>
115
- </li>
34
+ <p className="m-0 font-display text-lg font-medium text-dashboard-text">
35
+ Nothing needs you
36
+ </p>
37
+ </div>
116
38
  );
117
39
  }
118
40
 
119
- function BlockedAutomationRow(props: { automation: AutomationSummary }) {
41
+ /** Keep the inbox frame while the dashboard shell loads. */
42
+ export function ReviewDeskLoading() {
120
43
  return (
121
- <li>
122
- <Link
123
- className={cn(rowClass, "hover:bg-dashboard-fill-soft")}
124
- to={automationPath(props.automation.id)}
44
+ <div className={shellClass}>
45
+ <aside
46
+ aria-busy="true"
47
+ aria-label="Loading review items"
48
+ className="grid content-start gap-2 border-r border-dashboard-border bg-dashboard-surface-panel px-5 py-3"
125
49
  >
126
- <span className="truncate text-sm font-medium">
127
- {props.automation.title}
128
- </span>
129
- <span className="truncate text-xs text-amber-300">
130
- {props.automation.statusReason ?? "Blocked"}
131
- </span>
132
- </Link>
133
- </li>
134
- );
135
- }
136
-
137
- function ReviewListLoading() {
138
- return (
139
- <div
140
- aria-busy="true"
141
- aria-label="Loading review items"
142
- className="grid gap-2"
143
- >
144
- {[0, 1, 2, 3].map((row) => (
145
- <div className="grid gap-1.5 px-3 py-2" key={row}>
146
- <Skeleton className="h-4 w-3/4" />
147
- <Skeleton className="h-3 w-1/3" />
148
- </div>
149
- ))}
50
+ <Skeleton className="h-5 w-16" />
51
+ <Skeleton className="h-8 w-full" />
52
+ {[0, 1, 2, 3].map((row) => (
53
+ <div className="grid gap-1 py-2" key={row}>
54
+ <Skeleton className="h-4 w-4/5" />
55
+ <Skeleton className="h-3 w-2/5 opacity-70" />
56
+ </div>
57
+ ))}
58
+ </aside>
150
59
  </div>
151
60
  );
152
61
  }
153
62
 
154
63
  /**
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.
64
+ * Render the inbox: Review items that the viewer can see in a sidebar, and
65
+ * one selected item with the end of its Conversation, where the viewer
66
+ * decides it. Details show beside the item, or in a sheet on small screens.
158
67
  */
159
68
  export function ReviewDeskPage() {
69
+ useDocumentTitle("Inbox");
160
70
  const [searchParams] = useSearchParams();
161
71
  const [search, setSearch, q] = useDebouncedSearchParam();
162
72
  const { items: list, moreArchived, query: items } = useReviewItemsData({ q });
163
73
  const showBlocked = !q;
164
74
  const blocked = useBlockedAutomationsData(showBlocked);
165
- const selectedId = searchParams.get("item") ?? list[0]?.id;
75
+ const blockedAutomations = showBlocked
76
+ ? (blocked.data?.automations ?? [])
77
+ : [];
78
+ const groups = useMemo(() => inboxGroups(list), [list]);
79
+ // A search lists every match in one list. Later pages add open items, so
80
+ // keep open matches before archived ones.
81
+ const results = useMemo(
82
+ () =>
83
+ q
84
+ ? [
85
+ ...list.filter((item) => item.status === "open"),
86
+ ...list.filter((item) => item.status !== "open"),
87
+ ]
88
+ : undefined,
89
+ [list, q],
90
+ );
91
+ const open = [...groups.needsYou, ...groups.fyi];
92
+
93
+ const [collapsed, setCollapsed] = useState<ReadonlySet<InboxGroupKey>>(
94
+ () => new Set(),
95
+ );
96
+ const toggle = (key: InboxGroupKey) =>
97
+ setCollapsed((current) => {
98
+ const next = new Set(current);
99
+ if (next.has(key)) next.delete(key);
100
+ else next.add(key);
101
+ return next;
102
+ });
103
+
104
+ const itemParam = searchParams.get("item") ?? undefined;
105
+ // Wide screens select the item that waited longest. Small screens show
106
+ // the list until the reader picks an item.
107
+ const selectedId = itemParam ?? open[0]?.id;
166
108
  const listed = list.find((item) => item.id === selectedId);
167
109
  // A link can select an item that is not in the current view.
168
110
  const direct = useReviewItemData(listed ? undefined : selectedId);
169
111
  const selected = listed ?? direct.data;
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");
174
- const blockedAutomations = showBlocked
175
- ? (blocked.data?.automations ?? [])
176
- : [];
112
+ const nextItem = selected ? nextInboxItem(groups, selected) : undefined;
113
+
114
+ // Read source titles only for the rows on screen and the selected item.
115
+ const sourceIds = useMemo(() => {
116
+ const rows = results ?? [
117
+ ...(collapsed.has("needs-you") ? [] : groups.needsYou),
118
+ ...(collapsed.has("fyi") ? [] : groups.fyi),
119
+ ];
120
+ const shown = selected ? [...rows, selected] : rows;
121
+ return [
122
+ ...new Set(
123
+ shown
124
+ .filter((item) => !item.automationId)
125
+ .map((item) => item.addedByConversationId),
126
+ ),
127
+ ];
128
+ }, [collapsed, groups, results, selected]);
129
+ const sourceTitles = useLinkedConversationTitles(sourceIds);
130
+ const sourceTitle = (item: ReviewItemPayload) =>
131
+ sourceTitles[sourceIds.indexOf(item.addedByConversationId)];
132
+
133
+ const panelFits = useDetailsPanel();
134
+ const [detailsChoice, setDetailsChoice] = useState<boolean>();
135
+ const detailsOpen = Boolean(selected) && (detailsChoice ?? panelFits);
136
+
137
+ const listParams = new URLSearchParams(searchParams);
138
+ listParams.delete("item");
139
+ const loaded = Boolean(items.data);
177
140
  const empty =
178
- items.data && list.length === 0 && blockedAutomations.length === 0;
141
+ loaded && !q && open.length === 0 && blockedAutomations.length === 0;
179
142
 
180
143
  return (
181
- <PageLayout>
182
- <PageHeader
183
- description="Items that need a person, and who can act on them."
184
- title="Review desk"
185
- />
186
- <SearchInput
187
- label="Search review items"
188
- onChange={setSearch}
189
- placeholder="Search open and archived items"
190
- value={search}
191
- />
192
- <div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
193
- <Card
194
- className="grid content-start gap-4 py-3 lg:self-start"
195
- padding="none"
196
- >
197
- {items.error && !items.data ? (
198
- <InlineError className="px-3">
199
- Could not load review items.
200
- </InlineError>
201
- ) : !items.data ? (
202
- <ReviewListLoading />
203
- ) : empty ? (
204
- <EmptyMessage>
205
- {q ? `No items match "${q}".` : "No open items."}
206
- </EmptyMessage>
207
- ) : (
144
+ <div className={shellClass}>
145
+ <div
146
+ className={cn(
147
+ "h-full min-h-0 overflow-hidden",
148
+ itemParam && "max-md:hidden",
149
+ )}
150
+ >
151
+ <ReviewDeskSidebar
152
+ blocked={blockedAutomations}
153
+ collapsed={collapsed}
154
+ error={Boolean(items.error && !items.data)}
155
+ footer={
208
156
  <>
209
- {actions.length > 0 ? (
210
- <ListGroup title="Needs an answer">
211
- {actions.map((item) => (
212
- <ItemRow
213
- item={item}
214
- key={item.id}
215
- searchParams={searchParams}
216
- selected={item.id === selected?.id}
217
- />
218
- ))}
219
- </ListGroup>
220
- ) : null}
221
- {notices.length > 0 ? (
222
- <ListGroup title="Notices">
223
- {notices.map((item) => (
224
- <ItemRow
225
- item={item}
226
- key={item.id}
227
- searchParams={searchParams}
228
- selected={item.id === selected?.id}
229
- />
230
- ))}
231
- </ListGroup>
232
- ) : null}
233
157
  <LoadMorePagination
234
- className="px-3"
158
+ className="mx-3 mt-2"
235
159
  hasMore={!items.isPlaceholderData && Boolean(items.hasNextPage)}
236
160
  loading={items.isFetchingNextPage}
237
161
  onLoadMore={() => void items.fetchNextPage()}
238
162
  />
239
163
  {items.isFetchNextPageError ? (
240
- <InlineError className="px-3 text-center">
164
+ <InlineError className="px-3 py-2">
241
165
  Could not load more items. Try again.
242
166
  </InlineError>
243
167
  ) : 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>
168
+ {moreArchived ? (
169
+ <p className="m-0 px-3 py-2 text-xs text-dashboard-text-muted">
170
+ Narrow the search to find older archived items.
171
+ </p>
269
172
  ) : null}
270
- {blockedAutomations.length > 0 ? (
271
- <ListGroup title="Blocked Automations">
272
- {blockedAutomations.map((automation) => (
273
- <BlockedAutomationRow
274
- automation={automation}
275
- key={automation.id}
276
- />
277
- ))}
278
- </ListGroup>
173
+ {loaded && q && list.length === 0 ? (
174
+ <p className="m-0 px-3 py-8 text-center text-sm text-dashboard-text-muted">
175
+ No items match "{q}".
176
+ </p>
279
177
  ) : null}
178
+ {empty ? <InboxEmpty className="md:hidden" /> : null}
280
179
  </>
281
- )}
282
- </Card>
283
- <Card className="min-w-0" padding="md">
284
- {selected ? (
285
- // A key per item resets the answer form and its send lock, so
286
- // one item's answer can never reach another item.
287
- <ReviewItemDetail item={selected} key={selected.id} />
288
- ) : direct.error ? (
289
- <InlineError>Review item not found.</InlineError>
290
- ) : (
291
- <EmptyMessage>Select an item to see it here.</EmptyMessage>
292
- )}
293
- </Card>
180
+ }
181
+ fyi={groups.fyi}
182
+ loading={!items.data && !items.error}
183
+ needsYou={groups.needsYou}
184
+ onSearchChange={setSearch}
185
+ onToggle={toggle}
186
+ results={results}
187
+ search={search}
188
+ searchParams={listParams}
189
+ selectedId={selected?.id}
190
+ selectedShown={Boolean(itemParam)}
191
+ sourceTitle={sourceTitle}
192
+ />
294
193
  </div>
295
- </PageLayout>
194
+ <section
195
+ aria-label="Selected review item"
196
+ className={cn(
197
+ "grid min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden",
198
+ !itemParam && "max-md:hidden",
199
+ detailsOpen && "xl:grid-cols-[minmax(0,1fr)_25rem]",
200
+ )}
201
+ >
202
+ {selected ? (
203
+ <>
204
+ <ReviewItemView
205
+ backTo={pathWithSearch("/review", listParams)}
206
+ detailsOpen={detailsOpen}
207
+ item={selected}
208
+ // A key per item resets the answer form and its send lock, so
209
+ // one item's answer can never reach another item.
210
+ key={selected.id}
211
+ next={
212
+ nextItem
213
+ ? {
214
+ title: nextItem.title,
215
+ to: pathWithSearch(
216
+ reviewItemPath(nextItem.id),
217
+ listParams,
218
+ ),
219
+ }
220
+ : undefined
221
+ }
222
+ onDetailsClick={() => setDetailsChoice(!detailsOpen)}
223
+ sourceTitle={sourceTitle(selected)}
224
+ />
225
+ {detailsOpen ? (
226
+ <ReviewItemDetails
227
+ item={selected}
228
+ onClose={() => setDetailsChoice(false)}
229
+ />
230
+ ) : null}
231
+ </>
232
+ ) : direct.error ? (
233
+ <InlineError className="p-7">Review item not found.</InlineError>
234
+ ) : empty ? (
235
+ <InboxEmpty />
236
+ ) : null}
237
+ </section>
238
+ </div>
296
239
  );
297
240
  }