@sentry/junior-dashboard 0.270.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 (32) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +77 -77
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/client/App.tsx +89 -61
  7. package/src/client/components/AGENTS.md +30 -59
  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/AGENTS.md +12 -33
  12. package/src/client/conversations/ConversationPage.tsx +44 -8
  13. package/src/client/conversations/conversationRoutes.ts +30 -3
  14. package/src/client/pages/automations/AGENTS.md +6 -25
  15. package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
  16. package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
  17. package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
  18. package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +54 -62
  19. package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
  20. package/src/client/primaryNav.ts +1 -1
  21. package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
  22. package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
  23. package/src/client/review-desk/ReviewItemContext.tsx +108 -0
  24. package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
  25. package/src/client/review-desk/ReviewItemView.tsx +159 -0
  26. package/src/client/review-desk/queries.ts +4 -2
  27. package/src/client/review-desk/reviewItemFormat.ts +54 -0
  28. package/src/client/components/charts/AGENTS.md +0 -15
  29. package/src/client/pages/ConnectionsPage.tsx +0 -207
  30. package/src/client/pages/PersonalTokensPage.tsx +0 -205
  31. package/src/client/pages/system/AGENTS.md +0 -12
  32. package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
@@ -1,297 +1,258 @@
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";
8
+ import { Button } from "../components/ui/button";
16
9
  import { Skeleton } from "../components/ui/skeleton";
17
- import { automationPath, formatRelativeTime } from "../format";
10
+ import { useDocumentTitle } from "../documentTitle";
18
11
  import { pathWithSearch, useDebouncedSearchParam } from "../searchParams";
19
12
  import { cn } from "../styles";
20
- import { ReviewItemDetail } from "./ReviewItemDetail";
13
+ import { ReviewDeskSidebar, type InboxGroupKey } from "./ReviewDeskSidebar";
14
+ import { ReviewItemDetails } from "./ReviewItemDetails";
15
+ import { ReviewItemView } from "./ReviewItemView";
21
16
  import {
22
17
  useBlockedAutomationsData,
18
+ useLinkedConversationTitles,
23
19
  useReviewItemData,
24
20
  useReviewItemsData,
25
21
  } 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";
22
+ import { inboxGroups, nextInboxItem, reviewItemPath } from "./reviewItemFormat";
34
23
 
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
- }
24
+ const shellClass =
25
+ "grid h-full min-h-0 w-full overflow-hidden bg-dashboard-bg md:grid-cols-[20rem_minmax(0,1fr)]";
44
26
 
45
- function ListGroup(props: {
46
- children: ReactNode;
27
+ function InboxEmpty(props: {
47
28
  className?: string;
48
- icon?: ReactNode;
49
- title: string;
29
+ onShowArchived?: () => void;
50
30
  }) {
51
- const headingId = useId();
52
31
  return (
53
- <section
54
- aria-labelledby={headingId}
55
- className={cn("grid gap-1", props.className)}
32
+ <div
33
+ className={cn(
34
+ "grid content-center justify-items-center gap-3 px-4 py-12 text-center",
35
+ props.className,
36
+ )}
56
37
  >
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>
38
+ <p className="m-0 font-display text-lg font-medium text-dashboard-text">
39
+ Nothing needs you
40
+ </p>
41
+ {props.onShowArchived ? (
42
+ <Button onClick={props.onShowArchived} variant="quiet">
43
+ See archived items
44
+ </Button>
45
+ ) : null}
46
+ </div>
116
47
  );
117
48
  }
118
49
 
119
- function BlockedAutomationRow(props: { automation: AutomationSummary }) {
50
+ /** Keep the inbox frame while the dashboard shell loads. */
51
+ export function ReviewDeskLoading() {
120
52
  return (
121
- <li>
122
- <Link
123
- className={cn(rowClass, "hover:bg-dashboard-fill-soft")}
124
- to={automationPath(props.automation.id)}
53
+ <div className={shellClass}>
54
+ <aside
55
+ aria-busy="true"
56
+ aria-label="Loading review items"
57
+ className="grid content-start gap-2 border-r border-dashboard-border bg-dashboard-surface-panel px-5 py-3"
125
58
  >
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
- ))}
59
+ <Skeleton className="h-5 w-16" />
60
+ <Skeleton className="h-8 w-full" />
61
+ {[0, 1, 2, 3].map((row) => (
62
+ <div className="grid gap-1 py-2" key={row}>
63
+ <Skeleton className="h-4 w-4/5" />
64
+ <Skeleton className="h-3 w-2/5 opacity-70" />
65
+ </div>
66
+ ))}
67
+ </aside>
150
68
  </div>
151
69
  );
152
70
  }
153
71
 
154
72
  /**
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.
73
+ * Render the inbox: Review items that the viewer can see in a sidebar, and
74
+ * one selected item with the end of its Conversation, where the viewer
75
+ * decides it. Details show beside the item, or in a sheet on small screens.
158
76
  */
159
77
  export function ReviewDeskPage() {
78
+ useDocumentTitle("Inbox");
160
79
  const [searchParams] = useSearchParams();
161
80
  const [search, setSearch, q] = useDebouncedSearchParam();
162
- const { items: list, moreArchived, query: items } = useReviewItemsData({ q });
81
+ const {
82
+ items: list,
83
+ moreArchived,
84
+ query: items,
85
+ } = useReviewItemsData({ includeArchived: true, q });
163
86
  const showBlocked = !q;
164
87
  const blocked = useBlockedAutomationsData(showBlocked);
165
- const selectedId = searchParams.get("item") ?? list[0]?.id;
88
+ const blockedAutomations = showBlocked
89
+ ? (blocked.data?.automations ?? [])
90
+ : [];
91
+ const groups = useMemo(() => inboxGroups(list), [list]);
92
+ const open = [...groups.needsYou, ...groups.fyi];
93
+
94
+ // Archived items stay out of the way until a search or the reader asks.
95
+ const [collapsed, setCollapsed] = useState<ReadonlySet<InboxGroupKey>>(
96
+ () => new Set(["archived"]),
97
+ );
98
+ const toggle = (key: InboxGroupKey) =>
99
+ setCollapsed((current) => {
100
+ const next = new Set(current);
101
+ if (next.has(key)) next.delete(key);
102
+ else next.add(key);
103
+ return next;
104
+ });
105
+ const shownCollapsed = useMemo(() => {
106
+ if (!q || !collapsed.has("archived")) return collapsed;
107
+ const next = new Set(collapsed);
108
+ next.delete("archived");
109
+ return next;
110
+ }, [collapsed, q]);
111
+ const showArchived =
112
+ groups.archived.length > 0 && shownCollapsed.has("archived")
113
+ ? () => toggle("archived")
114
+ : undefined;
115
+
116
+ const itemParam = searchParams.get("item") ?? undefined;
117
+ // Wide screens select the item that waited longest. Small screens show
118
+ // the list until the reader picks an item.
119
+ const selectedId = itemParam ?? open[0]?.id;
166
120
  const listed = list.find((item) => item.id === selectedId);
167
121
  // A link can select an item that is not in the current view.
168
122
  const direct = useReviewItemData(listed ? undefined : selectedId);
169
123
  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
- : [];
124
+ const nextItem = selected ? nextInboxItem(groups, selected) : undefined;
125
+
126
+ // Read source titles only for the rows on screen and the selected item.
127
+ const sourceIds = useMemo(() => {
128
+ const shown = [
129
+ ...(shownCollapsed.has("needs-you") ? [] : groups.needsYou),
130
+ ...(shownCollapsed.has("fyi") ? [] : groups.fyi),
131
+ ...(shownCollapsed.has("archived") ? [] : groups.archived),
132
+ ...(selected ? [selected] : []),
133
+ ];
134
+ return [
135
+ ...new Set(
136
+ shown
137
+ .filter((item) => !item.automationId)
138
+ .map((item) => item.addedByConversationId),
139
+ ),
140
+ ];
141
+ }, [groups, selected, shownCollapsed]);
142
+ const sourceTitles = useLinkedConversationTitles(sourceIds);
143
+ const sourceTitle = (item: ReviewItemPayload) =>
144
+ sourceTitles[sourceIds.indexOf(item.addedByConversationId)];
145
+
146
+ const panelFits = useDetailsPanel();
147
+ const [detailsChoice, setDetailsChoice] = useState<boolean>();
148
+ const detailsOpen = Boolean(selected) && (detailsChoice ?? panelFits);
149
+
150
+ const listParams = new URLSearchParams(searchParams);
151
+ listParams.delete("item");
152
+ const loaded = Boolean(items.data);
177
153
  const empty =
178
- items.data && list.length === 0 && blockedAutomations.length === 0;
154
+ loaded && !q && open.length === 0 && blockedAutomations.length === 0;
179
155
 
180
156
  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
- ) : (
157
+ <div className={shellClass}>
158
+ <div
159
+ className={cn(
160
+ "h-full min-h-0 overflow-hidden",
161
+ itemParam && "max-md:hidden",
162
+ )}
163
+ >
164
+ <ReviewDeskSidebar
165
+ archived={groups.archived}
166
+ blocked={blockedAutomations}
167
+ collapsed={shownCollapsed}
168
+ error={Boolean(items.error && !items.data)}
169
+ footer={
208
170
  <>
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
171
  <LoadMorePagination
234
- className="px-3"
172
+ className="mx-3 mt-2"
235
173
  hasMore={!items.isPlaceholderData && Boolean(items.hasNextPage)}
236
174
  loading={items.isFetchingNextPage}
237
175
  onLoadMore={() => void items.fetchNextPage()}
238
176
  />
239
177
  {items.isFetchNextPageError ? (
240
- <InlineError className="px-3 text-center">
178
+ <InlineError className="px-3 py-2">
241
179
  Could not load more items. Try again.
242
180
  </InlineError>
243
181
  ) : 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>
182
+ {moreArchived && !shownCollapsed.has("archived") ? (
183
+ <p className="m-0 px-3 py-2 text-xs text-dashboard-text-muted">
184
+ Search to find older archived items.
185
+ </p>
186
+ ) : null}
187
+ {loaded && q && list.length === 0 ? (
188
+ <p className="m-0 px-3 py-8 text-center text-sm text-dashboard-text-muted">
189
+ No items match "{q}".
190
+ </p>
269
191
  ) : 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>
192
+ {empty ? (
193
+ <InboxEmpty
194
+ className="md:hidden"
195
+ onShowArchived={showArchived}
196
+ />
279
197
  ) : null}
280
198
  </>
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>
199
+ }
200
+ fyi={groups.fyi}
201
+ loading={!items.data && !items.error}
202
+ needsYou={groups.needsYou}
203
+ onSearchChange={setSearch}
204
+ onToggle={toggle}
205
+ search={search}
206
+ searchParams={listParams}
207
+ selectedId={selected?.id}
208
+ selectedShown={Boolean(itemParam)}
209
+ sourceTitle={sourceTitle}
210
+ />
294
211
  </div>
295
- </PageLayout>
212
+ <section
213
+ aria-label="Selected review item"
214
+ className={cn(
215
+ "grid min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden",
216
+ !itemParam && "max-md:hidden",
217
+ detailsOpen && "xl:grid-cols-[minmax(0,1fr)_25rem]",
218
+ )}
219
+ >
220
+ {selected ? (
221
+ <>
222
+ <ReviewItemView
223
+ backTo={pathWithSearch("/review", listParams)}
224
+ detailsOpen={detailsOpen}
225
+ item={selected}
226
+ // A key per item resets the answer form and its send lock, so
227
+ // one item's answer can never reach another item.
228
+ key={selected.id}
229
+ next={
230
+ nextItem
231
+ ? {
232
+ title: nextItem.title,
233
+ to: pathWithSearch(
234
+ reviewItemPath(nextItem.id),
235
+ listParams,
236
+ ),
237
+ }
238
+ : undefined
239
+ }
240
+ onDetailsClick={() => setDetailsChoice(!detailsOpen)}
241
+ sourceTitle={sourceTitle(selected)}
242
+ />
243
+ {detailsOpen ? (
244
+ <ReviewItemDetails
245
+ item={selected}
246
+ onClose={() => setDetailsChoice(false)}
247
+ />
248
+ ) : null}
249
+ </>
250
+ ) : direct.error ? (
251
+ <InlineError className="p-7">Review item not found.</InlineError>
252
+ ) : empty ? (
253
+ <InboxEmpty onShowArchived={showArchived} />
254
+ ) : null}
255
+ </section>
256
+ </div>
296
257
  );
297
258
  }