@sentry/junior-dashboard 0.268.0 → 0.269.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 (38) hide show
  1. package/README.md +7 -105
  2. package/dist/app.js +12 -9
  3. package/dist/client.js +72 -75
  4. package/dist/index.js +12 -9
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +4 -4
  7. package/src/api/schema.ts +2 -1
  8. package/src/app.ts +11 -7
  9. package/src/client/App.tsx +1 -5
  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/ParticipantAvatarStack.tsx +1 -0
  14. package/src/client/components/ProfileMenu.tsx +88 -29
  15. package/src/client/components/UserInputForm.tsx +1 -1
  16. package/src/client/components/charts/AGENTS.md +15 -0
  17. package/src/client/conversations/AGENTS.md +42 -0
  18. package/src/client/conversations/ConversationPage.tsx +0 -27
  19. package/src/client/format.ts +7 -23
  20. package/src/client/pages/automations/AGENTS.md +34 -0
  21. package/src/client/pages/automations/AutomationActions.tsx +18 -20
  22. package/src/client/pages/automations/AutomationEditor.tsx +3 -4
  23. package/src/client/pages/automations/AutomationOverview.tsx +3 -3
  24. package/src/client/pages/automations/AutomationStatus.tsx +2 -2
  25. package/src/client/pages/system/AGENTS.md +12 -0
  26. package/src/client/review-desk/ConversationReviewBar.tsx +14 -8
  27. package/src/client/review-desk/ReviewDeskPage.tsx +99 -79
  28. package/src/client/review-desk/ReviewItemDetail.tsx +15 -7
  29. package/src/client/review-desk/queries.ts +25 -16
  30. package/src/mock-reporting/routes.ts +1 -1
  31. package/src/tailwind.css +1 -1
  32. package/src/client/components/README.md +0 -219
  33. package/src/client/components/charts/README.md +0 -38
  34. package/src/client/conversations/README.md +0 -159
  35. package/src/client/conversations/UserInputRequestCard.tsx +0 -39
  36. package/src/client/conversations/userInputRequest.ts +0 -56
  37. package/src/client/pages/automations/README.md +0 -97
  38. package/src/client/pages/system/README.md +0 -23
@@ -0,0 +1,34 @@
1
+ # Automation pages
2
+
3
+ ## Boundaries
4
+
5
+ - Core owns validation, edit access, creator-only rules, revision checks,
6
+ Schedule previews, and the Event catalog. The page shows core errors. It
7
+ does not repeat these rules.
8
+ - Preview requests never save or run work.
9
+ - Completed scheduled Automations are read-only.
10
+
11
+ ## Editor rules
12
+
13
+ - Send only changed fields. Keep unsupported triggers and stored
14
+ Destinations unless the user replaces them. Keep outcome order.
15
+ - New messages can target only the current Destination or the creator.
16
+ - A public reader sees outcomes as read-only and cannot turn on creator
17
+ credentials. Warn that an instruction or event selector change switches to
18
+ system credentials.
19
+ - Pause, Resume, and Delete are creator-only. Hide them from other viewers.
20
+ - Query refreshes must not replace an unsaved draft. After a stale save, let
21
+ the user keep their changes or load the latest version.
22
+ - Block navigation with unsaved changes.
23
+ - Lifecycle actions and "Make active" read the latest revision first.
24
+
25
+ ## Page rules
26
+
27
+ - Each Automation has one page at `/automations/:id`. Keep the list query
28
+ string on the way back. Drop tab-only params: `result`, `run`, `version`.
29
+ - `AutomationRecentRuns` shows terminal runs only. Do not infer queued or
30
+ running work from terminal records.
31
+ - Delete keeps errors in the dialog and blocks dismissal while pending.
32
+
33
+ Browser journeys: `e2e/automation-editor.spec.ts` and
34
+ `e2e/automations.spec.ts`.
@@ -43,7 +43,7 @@ export function AutomationActions({
43
43
  const completed = automation.status === "completed";
44
44
  const paused = automation.status === "paused";
45
45
  const blocked = automation.status === "blocked";
46
- const canPause = automation.ownedByViewer && !completed;
46
+ const canPause = !completed;
47
47
  const action = paused || blocked ? "resume" : "pause";
48
48
  const mutation = useMutation({
49
49
  mutationFn: async () => {
@@ -135,25 +135,23 @@ export function AutomationActions({
135
135
  </Popover>
136
136
  </>
137
137
  ) : null}
138
- {automation.ownedByViewer ? (
139
- <Tooltip>
140
- <TooltipTrigger
141
- render={
142
- <Button
143
- focusableWhenDisabled
144
- aria-label="Delete automation"
145
- size="icon"
146
- variant="danger"
147
- onClick={(event) => onDelete(event.currentTarget)}
148
- disabled={mutation.isPending}
149
- >
150
- <Trash2 aria-hidden="true" size={16} />
151
- </Button>
152
- }
153
- />
154
- <TooltipContent size="compact">Delete automation</TooltipContent>
155
- </Tooltip>
156
- ) : null}
138
+ <Tooltip>
139
+ <TooltipTrigger
140
+ render={
141
+ <Button
142
+ focusableWhenDisabled
143
+ aria-label="Delete automation"
144
+ size="icon"
145
+ variant="danger"
146
+ onClick={(event) => onDelete(event.currentTarget)}
147
+ disabled={mutation.isPending}
148
+ >
149
+ <Trash2 aria-hidden="true" size={16} />
150
+ </Button>
151
+ }
152
+ />
153
+ <TooltipContent size="compact">Delete automation</TooltipContent>
154
+ </Tooltip>
157
155
  </div>
158
156
  {mutation.error ? (
159
157
  <div className="max-w-sm">
@@ -135,8 +135,7 @@ export function AutomationEditor(props: {
135
135
  const dropsCreatorCredentials =
136
136
  !owned &&
137
137
  original.credentialMode === "creator" &&
138
- (changes.instruction !== undefined ||
139
- (original.kind === "event" && changes.trigger !== undefined));
138
+ (changes.instruction !== undefined || changes[triggerField] !== undefined);
140
139
  const triggerError = fieldError(triggerField);
141
140
  const inputState = (key: string) => ({
142
141
  "aria-invalid": Boolean(fieldError(key)) || undefined,
@@ -426,8 +425,8 @@ export function AutomationEditor(props: {
426
425
  {dropsCreatorCredentials ? (
427
426
  <FormNotice title="Saving switches this automation to system credentials.">
428
427
  Only {creator} can let a changed instruction
429
- {original.kind === "event" ? " or trigger" : ""} use their
430
- connected accounts.
428
+ {original.kind === "event" ? " or trigger" : " or schedule"} use
429
+ their connected accounts.
431
430
  </FormNotice>
432
431
  ) : null}
433
432
  {fieldError("credentialMode") ? (
@@ -197,9 +197,9 @@ function AutomationAbout(props: { automation: AutomationSummary }) {
197
197
  <p className="mt-3 mb-0 text-xs leading-relaxed text-dashboard-text-muted">
198
198
  {automation.destination.visibility === "private"
199
199
  ? null
200
- : "You can edit its settings because it is in a public channel. "}
201
- Only {automation.createdBy} can pause, resume, or delete it, change
202
- where results go, or turn on their connected accounts.
200
+ : "You can edit, pause, or delete it because it is in a public channel. "}
201
+ Only {automation.createdBy} can change where results go, turn on their
202
+ connected accounts, or resume or run it while it uses them.
203
203
  </p>
204
204
  )}
205
205
  </RailCard>
@@ -140,8 +140,8 @@ export function AutomationAttention({
140
140
  <p className="m-0 break-words">
141
141
  {automation.statusReason ??
142
142
  "Check the last run to see what needs fixing."}{" "}
143
- {!automation.ownedByViewer
144
- ? `Ask ${automation.createdBy} to resume after this is fixed.`
143
+ {!automation.ownedByViewer && automation.credentialMode === "creator"
144
+ ? `It uses accounts connected by ${automation.createdBy}. Ask them to resume it, or switch it to Junior's credentials first.`
145
145
  : null}
146
146
  </p>
147
147
  ) : null}
@@ -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
@@ -54,6 +55,11 @@ export function ConversationReviewBar(props: { conversationId: string }) {
54
55
  {items.map((item) => (
55
56
  <ReviewItemCard item={item} key={item.id} />
56
57
  ))}
58
+ <LoadMorePagination
59
+ hasMore={more}
60
+ loading={query.isFetchingNextPage}
61
+ onLoadMore={() => void query.fetchNextPage()}
62
+ />
57
63
  </div>
58
64
  </Drawer>
59
65
  ) : 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) => (
@@ -1,3 +1,4 @@
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";
@@ -26,20 +27,27 @@ 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)}
@@ -1,6 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import {
3
3
  keepPreviousData,
4
+ useInfiniteQuery,
4
5
  useMutation,
5
6
  useQueries,
6
7
  useQuery,
@@ -19,38 +20,46 @@ import {
19
20
  import { conversationDetailTitle } from "../format";
20
21
  import { fetchDashboardJson, post } from "../http";
21
22
 
22
- /** Review desk views. Each shows open items, or archived ones. */
23
- export const REVIEW_DESK_VIEWS = ["mine", "everyone", "all"] as const;
24
- export type ReviewDeskView = (typeof REVIEW_DESK_VIEWS)[number];
25
-
26
23
  const REVIEW_ITEMS_QUERY_KEY = ["dashboard", "review-items"] as const;
27
24
  const COUNT_REFRESH_MS = 60_000;
28
25
 
29
- /** List the Review items that the viewer can see in one view. */
26
+ /**
27
+ * List the open Review items that the viewer can see, one page at a time.
28
+ * A search also finds archived items, with the first page. `items` holds
29
+ * the items of every loaded page, and `query` loads more.
30
+ */
30
31
  export function useReviewItemsData(filters: {
31
- archived: boolean;
32
32
  conversationId?: string;
33
33
  q?: string;
34
- view: ReviewDeskView;
35
34
  }) {
36
- const params = new URLSearchParams({ view: filters.view });
37
- if (filters.archived) params.set("archived", "true");
35
+ const params = new URLSearchParams();
38
36
  if (filters.conversationId) {
39
37
  params.set("conversationId", filters.conversationId);
40
38
  }
41
39
  if (filters.q) params.set("q", filters.q);
42
40
  const search = params.toString();
43
- return useQuery({
41
+ const query = useInfiniteQuery({
42
+ initialPageParam: undefined as string | undefined,
44
43
  queryKey: [...REVIEW_ITEMS_QUERY_KEY, "list", search],
45
- queryFn: ({ signal }) =>
46
- fetchDashboardJson(
44
+ queryFn: ({ pageParam, signal }) => {
45
+ const pageParams = new URLSearchParams(search);
46
+ if (pageParam) pageParams.set("cursor", pageParam);
47
+ return fetchDashboardJson(
47
48
  reviewItemListSchema,
48
- `/api/review-items?${search}`,
49
+ `/api/review-items?${pageParams.toString()}`,
49
50
  signal,
50
- ),
51
+ );
52
+ },
53
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
51
54
  placeholderData: keepPreviousData,
52
55
  retry: false,
53
56
  });
57
+ const pages = query.data?.pages;
58
+ const items = useMemo(
59
+ () => (pages ?? []).flatMap((page) => page.items),
60
+ [pages],
61
+ );
62
+ return { items, moreArchived: Boolean(pages?.[0]?.moreArchived), query };
54
63
  }
55
64
 
56
65
  /** Read one Review item, for a link to an item outside the current list. */
@@ -92,7 +101,7 @@ export function useLinkedConversationTitles(
92
101
  }).map((query) => query.data ?? undefined);
93
102
  }
94
103
 
95
- /** Count the open Review items assigned to the viewer, for the nav. */
104
+ /** Count the open action items assigned to the viewer, for the nav. */
96
105
  export function useReviewItemCountData(enabled: boolean) {
97
106
  return useQuery({
98
107
  enabled,
@@ -145,7 +154,7 @@ const NO_CONVERSATIONS: ReadonlySet<string> = new Set();
145
154
 
146
155
  /**
147
156
  * Return the Conversations that wait on the viewer. Today only the review
148
- * desk sets this: an open linked item assigned to the viewer or Everyone.
157
+ * desk sets this: a linked open action item assigned to the viewer.
149
158
  */
150
159
  export function useWaitingConversationIds(): ReadonlySet<string> {
151
160
  const query = useQuery({
@@ -303,7 +303,7 @@ export function createMockReportingApi(): Hono<{
303
303
  jsonResponse(reviewItemListSchema, { items: [] }),
304
304
  );
305
305
  app.get("/review-items/count", () =>
306
- jsonResponse(reviewItemCountSchema, { mine: 0 }),
306
+ jsonResponse(reviewItemCountSchema, { needsViewer: 0 }),
307
307
  );
308
308
  app.get("/review-items/waiting-conversations", () =>
309
309
  jsonResponse(reviewItemWaitingConversationsSchema, { conversationIds: [] }),
package/src/tailwind.css CHANGED
@@ -4,7 +4,7 @@
4
4
  @import "shadcn/tailwind.css";
5
5
 
6
6
  /*
7
- * Dashboard design tokens. components/README.md explains each role.
7
+ * Dashboard design tokens. components/AGENTS.md has the token rules.
8
8
  * Keep structural UI colors here so a second theme can redefine them.
9
9
  * Prefer roles over opacity ladders. Status accents stay as Tailwind palette utilities.
10
10
  */