@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
|
@@ -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,115 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { StatusChip } from "../components/StatusChip";
|
|
6
|
+
import { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
|
|
7
|
+
import { conversationPath } from "../conversations/conversationRoutes";
|
|
8
|
+
import { automationPath, formatRelativeTime } from "../format";
|
|
9
|
+
import { ReviewItemActions } from "./ReviewItemActions";
|
|
10
|
+
import { useLinkedConversationTitles } from "./queries";
|
|
11
|
+
import { assigneeLabel, reviewItemStatusLabel } from "./reviewItemFormat";
|
|
12
|
+
|
|
13
|
+
function Fact(props: { children: ReactNode; label: string }) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="grid content-start gap-0.5">
|
|
16
|
+
<dt className="text-xs font-medium text-dashboard-text-muted">
|
|
17
|
+
{props.label}
|
|
18
|
+
</dt>
|
|
19
|
+
<dd className="m-0 min-w-0 break-words text-sm">{props.children}</dd>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Show one Review item with its facts and the viewer's actions. */
|
|
25
|
+
export function ReviewItemDetail(props: { item: ReviewItemPayload }) {
|
|
26
|
+
const { item } = props;
|
|
27
|
+
const subject = item.subject;
|
|
28
|
+
const conversationTitles = useLinkedConversationTitles(item.conversationIds);
|
|
29
|
+
return (
|
|
30
|
+
<article className="grid min-w-0 content-start gap-5">
|
|
31
|
+
<header className="grid gap-1.5">
|
|
32
|
+
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
33
|
+
<h2 className="m-0 min-w-0 break-words font-display text-xl font-medium leading-snug">
|
|
34
|
+
{item.title}
|
|
35
|
+
</h2>
|
|
36
|
+
<StatusChip
|
|
37
|
+
className="mt-1 shrink-0"
|
|
38
|
+
size="compact"
|
|
39
|
+
tone={item.status === "open" ? "accent" : "neutral"}
|
|
40
|
+
>
|
|
41
|
+
{reviewItemStatusLabel(item)}
|
|
42
|
+
</StatusChip>
|
|
43
|
+
</div>
|
|
44
|
+
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
45
|
+
Added {formatRelativeTime(item.createdAt)}
|
|
46
|
+
{item.visibility === "private" ? " · Private" : null}
|
|
47
|
+
</p>
|
|
48
|
+
</header>
|
|
49
|
+
<TranscriptMarkdown text={item.body} />
|
|
50
|
+
<section
|
|
51
|
+
aria-label={item.status === "open" ? "Actions" : "Outcome"}
|
|
52
|
+
className="border-t border-dashboard-border-subtle pt-5"
|
|
53
|
+
>
|
|
54
|
+
<ReviewItemActions item={item} />
|
|
55
|
+
</section>
|
|
56
|
+
<dl className="m-0 grid gap-4 border-t border-dashboard-border-subtle pt-5 sm:grid-cols-2">
|
|
57
|
+
<Fact label="Assignees">
|
|
58
|
+
{item.assignees.length > 0 || item.assignedToEveryone ? (
|
|
59
|
+
assigneeLabel(item)
|
|
60
|
+
) : (
|
|
61
|
+
<StatusChip size="compact" tone="warning">
|
|
62
|
+
Unassigned
|
|
63
|
+
</StatusChip>
|
|
64
|
+
)}
|
|
65
|
+
</Fact>
|
|
66
|
+
{subject ? (
|
|
67
|
+
<Fact label="Subject">
|
|
68
|
+
{subject.url ? (
|
|
69
|
+
<a href={subject.url} rel="noreferrer" target="_blank">
|
|
70
|
+
{subject.title ?? subject.url}
|
|
71
|
+
</a>
|
|
72
|
+
) : (
|
|
73
|
+
(subject.title ?? subject.id)
|
|
74
|
+
)}
|
|
75
|
+
</Fact>
|
|
76
|
+
) : null}
|
|
77
|
+
<Fact label="Added by">
|
|
78
|
+
<Link to={conversationPath(item.addedByConversationId)}>
|
|
79
|
+
Conversation
|
|
80
|
+
</Link>
|
|
81
|
+
{item.automationId ? (
|
|
82
|
+
<>
|
|
83
|
+
{" · "}
|
|
84
|
+
<Link to={automationPath(item.automationId)}>Automation</Link>
|
|
85
|
+
</>
|
|
86
|
+
) : null}
|
|
87
|
+
</Fact>
|
|
88
|
+
{item.conversationIds.length > 0 ? (
|
|
89
|
+
<Fact label="Linked Conversations">
|
|
90
|
+
<ul className="m-0 grid list-none gap-0.5 p-0">
|
|
91
|
+
{item.conversationIds.map((conversationId, index) => {
|
|
92
|
+
const title = conversationTitles[index];
|
|
93
|
+
return (
|
|
94
|
+
<li
|
|
95
|
+
className={
|
|
96
|
+
title ? "truncate" : "truncate font-mono text-xs"
|
|
97
|
+
}
|
|
98
|
+
key={conversationId}
|
|
99
|
+
>
|
|
100
|
+
<Link
|
|
101
|
+
title={conversationId}
|
|
102
|
+
to={conversationPath(conversationId)}
|
|
103
|
+
>
|
|
104
|
+
{title ?? conversationId}
|
|
105
|
+
</Link>
|
|
106
|
+
</li>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</ul>
|
|
110
|
+
</Fact>
|
|
111
|
+
) : null}
|
|
112
|
+
</dl>
|
|
113
|
+
</article>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
keepPreviousData,
|
|
4
|
+
useMutation,
|
|
5
|
+
useQueries,
|
|
6
|
+
useQuery,
|
|
7
|
+
useQueryClient,
|
|
8
|
+
} from "@tanstack/react-query";
|
|
9
|
+
import {
|
|
10
|
+
automationListSchema,
|
|
11
|
+
conversationDetailReportSchema,
|
|
12
|
+
reviewItemCountSchema,
|
|
13
|
+
reviewItemListSchema,
|
|
14
|
+
reviewItemSchema,
|
|
15
|
+
reviewItemWaitingConversationsSchema,
|
|
16
|
+
type ReviewItemActionBody,
|
|
17
|
+
} from "@sentry/junior/api/schema";
|
|
18
|
+
|
|
19
|
+
import { conversationDetailTitle } from "../format";
|
|
20
|
+
import { fetchDashboardJson, post } from "../http";
|
|
21
|
+
|
|
22
|
+
/** Review desk views. Each shows open items, or archived ones. */
|
|
23
|
+
export const REVIEW_DESK_VIEWS = ["mine", "everyone", "all"] as const;
|
|
24
|
+
export type ReviewDeskView = (typeof REVIEW_DESK_VIEWS)[number];
|
|
25
|
+
|
|
26
|
+
const REVIEW_ITEMS_QUERY_KEY = ["dashboard", "review-items"] as const;
|
|
27
|
+
const COUNT_REFRESH_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
/** List the Review items that the viewer can see in one view. */
|
|
30
|
+
export function useReviewItemsData(filters: {
|
|
31
|
+
archived: boolean;
|
|
32
|
+
conversationId?: string;
|
|
33
|
+
q?: string;
|
|
34
|
+
view: ReviewDeskView;
|
|
35
|
+
}) {
|
|
36
|
+
const params = new URLSearchParams({ view: filters.view });
|
|
37
|
+
if (filters.archived) params.set("archived", "true");
|
|
38
|
+
if (filters.conversationId) {
|
|
39
|
+
params.set("conversationId", filters.conversationId);
|
|
40
|
+
}
|
|
41
|
+
if (filters.q) params.set("q", filters.q);
|
|
42
|
+
const search = params.toString();
|
|
43
|
+
return useQuery({
|
|
44
|
+
queryKey: [...REVIEW_ITEMS_QUERY_KEY, "list", search],
|
|
45
|
+
queryFn: ({ signal }) =>
|
|
46
|
+
fetchDashboardJson(
|
|
47
|
+
reviewItemListSchema,
|
|
48
|
+
`/api/review-items?${search}`,
|
|
49
|
+
signal,
|
|
50
|
+
),
|
|
51
|
+
placeholderData: keepPreviousData,
|
|
52
|
+
retry: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read one Review item, for a link to an item outside the current list. */
|
|
57
|
+
export function useReviewItemData(id: string | undefined) {
|
|
58
|
+
return useQuery({
|
|
59
|
+
enabled: Boolean(id),
|
|
60
|
+
queryKey: [...REVIEW_ITEMS_QUERY_KEY, "detail", id],
|
|
61
|
+
queryFn: ({ signal }) =>
|
|
62
|
+
fetchDashboardJson(
|
|
63
|
+
reviewItemSchema,
|
|
64
|
+
`/api/review-items/${encodeURIComponent(id!)}`,
|
|
65
|
+
signal,
|
|
66
|
+
),
|
|
67
|
+
retry: false,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read the titles of the Conversations linked to an item. Each read asks for
|
|
73
|
+
* one event only. A Conversation that the viewer cannot read has no title.
|
|
74
|
+
*/
|
|
75
|
+
export function useLinkedConversationTitles(
|
|
76
|
+
conversationIds: readonly string[],
|
|
77
|
+
): (string | undefined)[] {
|
|
78
|
+
return useQueries({
|
|
79
|
+
queries: conversationIds.map((conversationId) => ({
|
|
80
|
+
queryKey: ["dashboard", "conversation-title", conversationId],
|
|
81
|
+
queryFn: async ({ signal }: { signal: AbortSignal }) =>
|
|
82
|
+
conversationDetailTitle(
|
|
83
|
+
await fetchDashboardJson(
|
|
84
|
+
conversationDetailReportSchema,
|
|
85
|
+
`/api/conversations/${encodeURIComponent(conversationId)}?limit=1`,
|
|
86
|
+
signal,
|
|
87
|
+
),
|
|
88
|
+
) ?? null,
|
|
89
|
+
retry: false,
|
|
90
|
+
staleTime: 5 * 60_000,
|
|
91
|
+
})),
|
|
92
|
+
}).map((query) => query.data ?? undefined);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Count the open Review items assigned to the viewer, for the nav. */
|
|
96
|
+
export function useReviewItemCountData(enabled: boolean) {
|
|
97
|
+
return useQuery({
|
|
98
|
+
enabled,
|
|
99
|
+
queryKey: [...REVIEW_ITEMS_QUERY_KEY, "count"],
|
|
100
|
+
queryFn: ({ signal }) =>
|
|
101
|
+
fetchDashboardJson(
|
|
102
|
+
reviewItemCountSchema,
|
|
103
|
+
"/api/review-items/count",
|
|
104
|
+
signal,
|
|
105
|
+
),
|
|
106
|
+
refetchInterval: COUNT_REFRESH_MS,
|
|
107
|
+
retry: false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* List the viewer's blocked Automations. They show on the review desk, but
|
|
113
|
+
* their state stays derived from Automation status.
|
|
114
|
+
*/
|
|
115
|
+
export function useBlockedAutomationsData(enabled: boolean) {
|
|
116
|
+
return useQuery({
|
|
117
|
+
enabled,
|
|
118
|
+
queryKey: ["dashboard", "automations", "scope=mine&state=blocked"],
|
|
119
|
+
queryFn: ({ signal }) =>
|
|
120
|
+
fetchDashboardJson(
|
|
121
|
+
automationListSchema,
|
|
122
|
+
"/api/automations?scope=mine&state=blocked&pageSize=100",
|
|
123
|
+
signal,
|
|
124
|
+
),
|
|
125
|
+
retry: false,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Take, submit, complete, or dismiss one Review item. */
|
|
130
|
+
export function useReviewItemAction() {
|
|
131
|
+
const queryClient = useQueryClient();
|
|
132
|
+
return useMutation({
|
|
133
|
+
mutationFn: (args: { body: ReviewItemActionBody; id: string }) =>
|
|
134
|
+
post(
|
|
135
|
+
reviewItemSchema,
|
|
136
|
+
`/api/review-items/${encodeURIComponent(args.id)}/actions`,
|
|
137
|
+
args.body,
|
|
138
|
+
),
|
|
139
|
+
onSettled: () =>
|
|
140
|
+
queryClient.invalidateQueries({ queryKey: REVIEW_ITEMS_QUERY_KEY }),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const NO_CONVERSATIONS: ReadonlySet<string> = new Set();
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Return the Conversations that wait on the viewer. Today only the review
|
|
148
|
+
* desk sets this: an open linked item assigned to the viewer or Everyone.
|
|
149
|
+
*/
|
|
150
|
+
export function useWaitingConversationIds(): ReadonlySet<string> {
|
|
151
|
+
const query = useQuery({
|
|
152
|
+
queryKey: [...REVIEW_ITEMS_QUERY_KEY, "waiting-conversations"],
|
|
153
|
+
queryFn: ({ signal }) =>
|
|
154
|
+
fetchDashboardJson(
|
|
155
|
+
reviewItemWaitingConversationsSchema,
|
|
156
|
+
"/api/review-items/waiting-conversations",
|
|
157
|
+
signal,
|
|
158
|
+
),
|
|
159
|
+
refetchInterval: COUNT_REFRESH_MS,
|
|
160
|
+
retry: false,
|
|
161
|
+
});
|
|
162
|
+
const ids = query.data?.conversationIds;
|
|
163
|
+
return useMemo(() => (ids ? new Set(ids) : NO_CONVERSATIONS), [ids]);
|
|
164
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
3
|
+
|
|
4
|
+
/** Return the review desk route that selects one item. */
|
|
5
|
+
export function reviewItemPath(id: string): string {
|
|
6
|
+
return `/review?item=${encodeURIComponent(id)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Name the state of an item: what it needs while open, or how it closed. */
|
|
10
|
+
export function reviewItemStatusLabel(item: ReviewItemPayload): string {
|
|
11
|
+
if (item.status === "open") {
|
|
12
|
+
return item.kind === "action" ? "Needs an answer" : "Notice";
|
|
13
|
+
}
|
|
14
|
+
return item.status === "done" ? "Done" : "Dismissed";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Name the assignees of an item in one short line. */
|
|
18
|
+
export function assigneeLabel(item: ReviewItemPayload): string {
|
|
19
|
+
const names = item.assignees.map(
|
|
20
|
+
(person) => person.displayName ?? person.email,
|
|
21
|
+
);
|
|
22
|
+
if (item.assignedToEveryone) names.unshift("Everyone");
|
|
23
|
+
return names.length > 0 ? names.join(", ") : "Unassigned";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ADD_REVIEW_ITEM_TOOL_NAME = "addReviewItem";
|
|
27
|
+
const executeToolInputSchema = z.object({ tool_name: z.string() });
|
|
28
|
+
const addedItemSchema = z.object({ item: z.object({ id: z.string().min(1) }) });
|
|
29
|
+
|
|
30
|
+
/** Read the item id from a tool result, which can be JSON text. */
|
|
31
|
+
function addedItemId(output: unknown): string | undefined {
|
|
32
|
+
let value = output;
|
|
33
|
+
if (typeof output === "string") {
|
|
34
|
+
try {
|
|
35
|
+
value = JSON.parse(output);
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return addedItemSchema.safeParse(value).data?.item.id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return the id of the Review item that one completed tool call added. The
|
|
45
|
+
* agent calls `addReviewItem` directly or through `executeTool`. Returns
|
|
46
|
+
* undefined when the transcript hides the tool result.
|
|
47
|
+
*/
|
|
48
|
+
export function reviewItemIdFromToolCall(call: {
|
|
49
|
+
input?: unknown;
|
|
50
|
+
name: string;
|
|
51
|
+
output?: unknown;
|
|
52
|
+
status: string;
|
|
53
|
+
}): string | undefined {
|
|
54
|
+
if (call.status !== "completed") return undefined;
|
|
55
|
+
const toolName =
|
|
56
|
+
call.name === "executeTool"
|
|
57
|
+
? executeToolInputSchema.safeParse(call.input).data?.tool_name
|
|
58
|
+
: call.name;
|
|
59
|
+
if (toolName !== ADD_REVIEW_ITEM_TOOL_NAME) return undefined;
|
|
60
|
+
return addedItemId(call.output);
|
|
61
|
+
}
|
|
@@ -602,6 +602,16 @@ function activeConversation(nowMs: number): ConversationDetailReport {
|
|
|
602
602
|
},
|
|
603
603
|
],
|
|
604
604
|
}),
|
|
605
|
+
reportEvent(10, iso(Date.parse(startedAt), 46_000), {
|
|
606
|
+
type: "turn_context",
|
|
607
|
+
turnId: "active-turn",
|
|
608
|
+
pluginName: "junior",
|
|
609
|
+
kind: "user_customization",
|
|
610
|
+
version: 1,
|
|
611
|
+
content: {
|
|
612
|
+
text: "Keep replies short and simple.\nLead with the answer, then the evidence.",
|
|
613
|
+
},
|
|
614
|
+
}),
|
|
605
615
|
],
|
|
606
616
|
});
|
|
607
617
|
}
|
|
@@ -28,6 +28,9 @@ import {
|
|
|
28
28
|
personalSpendReportSchema,
|
|
29
29
|
personParamsSchema,
|
|
30
30
|
pluginOperationalReportFeedSchema,
|
|
31
|
+
reviewItemCountSchema,
|
|
32
|
+
reviewItemListSchema,
|
|
33
|
+
reviewItemWaitingConversationsSchema,
|
|
31
34
|
automationExecutionListSchema,
|
|
32
35
|
automationListSchema,
|
|
33
36
|
automationParamsSchema,
|
|
@@ -294,6 +297,23 @@ export function createMockReportingApi(): Hono<{
|
|
|
294
297
|
}
|
|
295
298
|
return errorResponse("Attachment not found.", 404);
|
|
296
299
|
});
|
|
300
|
+
// Mock reporting has no Review items. Answer the reads here so the
|
|
301
|
+
// dashboard does not query a database that has no review item tables.
|
|
302
|
+
app.get("/review-items", () =>
|
|
303
|
+
jsonResponse(reviewItemListSchema, { items: [] }),
|
|
304
|
+
);
|
|
305
|
+
app.get("/review-items/count", () =>
|
|
306
|
+
jsonResponse(reviewItemCountSchema, { mine: 0 }),
|
|
307
|
+
);
|
|
308
|
+
app.get("/review-items/waiting-conversations", () =>
|
|
309
|
+
jsonResponse(reviewItemWaitingConversationsSchema, { conversationIds: [] }),
|
|
310
|
+
);
|
|
311
|
+
app.get("/review-items/:id", () =>
|
|
312
|
+
errorResponse("Review item not found.", 404),
|
|
313
|
+
);
|
|
314
|
+
app.post("/review-items/:id/actions", () =>
|
|
315
|
+
errorResponse("Review item not found.", 404),
|
|
316
|
+
);
|
|
297
317
|
app.get("/automations", (c) => {
|
|
298
318
|
const report = readMockAutomationList();
|
|
299
319
|
return jsonResponse(
|
package/src/shell.ts
CHANGED
|
@@ -131,6 +131,9 @@ export function dashboardPagePaths(
|
|
|
131
131
|
nested: true,
|
|
132
132
|
path: basePath === "/" ? "/conversations" : `${basePath}/conversations`,
|
|
133
133
|
},
|
|
134
|
+
{
|
|
135
|
+
path: basePath === "/" ? "/review" : `${basePath}/review`,
|
|
136
|
+
},
|
|
134
137
|
{
|
|
135
138
|
nested: true,
|
|
136
139
|
path: basePath === "/" ? "/people" : `${basePath}/people`,
|