@sentry/junior-dashboard 0.268.0 → 0.270.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 +12 -9
- package/dist/client.js +81 -81
- package/dist/index.js +12 -9
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +2 -1
- package/src/app.ts +11 -7
- package/src/client/App.tsx +15 -9
- 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/AutomationCard.tsx +2 -1
- package/src/client/components/ParticipantAvatarStack.tsx +1 -0
- package/src/client/components/ProfileMenu.tsx +88 -29
- package/src/client/components/UserInputForm.tsx +110 -37
- package/src/client/components/charts/AGENTS.md +15 -0
- package/src/client/conversations/AGENTS.md +38 -0
- package/src/client/conversations/ConversationBrief.tsx +55 -36
- package/src/client/conversations/ConversationComposer.tsx +27 -8
- package/src/client/conversations/ConversationEventLog.tsx +1 -0
- package/src/client/conversations/ConversationHomeList.tsx +43 -26
- package/src/client/conversations/ConversationPage.tsx +90 -56
- package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
- package/src/client/conversations/ConversationSidebar.tsx +55 -25
- package/src/client/conversations/ConversationTranscript.tsx +3 -2
- package/src/client/conversations/ConversationWatches.tsx +4 -1
- package/src/client/conversations/ConversationWorkspace.tsx +43 -25
- package/src/client/conversations/EventDetails.tsx +6 -9
- package/src/client/conversations/PendingMailboxStack.tsx +5 -2
- package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
- package/src/client/conversations/TranscriptEventView.tsx +9 -3
- package/src/client/conversations/TranscriptMessageView.tsx +6 -5
- package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
- package/src/client/conversations/TranscriptRows.tsx +6 -1
- package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
- package/src/client/conversations/TranscriptToolView.tsx +2 -1
- package/src/client/conversations/TranscriptView.tsx +6 -6
- package/src/client/conversations/conversationOutbox.ts +13 -3
- package/src/client/conversations/conversationSections.ts +36 -8
- package/src/client/conversations/eventTranscript.ts +1 -0
- package/src/client/conversations/transcriptBottomPinning.ts +171 -81
- package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
- package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
- package/src/client/display-text/DisplayText.tsx +68 -0
- package/src/client/display-text/emoji.ts +54 -0
- package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
- package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
- package/src/client/display-text/mentions.tsx +148 -0
- package/src/client/display-text/plainText.ts +29 -0
- package/src/client/format.ts +7 -23
- 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/AutomationFilters.tsx +6 -6
- package/src/client/pages/automations/AutomationOverview.tsx +5 -5
- package/src/client/pages/automations/AutomationStatus.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
- package/src/client/pages/code/RepositoryTabs.tsx +6 -1
- package/src/client/pages/memory/MemoryDetails.tsx +5 -13
- package/src/client/pages/system/AGENTS.md +12 -0
- package/src/client/review-desk/ConversationReviewBar.tsx +14 -9
- package/src/client/review-desk/ReviewDeskPage.tsx +99 -79
- package/src/client/review-desk/ReviewItemActions.tsx +6 -2
- package/src/client/review-desk/ReviewItemCard.tsx +58 -17
- package/src/client/review-desk/ReviewItemDetail.tsx +17 -9
- package/src/client/review-desk/UnsentAnswers.tsx +148 -0
- package/src/client/review-desk/queries.ts +25 -16
- package/src/client/review-desk/reviewItemFormat.ts +29 -1
- package/src/mock-reporting/routes.ts +1 -1
- package/src/tailwind.css +1 -1
- package/src/client/components/README.md +0 -219
- package/src/client/components/charts/README.md +0 -38
- package/src/client/conversations/README.md +0 -159
- package/src/client/conversations/TranscriptText.tsx +0 -47
- package/src/client/conversations/UserInputRequestCard.tsx +0 -39
- package/src/client/conversations/conversationActivityPreview.ts +0 -16
- package/src/client/conversations/transcriptMentions.tsx +0 -134
- 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
|
@@ -29,6 +29,7 @@ import { InlineError } from "../../components/InlineError";
|
|
|
29
29
|
import { PageContentSkeleton } from "../../components/PageContentSkeleton";
|
|
30
30
|
import { StatusChip } from "../../components/StatusChip";
|
|
31
31
|
import { Card } from "../../components/layout/Card";
|
|
32
|
+
import { DisplayText } from "../../display-text/DisplayText";
|
|
32
33
|
import { formatRelativeTime } from "../../format";
|
|
33
34
|
import { DashboardApiError, fetchDashboardJson, post } from "../../http";
|
|
34
35
|
import { cn } from "../../styles";
|
|
@@ -364,7 +365,10 @@ function fieldValue(
|
|
|
364
365
|
return definition.title ?? "First line of the instruction";
|
|
365
366
|
case "instruction":
|
|
366
367
|
return (
|
|
367
|
-
<
|
|
368
|
+
<DisplayText
|
|
369
|
+
mentions={automation.mentions}
|
|
370
|
+
text={definition.instruction}
|
|
371
|
+
/>
|
|
368
372
|
);
|
|
369
373
|
case "when":
|
|
370
374
|
if (version.kind === "scheduled") {
|
|
@@ -431,7 +435,7 @@ function LineDiff(props: { after: string; before: string }) {
|
|
|
431
435
|
{line.kind === "added" ? "Added: " : "Removed: "}
|
|
432
436
|
</span>
|
|
433
437
|
)}
|
|
434
|
-
{line.
|
|
438
|
+
{line.value || " "}
|
|
435
439
|
</span>
|
|
436
440
|
</div>
|
|
437
441
|
))}
|
|
@@ -439,7 +443,7 @@ function LineDiff(props: { after: string; before: string }) {
|
|
|
439
443
|
);
|
|
440
444
|
}
|
|
441
445
|
|
|
442
|
-
type DiffLine = { kind: "added" | "removed" | "same";
|
|
446
|
+
type DiffLine = { kind: "added" | "removed" | "same"; value: string };
|
|
443
447
|
|
|
444
448
|
/** Line diff from the longest common subsequence. Instructions stay short. */
|
|
445
449
|
function diffLines(before: string, after: string): DiffLine[] {
|
|
@@ -461,17 +465,17 @@ function diffLines(before: string, after: string): DiffLine[] {
|
|
|
461
465
|
let j = 0;
|
|
462
466
|
while (i < a.length && j < b.length) {
|
|
463
467
|
if (a[i] === b[j]) {
|
|
464
|
-
lines.push({ kind: "same",
|
|
468
|
+
lines.push({ kind: "same", value: a[i]! });
|
|
465
469
|
i++;
|
|
466
470
|
j++;
|
|
467
471
|
} else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) {
|
|
468
|
-
lines.push({ kind: "removed",
|
|
472
|
+
lines.push({ kind: "removed", value: a[i++]! });
|
|
469
473
|
} else {
|
|
470
|
-
lines.push({ kind: "added",
|
|
474
|
+
lines.push({ kind: "added", value: b[j++]! });
|
|
471
475
|
}
|
|
472
476
|
}
|
|
473
|
-
while (i < a.length) lines.push({ kind: "removed",
|
|
474
|
-
while (j < b.length) lines.push({ kind: "added",
|
|
477
|
+
while (i < a.length) lines.push({ kind: "removed", value: a[i++]! });
|
|
478
|
+
while (j < b.length) lines.push({ kind: "added", value: b[j++]! });
|
|
475
479
|
return lines;
|
|
476
480
|
}
|
|
477
481
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "../../components/controls/TimeRangeSelector";
|
|
17
17
|
import { Card } from "../../components/layout/Card";
|
|
18
18
|
import { ConversationHomeList } from "../../conversations/ConversationHomeList";
|
|
19
|
+
import { attentionFirst } from "../../conversations/conversationSections";
|
|
19
20
|
import { buildConversations, getDashboardTimeZone } from "../../format";
|
|
20
21
|
import { useDebouncedValue } from "../../debouncedValue";
|
|
21
22
|
import { useWaitingConversationIds } from "../../review-desk/queries";
|
|
@@ -52,7 +53,11 @@ export function RepositoryConversationsTab(props: { repositoryId: string }) {
|
|
|
52
53
|
</EmptyTelemetry>
|
|
53
54
|
) : (
|
|
54
55
|
<ConversationHomeList
|
|
55
|
-
conversations={pageItems(
|
|
56
|
+
conversations={pageItems(
|
|
57
|
+
attentionFirst(conversations, waitingConversationIds),
|
|
58
|
+
page,
|
|
59
|
+
PAGE_SIZE,
|
|
60
|
+
)}
|
|
56
61
|
emptyLabel={
|
|
57
62
|
search
|
|
58
63
|
? "No conversations match this search."
|
|
@@ -18,16 +18,12 @@ import {
|
|
|
18
18
|
TooltipContent,
|
|
19
19
|
TooltipTrigger,
|
|
20
20
|
} from "../../components/ui/tooltip";
|
|
21
|
-
import {
|
|
21
|
+
import { DisplayText } from "../../display-text/DisplayText";
|
|
22
22
|
import type {
|
|
23
23
|
PluginUserPageRecord,
|
|
24
24
|
PluginUserPageRecordAction,
|
|
25
25
|
} from "../user/pluginUserPageData";
|
|
26
26
|
|
|
27
|
-
// The memory plugin API does not resolve mentions yet, so memory text shows
|
|
28
|
-
// Slack ids instead of names.
|
|
29
|
-
const NO_MEMORY_MENTIONS: readonly [] = [];
|
|
30
|
-
|
|
31
27
|
// The heading, origin, and scope rows already show these values.
|
|
32
28
|
const SUMMARIZED_METADATA = new Set([
|
|
33
29
|
"Learned",
|
|
@@ -81,15 +77,11 @@ export function MemoryDetails(props: {
|
|
|
81
77
|
{record ? (
|
|
82
78
|
<>
|
|
83
79
|
<div className="grid gap-3 text-sm leading-relaxed">
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
/>
|
|
80
|
+
{/* The memory plugin API does not resolve mentions yet, so
|
|
81
|
+
memory text shows Slack ids instead of names. */}
|
|
82
|
+
<DisplayText text={record.title} />
|
|
88
83
|
{record.description ? (
|
|
89
|
-
<
|
|
90
|
-
mentions={NO_MEMORY_MENTIONS}
|
|
91
|
-
text={record.description}
|
|
92
|
-
/>
|
|
84
|
+
<DisplayText text={record.description} />
|
|
93
85
|
) : null}
|
|
94
86
|
</div>
|
|
95
87
|
<MemoryFacts record={record} />
|
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
25
|
+
const more = Boolean(query.hasNextPage);
|
|
26
|
+
const label = itemCountLabel(items.length, more);
|
|
26
27
|
return (
|
|
27
28
|
<>
|
|
28
29
|
<button
|
|
@@ -48,12 +49,16 @@ export function ConversationReviewBar(props: { conversationId: string }) {
|
|
|
48
49
|
label="Review items"
|
|
49
50
|
onClose={() => setOpen(false)}
|
|
50
51
|
openKey={props.conversationId}
|
|
51
|
-
width="narrow"
|
|
52
52
|
>
|
|
53
53
|
<div className="grid gap-3">
|
|
54
54
|
{items.map((item) => (
|
|
55
55
|
<ReviewItemCard item={item} key={item.id} />
|
|
56
56
|
))}
|
|
57
|
+
<LoadMorePagination
|
|
58
|
+
hasMore={more}
|
|
59
|
+
loading={query.isFetchingNextPage}
|
|
60
|
+
onLoadMore={() => void query.fetchNextPage()}
|
|
61
|
+
/>
|
|
57
62
|
</div>
|
|
58
63
|
</Drawer>
|
|
59
64
|
) : null}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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: {
|
|
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
|
|
61
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
|
144
|
-
*
|
|
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
|
|
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
|
|
168
|
-
const
|
|
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
|
|
178
|
-
const
|
|
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
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
214
|
-
|
|
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
|
-
{
|
|
205
|
+
{q ? `No items match "${q}".` : "No open items."}
|
|
223
206
|
</EmptyMessage>
|
|
224
207
|
) : (
|
|
225
208
|
<>
|
|
226
209
|
{actions.length > 0 ? (
|
|
227
|
-
<ListGroup title=
|
|
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) => (
|
|
@@ -8,10 +8,11 @@ import {
|
|
|
8
8
|
import { InlineError } from "../components/InlineError";
|
|
9
9
|
import { UserInputForm } from "../components/UserInputForm";
|
|
10
10
|
import { Button } from "../components/ui/button";
|
|
11
|
-
import {
|
|
11
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
12
12
|
import { formatRelativeTime } from "../format";
|
|
13
13
|
import { DashboardApiError } from "../http";
|
|
14
14
|
import { useReviewItemAction } from "./queries";
|
|
15
|
+
import { useReportUnsentAnswer } from "./UnsentAnswers";
|
|
15
16
|
|
|
16
17
|
function personLabel(person: { displayName?: string; email: string }) {
|
|
17
18
|
return person.displayName ?? person.email;
|
|
@@ -79,6 +80,8 @@ export function ReviewItemActions(props: { item: ReviewItemPayload }) {
|
|
|
79
80
|
const { item } = props;
|
|
80
81
|
const action = useReviewItemAction();
|
|
81
82
|
const [error, setError] = useState<string>();
|
|
83
|
+
const [unsent, setUnsent] = useState(false);
|
|
84
|
+
useReportUnsentAnswer(item, unsent);
|
|
82
85
|
|
|
83
86
|
if (item.status !== "open") return <ClosedSummary item={item} />;
|
|
84
87
|
if (!item.canAct) {
|
|
@@ -126,9 +129,10 @@ export function ReviewItemActions(props: { item: ReviewItemPayload }) {
|
|
|
126
129
|
{item.kind === "action" && item.request ? (
|
|
127
130
|
<div className="text-sm">
|
|
128
131
|
<div className="font-medium">
|
|
129
|
-
<
|
|
132
|
+
<DisplayText text={item.request.question} />
|
|
130
133
|
</div>
|
|
131
134
|
<UserInputForm
|
|
135
|
+
onUnsentChange={setUnsent}
|
|
132
136
|
onSubmit={(answer) =>
|
|
133
137
|
run({
|
|
134
138
|
action: "submit",
|
|
@@ -1,38 +1,79 @@
|
|
|
1
|
+
import { ArrowRight, Inbox } from "lucide-react";
|
|
1
2
|
import { Link } from "react-router";
|
|
2
3
|
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
3
4
|
|
|
4
5
|
import { StatusChip } from "../components/StatusChip";
|
|
5
|
-
import {
|
|
6
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
7
|
+
import { cn } from "../styles";
|
|
6
8
|
import { ReviewItemActions } from "./ReviewItemActions";
|
|
7
9
|
import { useReviewItemData } from "./queries";
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
assigneeLabel,
|
|
12
|
+
reviewItemPath,
|
|
13
|
+
reviewItemStatusLabel,
|
|
14
|
+
} from "./reviewItemFormat";
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* Show one Review item as a card: its title, status, description, and the
|
|
12
|
-
* viewer's actions, with a link to the review desk.
|
|
18
|
+
* viewer's actions, with a link to the review desk. The card uses the same
|
|
19
|
+
* frame as transcript object cards, so it reads as one record, not as prose.
|
|
13
20
|
*/
|
|
14
21
|
export function ReviewItemCard(props: { item: ReviewItemPayload }) {
|
|
15
22
|
const { item } = props;
|
|
23
|
+
const open = item.status === "open";
|
|
24
|
+
const assigned = item.assignees.length > 0 || item.assignedToEveryone;
|
|
16
25
|
return (
|
|
17
26
|
<section
|
|
18
27
|
aria-label={`Review item: ${item.title}`}
|
|
19
|
-
className="
|
|
28
|
+
className="my-1 w-full min-w-0 max-w-[37.5rem] overflow-hidden rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-panel font-sans text-sm text-dashboard-text"
|
|
20
29
|
>
|
|
21
|
-
<header className="flex
|
|
22
|
-
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
<header className="flex items-start gap-3 px-4 pb-3 pt-4 md:px-5">
|
|
31
|
+
{/* The accent tile marks an item that still needs a person. */}
|
|
32
|
+
<span
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
className={cn(
|
|
35
|
+
"grid size-9 shrink-0 place-items-center rounded-lg border",
|
|
36
|
+
open
|
|
37
|
+
? "border-dashboard-accent/30 bg-dashboard-accent/10 text-dashboard-accent"
|
|
38
|
+
: "border-dashboard-border bg-dashboard-fill-soft text-dashboard-text-muted",
|
|
39
|
+
)}
|
|
27
40
|
>
|
|
28
|
-
{
|
|
29
|
-
</
|
|
41
|
+
<Inbox size={18} />
|
|
42
|
+
</span>
|
|
43
|
+
<div className="grid min-w-0 flex-1 gap-1">
|
|
44
|
+
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
45
|
+
<p className="m-0 text-xs text-dashboard-text-muted">Review item</p>
|
|
46
|
+
<StatusChip
|
|
47
|
+
className="shrink-0"
|
|
48
|
+
size="compact"
|
|
49
|
+
tone={open ? "accent" : "neutral"}
|
|
50
|
+
>
|
|
51
|
+
{reviewItemStatusLabel(item)}
|
|
52
|
+
</StatusChip>
|
|
53
|
+
</div>
|
|
54
|
+
<h3 className="m-0 break-words text-base font-semibold leading-snug">
|
|
55
|
+
{item.title}
|
|
56
|
+
</h3>
|
|
57
|
+
</div>
|
|
30
58
|
</header>
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
<div className="grid min-w-0 gap-4 px-4 pb-4 md:px-5">
|
|
60
|
+
<DisplayText text={item.body} />
|
|
61
|
+
<div className="border-t border-dashboard-border pt-4">
|
|
62
|
+
<ReviewItemActions item={item} />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-dashboard-border bg-dashboard-fill-faint px-4 py-3 text-xs md:px-5">
|
|
66
|
+
<Link
|
|
67
|
+
className="inline-flex items-center gap-2 font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus"
|
|
68
|
+
to={reviewItemPath(item.id)}
|
|
69
|
+
>
|
|
70
|
+
Open on the review desk
|
|
71
|
+
<ArrowRight aria-hidden="true" size={14} />
|
|
72
|
+
</Link>
|
|
73
|
+
<span className="min-w-0 truncate text-dashboard-text-muted">
|
|
74
|
+
{assigned ? `Assigned to ${assigneeLabel(item)}` : "Unassigned"}
|
|
75
|
+
</span>
|
|
76
|
+
</footer>
|
|
36
77
|
</section>
|
|
37
78
|
);
|
|
38
79
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
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";
|
|
4
5
|
|
|
5
6
|
import { StatusChip } from "../components/StatusChip";
|
|
6
|
-
import {
|
|
7
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
7
8
|
import { conversationPath } from "../conversations/conversationRoutes";
|
|
8
9
|
import { automationPath, formatRelativeTime } from "../format";
|
|
9
10
|
import { ReviewItemActions } from "./ReviewItemActions";
|
|
@@ -26,27 +27,34 @@ 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
|
-
|
|
37
|
-
className="mt-1 shrink-0"
|
|
38
|
-
|
|
39
|
-
|
|
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)}
|
|
46
54
|
{item.visibility === "private" ? " · Private" : null}
|
|
47
55
|
</p>
|
|
48
56
|
</header>
|
|
49
|
-
<
|
|
57
|
+
<DisplayText text={item.body} />
|
|
50
58
|
<section
|
|
51
59
|
aria-label={item.status === "open" ? "Actions" : "Outcome"}
|
|
52
60
|
className="border-t border-dashboard-border-subtle pt-5"
|