@sentry/junior-dashboard 0.267.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 (38) hide show
  1. package/dist/app.js +38 -2
  2. package/dist/client.js +81 -82
  3. package/dist/index.js +38 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/client/App.tsx +20 -0
  7. package/src/client/components/ParticipantAvatarStack.tsx +2 -1
  8. package/src/client/components/README.md +4 -0
  9. package/src/client/components/UserInputForm.tsx +283 -0
  10. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  11. package/src/client/components/ui/button.tsx +11 -1
  12. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  13. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  14. package/src/client/conversations/ConversationPage.tsx +4 -0
  15. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  16. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  17. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  18. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  19. package/src/client/conversations/TranscriptMessageView.tsx +3 -0
  20. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  21. package/src/client/conversations/TranscriptTurnContextView.tsx +142 -144
  22. package/src/client/conversations/UserInputRequestCard.tsx +14 -219
  23. package/src/client/conversations/turnContext.ts +15 -0
  24. package/src/client/format.ts +21 -5
  25. package/src/client/pages/SettingsPage.tsx +103 -3
  26. package/src/client/pages/code/RepositorySections.tsx +1 -0
  27. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  28. package/src/client/primaryNav.ts +15 -2
  29. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  30. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  31. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  32. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  33. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  34. package/src/client/review-desk/queries.ts +164 -0
  35. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  36. package/src/mock-reporting/fixtures.ts +10 -0
  37. package/src/mock-reporting/routes.ts +20 -0
  38. package/src/shell.ts +3 -0
@@ -33,3 +33,18 @@ export function memoryRecallContent(context: TranscriptViewTurnContext) {
33
33
  const parsed = memoryRecallContentSchema.safeParse(context.content);
34
34
  return parsed.success ? parsed.data : undefined;
35
35
  }
36
+
37
+ const userCustomizationContentSchema = z.object({ text: z.string() }).strict();
38
+
39
+ /** Parse the saved user customization that the runtime added to one Turn. */
40
+ export function userCustomizationContent(context: TranscriptViewTurnContext) {
41
+ if (
42
+ context.pluginName !== "junior" ||
43
+ context.kind !== "user_customization" ||
44
+ context.version !== 1
45
+ ) {
46
+ return undefined;
47
+ }
48
+ const parsed = userCustomizationContentSchema.safeParse(context.content);
49
+ return parsed.success ? parsed.data : undefined;
50
+ }
@@ -360,6 +360,21 @@ export type MessageSummary = {
360
360
  total: number;
361
361
  };
362
362
 
363
+ /**
364
+ * Return the person who wrote one transcript user message. Child agent input
365
+ * has no person. Other messages fall back to the conversation actor.
366
+ */
367
+ export function transcriptMessageActor(
368
+ conversation: ConversationTranscript,
369
+ message: TranscriptViewMessage,
370
+ ): ActorIdentity | undefined {
371
+ if (transcriptRoleKind(message.role) !== "user") return undefined;
372
+ if (message.agentInvocationId) return undefined;
373
+ if (actorLabel(message.actorIdentity)) return message.actorIdentity;
374
+ if (actorLabel(conversation.actorIdentity)) return conversation.actorIdentity;
375
+ return undefined;
376
+ }
377
+
363
378
  /** Return the best available actor label for one transcript message. */
364
379
  export function transcriptMessageActorLabel(
365
380
  conversation: ConversationTranscript,
@@ -371,11 +386,7 @@ export function transcriptMessageActorLabel(
371
386
  // The conversation actor started the child agent but did not write its
372
387
  // input, so do not fall back to that actor here.
373
388
  if (message.agentInvocationId) return "Agent invocation";
374
- return (
375
- actorLabel(message.actorIdentity) ??
376
- actorLabel(conversation.actorIdentity) ??
377
- "User"
378
- );
389
+ return actorLabel(transcriptMessageActor(conversation, message)) ?? "User";
379
390
  }
380
391
  if (kind === "system") return "System";
381
392
  if (kind === "tool") return "Tool";
@@ -786,6 +797,11 @@ export function peoplePath(email: string): string {
786
797
  return `/people/${encodeURIComponent(email)}`;
787
798
  }
788
799
 
800
+ /** Build the dashboard route for one memory's details. */
801
+ export function memoryPath(memoryId: string): string {
802
+ return `/memories/${encodeURIComponent(memoryId)}`;
803
+ }
804
+
789
805
  /** Build the canonical detail route for a persisted public location. */
790
806
  export function locationPath(locationId: string): string {
791
807
  return `/system/locations/${encodeURIComponent(locationId)}`;
@@ -1,21 +1,28 @@
1
- import { useMutation, useQueryClient } from "@tanstack/react-query";
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import { useEffect, useState, type FormEvent } from "react";
3
3
 
4
+ import {
5
+ USER_CUSTOMIZATION_MAX_CHARS,
6
+ userCustomizationSchema,
7
+ } from "@sentry/junior/api/schema";
4
8
  import {
5
9
  dashboardIdentitySchema,
6
10
  type DashboardIdentity,
7
11
  } from "../../api/schema";
8
12
  import { Button } from "../components/ui/button";
9
13
  import { Input } from "../components/ui/input";
14
+ import { Textarea } from "../components/ui/textarea";
10
15
  import { Field, FieldDescription, FieldLabel } from "../components/ui/field";
11
16
  import { InlineError } from "../components/InlineError";
12
17
  import { Card } from "../components/layout/Card";
13
18
  import { useDocumentTitle } from "../documentTitle";
14
- import { patch } from "../http";
19
+ import { DashboardApiError, fetchDashboardJson, patch, put } from "../http";
15
20
  import { dashboardContainerClass } from "../styles";
16
21
  import type { DashboardCoreData } from "../types";
17
22
 
18
23
  const dashboardCoreQueryKey = ["dashboard", "core"] as const;
24
+ const userCustomizationQueryKey = ["dashboard", "user-customization"] as const;
25
+ const USER_CUSTOMIZATION_PATH = "/api/user-customization";
19
26
 
20
27
  type SettingsPageProps = {
21
28
  identity: DashboardIdentity;
@@ -58,7 +65,7 @@ export function SettingsPage({ identity }: SettingsPageProps) {
58
65
  <section className="mx-auto w-full max-w-3xl">
59
66
  <h1 className="m-0 text-2xl font-bold">Settings</h1>
60
67
  <p className="mt-2 mb-0 max-w-2xl text-sm text-dashboard-text-muted">
61
- Manage how your account appears in the dashboard.
68
+ Manage your profile and preferences.
62
69
  </p>
63
70
 
64
71
  <form className="mt-6" onSubmit={submit}>
@@ -97,7 +104,100 @@ export function SettingsPage({ identity }: SettingsPageProps) {
97
104
  </div>
98
105
  </Card>
99
106
  </form>
107
+ <CustomizationForm />
100
108
  </section>
101
109
  </div>
102
110
  );
103
111
  }
112
+
113
+ /**
114
+ * Let the user save text that changes how the agent replies to them. The
115
+ * server returns 404 when the app does not enable this experiment.
116
+ */
117
+ function CustomizationForm() {
118
+ const queryClient = useQueryClient();
119
+ const [text, setText] = useState("");
120
+ const customizationQuery = useQuery({
121
+ queryKey: userCustomizationQueryKey,
122
+ queryFn: ({ signal }) =>
123
+ fetchDashboardJson(
124
+ userCustomizationSchema,
125
+ USER_CUSTOMIZATION_PATH,
126
+ signal,
127
+ ),
128
+ retry: false,
129
+ });
130
+ const savedText = customizationQuery.data?.customization;
131
+ useEffect(() => {
132
+ if (savedText !== undefined) setText(savedText);
133
+ }, [savedText]);
134
+ const save = useMutation({
135
+ mutationFn: (customization: string) =>
136
+ put(userCustomizationSchema, USER_CUSTOMIZATION_PATH, { customization }),
137
+ onSuccess: (updated) => {
138
+ queryClient.setQueryData(userCustomizationQueryKey, updated);
139
+ },
140
+ });
141
+ if (
142
+ customizationQuery.error instanceof DashboardApiError &&
143
+ customizationQuery.error.status === 404
144
+ ) {
145
+ return null;
146
+ }
147
+ if (savedText === undefined) {
148
+ return customizationQuery.isError ? (
149
+ <InlineError className="mt-6">
150
+ Could not load your customization.
151
+ </InlineError>
152
+ ) : null;
153
+ }
154
+ const canSave =
155
+ text.trim() !== savedText &&
156
+ text.trim().length <= USER_CUSTOMIZATION_MAX_CHARS &&
157
+ !save.isPending;
158
+
159
+ function submit(event: FormEvent<HTMLFormElement>) {
160
+ event.preventDefault();
161
+ if (canSave) save.mutate(text.trim());
162
+ }
163
+
164
+ return (
165
+ <form className="mt-6" onSubmit={submit}>
166
+ <Card className="mb-0" padding="md" variant="raised">
167
+ <h2 className="m-0 text-lg font-bold">Customization</h2>
168
+ <Field className="mt-5">
169
+ <FieldLabel htmlFor="user-customization">
170
+ How should replies to you change?
171
+ </FieldLabel>
172
+ <Textarea
173
+ id="user-customization"
174
+ maxLength={USER_CUSTOMIZATION_MAX_CHARS}
175
+ onChange={(event) => {
176
+ setText(event.target.value);
177
+ if (save.isError || save.isSuccess) save.reset();
178
+ }}
179
+ placeholder="Keep replies short and simple."
180
+ value={text}
181
+ />
182
+ <FieldDescription>
183
+ Added to each message you send. Do not put secrets here. (
184
+ {text.trim().length}/{USER_CUSTOMIZATION_MAX_CHARS})
185
+ </FieldDescription>
186
+ </Field>
187
+ <div className="mt-5 flex items-center gap-3">
188
+ <Button disabled={!canSave} type="submit">
189
+ {save.isPending ? "Saving…" : "Save customization"}
190
+ </Button>
191
+ {save.isSuccess ? (
192
+ <p className="m-0 text-sm text-emerald-300">Changes saved.</p>
193
+ ) : null}
194
+ {save.isError ? (
195
+ <InlineError>
196
+ Could not save your customization. Try again.
197
+ </InlineError>
198
+ ) : null}
199
+ </div>
200
+ </Card>
201
+ </form>
202
+ );
203
+ }
@@ -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
+ }