@sentry/junior-dashboard 0.266.0 → 0.268.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/app.js +85 -12
  2. package/dist/client.js +94 -64
  3. package/dist/index.js +85 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +55 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -2
  13. package/src/client/components/README.md +15 -2
  14. package/src/client/components/UserInputForm.tsx +283 -0
  15. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  16. package/src/client/components/ui/alert.tsx +1 -1
  17. package/src/client/components/ui/button-group.tsx +59 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/components/ui/dialog.tsx +29 -35
  20. package/src/client/components/ui/drawer.tsx +140 -0
  21. package/src/client/components/ui/empty.tsx +30 -1
  22. package/src/client/components/ui/separator.tsx +22 -0
  23. package/src/client/components/ui/useSheet.ts +5 -1
  24. package/src/client/conversations/ComposerImages.tsx +66 -14
  25. package/src/client/conversations/ConversationComposer.tsx +84 -12
  26. package/src/client/conversations/ConversationDetails.tsx +3 -27
  27. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  28. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  29. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  30. package/src/client/conversations/ConversationPage.tsx +160 -2
  31. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  32. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  33. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  34. package/src/client/conversations/DetailsList.tsx +33 -0
  35. package/src/client/conversations/EventDetails.tsx +46 -19
  36. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  37. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  38. package/src/client/conversations/README.md +30 -13
  39. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  40. package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
  41. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  42. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  43. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  44. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  45. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  46. package/src/client/conversations/TranscriptMessageView.tsx +10 -1
  47. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  48. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  49. package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
  50. package/src/client/conversations/TranscriptView.tsx +7 -3
  51. package/src/client/conversations/UserInputRequestCard.tsx +39 -0
  52. package/src/client/conversations/conversationOutbox.ts +46 -0
  53. package/src/client/conversations/eventTime.ts +13 -1
  54. package/src/client/conversations/eventTranscript.ts +12 -5
  55. package/src/client/conversations/queries.ts +127 -26
  56. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  57. package/src/client/conversations/turnContext.ts +15 -0
  58. package/src/client/conversations/userInputRequest.ts +56 -0
  59. package/src/client/format.ts +31 -8
  60. package/src/client/pages/SettingsPage.tsx +103 -3
  61. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  62. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  63. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  64. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  65. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  66. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  67. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  69. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  70. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  71. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  72. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  73. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  74. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  75. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  76. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  77. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  78. package/src/client/pages/automations/README.md +56 -46
  79. package/src/client/pages/code/RepositorySections.tsx +1 -0
  80. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  81. package/src/client/primaryNav.ts +15 -2
  82. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  83. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  84. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  85. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  86. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  87. package/src/client/review-desk/queries.ts +164 -0
  88. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  89. package/src/client/types.ts +3 -0
  90. package/src/mock-reporting/fixtures.ts +54 -2
  91. package/src/mock-reporting/routes.ts +20 -0
  92. package/src/shell.ts +3 -0
  93. package/src/client/conversations/transcriptStyles.ts +0 -10
  94. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  95. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  96. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ObjectType } from "@sentry/junior-plugin-api";
2
2
  import type { BundledLanguage } from "shiki/bundle/web";
3
3
  import type {
4
+ InputImage,
4
5
  MessageAttachment,
5
6
  MessageCard,
6
7
  PluginOperationalReportFeed,
@@ -151,6 +152,8 @@ export type TranscriptViewMessage = {
151
152
  failureReason?: ConversationTurnFailureReason;
152
153
  sentryEventUrl?: string;
153
154
  attachments?: MessageAttachment[];
155
+ /** Local images of a pending send that the server has not stored yet. */
156
+ images?: InputImage[];
154
157
  parts: TranscriptViewPart[];
155
158
  role: "assistant" | "system" | "tool" | "user";
156
159
  source?: "slack" | "web";
@@ -572,7 +572,16 @@ function activeConversation(nowMs: number): ConversationDetailReport {
572
572
  type: "message",
573
573
  messageId: "active-user-followup",
574
574
  role: "user",
575
- text: "Compare the pre/post deploy spans next.",
575
+ // Quoted reply keeps blockquote and nested-list rendering in visual QA.
576
+ text: [
577
+ "> error volume is flat, so this looks like slow path not hard fail",
578
+ "",
579
+ "Agreed. Compare the pre/post deploy spans next:",
580
+ "1. `checkout.complete` p95",
581
+ " - stable hosts",
582
+ " - canary hosts",
583
+ "2. ~~error rate~~ serializer span time",
584
+ ].join("\n"),
576
585
  }),
577
586
  reportEvent(8, iso(Date.parse(startedAt), 42_000), {
578
587
  type: "turn_lifecycle",
@@ -593,6 +602,16 @@ function activeConversation(nowMs: number): ConversationDetailReport {
593
602
  },
594
603
  ],
595
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
+ }),
596
615
  ],
597
616
  });
598
617
  }
@@ -1451,7 +1470,7 @@ function eventAutomationRunConversation(
1451
1470
  "This is a task, not a message from a person.",
1452
1471
  "",
1453
1472
  "About: Issue · ACME-42",
1454
- 'Created by: <@U123>. "Me" and "my" in the instructions mean this person: mention them in the message. If the instructions do not say "me" or "my", do not mention this person.',
1473
+ 'Created by: <@U123>. This person is the Automation creator. "The Automation creator", "me", and "my" in the instructions mean this person. Where the instructions refer to this person, write this mention in the message. If they do not, do not mention this person.',
1455
1474
  "Instructions: Summarize the closed issue",
1456
1475
  "",
1457
1476
  "Trusted summary: ACME-42 Checkout fails for saved cards was closed by Morgan Lee.",
@@ -1944,6 +1963,14 @@ export function readMockConversationPendingMessages(
1944
1963
  role: "user" as const,
1945
1964
  source: "web" as const,
1946
1965
  text: "Keep the reply in Junior. I will paste the dashboard link next.",
1966
+ attachments: [
1967
+ {
1968
+ id: "qa-chart-png",
1969
+ filename: "queued-chart.png",
1970
+ contentType: "image/png",
1971
+ bytes: 18211,
1972
+ },
1973
+ ],
1947
1974
  },
1948
1975
  {
1949
1976
  actorIdentity: actor("dev@example.com", "Morgan Lee", "morgan"),
@@ -1978,6 +2005,28 @@ export function readMockConversationPendingMessages(
1978
2005
  source: "web" as const,
1979
2006
  text: "Fifth queued message.",
1980
2007
  },
2008
+ {
2009
+ actorIdentity: actor("dev@example.com", "Morgan Lee", "morgan"),
2010
+ createdAt: iso(nowMs, -800),
2011
+ delivery: "defer" as const,
2012
+ inboundMessageId: `${conversationId}:pending-sixth`,
2013
+ messageId: `${conversationId}:pending-sixth`,
2014
+ receivedAt: iso(nowMs, -400),
2015
+ role: "user" as const,
2016
+ source: "web" as const,
2017
+ text: "Sixth queued message.",
2018
+ },
2019
+ {
2020
+ actorIdentity: actor("dev@example.com", "Morgan Lee", "morgan"),
2021
+ createdAt: iso(nowMs, -600),
2022
+ delivery: "defer" as const,
2023
+ inboundMessageId: `${conversationId}:pending-seventh`,
2024
+ messageId: `${conversationId}:pending-seventh`,
2025
+ receivedAt: iso(nowMs, -300),
2026
+ role: "user" as const,
2027
+ source: "web" as const,
2028
+ text: "Seventh queued message.",
2029
+ },
1981
2030
  ]
1982
2031
  : [];
1983
2032
 
@@ -2949,6 +2998,7 @@ function mockTasks(): AutomationSummary[] {
2949
2998
  nextRunAt: "2026-08-10T16:00:00.000Z",
2950
2999
  credentialMode: "creator",
2951
3000
  ownedByViewer: true,
3001
+ costUsd: { 1: 0.42, 7: 1.02, 30: 4.18, 90: 16.4 },
2952
3002
  runs: { 1: 1, 7: 3, 30: 12, 90: 48 },
2953
3003
  timezone: "America/Los_Angeles",
2954
3004
  schedule: "Every Monday at 9:00 AM",
@@ -2980,6 +3030,7 @@ function mockTasks(): AutomationSummary[] {
2980
3030
  credentialMode: "creator",
2981
3031
  ownedByViewer: true,
2982
3032
  resource: "Issue · ACME-42",
3033
+ costUsd: { 1: 0, 7: 0.12, 30: 0.48, 90: 0.84 },
2983
3034
  runs: { 1: 0, 7: 1, 30: 4, 90: 7 },
2984
3035
  source: "github",
2985
3036
  outcomes: [],
@@ -3008,6 +3059,7 @@ function mockTasks(): AutomationSummary[] {
3008
3059
  credentialMode: "creator",
3009
3060
  ownedByViewer: false,
3010
3061
  resource: "Incident · INC-17",
3062
+ costUsd: { 1: 0, 7: 0, 30: 0, 90: 0 },
3011
3063
  runs: { 1: 0, 7: 0, 30: 0, 90: 0 },
3012
3064
  source: "pagerduty",
3013
3065
  outcomes: [