@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.
- package/dist/app.js +38 -2
- package/dist/client.js +81 -82
- package/dist/index.js +38 -2
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +20 -0
- package/src/client/components/ParticipantAvatarStack.tsx +2 -1
- package/src/client/components/README.md +4 -0
- package/src/client/components/UserInputForm.tsx +283 -0
- package/src/client/components/layout/DashboardHeader.tsx +13 -0
- package/src/client/components/ui/button.tsx +11 -1
- package/src/client/conversations/ConversationHomeList.tsx +6 -0
- package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
- package/src/client/conversations/ConversationPage.tsx +4 -0
- 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/UserInputRequestCard.tsx +14 -219
- package/src/client/conversations/turnContext.ts +15 -0
- package/src/client/format.ts +21 -5
- package/src/client/pages/SettingsPage.tsx +103 -3
- package/src/client/pages/code/RepositorySections.tsx +1 -0
- package/src/client/pages/code/RepositoryTabs.tsx +3 -0
- package/src/client/primaryNav.ts +15 -2
- package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +277 -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 +115 -0
- package/src/client/review-desk/queries.ts +164 -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
|
@@ -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
|
+
}
|
package/src/client/format.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/client/primaryNav.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|