@sentry/junior-dashboard 0.267.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.
- package/README.md +7 -105
- package/dist/app.js +47 -8
- package/dist/client.js +74 -78
- package/dist/index.js +47 -8
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/api/schema.ts +2 -1
- package/src/app.ts +11 -7
- package/src/client/App.tsx +20 -4
- package/src/client/api.ts +4 -19
- package/src/client/components/AGENTS.md +65 -0
- package/src/client/components/ActorAvatar.tsx +1 -1
- package/src/client/components/ParticipantAvatarStack.tsx +3 -1
- package/src/client/components/ProfileMenu.tsx +88 -29
- package/src/client/components/UserInputForm.tsx +283 -0
- package/src/client/components/charts/AGENTS.md +15 -0
- package/src/client/components/layout/DashboardHeader.tsx +13 -0
- package/src/client/components/ui/button.tsx +11 -1
- package/src/client/conversations/AGENTS.md +42 -0
- package/src/client/conversations/ConversationHomeList.tsx +6 -0
- package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
- package/src/client/conversations/ConversationPage.tsx +4 -27
- package/src/client/conversations/ConversationSidebar.tsx +6 -0
- package/src/client/conversations/ConversationWorkspace.tsx +4 -0
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
- package/src/client/conversations/TranscriptMessageView.tsx +3 -0
- package/src/client/conversations/TranscriptToolView.tsx +13 -1
- package/src/client/conversations/TranscriptTurnContextView.tsx +142 -144
- package/src/client/conversations/turnContext.ts +15 -0
- package/src/client/format.ts +28 -28
- package/src/client/pages/SettingsPage.tsx +103 -3
- package/src/client/pages/automations/AGENTS.md +34 -0
- package/src/client/pages/automations/AutomationActions.tsx +18 -20
- package/src/client/pages/automations/AutomationEditor.tsx +3 -4
- package/src/client/pages/automations/AutomationOverview.tsx +3 -3
- package/src/client/pages/automations/AutomationStatus.tsx +2 -2
- package/src/client/pages/code/RepositorySections.tsx +1 -0
- package/src/client/pages/code/RepositoryTabs.tsx +3 -0
- package/src/client/pages/system/AGENTS.md +12 -0
- package/src/client/primaryNav.ts +15 -2
- package/src/client/review-desk/ConversationReviewBar.tsx +68 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +297 -0
- package/src/client/review-desk/ReviewItemActions.tsx +165 -0
- package/src/client/review-desk/ReviewItemCard.tsx +47 -0
- package/src/client/review-desk/ReviewItemDetail.tsx +123 -0
- package/src/client/review-desk/queries.ts +173 -0
- package/src/client/review-desk/reviewItemFormat.ts +61 -0
- package/src/mock-reporting/fixtures.ts +10 -0
- package/src/mock-reporting/routes.ts +20 -0
- package/src/shell.ts +3 -0
- package/src/tailwind.css +1 -1
- package/src/client/components/README.md +0 -215
- package/src/client/components/charts/README.md +0 -38
- package/src/client/conversations/README.md +0 -159
- package/src/client/conversations/UserInputRequestCard.tsx +0 -244
- package/src/client/conversations/userInputRequest.ts +0 -56
- package/src/client/pages/automations/README.md +0 -97
- package/src/client/pages/system/README.md +0 -23
|
@@ -0,0 +1,297 @@
|
|
|
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";
|
|
8
|
+
|
|
9
|
+
import { InlineError } from "../components/InlineError";
|
|
10
|
+
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";
|
|
16
|
+
import { Skeleton } from "../components/ui/skeleton";
|
|
17
|
+
import { automationPath, formatRelativeTime } from "../format";
|
|
18
|
+
import { pathWithSearch, useDebouncedSearchParam } from "../searchParams";
|
|
19
|
+
import { cn } from "../styles";
|
|
20
|
+
import { ReviewItemDetail } from "./ReviewItemDetail";
|
|
21
|
+
import {
|
|
22
|
+
useBlockedAutomationsData,
|
|
23
|
+
useReviewItemData,
|
|
24
|
+
useReviewItemsData,
|
|
25
|
+
} 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";
|
|
34
|
+
|
|
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
|
+
}
|
|
44
|
+
|
|
45
|
+
function ListGroup(props: {
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
className?: string;
|
|
48
|
+
icon?: ReactNode;
|
|
49
|
+
title: string;
|
|
50
|
+
}) {
|
|
51
|
+
const headingId = useId();
|
|
52
|
+
return (
|
|
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
|
+
{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>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function BlockedAutomationRow(props: { automation: AutomationSummary }) {
|
|
120
|
+
return (
|
|
121
|
+
<li>
|
|
122
|
+
<Link
|
|
123
|
+
className={cn(rowClass, "hover:bg-dashboard-fill-soft")}
|
|
124
|
+
to={automationPath(props.automation.id)}
|
|
125
|
+
>
|
|
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
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
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.
|
|
158
|
+
*/
|
|
159
|
+
export function ReviewDeskPage() {
|
|
160
|
+
const [searchParams] = useSearchParams();
|
|
161
|
+
const [search, setSearch, q] = useDebouncedSearchParam();
|
|
162
|
+
const { items: list, moreArchived, query: items } = useReviewItemsData({ q });
|
|
163
|
+
const showBlocked = !q;
|
|
164
|
+
const blocked = useBlockedAutomationsData(showBlocked);
|
|
165
|
+
const selectedId = searchParams.get("item") ?? list[0]?.id;
|
|
166
|
+
const listed = list.find((item) => item.id === selectedId);
|
|
167
|
+
// A link can select an item that is not in the current view.
|
|
168
|
+
const direct = useReviewItemData(listed ? undefined : selectedId);
|
|
169
|
+
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
|
+
: [];
|
|
177
|
+
const empty =
|
|
178
|
+
items.data && list.length === 0 && blockedAutomations.length === 0;
|
|
179
|
+
|
|
180
|
+
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
|
+
) : (
|
|
208
|
+
<>
|
|
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
|
+
<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}
|
|
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>
|
|
279
|
+
) : null}
|
|
280
|
+
</>
|
|
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>
|
|
294
|
+
</div>
|
|
295
|
+
</PageLayout>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
normalizeUserInputAnswer,
|
|
4
|
+
type ReviewItemActionBody,
|
|
5
|
+
type ReviewItemPayload,
|
|
6
|
+
} from "@sentry/junior/api/schema";
|
|
7
|
+
|
|
8
|
+
import { InlineError } from "../components/InlineError";
|
|
9
|
+
import { UserInputForm } from "../components/UserInputForm";
|
|
10
|
+
import { Button } from "../components/ui/button";
|
|
11
|
+
import { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
|
|
12
|
+
import { formatRelativeTime } from "../format";
|
|
13
|
+
import { DashboardApiError } from "../http";
|
|
14
|
+
import { useReviewItemAction } from "./queries";
|
|
15
|
+
|
|
16
|
+
function personLabel(person: { displayName?: string; email: string }) {
|
|
17
|
+
return person.displayName ?? person.email;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function actionError(error: unknown): string {
|
|
21
|
+
if (error instanceof DashboardApiError && error.apiError) {
|
|
22
|
+
return error.apiError;
|
|
23
|
+
}
|
|
24
|
+
return "Could not save this action. Try again.";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Show the submitted answer as one line for each answered field. */
|
|
28
|
+
function AnswerSummary(props: {
|
|
29
|
+
answer: NonNullable<ReviewItemPayload["answer"]>;
|
|
30
|
+
request: NonNullable<ReviewItemPayload["request"]>;
|
|
31
|
+
}) {
|
|
32
|
+
const answer = normalizeUserInputAnswer(props.request, props.answer);
|
|
33
|
+
const rows = props.request.fields.flatMap((field) => {
|
|
34
|
+
const values = answer.values[field.id];
|
|
35
|
+
return values?.length
|
|
36
|
+
? [{ label: field.label, value: values.join(", ") }]
|
|
37
|
+
: [];
|
|
38
|
+
});
|
|
39
|
+
if (answer.other) rows.push({ label: "Other", value: answer.other });
|
|
40
|
+
return (
|
|
41
|
+
<dl className="m-0 grid gap-2 rounded-md bg-dashboard-fill-soft p-3">
|
|
42
|
+
{rows.map((row) => (
|
|
43
|
+
<div className="grid gap-0.5" key={row.label}>
|
|
44
|
+
<dt className="text-xs font-medium text-dashboard-text-muted">
|
|
45
|
+
{row.label}
|
|
46
|
+
</dt>
|
|
47
|
+
<dd className="m-0 whitespace-pre-wrap break-words">{row.value}</dd>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</dl>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Show who closed an item, when, and the submitted answer. */
|
|
55
|
+
function ClosedSummary(props: { item: ReviewItemPayload }) {
|
|
56
|
+
const { item } = props;
|
|
57
|
+
const who = item.closedBy ? personLabel(item.closedBy) : "An agent";
|
|
58
|
+
const what = item.status === "dismissed" ? "dismissed" : "closed";
|
|
59
|
+
return (
|
|
60
|
+
<div className="grid gap-2 text-sm">
|
|
61
|
+
<p className="m-0 text-dashboard-text-muted">
|
|
62
|
+
{who} {what} this {formatRelativeTime(item.closedAt)}.
|
|
63
|
+
</p>
|
|
64
|
+
{item.closeReason ? (
|
|
65
|
+
<p className="m-0 text-dashboard-text-muted">{item.closeReason}</p>
|
|
66
|
+
) : null}
|
|
67
|
+
{item.request && item.answer ? (
|
|
68
|
+
<AnswerSummary answer={item.answer} request={item.request} />
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Show the actions that the viewer can take on one Review item, or how it
|
|
76
|
+
* closed. The review desk, the transcript card, and the side panel use it.
|
|
77
|
+
*/
|
|
78
|
+
export function ReviewItemActions(props: { item: ReviewItemPayload }) {
|
|
79
|
+
const { item } = props;
|
|
80
|
+
const action = useReviewItemAction();
|
|
81
|
+
const [error, setError] = useState<string>();
|
|
82
|
+
|
|
83
|
+
if (item.status !== "open") return <ClosedSummary item={item} />;
|
|
84
|
+
if (!item.canAct) {
|
|
85
|
+
return (
|
|
86
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
87
|
+
Only an assignee can act on this item.
|
|
88
|
+
</p>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function run(body: ReviewItemActionBody) {
|
|
93
|
+
setError(undefined);
|
|
94
|
+
try {
|
|
95
|
+
await action.mutateAsync({ body, id: item.id });
|
|
96
|
+
} catch (failure) {
|
|
97
|
+
setError(actionError(failure));
|
|
98
|
+
throw failure;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Take and Dismiss are quiet, so the answer or Done stays the main action.
|
|
103
|
+
const secondaryActions = (
|
|
104
|
+
<>
|
|
105
|
+
{item.canTake ? (
|
|
106
|
+
<Button
|
|
107
|
+
disabled={action.isPending}
|
|
108
|
+
onClick={() => void run({ action: "take" }).catch(() => {})}
|
|
109
|
+
variant="quiet"
|
|
110
|
+
>
|
|
111
|
+
Take
|
|
112
|
+
</Button>
|
|
113
|
+
) : null}
|
|
114
|
+
<Button
|
|
115
|
+
disabled={action.isPending}
|
|
116
|
+
onClick={() => void run({ action: "dismiss" }).catch(() => {})}
|
|
117
|
+
variant="quiet"
|
|
118
|
+
>
|
|
119
|
+
Dismiss
|
|
120
|
+
</Button>
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="grid gap-3">
|
|
126
|
+
{item.kind === "action" && item.request ? (
|
|
127
|
+
<div className="text-sm">
|
|
128
|
+
<div className="font-medium">
|
|
129
|
+
<TranscriptMarkdown text={item.request.question} />
|
|
130
|
+
</div>
|
|
131
|
+
<UserInputForm
|
|
132
|
+
onSubmit={(answer) =>
|
|
133
|
+
run({
|
|
134
|
+
action: "submit",
|
|
135
|
+
answer: {
|
|
136
|
+
values: answer.values,
|
|
137
|
+
...(answer.other ? { other: answer.other } : undefined),
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
request={item.request}
|
|
142
|
+
requestId={item.id}
|
|
143
|
+
secondaryActions={secondaryActions}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
) : (
|
|
147
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
148
|
+
{item.kind === "notice" ? (
|
|
149
|
+
<Button
|
|
150
|
+
disabled={action.isPending}
|
|
151
|
+
onClick={() => void run({ action: "complete" }).catch(() => {})}
|
|
152
|
+
variant="primary"
|
|
153
|
+
>
|
|
154
|
+
Done
|
|
155
|
+
</Button>
|
|
156
|
+
) : null}
|
|
157
|
+
<div className="flex flex-wrap gap-1 sm:ml-auto">
|
|
158
|
+
{secondaryActions}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
{error ? <InlineError>{error}</InlineError> : null}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Link } from "react-router";
|
|
2
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
3
|
+
|
|
4
|
+
import { StatusChip } from "../components/StatusChip";
|
|
5
|
+
import { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
|
|
6
|
+
import { ReviewItemActions } from "./ReviewItemActions";
|
|
7
|
+
import { useReviewItemData } from "./queries";
|
|
8
|
+
import { reviewItemPath, reviewItemStatusLabel } from "./reviewItemFormat";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Show one Review item as a card: its title, status, description, and the
|
|
12
|
+
* viewer's actions, with a link to the review desk.
|
|
13
|
+
*/
|
|
14
|
+
export function ReviewItemCard(props: { item: ReviewItemPayload }) {
|
|
15
|
+
const { item } = props;
|
|
16
|
+
return (
|
|
17
|
+
<section
|
|
18
|
+
aria-label={`Review item: ${item.title}`}
|
|
19
|
+
className="grid gap-3 rounded-lg border border-violet-300/25 bg-violet-300/[0.06] p-3 font-sans text-sm text-dashboard-text"
|
|
20
|
+
>
|
|
21
|
+
<header className="flex min-w-0 items-baseline justify-between gap-3">
|
|
22
|
+
<span className="min-w-0 truncate font-medium">{item.title}</span>
|
|
23
|
+
<StatusChip
|
|
24
|
+
className="shrink-0"
|
|
25
|
+
size="compact"
|
|
26
|
+
tone={item.status === "open" ? "accent" : "neutral"}
|
|
27
|
+
>
|
|
28
|
+
{reviewItemStatusLabel(item)}
|
|
29
|
+
</StatusChip>
|
|
30
|
+
</header>
|
|
31
|
+
<TranscriptMarkdown text={item.body} />
|
|
32
|
+
<ReviewItemActions item={item} />
|
|
33
|
+
<Link className="w-fit text-xs" to={reviewItemPath(item.id)}>
|
|
34
|
+
Open on the review desk
|
|
35
|
+
</Link>
|
|
36
|
+
</section>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Show the card of the Review item that a tool call added. It shows nothing
|
|
42
|
+
* when the viewer cannot see the item.
|
|
43
|
+
*/
|
|
44
|
+
export function TranscriptReviewItemCard(props: { itemId: string }) {
|
|
45
|
+
const item = useReviewItemData(props.itemId).data;
|
|
46
|
+
return item ? <ReviewItemCard item={item} /> : null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Archive } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Link } from "react-router";
|
|
4
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
5
|
+
|
|
6
|
+
import { StatusChip } from "../components/StatusChip";
|
|
7
|
+
import { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
|
|
8
|
+
import { conversationPath } from "../conversations/conversationRoutes";
|
|
9
|
+
import { automationPath, formatRelativeTime } from "../format";
|
|
10
|
+
import { ReviewItemActions } from "./ReviewItemActions";
|
|
11
|
+
import { useLinkedConversationTitles } from "./queries";
|
|
12
|
+
import { assigneeLabel, reviewItemStatusLabel } from "./reviewItemFormat";
|
|
13
|
+
|
|
14
|
+
function Fact(props: { children: ReactNode; label: string }) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="grid content-start gap-0.5">
|
|
17
|
+
<dt className="text-xs font-medium text-dashboard-text-muted">
|
|
18
|
+
{props.label}
|
|
19
|
+
</dt>
|
|
20
|
+
<dd className="m-0 min-w-0 break-words text-sm">{props.children}</dd>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Show one Review item with its facts and the viewer's actions. */
|
|
26
|
+
export function ReviewItemDetail(props: { item: ReviewItemPayload }) {
|
|
27
|
+
const { item } = props;
|
|
28
|
+
const subject = item.subject;
|
|
29
|
+
const conversationTitles = useLinkedConversationTitles(item.conversationIds);
|
|
30
|
+
const closed = item.status !== "open";
|
|
31
|
+
return (
|
|
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}
|
|
41
|
+
<header className="grid gap-1.5">
|
|
42
|
+
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
43
|
+
<h2 className="m-0 min-w-0 break-words font-display text-xl font-medium leading-snug">
|
|
44
|
+
{item.title}
|
|
45
|
+
</h2>
|
|
46
|
+
{closed ? null : (
|
|
47
|
+
<StatusChip className="mt-1 shrink-0" size="compact" tone="accent">
|
|
48
|
+
{reviewItemStatusLabel(item)}
|
|
49
|
+
</StatusChip>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
53
|
+
Added {formatRelativeTime(item.createdAt)}
|
|
54
|
+
{item.visibility === "private" ? " · Private" : null}
|
|
55
|
+
</p>
|
|
56
|
+
</header>
|
|
57
|
+
<TranscriptMarkdown text={item.body} />
|
|
58
|
+
<section
|
|
59
|
+
aria-label={item.status === "open" ? "Actions" : "Outcome"}
|
|
60
|
+
className="border-t border-dashboard-border-subtle pt-5"
|
|
61
|
+
>
|
|
62
|
+
<ReviewItemActions item={item} />
|
|
63
|
+
</section>
|
|
64
|
+
<dl className="m-0 grid gap-4 border-t border-dashboard-border-subtle pt-5 sm:grid-cols-2">
|
|
65
|
+
<Fact label="Assignees">
|
|
66
|
+
{item.assignees.length > 0 || item.assignedToEveryone ? (
|
|
67
|
+
assigneeLabel(item)
|
|
68
|
+
) : (
|
|
69
|
+
<StatusChip size="compact" tone="warning">
|
|
70
|
+
Unassigned
|
|
71
|
+
</StatusChip>
|
|
72
|
+
)}
|
|
73
|
+
</Fact>
|
|
74
|
+
{subject ? (
|
|
75
|
+
<Fact label="Subject">
|
|
76
|
+
{subject.url ? (
|
|
77
|
+
<a href={subject.url} rel="noreferrer" target="_blank">
|
|
78
|
+
{subject.title ?? subject.url}
|
|
79
|
+
</a>
|
|
80
|
+
) : (
|
|
81
|
+
(subject.title ?? subject.id)
|
|
82
|
+
)}
|
|
83
|
+
</Fact>
|
|
84
|
+
) : null}
|
|
85
|
+
<Fact label="Added by">
|
|
86
|
+
<Link to={conversationPath(item.addedByConversationId)}>
|
|
87
|
+
Conversation
|
|
88
|
+
</Link>
|
|
89
|
+
{item.automationId ? (
|
|
90
|
+
<>
|
|
91
|
+
{" · "}
|
|
92
|
+
<Link to={automationPath(item.automationId)}>Automation</Link>
|
|
93
|
+
</>
|
|
94
|
+
) : null}
|
|
95
|
+
</Fact>
|
|
96
|
+
{item.conversationIds.length > 0 ? (
|
|
97
|
+
<Fact label="Linked Conversations">
|
|
98
|
+
<ul className="m-0 grid list-none gap-0.5 p-0">
|
|
99
|
+
{item.conversationIds.map((conversationId, index) => {
|
|
100
|
+
const title = conversationTitles[index];
|
|
101
|
+
return (
|
|
102
|
+
<li
|
|
103
|
+
className={
|
|
104
|
+
title ? "truncate" : "truncate font-mono text-xs"
|
|
105
|
+
}
|
|
106
|
+
key={conversationId}
|
|
107
|
+
>
|
|
108
|
+
<Link
|
|
109
|
+
title={conversationId}
|
|
110
|
+
to={conversationPath(conversationId)}
|
|
111
|
+
>
|
|
112
|
+
{title ?? conversationId}
|
|
113
|
+
</Link>
|
|
114
|
+
</li>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</ul>
|
|
118
|
+
</Fact>
|
|
119
|
+
) : null}
|
|
120
|
+
</dl>
|
|
121
|
+
</article>
|
|
122
|
+
);
|
|
123
|
+
}
|