@sentry/junior-dashboard 0.266.0 → 0.268.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 (96) hide show
  1. package/dist/app.js +85 -12
  2. package/dist/client.js +94 -64
  3. package/dist/index.js +85 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +55 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -2
  13. package/src/client/components/README.md +15 -2
  14. package/src/client/components/UserInputForm.tsx +283 -0
  15. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  16. package/src/client/components/ui/alert.tsx +1 -1
  17. package/src/client/components/ui/button-group.tsx +59 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/components/ui/dialog.tsx +29 -35
  20. package/src/client/components/ui/drawer.tsx +140 -0
  21. package/src/client/components/ui/empty.tsx +30 -1
  22. package/src/client/components/ui/separator.tsx +22 -0
  23. package/src/client/components/ui/useSheet.ts +5 -1
  24. package/src/client/conversations/ComposerImages.tsx +66 -14
  25. package/src/client/conversations/ConversationComposer.tsx +84 -12
  26. package/src/client/conversations/ConversationDetails.tsx +3 -27
  27. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  28. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  29. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  30. package/src/client/conversations/ConversationPage.tsx +160 -2
  31. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  32. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  33. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  34. package/src/client/conversations/DetailsList.tsx +33 -0
  35. package/src/client/conversations/EventDetails.tsx +46 -19
  36. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  37. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  38. package/src/client/conversations/README.md +30 -13
  39. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  40. package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
  41. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  42. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  43. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  44. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  45. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  46. package/src/client/conversations/TranscriptMessageView.tsx +10 -1
  47. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  48. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  49. package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
  50. package/src/client/conversations/TranscriptView.tsx +7 -3
  51. package/src/client/conversations/UserInputRequestCard.tsx +39 -0
  52. package/src/client/conversations/conversationOutbox.ts +46 -0
  53. package/src/client/conversations/eventTime.ts +13 -1
  54. package/src/client/conversations/eventTranscript.ts +12 -5
  55. package/src/client/conversations/queries.ts +127 -26
  56. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  57. package/src/client/conversations/turnContext.ts +15 -0
  58. package/src/client/conversations/userInputRequest.ts +56 -0
  59. package/src/client/format.ts +31 -8
  60. package/src/client/pages/SettingsPage.tsx +103 -3
  61. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  62. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  63. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  64. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  65. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  66. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  67. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  69. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  70. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  71. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  72. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  73. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  74. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  75. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  76. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  77. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  78. package/src/client/pages/automations/README.md +56 -46
  79. package/src/client/pages/code/RepositorySections.tsx +1 -0
  80. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  81. package/src/client/primaryNav.ts +15 -2
  82. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  83. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  84. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  85. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  86. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  87. package/src/client/review-desk/queries.ts +164 -0
  88. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  89. package/src/client/types.ts +3 -0
  90. package/src/mock-reporting/fixtures.ts +54 -2
  91. package/src/mock-reporting/routes.ts +20 -0
  92. package/src/shell.ts +3 -0
  93. package/src/client/conversations/transcriptStyles.ts +0 -10
  94. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  95. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  96. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -9,40 +9,22 @@ import { PageHeader } from "../../components/layout/PageHeader";
9
9
  import { PageLayout } from "../../components/layout/PageLayout";
10
10
  import { SecondaryNavigation } from "../../components/layout/SecondaryNavigation";
11
11
 
12
- /** Identify the Automation list and its selected Automation routes. */
13
- function isAutomationListPath(pathname: string): boolean {
14
- const match = matchPath("/automations/:automationId", pathname);
15
- return Boolean(match && match.params.automationId?.toLowerCase() !== "runs");
16
- }
17
-
18
- /** Identify Automation routes whose list scrolls on its own beside details. */
12
+ /** Identify Automation routes whose run list scrolls on its own beside run details. */
19
13
  export function hasAutomationDetailsFrame(pathname: string): boolean {
20
14
  return (
21
- isAutomationListPath(pathname) ||
22
15
  Boolean(matchPath("/automations/runs", pathname)) ||
23
- Boolean(matchPath("/automations/:automationId/executions", pathname))
24
- );
25
- }
26
-
27
- function isAutomationListTabPath(pathname: string): boolean {
28
- return (
29
- isAutomationListPath(pathname) ||
30
- Boolean(matchPath("/automations/:automationId/edit", pathname))
16
+ Boolean(matchPath("/automations/:automationId/runs", pathname))
31
17
  );
32
18
  }
33
19
 
34
20
  const taskNavigationItems = [
35
21
  { end: true, label: "Overview", to: "/automations" },
36
- {
37
- isActive: isAutomationListTabPath,
38
- label: "Automations",
39
- to: "/automations/list",
40
- },
22
+ { label: "Automations", to: "/automations/list" },
41
23
  { label: "Runs", to: "/automations/runs" },
42
24
  ];
43
25
 
44
- /** Keep Automation navigation consistent across lists, details, and editing. */
45
- export function AutomationsNavigation(props: {
26
+ /** Keep Automation navigation consistent across the overview, list, and runs. */
27
+ function AutomationsNavigation(props: {
46
28
  children: ReactNode;
47
29
  className?: string;
48
30
  }) {
@@ -4,7 +4,7 @@ Core owns validation, edit access, creator-only rules, revision checks,
4
4
  Schedule previews, and the Event catalog. Preview requests cannot save or run
5
5
  work. Completed scheduled Automations remain read-only.
6
6
 
7
- Owners and public Destination readers can edit. For a public reader, the
7
+ Creators and public Destination readers can edit. For a public reader, the
8
8
  editor shows outcomes as read-only and blocks turning on creator credential
9
9
  use. It warns that an instruction or event selector change switches to system
10
10
  credentials. Pause and resume stay creator-only.
@@ -18,7 +18,8 @@ can keep their changed fields or load the latest saved version. Unchanged fields
18
18
  come from that version; core checks its revision again on save.
19
19
 
20
20
  The data router blocks navigation with unsaved changes. Browser unload uses the
21
- native prompt. Save and Cancel keep the list query string.
21
+ native prompt. Save and Cancel return to the Automation page and keep its query
22
+ string.
22
23
 
23
24
  Version history lists saved versions, newest first, and marks the active
24
25
  version. "Make active" uses the native confirm dialog, like delete. It then
@@ -29,36 +30,36 @@ shows its error when a public reader cannot make a version active.
29
30
  Browser behavior lives in `e2e/automation-editor.spec.ts`. API and persistence
30
31
  coverage lives in core's `tests/integration/api/automation-edits.test.ts`.
31
32
 
32
- ## Automation details
33
-
34
- The URL selects one Automation in the list. Route matching follows React
35
- Router, including trailing slashes. `AutomationDetails` uses the shared
36
- `DetailsPanel`: a 400px panel at 1280px and above, and a Base UI bottom sheet on
37
- smaller screens. The list stays interactive beside the panel. Each pane scrolls
38
- on its own. `VisualViewportShell` owns the viewport height and mobile body lock;
39
- `AutomationsPageLayout` keeps navigation above the list and details.
40
-
41
- Loading and access errors stay inside the details frame. Closing keeps the list
42
- query string and returns focus to the selected row. If that row is not on the
43
- current page, focus returns to the list. Selecting another Automation resets
44
- its disclosures and lifecycle feedback. Deleting the selected Automation closes
45
- its details. Edit and version history retain the list query string.
46
-
47
- The existing Automation browser journeys cover selection, loading and retry,
48
- focus, mobile dismissal, deletion, and lifecycle actions. Use mock reporting to
49
- review scheduled and event Automations in both themes at desktop and mobile
50
- widths. Keep layout and scrollbar checks in manual QA.
51
-
52
- Details lead with lifecycle, the next trigger, and the last execution result.
53
- Instructions follow, then compact trigger and delivery rows. The sheet header
54
- uses the Automation name. `AutomationStatus`
55
- keeps a failed execution distinct from whether future runs remain enabled. It
56
- shows block reasons and links to the last Conversation when attention is needed.
57
- Scheduled times use the shared shadcn Accordion to show the schedule timezone
58
- and browser time. About this Automation uses the same component. Both start
59
- collapsed and reset when the selection changes.
60
- Paused, blocked, completed, and unavailable Automations explain why there is no
61
- next run instead of showing "None".
33
+ ## Automation page
34
+
35
+ Each Automation has one page at `/automations/:id`. Overview, Runs, and
36
+ Versions are tabs. They use `SecondaryNavigation`, like the repository page.
37
+ `/automations/:id/edit` is edit mode of the Overview tab. The old
38
+ `/executions` route redirects to `/runs` and keeps the query string. List rows
39
+ link to the page. There is no details slideout.
40
+
41
+ The page keeps the list query string. "All automations" returns to the same
42
+ list page and filters. The back link drops params that belong to one tab:
43
+ `result`, `run`, and `version`. One `range` param drives the Overview numbers
44
+ and the Runs chart.
45
+
46
+ The header shows the name, the state, the trigger, the Destination, and the
47
+ creator. `AutomationStateChip` is a live region, so Pause and Resume results
48
+ are announced. `AutomationActions` owns Edit, Pause or Resume, pause help, and
49
+ Delete. Lifecycle actions re-read the revision before they apply. Public
50
+ readers can edit settings. Pause, Resume, and Delete stay creator-only, so
51
+ other viewers do not see them. The About card names the creator-only actions.
52
+ Completed Automations have no Edit action, and `/edit` returns to Overview.
53
+
54
+ Overview shows `AutomationAttention` first when something needs action: a
55
+ block, an unavailable trigger, or a failed last run. The notice has one main
56
+ action, such as Change trigger or Open last run. While it shows, the header
57
+ Edit button becomes a secondary button. The saved settings follow
58
+ in the same `AutomationFormSection` rows as the editor, so edit mode changes
59
+ them in place. The side column shows the next trigger, the last result, runs,
60
+ failures, and spend for the range, and About. Spend is `costUsd` from the
61
+ Automation summary. On small screens, status comes first and About moves below
62
+ the settings.
62
63
 
63
64
  `AutomationRecentRuns` uses the existing execution-history query and cache. It
64
65
  shows up to three newest terminal runs, links to their Conversations when one
@@ -67,21 +68,30 @@ titles that repeat the Automation name. Its loading,
67
68
  empty, and retry states do not replace Automation settings. It does not infer
68
69
  queued or running work from terminal records.
69
70
 
70
- `AutomationLifecycle` owns revision-checked actions in a fixed footer. Edit and
71
- Delete use labeled icons there; list rows have no action menu. Public readers
72
- can edit settings. Delete and lifecycle actions stay creator-only. The shared
73
- shadcn Popover provides pause help beside those controls. The
74
- scroll region above it owns the summary and settings. Creator, creation date,
75
- connected accounts, run counts, and version history stay under About this
76
- Automation. Selection changes remount the content and actions together.
71
+ In edit mode, "Your changes" lists each changed field with the saved value, the
72
+ new value, and its own Undo. It is beside the form on wide screens and below
73
+ the form on small screens when there are changes. Undo remounts the fields, so fields with local state show the saved value again.
74
+ Changed sections get an accent mark. Save and Cancel return to Overview.
77
75
 
78
- Filters use a shadcn Popover at 768px and above and the shared BottomSheet below
79
- that width. Both edit the same URL filters immediately. Active filter chips
80
- remove one filter without clearing search, scope, or other filters. The mobile
81
- sheet fits its controls instead of taking the full details height.
76
+ The Runs tab filters loaded runs by result through the `result` param. Rows
77
+ show the version that ran and the recorded error. A row opens the run details
78
+ panel. The run list scrolls on its own beside it.
79
+
80
+ The Versions tab lists versions and compares the selected one with the version
81
+ before it. The `version` param selects a version; run details link to it.
82
+ Changed fields show the previous value. Instruction changes use a line diff.
82
83
 
83
84
  `AutomationDeleteDialog` names the Automation before deletion. It keeps errors
84
85
  and retry inside the dialog and blocks dismissal while the request is pending.
85
- Cancel returns focus to Delete. Successful deletion closes the selected details
86
- and returns focus to the list. Success toasts confirm Save, Pause, Resume, and
87
- Delete. Their state belongs to the mounted dashboard's Toaster provider.
86
+ Cancel returns focus to Delete. Successful deletion returns to the list.
87
+ Success toasts confirm Save, Pause, Resume, and Delete. Their state belongs to
88
+ the mounted dashboard's Toaster provider.
89
+
90
+ Browser journeys for the page live in `e2e/automations.spec.ts`. Use mock
91
+ reporting to review scheduled and event Automations in both themes at desktop
92
+ and mobile widths. Keep layout checks in manual QA.
93
+
94
+ Filters use a shadcn Popover at 768px and above and the shared BottomSheet below
95
+ that width. Both edit the same URL filters immediately. Active filter chips
96
+ remove one filter without clearing search, scope, or other filters. The mobile
97
+ sheet fits its controls instead of taking the full screen height.
@@ -69,6 +69,7 @@ export function RecentConversationRow(props: { conversation: Conversation }) {
69
69
  finishedSinceSeen={false}
70
70
  isPrivate={conversation.visibility === "private"}
71
71
  status={visualStatusForConversation(conversation)}
72
+ waitingOnViewer={false}
72
73
  />
73
74
  </span>
74
75
  <div className="min-w-0 truncate font-display text-sm text-dashboard-text">
@@ -18,6 +18,7 @@ import { Card } from "../../components/layout/Card";
18
18
  import { ConversationHomeList } from "../../conversations/ConversationHomeList";
19
19
  import { buildConversations, getDashboardTimeZone } from "../../format";
20
20
  import { useDebouncedValue } from "../../debouncedValue";
21
+ import { useWaitingConversationIds } from "../../review-desk/queries";
21
22
  import { CodeActivityChart } from "./CodeActivityChart";
22
23
  import { CodeChangeRow } from "./CodeChangeRow";
23
24
  import { repositoryActivity } from "./RepositorySections";
@@ -32,6 +33,7 @@ export function RepositoryConversationsTab(props: { repositoryId: string }) {
32
33
  const search = useDebouncedValue(query.trim());
33
34
  const feed = useCodeRepositoryConversationsData(props.repositoryId, search);
34
35
  const conversations = buildConversations(feed.data?.conversations ?? []);
36
+ const waitingConversationIds = useWaitingConversationIds();
35
37
  useEffect(() => {
36
38
  setPage(1);
37
39
  }, [search]);
@@ -59,6 +61,7 @@ export function RepositoryConversationsTab(props: { repositoryId: string }) {
59
61
  finishedConversationIds={noFinishedConversations}
60
62
  loading={!feed.data}
61
63
  timeZone={getDashboardTimeZone()}
64
+ waitingConversationIds={waitingConversationIds}
62
65
  />
63
66
  )}
64
67
  <PagePagination
@@ -9,6 +9,7 @@ const AUTH_PRIMARY_NAV_PREFIXES = [
9
9
  "/memories",
10
10
  "/settings",
11
11
  "/plugins/",
12
+ "/review",
12
13
  ] as const;
13
14
 
14
15
  /** True when the path is only available to a signed-in viewer. */
@@ -24,6 +25,8 @@ export function buildPrimaryNavItems(input: {
24
25
  loggedIn: boolean;
25
26
  pathname: string;
26
27
  primaryUserPages: PluginUserPageLink[];
28
+ /** Open Review items assigned to the viewer. */
29
+ reviewCount?: number;
27
30
  userPagesPending: boolean;
28
31
  }): DashboardHeaderNavItem[] {
29
32
  const memoryNavPage = input.primaryUserPages.find(
@@ -32,8 +35,8 @@ export function buildPrimaryNavItems(input: {
32
35
  const otherPrimaryUserPages = input.primaryUserPages.filter(
33
36
  (page) => !(page.pluginName === "memory" && page.id === "memories"),
34
37
  );
35
- // Auth-only routes already imply the signed-in primary nav. Hold Tasks and
36
- // Memories slots while core/user-pages catch up so the header does not jump.
38
+ // Auth-only routes already imply the signed-in primary nav. Hold Review
39
+ // desk, Tasks, and Memories slots while core/user-pages catch up so the header does not jump.
37
40
  const reserveAuthPrimaryNav =
38
41
  input.loading && isAuthPrimaryNavPath(input.pathname);
39
42
  const showTasksNav = input.loggedIn || reserveAuthPrimaryNav;
@@ -43,6 +46,16 @@ export function buildPrimaryNavItems(input: {
43
46
  (input.loggedIn && input.userPagesPending);
44
47
 
45
48
  return [
49
+ ...(showTasksNav
50
+ ? [
51
+ {
52
+ count: input.reviewCount,
53
+ key: "review",
54
+ label: "Review desk",
55
+ to: "/review",
56
+ },
57
+ ]
58
+ : []),
46
59
  { key: "code", label: "Code", to: "/code" },
47
60
  ...(showTasksNav
48
61
  ? [{ key: "automations", label: "Automations", to: "/automations" }]
@@ -0,0 +1,62 @@
1
+ import { ChevronRight } from "lucide-react";
2
+ import { useState } from "react";
3
+
4
+ import { Drawer } from "../components/Drawer";
5
+ import { ReviewItemCard } from "./ReviewItemCard";
6
+ import { useReviewItemsData } from "./queries";
7
+
8
+ function itemCountLabel(count: number): string {
9
+ return count === 1 ? "1 open review item" : `${count} open review items`;
10
+ }
11
+
12
+ /**
13
+ * Show how many open Review items are linked to a Conversation, above its
14
+ * message box. The bar opens a side panel where the viewer can act on each.
15
+ */
16
+ export function ConversationReviewBar(props: { conversationId: string }) {
17
+ const [open, setOpen] = useState(false);
18
+ const items =
19
+ useReviewItemsData({
20
+ archived: false,
21
+ conversationId: props.conversationId,
22
+ view: "all",
23
+ }).data?.items ?? [];
24
+ if (items.length === 0) return null;
25
+ const label = itemCountLabel(items.length);
26
+ return (
27
+ <>
28
+ <button
29
+ className="mb-2 flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg border border-violet-300/25 bg-violet-300/[0.06] px-3 py-2 text-left font-sans text-sm text-dashboard-text transition-colors hover:bg-violet-300/[0.1]"
30
+ onClick={() => setOpen(true)}
31
+ type="button"
32
+ >
33
+ <span className="flex items-center gap-2">
34
+ <span
35
+ aria-hidden="true"
36
+ className="size-2 shrink-0 rounded-full bg-violet-300"
37
+ />
38
+ {label}
39
+ </span>
40
+ <ChevronRight aria-hidden="true" size={16} />
41
+ </button>
42
+ {open ? (
43
+ <Drawer
44
+ closeLabel="Close review items"
45
+ header={
46
+ <h2 className="m-0 font-sans text-base font-semibold">{label}</h2>
47
+ }
48
+ label="Review items"
49
+ onClose={() => setOpen(false)}
50
+ openKey={props.conversationId}
51
+ width="narrow"
52
+ >
53
+ <div className="grid gap-3">
54
+ {items.map((item) => (
55
+ <ReviewItemCard item={item} key={item.id} />
56
+ ))}
57
+ </div>
58
+ </Drawer>
59
+ ) : null}
60
+ </>
61
+ );
62
+ }
@@ -0,0 +1,277 @@
1
+ import type { ReactNode } from "react";
2
+ import { Link, useSearchParams } from "react-router";
3
+ import type {
4
+ AutomationSummary,
5
+ ReviewItemPayload,
6
+ } from "@sentry/junior/api/schema";
7
+
8
+ import { FilterTabList } from "../components/FilterBar";
9
+ import { InlineError } from "../components/InlineError";
10
+ import { SearchInput } from "../components/SearchInput";
11
+ import { StatusChip } from "../components/StatusChip";
12
+ import { Card } from "../components/layout/Card";
13
+ import { PageHeader } from "../components/layout/PageHeader";
14
+ import { PageLayout } from "../components/layout/PageLayout";
15
+ import { Skeleton } from "../components/ui/skeleton";
16
+ import { automationPath, formatRelativeTime } from "../format";
17
+ import {
18
+ parseSearchParamEnum,
19
+ pathWithSearch,
20
+ useDebouncedSearchParam,
21
+ } from "../searchParams";
22
+ import { cn } from "../styles";
23
+ import { ReviewItemDetail } from "./ReviewItemDetail";
24
+ import {
25
+ REVIEW_DESK_VIEWS,
26
+ useBlockedAutomationsData,
27
+ useReviewItemData,
28
+ useReviewItemsData,
29
+ } 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;
44
+
45
+ const rowClass =
46
+ "grid gap-0.5 rounded-md px-3 py-2 text-inherit no-underline transition-colors";
47
+
48
+ // The list and the detail are already cards, so an empty state is plain
49
+ // text, not a second box.
50
+ function EmptyMessage(props: { children: ReactNode }) {
51
+ return (
52
+ <p className="m-0 px-4 py-8 text-center text-sm text-dashboard-text-muted">
53
+ {props.children}
54
+ </p>
55
+ );
56
+ }
57
+
58
+ function ListGroup(props: { children: ReactNode; title: string }) {
59
+ return (
60
+ <section className="grid gap-1">
61
+ <h2 className="m-0 px-3 font-sans text-xs font-medium text-dashboard-text-muted">
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 unassigned = !item.assignedToEveryone && item.assignees.length === 0;
76
+ return (
77
+ <li>
78
+ <Link
79
+ aria-current={props.selected ? "true" : undefined}
80
+ className={cn(
81
+ rowClass,
82
+ props.selected
83
+ ? "bg-dashboard-accent/[0.08]"
84
+ : "hover:bg-dashboard-fill-soft",
85
+ )}
86
+ replace
87
+ to={pathWithSearch(reviewItemPath(item.id), props.searchParams)}
88
+ >
89
+ <span className="truncate text-sm font-medium">{item.title}</span>
90
+ <span className="flex min-w-0 items-center gap-2 text-xs text-dashboard-text-muted">
91
+ {unassigned ? (
92
+ <StatusChip size="compact" tone="warning">
93
+ Unassigned
94
+ </StatusChip>
95
+ ) : (
96
+ <span className="truncate">{assigneeLabel(item)}</span>
97
+ )}
98
+ <span className="shrink-0">
99
+ {formatRelativeTime(item.closedAt ?? item.updatedAt)}
100
+ </span>
101
+ </span>
102
+ </Link>
103
+ </li>
104
+ );
105
+ }
106
+
107
+ function BlockedAutomationRow(props: { automation: AutomationSummary }) {
108
+ return (
109
+ <li>
110
+ <Link
111
+ className={cn(rowClass, "hover:bg-dashboard-fill-soft")}
112
+ to={automationPath(props.automation.id)}
113
+ >
114
+ <span className="truncate text-sm font-medium">
115
+ {props.automation.title}
116
+ </span>
117
+ <span className="truncate text-xs text-amber-300">
118
+ {props.automation.statusReason ?? "Blocked"}
119
+ </span>
120
+ </Link>
121
+ </li>
122
+ );
123
+ }
124
+
125
+ function ReviewListLoading() {
126
+ return (
127
+ <div
128
+ aria-busy="true"
129
+ aria-label="Loading review items"
130
+ className="grid gap-2"
131
+ >
132
+ {[0, 1, 2, 3].map((row) => (
133
+ <div className="grid gap-1.5 px-3 py-2" key={row}>
134
+ <Skeleton className="h-4 w-3/4" />
135
+ <Skeleton className="h-3 w-1/3" />
136
+ </div>
137
+ ))}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ /**
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.
145
+ */
146
+ 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
+ }
166
+ const [search, setSearch, q] = useDebouncedSearchParam();
167
+ const archived = state === "archived";
168
+ const items = useReviewItemsData({ archived, q, view });
169
+ const showBlocked = !archived && view !== "everyone" && !q;
170
+ const blocked = useBlockedAutomationsData(showBlocked);
171
+ const list = items.data?.items ?? [];
172
+ const selectedId = searchParams.get("item") ?? list[0]?.id;
173
+ const listed = list.find((item) => item.id === selectedId);
174
+ // A link can select an item that is not in the current view.
175
+ const direct = useReviewItemData(listed ? undefined : selectedId);
176
+ const selected = listed ?? direct.data;
177
+ const actions = list.filter((item) => item.kind === "action");
178
+ const notices = list.filter((item) => item.kind === "notice");
179
+ const blockedAutomations = showBlocked
180
+ ? (blocked.data?.automations ?? [])
181
+ : [];
182
+ const empty =
183
+ items.data && list.length === 0 && blockedAutomations.length === 0;
184
+
185
+ return (
186
+ <PageLayout>
187
+ <PageHeader
188
+ description="Items that need a person, and who can act on them."
189
+ title="Review desk"
190
+ />
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>
212
+ <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 ? (
215
+ <InlineError className="px-3">
216
+ Could not load review items.
217
+ </InlineError>
218
+ ) : !items.data ? (
219
+ <ReviewListLoading />
220
+ ) : empty ? (
221
+ <EmptyMessage>
222
+ {archived ? "No archived items." : "Nothing needs you right now."}
223
+ </EmptyMessage>
224
+ ) : (
225
+ <>
226
+ {actions.length > 0 ? (
227
+ <ListGroup title={archived ? "Questions" : "Needs an answer"}>
228
+ {actions.map((item) => (
229
+ <ItemRow
230
+ item={item}
231
+ key={item.id}
232
+ searchParams={searchParams}
233
+ selected={item.id === selected?.id}
234
+ />
235
+ ))}
236
+ </ListGroup>
237
+ ) : null}
238
+ {notices.length > 0 ? (
239
+ <ListGroup title="Notices">
240
+ {notices.map((item) => (
241
+ <ItemRow
242
+ item={item}
243
+ key={item.id}
244
+ searchParams={searchParams}
245
+ selected={item.id === selected?.id}
246
+ />
247
+ ))}
248
+ </ListGroup>
249
+ ) : null}
250
+ {blockedAutomations.length > 0 ? (
251
+ <ListGroup title="Blocked Automations">
252
+ {blockedAutomations.map((automation) => (
253
+ <BlockedAutomationRow
254
+ automation={automation}
255
+ key={automation.id}
256
+ />
257
+ ))}
258
+ </ListGroup>
259
+ ) : null}
260
+ </>
261
+ )}
262
+ </Card>
263
+ <Card className="min-w-0" padding="md">
264
+ {selected ? (
265
+ // A key per item resets the answer form and its send lock, so
266
+ // one item's answer can never reach another item.
267
+ <ReviewItemDetail item={selected} key={selected.id} />
268
+ ) : direct.error ? (
269
+ <InlineError>Review item not found.</InlineError>
270
+ ) : (
271
+ <EmptyMessage>Select an item to see it here.</EmptyMessage>
272
+ )}
273
+ </Card>
274
+ </div>
275
+ </PageLayout>
276
+ );
277
+ }